From abe5b67a80dc8796b3ce9d339358c9088da354da Mon Sep 17 00:00:00 2001 From: Kiran Dama <69480841+sfc-gh-kdama@users.noreply.github.com> Date: Fri, 1 Dec 2023 09:36:12 -0800 Subject: [PATCH] Release Snowflake-ml-python 1.1.0 (#72) Co-authored-by: Snowflake Authors --- .flake8 | 5 +- .pre-commit-config.yaml | 5 +- CHANGELOG.md | 19 +- CONTRIBUTING.md | 19 +- bazel/environments/conda-env-snowflake.yml | 2 +- bazel/environments/conda-env.yml | 3 +- bazel/environments/conda-gpu-env.yml | 2 +- bazel/filter_affected_targets.py | 37 - .../parse_and_generate_requirements.py | 39 +- bazel/requirements/requirements.schema.json | 8 + ci/build_and_run_tests.sh | 64 +- ci/conda_recipe/meta.yaml | 4 +- ci/get_excluded_tests.sh | 4 +- codegen/sklearn_wrapper_generator.py | 22 + codegen/sklearn_wrapper_template.py_template | 72 +- ...nsformer_autogen_test_template.py_template | 14 +- docs/source/index.rst | 3 + mypy.ini | 2 +- requirements.txt | 7 +- requirements.yml | 41 +- snowflake/cortex/BUILD.bazel | 147 ++ snowflake/cortex/__init__.py | 13 + snowflake/cortex/_complete.py | 35 + snowflake/cortex/_extract_answer.py | 37 + snowflake/cortex/_sentiment.py | 31 + snowflake/cortex/_summarize.py | 34 + snowflake/cortex/_test_util.py | 18 + snowflake/cortex/_translate.py | 40 + snowflake/cortex/_util.py | 42 + snowflake/cortex/complete_test.py | 43 + snowflake/cortex/extract_answer_test.py | 53 + snowflake/cortex/package_visibility_test.py | 26 + snowflake/cortex/sentiment_test.py | 42 + snowflake/cortex/summarize_test.py | 42 + snowflake/cortex/translate_test.py | 58 + snowflake/ml/_internal/BUILD.bazel | 13 + snowflake/ml/_internal/cuda_utils.py | 98 + snowflake/ml/_internal/cuda_utils_test.py | 26 + snowflake/ml/_internal/file_utils.py | 200 +- snowflake/ml/_internal/file_utils_test.py | 126 +- snowflake/ml/_internal/utils/BUILD.bazel | 24 + snowflake/ml/_internal/utils/identifier.py | 6 + .../utils/image_registry_http_client.py | 120 + .../utils/image_registry_http_client_test.py | 137 ++ .../_internal/utils/session_token_manager.py | 46 + .../ml/_internal/utils/spcs_image_registry.py | 1 + .../ml/_internal/utils/sql_identifier.py | 35 +- .../ml/_internal/utils/sql_identifier_test.py | 28 +- .../ml/_internal/utils/string_matcher.py | 24 +- .../ml/_internal/utils/temp_file_utils.py | 6 +- .../scripts/install-snowpark-ml-conda.sh | 113 + .../scripts/run_synthetic_data_generator.py | 4 - .../_internal/scripts/upload_test_datasets.py | 10 +- .../_internal/synthetic_data_generator.py | 6 +- snowflake/ml/feature_store/entity.py | 36 +- snowflake/ml/feature_store/feature_store.py | 294 +-- snowflake/ml/feature_store/feature_view.py | 62 +- .../customer_demo/Basic_Feature_Demo.ipynb | 181 +- .../customer_demo/Basic_Feature_Demo.pdf | Bin 209795 -> 77415 bytes .../Time_Series_Feature_Demo.ipynb | 232 +- .../Time_Series_Feature_Demo.pdf | Bin 249931 -> 87653 bytes .../Basic_Feature_Store_Demo.ipynb | 3 +- .../Time_Series_Feature_Demo.ipynb | 3 +- snowflake/ml/feature_store/tests/BUILD.bazel | 2 +- .../ml/feature_store/tests/common_utils.py | 9 +- .../feature_store_case_sensitivity_test.py | 27 +- .../tests/feature_store_large_scale_test.py | 11 +- .../tests/feature_store_object_test.py | 2 +- .../feature_store/tests/feature_store_test.py | 94 +- snowflake/ml/model/BUILD.bazel | 2 +- snowflake/ml/model/_api.py | 54 +- .../image_builds/docker_context.py | 48 +- .../image_builds/inference_server/main.py | 11 +- .../inference_server/main_test.py | 23 +- .../inference_server/main_vllm_test.py | 11 +- .../image_builds/server_image_builder.py | 32 +- .../templates/kaniko_shell_script_template | 3 + .../kaniko_shell_script_fixture.sh | 3 + .../_deploy_client/snowservice/deploy.py | 72 +- .../snowservice/deploy_options.py | 4 + .../_deploy_client/snowservice/deploy_test.py | 103 +- .../templates/service_spec_template | 1 + .../service_spec_template_with_model | 1 + .../ml/model/_deploy_client/utils/BUILD.bazel | 14 +- .../model/_deploy_client/utils/constants.py | 1 + .../utils/image_auth_manager.py | 50 - .../utils/image_registry_client.py | 201 +- .../utils/image_registry_client_test.py | 211 +- .../ml/model/_deploy_client/utils/imagelib.py | 120 +- .../utils/snowservice_client.py | 17 +- .../warehouse/infer_template.py | 4 +- .../BUILD.bazel | 11 +- .../model_composer.py} | 36 +- .../_model_composer/model_composer_test.py | 62 + .../model_manifest/BUILD.bazel | 34 + .../model_manifest/model_manifest.py | 86 + .../model_manifest/model_manifest_schema.py | 45 + .../model_manifest/model_manifest_test.py | 243 ++ .../_model_composer/model_method/BUILD.bazel | 60 + .../fixtures/function_fixture_1.py_fixture} | 2 +- .../fixtures/function_fixture_2.py_fixture} | 2 +- .../model_method/function_generator.py | 52 + .../model_method/function_generator_test.py} | 22 +- .../model_method/infer_function.py_template} | 4 +- .../model_method/model_method.py | 90 + .../model_method/model_method_test.py | 173 ++ .../_model_composer/model_runtime/BUILD.bazel | 47 + .../model_runtime/model_runtime.py | 91 + .../model_runtime/model_runtime_test.py | 274 +++ .../_module_model/module_manifest/BUILD.bazel | 8 - .../module_manifest/module_manifest.py | 2 - .../_module_model/module_method/BUILD.bazel | 28 - .../module_method/handler_generator.py | 43 - .../module_method/module_method.py | 2 - .../model/_module_model/module_model_test.py | 57 - .../_module_model/module_runtime/BUILD.bazel | 8 - .../module_runtime/module_runtime.py | 2 - .../ml/model/_packager/model_env/model_env.py | 152 +- .../_packager/model_env/model_env_test.py | 181 +- .../_packager/model_handlers/BUILD.bazel | 1 - .../ml/model/_packager/model_handlers/llm.py | 93 +- .../_packager/model_handlers/snowmlmodel.py | 9 +- .../model/_packager/model_meta/model_meta.py | 15 +- .../_packager/model_meta/model_meta_test.py | 19 + .../ml/model/_packager/model_packager.py | 4 +- .../ml/model/_packager/model_packager_test.py | 5 +- .../ml/model/_signatures/pandas_handler.py | 28 +- snowflake/ml/model/_signatures/pandas_test.py | 6 + .../ml/model/_signatures/snowpark_handler.py | 30 +- snowflake/ml/model/model_signature.py | 43 +- snowflake/ml/model/model_signature_test.py | 8 + snowflake/ml/model/models/llm.py | 2 - snowflake/ml/model/type_hints.py | 30 +- .../modeling/_internal/estimator_protocols.py | 16 +- .../ml/modeling/_internal/estimator_utils.py | 2 +- .../modeling/_internal/snowpark_handlers.py | 128 +- snowflake/ml/modeling/framework/base.py | 2 - .../ml/modeling/model_selection/BUILD.bazel | 45 +- .../{_internal => }/__init__.py | 0 .../model_selection/_internal/BUILD.bazel | 44 - .../_grid_search_cv.py => grid_search_cv.py} | 29 +- ...d_search_cv.py => randomized_search_cv.py} | 29 +- snowflake/ml/modeling/parameters/BUILD.bazel | 35 + .../parameters/disable_distributed_hpo.py | 8 + .../disable_distributed_hpo_test.py | 144 ++ snowflake/ml/modeling/pipeline/pipeline.py | 2 +- .../modeling/preprocessing/ordinal_encoder.py | 6 - snowflake/ml/packages.bzl | 5 +- snowflake/ml/registry/model_registry.py | 67 +- snowflake/ml/registry/model_registry_test.py | 4 +- ...t to Snowpark Container Service Demo.ipynb | 340 ++- .../notebooks/Finetune_Registry.ipynb | 659 ++---- snowflake/ml/requirements.bzl | 8 +- snowflake/ml/version.bzl | 2 +- .../integ/snowflake/ml/_internal/BUILD.bazel | 54 - .../ml/_internal/file_utils_integ_test.py | 42 +- .../ml/_internal/grid_search_integ_test.py | 142 -- .../ml/_internal/grid_search_pipeline_test.py | 117 - .../_internal/randomized_search_integ_test.py | 115 - .../ml/_internal/snowpark_handlers_test.py | 3 - .../snowflake/ml/extra_tests/BUILD.bazel | 14 + .../ml/extra_tests/fit_predict_test.py | 64 + .../grid_search_on_pipeline_test.py | 52 +- .../ml/extra_tests/grid_search_test.py | 9 +- .../pipeline_with_ohe_and_xgbr_test.py | 55 +- .../image_registry_client_integ_test.py | 14 +- .../ml/model/model_badcase_integ_test.py | 4 +- ...e_huggingface_pipeline_model_integ_test.py | 2 - .../model/warehouse_model_compat_v1_test.py | 8 +- .../ml/modeling/model_selection/BUILD.bazel | 49 +- .../model_selection/grid_search_integ_test.py | 257 +++ .../randomized_search_integ_test.py | 234 ++ .../search_single_node_test.py | 29 +- .../ml/modeling/pipeline/BUILD.bazel | 1 + .../ml/modeling/pipeline/pipeline_test.py | 89 +- .../modeling/preprocessing/BUILD_NATIVE.bzl | 1 + .../preprocessing/one_hot_encoder_test.py | 37 +- .../preprocessing/ordinal_encoder_test.py | 24 + .../ml/registry/model_registry_compat_test.py | 74 +- ...el_registry_snowservice_integ_test_base.py | 3 +- .../integ/snowflake/ml/test_data/BUILD.bazel | 3 + .../UCI_BANK_MARKETING_20COLUMNS.csv | 2001 +++++++++++++++++ .../ml/test_utils/common_test_base.py | 187 +- 183 files changed, 8267 insertions(+), 3100 deletions(-) delete mode 100644 bazel/filter_affected_targets.py create mode 100644 snowflake/cortex/BUILD.bazel create mode 100644 snowflake/cortex/__init__.py create mode 100644 snowflake/cortex/_complete.py create mode 100644 snowflake/cortex/_extract_answer.py create mode 100644 snowflake/cortex/_sentiment.py create mode 100644 snowflake/cortex/_summarize.py create mode 100644 snowflake/cortex/_test_util.py create mode 100644 snowflake/cortex/_translate.py create mode 100644 snowflake/cortex/_util.py create mode 100644 snowflake/cortex/complete_test.py create mode 100644 snowflake/cortex/extract_answer_test.py create mode 100644 snowflake/cortex/package_visibility_test.py create mode 100644 snowflake/cortex/sentiment_test.py create mode 100644 snowflake/cortex/summarize_test.py create mode 100644 snowflake/cortex/translate_test.py create mode 100644 snowflake/ml/_internal/cuda_utils.py create mode 100644 snowflake/ml/_internal/cuda_utils_test.py create mode 100644 snowflake/ml/_internal/utils/image_registry_http_client.py create mode 100644 snowflake/ml/_internal/utils/image_registry_http_client_test.py create mode 100644 snowflake/ml/_internal/utils/session_token_manager.py create mode 100755 snowflake/ml/feature_store/_internal/scripts/install-snowpark-ml-conda.sh delete mode 100644 snowflake/ml/model/_deploy_client/utils/image_auth_manager.py rename snowflake/ml/model/{_module_model => _model_composer}/BUILD.bazel (74%) rename snowflake/ml/model/{_module_model/module_model.py => _model_composer/model_composer.py} (82%) create mode 100644 snowflake/ml/model/_model_composer/model_composer_test.py create mode 100644 snowflake/ml/model/_model_composer/model_manifest/BUILD.bazel create mode 100644 snowflake/ml/model/_model_composer/model_manifest/model_manifest.py create mode 100644 snowflake/ml/model/_model_composer/model_manifest/model_manifest_schema.py create mode 100644 snowflake/ml/model/_model_composer/model_manifest/model_manifest_test.py create mode 100644 snowflake/ml/model/_model_composer/model_method/BUILD.bazel rename snowflake/ml/model/{_module_model/module_method/fixtures/handler_fixture_1.py_fixture => _model_composer/model_method/fixtures/function_fixture_1.py_fixture} (99%) rename snowflake/ml/model/{_module_model/module_method/fixtures/handler_fixture_2.py_fixture => _model_composer/model_method/fixtures/function_fixture_2.py_fixture} (99%) create mode 100644 snowflake/ml/model/_model_composer/model_method/function_generator.py rename snowflake/ml/model/{_module_model/module_method/handler_generator_test.py => _model_composer/model_method/function_generator_test.py} (65%) rename snowflake/ml/model/{_module_model/module_method/infer_handler.py_template => _model_composer/model_method/infer_function.py_template} (97%) create mode 100644 snowflake/ml/model/_model_composer/model_method/model_method.py create mode 100644 snowflake/ml/model/_model_composer/model_method/model_method_test.py create mode 100644 snowflake/ml/model/_model_composer/model_runtime/BUILD.bazel create mode 100644 snowflake/ml/model/_model_composer/model_runtime/model_runtime.py create mode 100644 snowflake/ml/model/_model_composer/model_runtime/model_runtime_test.py delete mode 100644 snowflake/ml/model/_module_model/module_manifest/BUILD.bazel delete mode 100644 snowflake/ml/model/_module_model/module_manifest/module_manifest.py delete mode 100644 snowflake/ml/model/_module_model/module_method/BUILD.bazel delete mode 100644 snowflake/ml/model/_module_model/module_method/handler_generator.py delete mode 100644 snowflake/ml/model/_module_model/module_method/module_method.py delete mode 100644 snowflake/ml/model/_module_model/module_model_test.py delete mode 100644 snowflake/ml/model/_module_model/module_runtime/BUILD.bazel delete mode 100644 snowflake/ml/model/_module_model/module_runtime/module_runtime.py rename snowflake/ml/modeling/model_selection/{_internal => }/__init__.py (100%) delete mode 100644 snowflake/ml/modeling/model_selection/_internal/BUILD.bazel rename snowflake/ml/modeling/model_selection/{_internal/_grid_search_cv.py => grid_search_cv.py} (97%) rename snowflake/ml/modeling/model_selection/{_internal/_randomized_search_cv.py => randomized_search_cv.py} (97%) create mode 100644 snowflake/ml/modeling/parameters/BUILD.bazel create mode 100644 snowflake/ml/modeling/parameters/disable_distributed_hpo.py create mode 100644 snowflake/ml/modeling/parameters/disable_distributed_hpo_test.py delete mode 100644 tests/integ/snowflake/ml/_internal/grid_search_integ_test.py delete mode 100644 tests/integ/snowflake/ml/_internal/grid_search_pipeline_test.py delete mode 100644 tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py create mode 100644 tests/integ/snowflake/ml/extra_tests/fit_predict_test.py create mode 100644 tests/integ/snowflake/ml/modeling/model_selection/grid_search_integ_test.py create mode 100644 tests/integ/snowflake/ml/modeling/model_selection/randomized_search_integ_test.py rename tests/integ/snowflake/ml/{_internal => modeling/model_selection}/search_single_node_test.py (82%) create mode 100644 tests/integ/snowflake/ml/test_data/BUILD.bazel create mode 100644 tests/integ/snowflake/ml/test_data/UCI_BANK_MARKETING_20COLUMNS.csv diff --git a/.flake8 b/.flake8 index afafbc62..9b8a270a 100644 --- a/.flake8 +++ b/.flake8 @@ -22,6 +22,9 @@ max_line_length=120 ; E731: Do not assign a lambda expression, use a def (E731) https://www.flake8rules.com/rules/E731.html ; F821: Undefined name name (F821) https://www.flake8rules.com/rules/F821.html ; W504: Line break occurred after a binary operator (W504) https://www.flake8rules.com/rules/W504.html +; T2xx: Use print https://github.com/jbkahn/flake8-print extend-ignore=E203 -exclude=build,setup,tool,.tox,connector_python3,parameters.py" +exclude=build,setup,tool,.tox,connector_python3,parameters.py +per-file-ignores = + tests/*: T2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4dc0e237..5e487a89 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,12 +45,13 @@ repos: - jupyter exclude: (?x)^(\.vscode\-bootstrap/.*\.json)$ - repo: https://github.com/pycqa/flake8 # config: .flake8 - rev: 3.9.2 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: - - flake8-bugbear == 20.11.1 + - flake8-bugbear == 23.9.16 - flake8-init-return == 1.0.0 + - flake8-print == 5.0.0 - repo: https://github.com/terrencepreilly/darglint rev: v1.7.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2632a62c..dc2febe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,29 @@ # Release History +## 1.1.0 + +### Bug Fixes + +- Model Registry: Fix panda dataframe input not handling first row properly. +- Model Development: OrdinalEncoder and LabelEncoder output_columns do not need to be valid snowflake identifiers. They + would previously be excluded if the normalized name did not match the name specified in output_columns. + +### Behavior Changes + +### New Features + +- Model Registry: Add support for invoking public endpoint on SPCS service, by providing a "enable_ingress" SPCS + deployment option. +- Model Development: Add support for distributed HPO - GridSearchCV and RandomizedSearchCV execution will be + distributed on multi-node warehouses. + ## 1.0.12 ### Bug Fixes - Model Registry: Fix regression issue that container logging is not shown during model deployment to SPCS. - Model Development: Enhance the column capacity of OrdinalEncoder. -- Model Registry: Fix unbound `batch_size`` error when deploying a model other than Hugging Face Pipeline +- Model Registry: Fix unbound `batch_size` error when deploying a model other than Hugging Face Pipeline and LLM with GPU on SPCS. ### Behavior Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 727b8eb1..2a4074d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -246,6 +246,8 @@ available in `conda` only. You can also set this along with `dev_version_pypi` i (At least one of these three fields should be set.) +`require_gpu`: Set this to true if the package is only a requirement for the environment with GPUs. + #### Snowflake Anaconda Channel `from_channel`: Set this if the package is not available in the Snowflake Anaconda Channel @@ -357,17 +359,15 @@ To test if your code is working in store procedure or not simply, you could work To write a such test, you need to +1. Your test cannot have a parameter called `_sproc_test_mode`. 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. 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 @@ -376,9 +376,11 @@ To test if your code is compatible with previous version simply, you could work To write a such test, you need to +1. Your test cannot have a parameter called `_snowml_pkg_ver`. 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. 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. @@ -393,11 +395,6 @@ function will be run as a store procedure in the environment with previous versi 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/bazel/environments/conda-env-snowflake.yml b/bazel/environments/conda-env-snowflake.yml index 3e644689..2f727aa3 100644 --- a/bazel/environments/conda-env-snowflake.yml +++ b/bazel/environments/conda-env-snowflake.yml @@ -43,7 +43,7 @@ dependencies: - sentencepiece==0.1.99 - shap==0.42.1 - snowflake-connector-python==3.2.0 - - snowflake-snowpark-python==1.6.1 + - snowflake-snowpark-python==1.8.0 - sphinx==5.0.2 - sqlparse==0.4.4 - tensorflow==2.10.0 diff --git a/bazel/environments/conda-env.yml b/bazel/environments/conda-env.yml index 30024989..9ac7c323 100644 --- a/bazel/environments/conda-env.yml +++ b/bazel/environments/conda-env.yml @@ -48,7 +48,7 @@ dependencies: - sentencepiece==0.1.99 - shap==0.42.1 - snowflake-connector-python==3.2.0 - - snowflake-snowpark-python==1.6.1 + - snowflake-snowpark-python==1.8.0 - sphinx==5.0.2 - sqlparse==0.4.4 - tensorflow==2.10.0 @@ -63,4 +63,3 @@ dependencies: - pip: - --extra-index-url https://pypi.org/simple - peft==0.5.0 - - vllm==0.2.1.post1 diff --git a/bazel/environments/conda-gpu-env.yml b/bazel/environments/conda-gpu-env.yml index 833979ff..6d213617 100755 --- a/bazel/environments/conda-gpu-env.yml +++ b/bazel/environments/conda-gpu-env.yml @@ -50,7 +50,7 @@ dependencies: - sentencepiece==0.1.99 - shap==0.42.1 - snowflake-connector-python==3.2.0 - - snowflake-snowpark-python==1.6.1 + - snowflake-snowpark-python==1.8.0 - sphinx==5.0.2 - sqlparse==0.4.4 - tensorflow==2.10.0 diff --git a/bazel/filter_affected_targets.py b/bazel/filter_affected_targets.py deleted file mode 100644 index 7449fd21..00000000 --- a/bazel/filter_affected_targets.py +++ /dev/null @@ -1,37 +0,0 @@ -"""A commandline tool to assemble a bazel query to filter affected targets generated by `bazel-diff`. - -The following targets will be filtered: - - source files - - //external/... -Note: Please only import built-in libraries and do not introduce dependencies on third-party libraries, -as this program is run in a github action without `bazel build` or any 3rd-party Python package available. -""" - -import argparse - -_AFFECTED_TARGETS_QUERY_PATTERN = """ -let raw_targets = set({raw_targets}) in - $raw_targets - kind('source file', $raw_targets) - filter('//external[:/].*', $raw_targets) -""" - -_AFFECTED_TESTS_QUERY_PATTERN = """ -let raw_targets = set({raw_targets}) in - kind('.*_test rule', $raw_targets) - filter('//external[:/].*', $raw_targets) -""" - - -def main(input_file: str, test_target_only: bool) -> None: - with open(input_file, encoding="utf-8") as f: - raw_targets = f.read() - pattern = _AFFECTED_TESTS_QUERY_PATTERN if test_target_only else _AFFECTED_TARGETS_QUERY_PATTERN - print(pattern.format(raw_targets=raw_targets)) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - usage="%(prog)s [OPTION] INPUT_FILE", description="Assemble a bazel query to filter affected targets" - ) - parser.add_argument("--test_targets_only", action="store_true", default=False) - parser.add_argument("INPUT_FILE", nargs=1) - args = parser.parse_args() - main(args.INPUT_FILE[0], args.test_targets_only) diff --git a/bazel/requirements/parse_and_generate_requirements.py b/bazel/requirements/parse_and_generate_requirements.py index 87a87dc6..a70a999c 100644 --- a/bazel/requirements/parse_and_generate_requirements.py +++ b/bazel/requirements/parse_and_generate_requirements.py @@ -49,6 +49,7 @@ class RequirementInfo(TypedDict, total=False): version_requirements: str version_requirements_pypi: str version_requirements_conda: str + require_gpu: bool requirements_extra_tags: Sequence[str] tags: Sequence[str] @@ -67,7 +68,7 @@ def filter_by_tag( tag_filter: tag to filter the requirement. Defaults to None. Returns: - True if tag_filter is None, or in the array of given field if presented. + True if tag_filter is None, or in the array of given field in presented. """ return tag_filter is None or tag_filter in req_info.get(field, []) @@ -100,6 +101,7 @@ def get_req_name(req_info: RequirementInfo, env: Literal["conda", "pip", "conda- req_info: requirement information. env: environment indicator, choose from conda and pip. + Raises: ValueError: Illegal env argument. @@ -123,7 +125,7 @@ def get_req_name(req_info: RequirementInfo, env: Literal["conda", "pip", "conda- def generate_dev_pinned_string( - req_info: RequirementInfo, env: Literal["conda", "pip", "conda-only", "pip-only"] + req_info: RequirementInfo, env: Literal["conda", "pip", "conda-only", "pip-only"], has_gpu: bool = False ) -> Optional[str]: """Get the pinned version for dev environment of the requirement in the given env. For each env, env specific pinned version will be chosen, if not presented, common pinned version will be chosen. @@ -131,6 +133,7 @@ def generate_dev_pinned_string( Args: req_info: requirement information. env: environment indicator, choose from conda and pip. + has_gpu: If the environment has GPU, present to filter require required GPU package. Raises: ValueError: Illegal env argument. @@ -143,6 +146,8 @@ def generate_dev_pinned_string( name = get_req_name(req_info, env) if name is None: return None + if not has_gpu and req_info.get("require_gpu", False): + return None if env.startswith("conda"): version = req_info.get("dev_version_conda", req_info.get("dev_version", None)) if version is None: @@ -348,7 +353,7 @@ def generate_requirements( filter( None, map( - lambda req_info: generate_dev_pinned_string(req_info, "conda"), + lambda req_info: generate_dev_pinned_string(req_info, "conda", has_gpu=(mode == "dev_gpu_version")), filter( lambda req_info: req_info.get("from_channel", SNOWFLAKE_CONDA_CHANNEL) == SNOWFLAKE_CONDA_CHANNEL, @@ -359,7 +364,15 @@ def generate_requirements( ) ) extended_env_conda = list( - sorted(filter(None, map(lambda req_info: generate_dev_pinned_string(req_info, "conda"), requirements))) + sorted( + filter( + None, + map( + lambda req_info: generate_dev_pinned_string(req_info, "conda", has_gpu=(mode == "dev_gpu_version")), + requirements, + ), + ) + ) ) extended_env: List[Union[str, MutableMapping[str, Sequence[str]]]] = copy.deepcopy( @@ -370,7 +383,13 @@ def generate_requirements( # 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( - 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", has_gpu=(mode == "dev_gpu_version")), + requirements, + ), + ) ) if pip_only_reqs: extended_env.extend(["pip", {"pip": pip_only_reqs}]) @@ -383,7 +402,15 @@ def generate_requirements( sorted( map( lambda s: s + "\n", - filter(None, map(lambda req_info: generate_dev_pinned_string(req_info, "pip"), requirements)), + filter( + None, + map( + lambda req_info: generate_dev_pinned_string( + req_info, "pip", has_gpu=(mode == "dev_gpu_version") + ), + requirements, + ), + ), ) ) ) diff --git a/bazel/requirements/requirements.schema.json b/bazel/requirements/requirements.schema.json index 83230871..0f97a156 100644 --- a/bazel/requirements/requirements.schema.json +++ b/bazel/requirements/requirements.schema.json @@ -64,6 +64,11 @@ "description": "The channel where the package come from, set if not from Snowflake Anaconda Channel.", "type": "string" }, + "gpu_only": { + "default": false, + "description": "The package is required when running in an environment where GPU is available.", + "type": "boolean" + }, "name": { "description": "The name of the required packages.", "type": "string" @@ -90,6 +95,9 @@ { "enum": [ "deployment_core", + "udf_inference", + "spcs_inference", + "model_packaging", "build_essential" ], "type": "string" diff --git a/ci/build_and_run_tests.sh b/ci/build_and_run_tests.sh index 78524baf..209b9e1a 100755 --- a/ci/build_and_run_tests.sh +++ b/ci/build_and_run_tests.sh @@ -37,6 +37,8 @@ BAZEL="bazel" ENV="pip" WITH_SNOWPARK=false MODE="continuous_run" +PYTHON_VERSION=3.8 +PYTHON_JENKINS_ENABLE="/opt/rh/rh-python38/enable" SNOWML_DIR="snowml" SNOWPARK_DIR="snowpark-python" IS_NT=false @@ -71,6 +73,10 @@ while (($#)); do shift JUNIT_REPORT_PATH=$1 ;; + --python-version) + shift + PYTHON_VERSION=$1 + ;; -h | --help) help 0 ;; @@ -81,6 +87,23 @@ while (($#)); do shift done +case ${PYTHON_VERSION} in + 3.8) + PYTHON_EXECUTABLE="python3.8" + PYTHON_JENKINS_ENABLE="/opt/rh/rh-python38/enable" + ;; + 3.9) + PYTHON_EXECUTABLE="python3.9" + PYTHON_JENKINS_ENABLE="/opt/rh/rh-python39/enable" + ;; + 3.10) + PYTHON_EXECUTABLE="python3.10" + PYTHON_JENKINS_ENABLE="/opt/rh/rh-python310/enable" + ;; +esac + +echo "Running build_and_run_tests with PYTHON_VERSION ${PYTHON_VERSION}" + EXT="" BAZEL_ADDITIONAL_BUILD_FLAGS=() BAZEL_ADDITIONAL_STARTUP_FLAGS=() @@ -116,16 +139,17 @@ case "${PLATFORM}_${ARCH}" in ;; esac -# Check Python3.8 exist -# TODO(SNOW-845592): ideally we should download py3.8 from conda if not exist. Currently we just fail. +# Verify that the requested python version exists +# TODO(SNOW-845592): ideally we should download python from conda if it's not present. Currently we just fail. if [ "${ENV}" = "pip" ]; then set +eu - source /opt/rh/rh-python38/enable - PYTHON38_EXIST=$? - if [ $PYTHON38_EXIST -ne 0 ]; then - echo "Failed to execute tests: Python3.8 is not installed." + # shellcheck source=/dev/null + source ${PYTHON_JENKINS_ENABLE} + PYTHON_EXIST=$? + if [ $PYTHON_EXIST -ne 0 ]; then + echo "Failed to execute tests: ${PYTHON_EXECUTABLE} is not installed." rm -rf "${TEMP_TEST_DIR}" - exit ${PYTHON38_EXIST} + exit ${PYTHON_EXIST} fi set -eu fi @@ -204,9 +228,9 @@ if [ "${ENV}" = "pip" ]; then if [ "${WITH_SNOWPARK}" = true ]; then pushd ${SNOWPARK_DIR} rm -rf venv - python3.8 -m venv venv + ${PYTHON_EXECUTABLE} -m venv venv source venv/bin/activate - python3.8 -m pip install -U pip setuptools wheel + ${PYTHON_EXECUTABLE} -m pip install -U pip setuptools wheel echo "Building snowpark wheel from main:$(git rev-parse HEAD)." pip wheel . --no-deps cp "$(find . -maxdepth 1 -iname 'snowflake_snowpark_python-*.whl')" "${WORKSPACE}" @@ -229,14 +253,14 @@ else # Build Snowpark if [ "${WITH_SNOWPARK}" = true ]; then pushd ${SNOWPARK_DIR} - conda build recipe/ --python=3.8 --numpy=1.16 --croot "${WORKSPACE}/conda-bld" + conda build recipe/ --python=${PYTHON_VERSION} --numpy=1.16 --croot "${WORKSPACE}/conda-bld" popd fi # Build SnowML pushd ${SNOWML_DIR} # Build conda package - conda build --prefix-length 50 --python=3.8 --croot "${WORKSPACE}/conda-bld" ci/conda_recipe + conda build --prefix-length 50 --python=${PYTHON_VERSION} --croot "${WORKSPACE}/conda-bld" ci/conda_recipe conda build purge popd fi @@ -248,7 +272,9 @@ pushd "${TEMP_TEST_DIR}" COMMON_PYTEST_FLAG=() COMMON_PYTEST_FLAG+=(--strict-markers) # Strict the pytest markers to avoid typo in markers COMMON_PYTEST_FLAG+=(--import-mode=append) +COMMON_PYTEST_FLAG+=(--log-cli-level=INFO) COMMON_PYTEST_FLAG+=(-n logical) + if [[ -n "${JUNIT_REPORT_PATH}" ]]; then COMMON_PYTEST_FLAG+=(--junitxml "${JUNIT_REPORT_PATH}") fi @@ -258,22 +284,22 @@ if [ "${ENV}" = "pip" ]; then cp "${WORKSPACE}/snowflake_ml_python-${VERSION}-py3-none-any.whl" "${TEMP_TEST_DIR}" # Create testing env - python3.8 -m venv testenv + ${PYTHON_EXECUTABLE} -m venv testenv source testenv/bin/activate # Install all of the packages in single line, # otherwise it will fail in dependency resolution. - python3.8 -m pip install --upgrade pip - python3.8 -m pip list - python3.8 -m pip install "snowflake_ml_python-${VERSION}-py3-none-any.whl[all]" "pytest-xdist[psutil]==2.5.0" -r "${WORKSPACE}/${SNOWML_DIR}/requirements.txt" --no-cache-dir --force-reinstall + ${PYTHON_EXECUTABLE} -m pip install --upgrade pip + ${PYTHON_EXECUTABLE} -m pip list + ${PYTHON_EXECUTABLE} -m pip install "snowflake_ml_python-${VERSION}-py3-none-any.whl[all]" "pytest-xdist[psutil]==2.5.0" -r "${WORKSPACE}/${SNOWML_DIR}/requirements.txt" --no-cache-dir --force-reinstall if [ "${WITH_SNOWPARK}" = true ]; then cp "$(find "${WORKSPACE}" -maxdepth 1 -iname 'snowflake_snowpark_python-*.whl')" "${TEMP_TEST_DIR}" - python3.8 -m pip install "$(find . -maxdepth 1 -iname 'snowflake_snowpark_python-*.whl')" --no-deps --force-reinstall + ${PYTHON_EXECUTABLE} -m pip install "$(find . -maxdepth 1 -iname 'snowflake_snowpark_python-*.whl')" --no-deps --force-reinstall fi - python3.8 -m pip list + ${PYTHON_EXECUTABLE} -m pip list # Run the tests set +e - TEST_SRCDIR="${TEMP_TEST_DIR}" python3.8 -m pytest "${COMMON_PYTEST_FLAG[@]}" -m "not pip_incompatible" tests/integ/ + TEST_SRCDIR="${TEMP_TEST_DIR}" ${PYTHON_EXECUTABLE} -m pytest "${COMMON_PYTEST_FLAG[@]}" -m "not pip_incompatible" tests/integ/ TEST_RETCODE=$? set -e else @@ -284,7 +310,7 @@ else conda clean --all --force-pkgs-dirs -y # Create testing env - conda create -y -p testenv -c "${WORKSPACE}/conda-bld" -c "https://repo.anaconda.com/pkgs/snowflake/" --override-channels "python=3.8" snowflake-ml-python "py==1.9.0" "pytest-xdist==2.5.0" psutil inflection "${OPTIONAL_REQUIREMENTS[@]}" + conda create -y -p testenv -c "${WORKSPACE}/conda-bld" -c "https://repo.anaconda.com/pkgs/snowflake/" --override-channels "python=${PYTHON_VERSION}" snowflake-ml-python "py==1.9.0" "pytest-xdist==2.5.0" psutil inflection "${OPTIONAL_REQUIREMENTS[@]}" conda list -p testenv # Run integration tests diff --git a/ci/conda_recipe/meta.yaml b/ci/conda_recipe/meta.yaml index 8ab59e33..cc5c1966 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.12 + version: 1.1.0 requirements: build: - python @@ -40,7 +40,7 @@ requirements: - scikit-learn>=1.2.1,<1.4 - scipy>=1.9,<2 - snowflake-connector-python>=3.0.4,<4 - - snowflake-snowpark-python>=1.5.1,<2 + - snowflake-snowpark-python>=1.8.0,<2 - sqlparse>=0.4,<1 - typing-extensions>=4.1.0,<5 - xgboost>=1.7.3,<2 diff --git a/ci/get_excluded_tests.sh b/ci/get_excluded_tests.sh index 64c74e1b..0b66c7a5 100755 --- a/ci/get_excluded_tests.sh +++ b/ci/get_excluded_tests.sh @@ -63,14 +63,14 @@ trap 'rm -rf "${working_dir}"' EXIT if [[ $mode = "unused" || $mode = "all" ]]; then # Compute missing dependencies by subtracting deps included in wheel from deps required by tests. - # We only care about dependencies in //snowflake/ml since that's our dev directory. + # We only care about dependencies in //snowflake since that's our dev directory. # Reverse search on testing files depending on missing deps and exclude those. unused_test_rule_file=${working_dir}/unused_test_rule # -- Begin of Query Rules Heredoc -- cat >"${unused_test_rule_file}" < bool: """ return WrapperGeneratorFactory._is_class_of_type(class_object[1], "ClassifierMixin") + @staticmethod + def _is_cluster_obj(class_object: Tuple[str, type]) -> bool: + """Check if the given estimator object can cluster features and conduct fit_predict methods. + + Args: + class_object: Meta class object which needs to be checked. + + Returns: + True if the class inherits from ClusterMixin, otherwise False. + """ + return WrapperGeneratorFactory._is_class_of_type(class_object[1], "ClusterMixin") + @staticmethod def _is_meta_estimator_obj(class_object: Tuple[str, type]) -> bool: """Check if the given estimator object requires an `estimator` parameter. @@ -515,6 +527,7 @@ def __init__(self, module_name: str, class_object: Tuple[str, type]) -> None: self.transform_docstring = "" self.original_predict_docstring = "" self.predict_docstring = "" + self.fit_predict_docstring = "" self.predict_proba_docstring = "" self.score_docstring = "" self.predict_log_proba_docstring = "" @@ -536,6 +549,9 @@ def __init__(self, module_name: str, class_object: Tuple[str, type]) -> None: self.test_estimator_imports = "" self.test_estimator_imports_list: List[str] = [] + # Optional function support + self.fit_predict_cluster_function_support = False + # Dependencies self.predict_udf_deps = "" self.fit_sproc_deps = "" @@ -582,6 +598,7 @@ def _populate_flags(self) -> None: 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_cluster = WrapperGeneratorFactory._is_cluster_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) self._is_chain_multioutput = WrapperGeneratorFactory._is_chain_multioutput_obj(self.class_object) @@ -644,6 +661,7 @@ def _populate_class_doc_fields(self) -> None: def _populate_function_doc_fields(self) -> None: _METHODS = [ "fit", + "fit_predict", "predict", "predict_log_proba", "predict_proba", @@ -678,6 +696,7 @@ def _populate_function_doc_fields(self) -> None: self.fit_docstring = self.estimator_function_docstring["fit"] self.transform_docstring = self.estimator_function_docstring["transform"] self.predict_docstring = self.estimator_function_docstring["predict"] + self.fit_predict_docstring = self.estimator_function_docstring["fit_predict"] self.predict_proba_docstring = self.estimator_function_docstring["predict_proba"] self.predict_log_proba_docstring = self.estimator_function_docstring["predict_log_proba"] self.decision_function_docstring = self.estimator_function_docstring["decision_function"] @@ -885,6 +904,9 @@ def generate(self) -> "SklearnWrapperGenerator": ] self.test_estimator_input_args_list.append(f"dictionary={dictionary}") + if self._is_cluster: + self.fit_predict_cluster_function_support = True + if WrapperGeneratorFactory._is_class_of_type(self.class_object[1], "SelectKBest"): # Set the k of SelectKBest features transformer to half the number of columns in the dataset. self.test_estimator_input_args_list.append("k=int(len(cols)/2)") diff --git a/codegen/sklearn_wrapper_template.py_template b/codegen/sklearn_wrapper_template.py_template index 280d7cfe..82966cdb 100644 --- a/codegen/sklearn_wrapper_template.py_template +++ b/codegen/sklearn_wrapper_template.py_template @@ -8,6 +8,7 @@ from uuid import uuid4 import cloudpickle as cp import pandas as pd import numpy as np +from numpy import typing as npt {transform.estimator_imports} from sklearn.utils.metaestimators import available_if @@ -33,6 +34,7 @@ from snowflake.ml.model.model_signature import ( FeatureSpec, ModelSignature, _infer_signature, + _rename_signature_with_snowflake_identifiers, BaseFeatureSpec, ) from snowflake.ml.model._signatures import utils as model_signature_utils @@ -453,6 +455,20 @@ class {transform.original_class_name}(BaseTransformer): ) return output_df + + @available_if(original_estimator_has_callable("fit_predict")) # type: ignore[misc] + def fit_predict(self, dataset: Union[DataFrame, pd.DataFrame]) -> npt.NDArray[Any]: + """ {transform.fit_predict_docstring} + Returns: + Predicted dataset. + """ + if {transform.fit_predict_cluster_function_support}: + self.fit(dataset) + assert self._sklearn_object is not None + return self._sklearn_object.labels_ + else: + # TODO(xinyi): support fit_predict for mixture classes + raise NotImplementedError 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. @@ -460,29 +476,35 @@ class {transform.original_class_name}(BaseTransformer): """ 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] + output_cols = [ + identifier.concat_names([output_cols_prefix, identifier.resolve_identifier(c)]) + for c in output_cols + ] + elif getattr(self._sklearn_object, "classes_", None) is None: + output_cols = [output_cols_prefix] + elif self._sklearn_object is not None: + classes = self._sklearn_object.classes_ + if isinstance(classes, numpy.ndarray): + output_cols = [f'{{output_cols_prefix}}{{str(c)}}' for c in classes.tolist()] + elif isinstance(classes, list) and len(classes) > 0 and isinstance(classes[0], numpy.ndarray): + # If the estimator is a multioutput estimator, classes_ will be a list of ndarrays. + output_cols = [] + for i, cl in enumerate(classes): + # 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]}}') + else: + output_cols.extend([ + f'{{output_cols_prefix}}{{i}}_{{c}}' for c in cl.tolist() + ]) + else: + output_cols = [] - if getattr(self._sklearn_object, "classes_", None) is None: - return [output_cols_prefix] + # Make sure column names are valid snowflake identifiers. + rv = [identifier.rename_to_valid_snowflake_identifier(c) for c in output_cols] - assert self._sklearn_object is not None # keep mypy happy - classes = self._sklearn_object.classes_ - if isinstance(classes, numpy.ndarray): - return [f'{{output_cols_prefix}}{{c}}' for c in classes.tolist()] - elif isinstance(classes, list) and len(classes) > 0 and isinstance(classes[0], numpy.ndarray): - # If the estimator is a multioutput estimator, classes_ will be a list of ndarrays. - output_cols = [] - for i, cl in enumerate(classes): - # 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]}}') - else: - output_cols.extend([ - f'{{output_cols_prefix}}{{i}}_{{c}}' for c in cl.tolist() - ]) - return output_cols - return [] + return rv @available_if(original_estimator_has_callable("predict_proba")) # type: ignore[misc] @telemetry.send_api_usage_telemetry( @@ -709,7 +731,7 @@ class {transform.original_class_name}(BaseTransformer): # For classifier, the type of predict is the same as the type of label if self._sklearn_object._estimator_type == 'classifier': # label columns is the desired type for output - outputs = _infer_signature(dataset[self.label_cols], "output") + outputs = _infer_signature(dataset[self.label_cols], "output", use_snowflake_identifiers=True) # rename the output columns outputs = model_signature_utils.rename_features(outputs, self.output_cols) self._model_signature_dict["predict"] = ModelSignature(inputs, @@ -730,6 +752,12 @@ class {transform.original_class_name}(BaseTransformer): ([] if self._drop_input_cols else inputs) + outputs) + # Output signature names may still need to be renamed, since they were not created with `_infer_signature`. + items = list(self._model_signature_dict.items()) + for method, signature in items: + signature._outputs = _rename_signature_with_snowflake_identifiers(signature._outputs) + self._model_signature_dict[method] = signature + @property def model_signatures(self) -> Dict[str, ModelSignature]: """Returns model signature of current class. diff --git a/codegen/transformer_autogen_test_template.py_template b/codegen/transformer_autogen_test_template.py_template index 9025ff57..7a0592e8 100644 --- a/codegen/transformer_autogen_test_template.py_template +++ b/codegen/transformer_autogen_test_template.py_template @@ -28,8 +28,8 @@ class {transform.test_class_name}(TestCase): Args: sklearn_obj: SKLearn object under tests. If the sklearn_obj supports multioutput, then this method will - add extra lable columns to test multioutput functionality. - add_sample_weight_col: If true and addiptional column named "SAMPLE_WEIGHT" will be added to the dataset + add extra label columns to test multioutput functionality. + add_sample_weight_col: If true and additional column named "SAMPLE_WEIGHT" will be added to the dataset representing the weight of each sample. Returns: @@ -140,10 +140,10 @@ class {transform.test_class_name}(TestCase): # transform() method of HeterogeneousEnsemble estimators return responses of varying # shapes from (n_samples, n_estimators) to (n_samples, n_estimators * n_classes) # based on init param values. We will convert that to pandas dataframe of shape (n_samples, 1) with - # each row containing a list of values in the transfrom() UDF. + # each row containing a list of values in the transform() UDF. # # We need to flatten the response from (n_samples, 1) to original - # dimentions (n_samples, n_original_columns) by flattening list objects. + # dimensions (n_samples, n_original_columns) by flattening list objects. output_df_pandas = output_df_pandas.apply(lambda row: pd.Series(json.loads(row[0])), axis=1) # TODO(snandamuri): Implement type inference for transform and predict methods to return results with @@ -162,13 +162,13 @@ class {transform.test_class_name}(TestCase): sklearn_numpy_arr = getattr(sklearn_reg, m)(input_df_pandas[input_cols]) if len(sklearn_numpy_arr.shape) == 3: - # VotingClassifier will retunr results of shape (n_classifiers, n_samples, n_classes) + # 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. We need flatten sklearn results # also to compare with snowflake results. sklearn_numpy_arr = np.hstack(sklearn_numpy_arr) elif len(sklearn_numpy_arr.shape) == 1: - # Some times sklearn retuns results as 1D array of shape (n_samples,), but snowfkale always retunrs + # Some times sklearn returns results as 1D array of shape (n_samples,), but snowflake always returns # response as 2D array of shape (n_samples, 1). Flatten the snowflake response to compare results. actual_arr = actual_arr.flatten() @@ -218,7 +218,7 @@ class {transform.test_class_name}(TestCase): # 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 + # Some times sklearn returns results as 1D array of shape (n_samples,), but snowflake always returns # response as 2D array of shape (n_samples, 1). Flatten the snowflake response to compare results. actual_inference_result = actual_inference_result.flatten() diff --git a/docs/source/index.rst b/docs/source/index.rst index 61d86243..18365333 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,6 +16,9 @@ Portions of the Snowpark ML API Reference are derived from | **xgboost** Copyright © 2019 by xgboost contributors. | **lightgbm** Copyright © Microsoft Corporation. +Table of Contents +================= + .. toctree:: :maxdepth: 3 diff --git a/mypy.ini b/mypy.ini index a634503b..095dae02 100644 --- a/mypy.ini +++ b/mypy.ini @@ -45,5 +45,5 @@ exclude = (?x)( (^.*\/experimental\/.*)|(^bazel-.*) # ignore everything in the `/experimental/` directory ) -[mypy-snowflake.ml.*] +[mypy-snowflake.*] disallow_untyped_defs = True diff --git a/requirements.txt b/requirements.txt index 29d37aac..05a963a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,19 +38,18 @@ scipy==1.9.3 sentencepiece==0.1.99 shap==0.42.1 snowflake-connector-python[pandas]==3.2.0 -snowflake-snowpark-python==1.6.1 +snowflake-snowpark-python==1.8.0 sphinx==5.0.2 sqlparse==0.4.4 starlette==0.27.0 tensorflow==2.13.1 -tokenizers==0.14.1 +tokenizers==0.13.2 torch==2.0.1 torchdata==0.6.1 -transformers==4.35.0 +transformers==4.32.1 types-PyYAML==6.0.12 types-cachetools==4.2.2 types-protobuf==4.23.0.1 types-requests==2.30.0.0 typing-extensions==4.5.0 -vllm==0.2.1.post1 xgboost==1.7.3 diff --git a/requirements.yml b/requirements.yml index e2d9f390..48a658b7 100644 --- a/requirements.yml +++ b/requirements.yml @@ -30,6 +30,8 @@ # (At least one of these three fields should be set.) +# `require_gpu`: Set this to true if the package is only a requirement for the environment with GPUs. + # # Snowflake Anaconda Channel # `from_channel`: Set this if the package is not available in the Snowflake Anaconda Channel @@ -60,7 +62,11 @@ # `tags`: Set tags to filter some of the requirements in specific cases. The current valid tags include: # - `deployment_core`: Used by model deployment to indicate dependencies required to execute model deployment code -# on the server-side. +# on the server-side. (Obsolete) +# - `model_packaging`: Used by model packaging and deployment to indicate the core requirements to save and load the +# model. +# - `udf_inference`: Used by model packaging and deployment to indicate the requirements to run inference in UDF. +# - `spcs_inference`: Used by model packaging and deployment to indicate the requirements to run inference in SPCS. # - `build_essential`: Used to indicate the packages composing the build environment. - name: absl-py @@ -69,6 +75,8 @@ tags: - build_essential - deployment_core + - udf_inference + - spcs_inference - name: accelerate dev_version: 0.22.0 from_channel: conda-forge @@ -81,6 +89,8 @@ version_requirements: '>=3.5.0,<4' tags: - deployment_core + - udf_inference + - spcs_inference - name: boto3 dev_version: 1.24.28 - name_conda: conda-libmamba-solver @@ -92,6 +102,7 @@ version_requirements: '>=2.0.0' tags: - deployment_core + - model_packaging - name: cryptography dev_version: 39.0.1 # Skipping version requirements as it should come as part of connector. @@ -151,17 +162,23 @@ tags: - deployment_core - build_essential + - udf_inference + - spcs_inference - name: packaging dev_version: '23.0' version_requirements: '>=20.9,<24' tags: - deployment_core - build_essential + - udf_inference + - spcs_inference - name: pandas dev_version: 1.5.3 version_requirements: '>=1.0.0,<2' tags: - deployment_core + - udf_inference + - spcs_inference - name: protobuf dev_version: 3.20.3 - name: pytest @@ -174,6 +191,8 @@ version_requirements: '>=6.0,<7' tags: - deployment_core + - udf_inference + - spcs_inference # For fsspec[http] in conda - name_conda: requests dev_version_conda: 2.29.0 @@ -203,10 +222,12 @@ dev_version: 3.2.0 version_requirements: '>=3.0.4,<4' - name: snowflake-snowpark-python - dev_version: 1.6.1 - version_requirements: '>=1.5.1,<2' + dev_version: 1.8.0 + version_requirements: '>=1.8.0,<2' tags: - deployment_core + - udf_inference + - spcs_inference - name: sphinx dev_version: 5.0.2 tags: @@ -214,6 +235,8 @@ - name: starlette dev_version: 0.27.0 from_channel: conda-forge + tags: + - spcs_inference - name: sqlparse dev_version: 0.4.4 version_requirements: '>=0.4,<1' @@ -224,8 +247,7 @@ requirements_extra_tags: - tensorflow - name: tokenizers - dev_version_conda: 0.13.2 - dev_version_pypi: 0.14.1 + dev_version: 0.13.2 version_requirements: '>=0.10,<1' requirements_extra_tags: - transformers @@ -235,8 +257,7 @@ requirements_extra_tags: - torch - name: transformers - dev_version_conda: 4.32.1 - dev_version_pypi: 4.35.0 + dev_version: 4.32.1 version_requirements: '>=4.32.1,<5' requirements_extra_tags: - transformers @@ -252,6 +273,8 @@ version_requirements: '>=4.1.0,<5' tags: - deployment_core + - udf_inference + - spcs_inference - name: xgboost dev_version: 1.7.3 version_requirements: '>=1.7.3,<2' @@ -277,6 +300,4 @@ - llm - name_pypi: vllm dev_version_pypi: 0.2.1.post1 - version_requirements_pypi: '>=0.2.1.post1,<1' - requirements_extra_tags: - - llm + require_gpu: true diff --git a/snowflake/cortex/BUILD.bazel b/snowflake/cortex/BUILD.bazel new file mode 100644 index 00000000..da7c8ef7 --- /dev/null +++ b/snowflake/cortex/BUILD.bazel @@ -0,0 +1,147 @@ +load("//bazel:py_rules.bzl", "py_library", "py_package", "py_test") + +package_group( + name = "cortex", + packages = [ + "//docs/...", + "//snowflake/cortex/...", + "//snowflake/ml/...", + ], +) + +package(default_visibility = [":cortex"]) + +py_library( + name = "util", + srcs = ["_util.py"], +) + +py_library( + name = "test_util", + srcs = ["_test_util.py"], +) + +py_library( + name = "complete", + srcs = ["_complete.py"], + deps = [ + ":util", + "//snowflake/ml/_internal:telemetry", + ], +) + +py_test( + name = "complete_test", + srcs = ["complete_test.py"], + deps = [ + ":complete", + ":test_util", + "//snowflake/ml/utils:connection_params", + ], +) + +py_library( + name = "extract_answer", + srcs = ["_extract_answer.py"], + deps = [ + ":util", + "//snowflake/ml/_internal:telemetry", + ], +) + +py_test( + name = "extract_answer_test", + srcs = ["extract_answer_test.py"], + deps = [ + ":extract_answer", + ":test_util", + "//snowflake/ml/utils:connection_params", + ], +) + +py_library( + name = "sentiment", + srcs = ["_sentiment.py"], + deps = [ + ":util", + "//snowflake/ml/_internal:telemetry", + ], +) + +py_test( + name = "sentiment_test", + srcs = ["sentiment_test.py"], + deps = [ + ":sentiment", + ":test_util", + "//snowflake/ml/utils:connection_params", + ], +) + +py_library( + name = "summarize", + srcs = ["_summarize.py"], + deps = [ + ":util", + "//snowflake/ml/_internal:telemetry", + ], +) + +py_test( + name = "summarize_test", + srcs = ["summarize_test.py"], + deps = [ + ":summarize", + ":test_util", + "//snowflake/ml/utils:connection_params", + ], +) + +py_library( + name = "translate", + srcs = ["_translate.py"], + deps = [ + ":util", + "//snowflake/ml/_internal:telemetry", + ], +) + +py_test( + name = "translate_test", + srcs = ["translate_test.py"], + deps = [ + ":test_util", + ":translate", + "//snowflake/ml/utils:connection_params", + ], +) + +py_library( + name = "init", + srcs = [ + "__init__.py", + ], + deps = [ + ":complete", + ":extract_answer", + ":sentiment", + ":summarize", + ":translate", + ], +) + +py_test( + name = "package_visibility_test", + srcs = ["package_visibility_test.py"], + deps = [ + ":init", + ], +) + +py_package( + name = "cortex_pkg", + packages = ["snowflake.cortex"], + deps = [ + ":init", + ], +) diff --git a/snowflake/cortex/__init__.py b/snowflake/cortex/__init__.py new file mode 100644 index 00000000..57e4c0cb --- /dev/null +++ b/snowflake/cortex/__init__.py @@ -0,0 +1,13 @@ +from snowflake.cortex._complete import Complete +from snowflake.cortex._extract_answer import ExtractAnswer +from snowflake.cortex._sentiment import Sentiment +from snowflake.cortex._summarize import Summarize +from snowflake.cortex._translate import Translate + +__all__ = [ + "Complete", + "ExtractAnswer", + "Sentiment", + "Summarize", + "Translate", +] diff --git a/snowflake/cortex/_complete.py b/snowflake/cortex/_complete.py new file mode 100644 index 00000000..4dd01e92 --- /dev/null +++ b/snowflake/cortex/_complete.py @@ -0,0 +1,35 @@ +from typing import Optional, Union + +from snowflake import snowpark +from snowflake.cortex._util import CORTEX_FUNCTIONS_TELEMETRY_PROJECT, call_sql_function +from snowflake.ml._internal import telemetry + + +@snowpark._internal.utils.experimental(version="1.0.12") +@telemetry.send_api_usage_telemetry( + project=CORTEX_FUNCTIONS_TELEMETRY_PROJECT, +) +def Complete( + model: Union[str, snowpark.Column], prompt: Union[str, snowpark.Column], session: Optional[snowpark.Session] = None +) -> Union[str, snowpark.Column]: + """Complete calls into the LLM inference service to perform completion. + + Args: + model: A Column of strings representing model types. + prompt: A Column of prompts to send to the LLM. + session: The snowpark session to use. Will be inferred by context if not specified. + + Returns: + A column of string responses. + """ + + return _complete_impl("snowflake.ml.complete", model, prompt, session=session) + + +def _complete_impl( + function: str, + model: Union[str, snowpark.Column], + prompt: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + return call_sql_function(function, session, model, prompt) diff --git a/snowflake/cortex/_extract_answer.py b/snowflake/cortex/_extract_answer.py new file mode 100644 index 00000000..80e6e8ea --- /dev/null +++ b/snowflake/cortex/_extract_answer.py @@ -0,0 +1,37 @@ +from typing import Optional, Union + +from snowflake import snowpark +from snowflake.cortex._util import CORTEX_FUNCTIONS_TELEMETRY_PROJECT, call_sql_function +from snowflake.ml._internal import telemetry + + +@snowpark._internal.utils.experimental(version="1.0.12") +@telemetry.send_api_usage_telemetry( + project=CORTEX_FUNCTIONS_TELEMETRY_PROJECT, +) +def ExtractAnswer( + from_text: Union[str, snowpark.Column], + question: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + """ExtractAnswer calls into the LLM inference service to extract an answer from within specified text. + + Args: + from_text: A Column of strings representing input text. + question: A Column of strings representing a question to ask against from_text. + session: The snowpark session to use. Will be inferred by context if not specified. + + Returns: + A column of strings containing answers. + """ + + return _extract_answer_impl("snowflake.ml.extract_answer", from_text, question, session=session) + + +def _extract_answer_impl( + function: str, + from_text: Union[str, snowpark.Column], + question: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + return call_sql_function(function, session, from_text, question) diff --git a/snowflake/cortex/_sentiment.py b/snowflake/cortex/_sentiment.py new file mode 100644 index 00000000..8843f6fc --- /dev/null +++ b/snowflake/cortex/_sentiment.py @@ -0,0 +1,31 @@ +from typing import Optional, Union + +from snowflake import snowpark +from snowflake.cortex._util import CORTEX_FUNCTIONS_TELEMETRY_PROJECT, call_sql_function +from snowflake.ml._internal import telemetry + + +@snowpark._internal.utils.experimental(version="1.0.12") +@telemetry.send_api_usage_telemetry( + project=CORTEX_FUNCTIONS_TELEMETRY_PROJECT, +) +def Sentiment( + text: Union[str, snowpark.Column], session: Optional[snowpark.Session] = None +) -> Union[str, snowpark.Column]: + """Sentiment calls into the LLM inference service to perform sentiment analysis on the input text. + + Args: + text: A Column of text strings to send to the LLM. + session: The snowpark session to use. Will be inferred by context if not specified. + + Returns: + A column of floats. 1 represents positive sentiment, -1 represents negative sentiment. + """ + + return _sentiment_impl("snowflake.ml.sentiment", text, session=session) + + +def _sentiment_impl( + function: str, text: Union[str, snowpark.Column], session: Optional[snowpark.Session] = None +) -> Union[str, snowpark.Column]: + return call_sql_function(function, session, text) diff --git a/snowflake/cortex/_summarize.py b/snowflake/cortex/_summarize.py new file mode 100644 index 00000000..54c47050 --- /dev/null +++ b/snowflake/cortex/_summarize.py @@ -0,0 +1,34 @@ +from typing import Optional, Union + +from snowflake import snowpark +from snowflake.cortex._util import CORTEX_FUNCTIONS_TELEMETRY_PROJECT, call_sql_function +from snowflake.ml._internal import telemetry + + +@snowpark._internal.utils.experimental(version="1.0.12") +@telemetry.send_api_usage_telemetry( + project=CORTEX_FUNCTIONS_TELEMETRY_PROJECT, +) +def Summarize( + text: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + """Summarize calls into the LLM inference service to summarize the input text. + + Args: + text: A Column of strings to summarize. + session: The snowpark session to use. Will be inferred by context if not specified. + + Returns: + A column of string summaries. + """ + + return _summarize_impl("snowflake.ml.summarize", text, session=session) + + +def _summarize_impl( + function: str, + text: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + return call_sql_function(function, session, text) diff --git a/snowflake/cortex/_test_util.py b/snowflake/cortex/_test_util.py new file mode 100644 index 00000000..70b11440 --- /dev/null +++ b/snowflake/cortex/_test_util.py @@ -0,0 +1,18 @@ +import signal +from typing import Any, cast + +from snowflake import snowpark +from snowflake.ml.utils import connection_params + + +def create_test_session() -> snowpark.Session: + """Creates a Snowflake session under a timeout.""" + + def handle_timeout(_signum: Any, _frame: Any) -> None: + raise Exception("Timed out creating snowflake session. VPN connection may be required.") + + signal.signal(signal.SIGALRM, handle_timeout) + signal.alarm(30) # 30s timeout. + session = snowpark.Session.builder.configs(connection_params.SnowflakeLoginOptions()).create() + signal.alarm(0) + return cast(snowpark.Session, session) diff --git a/snowflake/cortex/_translate.py b/snowflake/cortex/_translate.py new file mode 100644 index 00000000..2c18671c --- /dev/null +++ b/snowflake/cortex/_translate.py @@ -0,0 +1,40 @@ +from typing import Optional, Union + +from snowflake import snowpark +from snowflake.cortex._util import CORTEX_FUNCTIONS_TELEMETRY_PROJECT, call_sql_function +from snowflake.ml._internal import telemetry + + +@snowpark._internal.utils.experimental(version="1.0.12") +@telemetry.send_api_usage_telemetry( + project=CORTEX_FUNCTIONS_TELEMETRY_PROJECT, +) +def Translate( + text: Union[str, snowpark.Column], + from_language: Union[str, snowpark.Column], + to_language: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + """Translate calls into the LLM inference service to perform translation. + + Args: + text: A Column of strings to translate. + from_language: A Column of input languages. + to_language: A Column of output languages. + session: The snowpark session to use. Will be inferred by context if not specified. + + Returns: + A column of string translations. + """ + + return _translate_impl("snowflake.ml.translate", text, from_language, to_language, session=session) + + +def _translate_impl( + function: str, + text: Union[str, snowpark.Column], + from_language: Union[str, snowpark.Column], + to_language: Union[str, snowpark.Column], + session: Optional[snowpark.Session] = None, +) -> Union[str, snowpark.Column]: + return call_sql_function(function, session, text, from_language, to_language) diff --git a/snowflake/cortex/_util.py b/snowflake/cortex/_util.py new file mode 100644 index 00000000..7878e51e --- /dev/null +++ b/snowflake/cortex/_util.py @@ -0,0 +1,42 @@ +from typing import Optional, Union, cast + +from snowflake import snowpark +from snowflake.snowpark import context, functions + +CORTEX_FUNCTIONS_TELEMETRY_PROJECT = "CortexFunctions" + + +# Calls a sql function, handling both immediate (e.g. python types) and batch +# (e.g. snowpark column and literal type modes). +def call_sql_function( + function: str, session: Optional[snowpark.Session], *args: Union[str, snowpark.Column] +) -> Union[str, snowpark.Column]: + handle_as_column = False + for arg in args: + if isinstance(arg, snowpark.Column): + handle_as_column = True + + if handle_as_column: + return cast(Union[str, snowpark.Column], call_sql_function_column(function, *args)) + return cast(Union[str, snowpark.Column], call_sql_function_immediate(function, session, *args)) + + +def call_sql_function_column(function: str, *args: Union[str, snowpark.Column]) -> snowpark.Column: + return cast(snowpark.Column, functions.builtin(function)(*args)) + + +def call_sql_function_immediate( + function: str, session: Optional[snowpark.Session], *args: Union[str, snowpark.Column] +) -> str: + if session is None: + session = context.get_active_session() + if session is None: + raise Exception("No session available in the current context nor specified as an argument.") + + lit_args = [] + for arg in args: + lit_args.append(functions.lit(arg)) + + empty_df = session.create_dataframe([snowpark.Row()]) + df = empty_df.select(functions.builtin(function)(*lit_args)) + return cast(str, df.collect()[0][0]) diff --git a/snowflake/cortex/complete_test.py b/snowflake/cortex/complete_test.py new file mode 100644 index 00000000..4bf21d7a --- /dev/null +++ b/snowflake/cortex/complete_test.py @@ -0,0 +1,43 @@ +import _test_util +from absl.testing import absltest + +from snowflake import snowpark +from snowflake.cortex import _complete +from snowflake.snowpark import functions, types + + +class CompleteTest(absltest.TestCase): + model = "|model|" + prompt = "|prompt|" + + @staticmethod + def complete_for_test(model: str, prompt: str) -> str: + return f"answered: {model}, {prompt}" + + def setUp(self) -> None: + self._session = _test_util.create_test_session() + functions.udf( + self.complete_for_test, + name="complete", + return_type=types.StringType(), + input_types=[types.StringType(), types.StringType()], + is_permanent=False, + ) + + def tearDown(self) -> None: + self._session.sql("drop function complete(string,string)").collect() + self._session.close() + + def test_complete_str(self) -> None: + res = _complete._complete_impl("complete", self.model, self.prompt) + self.assertEqual(self.complete_for_test(self.model, self.prompt), res) + + def test_complete_column(self) -> None: + df_in = self._session.create_dataframe([snowpark.Row(model=self.model, prompt=self.prompt)]) + df_out = df_in.select(_complete._complete_impl("complete", functions.col("model"), functions.col("prompt"))) + res = df_out.collect()[0][0] + self.assertEqual(self.complete_for_test(self.model, self.prompt), res) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/cortex/extract_answer_test.py b/snowflake/cortex/extract_answer_test.py new file mode 100644 index 00000000..362f56a8 --- /dev/null +++ b/snowflake/cortex/extract_answer_test.py @@ -0,0 +1,53 @@ +import _test_util +from absl.testing import absltest + +from snowflake import snowpark +from snowflake.cortex import _extract_answer +from snowflake.snowpark import functions, types + + +class ExtractAnswerTest(absltest.TestCase): + from_text = "|from_text|" + question = "|question|" + + @staticmethod + def extract_answer_for_test(from_text: str, question: str) -> str: + return f"answered: {from_text}, {question}" + + def setUp(self) -> None: + self._session = _test_util.create_test_session() + functions.udf( + self.extract_answer_for_test, + name="extract_answer", + return_type=types.StringType(), + input_types=[types.StringType(), types.StringType()], + is_permanent=False, + ) + + def tearDown(self) -> None: + self._session.sql("drop function extract_answer(string,string)").collect() + self._session.close() + + def test_embed_text_str(self) -> None: + res = _extract_answer._extract_answer_impl("extract_answer", self.from_text, self.question) + + # UDFs output non-integer / string values as JSON. + assert isinstance(res, str) + self.assertEqual(self.extract_answer_for_test(self.from_text, self.question), res) + + def test_embed_text_column(self) -> None: + df_in = self._session.create_dataframe([snowpark.Row(from_text=self.from_text, question=self.question)]) + df_out = df_in.select( + _extract_answer._extract_answer_impl( + "extract_answer", functions.col("from_text"), functions.col("question") + ) + ) + res = df_out.collect()[0][0] + + # UDFs output non-integer / string values as JSON. + assert isinstance(res, str) + self.assertEqual(self.extract_answer_for_test(self.from_text, self.question), res) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/cortex/package_visibility_test.py b/snowflake/cortex/package_visibility_test.py new file mode 100644 index 00000000..69b78392 --- /dev/null +++ b/snowflake/cortex/package_visibility_test.py @@ -0,0 +1,26 @@ +from absl.testing import absltest + +from snowflake import cortex + + +class PackageVisibilityTest(absltest.TestCase): + """Ensure that the functions in this package are visible externally.""" + + def test_complete_visible(self) -> None: + self.assertTrue(callable(cortex.Complete)) + + def test_extract_answer_visible(self) -> None: + self.assertTrue(callable(cortex.ExtractAnswer)) + + def test_sentiment_visible(self) -> None: + self.assertTrue(callable(cortex.Sentiment)) + + def test_summarize_visible(self) -> None: + self.assertTrue(callable(cortex.Summarize)) + + def test_translate_visible(self) -> None: + self.assertTrue(callable(cortex.Translate)) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/cortex/sentiment_test.py b/snowflake/cortex/sentiment_test.py new file mode 100644 index 00000000..3b2964db --- /dev/null +++ b/snowflake/cortex/sentiment_test.py @@ -0,0 +1,42 @@ +import _test_util +from absl.testing import absltest + +from snowflake import snowpark +from snowflake.cortex import _sentiment +from snowflake.snowpark import functions, types + + +class SentimentTest(absltest.TestCase): + prompt = "|prompt|" + + @staticmethod + def sentiment_for_test(prompt: str) -> str: + return f"result: {prompt}" + + def setUp(self) -> None: + self._session = _test_util.create_test_session() + functions.udf( + self.sentiment_for_test, + name="sentiment", + return_type=types.StringType(), + input_types=[types.StringType()], + is_permanent=False, + ) + + def tearDown(self) -> None: + self._session.sql("drop function sentiment(string)").collect() + self._session.close() + + def test_sentiment_str(self) -> None: + res = _sentiment._sentiment_impl("sentiment", self.prompt) + self.assertEqual(self.sentiment_for_test(self.prompt), res) + + def test_sentiment_column(self) -> None: + df_in = self._session.create_dataframe([snowpark.Row(prompt=self.prompt)]) + df_out = df_in.select(_sentiment._sentiment_impl("sentiment", functions.col("prompt"))) + res = df_out.collect()[0][0] + self.assertEqual(self.sentiment_for_test(self.prompt), res) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/cortex/summarize_test.py b/snowflake/cortex/summarize_test.py new file mode 100644 index 00000000..a2bfc800 --- /dev/null +++ b/snowflake/cortex/summarize_test.py @@ -0,0 +1,42 @@ +import _test_util +from absl.testing import absltest + +from snowflake import snowpark +from snowflake.cortex import _summarize +from snowflake.snowpark import functions, types + + +class SummarizeTest(absltest.TestCase): + prompt = "|prompt|" + + @staticmethod + def summarize_for_test(prompt: str) -> str: + return f"summarized: {prompt}" + + def setUp(self) -> None: + self._session = _test_util.create_test_session() + functions.udf( + self.summarize_for_test, + name="summarize", + return_type=types.StringType(), + input_types=[types.StringType()], + is_permanent=False, + ) + + def tearDown(self) -> None: + self._session.sql("drop function summarize(string)").collect() + self._session.close() + + def test_summarize_str(self) -> None: + res = _summarize._summarize_impl("summarize", self.prompt) + self.assertEqual(self.summarize_for_test(self.prompt), res) + + def test_summarize_column(self) -> None: + df_in = self._session.create_dataframe([snowpark.Row(prompt=self.prompt)]) + df_out = df_in.select(_summarize._summarize_impl("summarize", functions.col("prompt"))) + res = df_out.collect()[0][0] + self.assertEqual(self.summarize_for_test(self.prompt), res) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/cortex/translate_test.py b/snowflake/cortex/translate_test.py new file mode 100644 index 00000000..b309844b --- /dev/null +++ b/snowflake/cortex/translate_test.py @@ -0,0 +1,58 @@ +import _test_util +from absl.testing import absltest + +from snowflake import snowpark +from snowflake.cortex import _translate +from snowflake.snowpark import functions, types + + +class TranslateTest(absltest.TestCase): + text = "|text|" + from_language = "|from_language|" + to_language = "|to_language|" + + @staticmethod + def translate_for_test(text: str, from_language: str, to_language: str) -> str: + return f"translated: {text}, {from_language}, {to_language}" + + def setUp(self) -> None: + self._session = _test_util.create_test_session() + functions.udf( + self.translate_for_test, + name="translate_for_test", + return_type=types.StringType(), + input_types=[types.StringType(), types.StringType(), types.StringType()], + is_permanent=False, + ) + + def tearDown(self) -> None: + self._session.sql("drop function translate_for_test(string,string,string)").collect() + self._session.close() + + def test_translate_str(self) -> None: + res = _translate._translate_impl( + "translate_for_test", + self.text, + self.from_language, + self.to_language, + ) + self.assertEqual(self.translate_for_test(self.text, self.from_language, self.to_language), res) + + def test_translate_column(self) -> None: + df_in = self._session.create_dataframe( + [snowpark.Row(text=self.text, from_language=self.from_language, to_language=self.to_language)] + ) + df_out = df_in.select( + _translate._translate_impl( + "translate_for_test", + functions.col("text"), + functions.col("from_language"), + functions.col("to_language"), + ) + ) + res = df_out.collect()[0][0] + self.assertEqual(self.translate_for_test(self.text, self.from_language, self.to_language), res) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/_internal/BUILD.bazel b/snowflake/ml/_internal/BUILD.bazel index a1139433..4c012bb3 100644 --- a/snowflake/ml/_internal/BUILD.bazel +++ b/snowflake/ml/_internal/BUILD.bazel @@ -76,6 +76,19 @@ py_test( ], ) +py_library( + name = "cuda_utils", + srcs = ["cuda_utils.py"], +) + +py_test( + name = "cuda_utils_test", + srcs = ["cuda_utils_test.py"], + deps = [ + ":cuda_utils", + ], +) + py_library( name = "migrator_utils", srcs = ["migrator_utils.py"], diff --git a/snowflake/ml/_internal/cuda_utils.py b/snowflake/ml/_internal/cuda_utils.py new file mode 100644 index 00000000..9ef56327 --- /dev/null +++ b/snowflake/ml/_internal/cuda_utils.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import List, Optional + +from packaging import version + + +# TODO(halu): Once we add multiple py version support +# we should include as well as py3.8 is gradually +# on its path to be dropped. +@dataclass(frozen=True) +class TorchCompatibilityConfig: + # Pytorch version(major.minor.micro). + torch: str + # List of supporting CUDA versions(major.minor). + cudas: List[str] + + +# Used for testing only to make sure valid config +# Make sure check with SPCS team before bumping this up. +_SPCS_CUDA_VERSION = "12.1" + +# Drop support for 1.* +_TORCH_CUDA_COMPAT_CONFIGS = [ + TorchCompatibilityConfig(torch="2.1.0", cudas=["11.8", "12.1"]), + TorchCompatibilityConfig(torch="2.0.1", cudas=["11.7", "11.8"]), + TorchCompatibilityConfig(torch="2.0.0", cudas=["11.7", "11.8"]), +] + + +def _normalized_cuda_version(user_cuda_version: str) -> str: + """Normalize cuda version to major.minor only. + We round down the micro version to rely on backward compatibility + Rather than forward. + + Args: + user_cuda_version: User local or provided full cuda version. + + Returns: + Normalized version + """ + v = version.Version(user_cuda_version) + return f"{v.major}.{v.minor}" + + +def _normalized_torch_version(user_torch_version: str) -> str: + """Normalize torch version to release version. + + Args: + user_torch_version: User local or provided full torch version. + + Returns: + Normalized "major.minor.micro". + + Raises: + InvalidVersion if not PEP 440 compliant. + """ + v = version.Version(user_torch_version) + return f"{v.major}.{v.minor}.{v.micro}" + + +def is_torch_cuda_compatible( + torch_version: str, + cuda_version: str, +) -> bool: + """Check if provided pair is compatible. + + Args: + torch_version: User torch version. + cuda_version: User cuda version. + + Returns: + True if compatible. + """ + n_torch_version = _normalized_torch_version(torch_version) + n_cuda = _normalized_cuda_version(cuda_version) + for cfg in _TORCH_CUDA_COMPAT_CONFIGS: + if cfg.torch == n_torch_version: + if n_cuda in cfg.cudas: + return True + else: + return False + return False + + +def get_latest_cuda_for_torch(torch_version: str) -> Optional[str]: + """Get latest supporting CUDA version if possible. + + Args: + torch_version (str): User torch version. + + Returns: + Latest supporting CUDA version or None. + """ + parsed_torch_version = _normalized_torch_version(torch_version) + for cfg in _TORCH_CUDA_COMPAT_CONFIGS: + if cfg.torch == parsed_torch_version: + return sorted(cfg.cudas, reverse=True)[0] + return None diff --git a/snowflake/ml/_internal/cuda_utils_test.py b/snowflake/ml/_internal/cuda_utils_test.py new file mode 100644 index 00000000..c115efdd --- /dev/null +++ b/snowflake/ml/_internal/cuda_utils_test.py @@ -0,0 +1,26 @@ +from absl.testing import absltest +from packaging import version + +from snowflake.ml._internal import cuda_utils + + +class CudaUtilsTest(absltest.TestCase): + def test_validate_torch_config(self) -> None: + # Check all torch, cuda versions + for cfg in cuda_utils._TORCH_CUDA_COMPAT_CONFIGS: + _ = version.Version(cfg.torch) + for c in cfg.cudas: + self.assertLessEqual(version.Version(c), version.Version(cuda_utils._SPCS_CUDA_VERSION)) + + def test_is_torch_cuda_compatible(self) -> None: + self.assertFalse(cuda_utils.is_torch_cuda_compatible("1.13", "12.1")) + self.assertFalse(cuda_utils.is_torch_cuda_compatible("2.0.1", "12.1")) + self.assertTrue(cuda_utils.is_torch_cuda_compatible("2.0.1.dev123", "11.7")) + + def test_get_latest_cuda_for_torch(self) -> None: + self.assertEqual(cuda_utils.get_latest_cuda_for_torch("2.0.1"), "11.8") + self.assertEqual(cuda_utils.get_latest_cuda_for_torch("10.0.1"), None) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/_internal/file_utils.py b/snowflake/ml/_internal/file_utils.py index afd12a71..37fc23c8 100644 --- a/snowflake/ml/_internal/file_utils.py +++ b/snowflake/ml/_internal/file_utils.py @@ -2,6 +2,7 @@ import hashlib import importlib import io +import logging import os import pathlib import pkgutil @@ -11,7 +12,6 @@ import tempfile import zipfile from typing import ( - IO, Any, Callable, Dict, @@ -30,10 +30,6 @@ from snowflake.ml._internal.exceptions import exceptions GENERATED_PY_FILE_EXT = (".pyc", ".pyo", ".pyd", ".pyi") -_SNOWFLAKE_ML_PKG_NAME = "snowflake.ml" - -# Cache mapping for zip file to unzipped directory. -_EXTRACTED_ZIP: Dict[str, str] = {} def copytree( @@ -106,105 +102,69 @@ def copy_file_or_tree(src: str, dst_dir: str) -> None: copytree(src=src, dst=dst_path, ignore=shutil.ignore_patterns("__pycache__")) -@contextlib.contextmanager -def zip_file_or_directory_to_stream( - path: str, - leading_path: Optional[str] = None, - ignore_generated_py_file: bool = True, -) -> Generator[io.BytesIO, None, None]: - """This is a temporary fixed version of snowflake.snowpark._internal.utils.zip_file_or_directory_to_stream function. - It compresses the file or directory as a zip file to a binary stream. The zip file could be later imported as a - Python package. - - The original version did not implement correctly as it did not add folder record for those directory level between - the leading_path and path. In this case, the generated zip file could not be imported as a Python namespace package. - - The original version wrongly believe that __init__.py is needed for all directories along the import path when - importing a module as a zip file. However, it is not necessary as modern Python has already support namespace - package where __init__.py is no longer required. - - Args: - path: The absolute path to a file or directory. - leading_path: This argument is used to determine where directory should - start in the zip file. Basically, this argument works as the role - of `start` argument in os.path.relpath(path, start), i.e., - absolute path = [leading path]/[relative path]. For example, - when the path is "/tmp/dir1/dir2/test.py", and the leading path - is "/tmp/dir1", the generated filesystem structure in the zip file - will be "dir2/" and "dir2/test.py". Defaults to None. - ignore_generated_py_file: Whether to ignore some generated python files - in the directory. Defaults to True. - - Raises: - FileNotFoundError: Raised when the given path does not exist. - ValueError: Raised when the leading path is not a actual leading path of path - ValueError: Raised when the arcname cannot be encoded using ASCII. - - Yields: - A bytes IO stream containing the zip file. - """ - # TODO(SNOW-862576): Should remove check on ASCII encoding after SNOW-862576 fixed. - if not os.path.exists(path): - raise FileNotFoundError(f"{path} is not found") - if leading_path and not path.startswith(leading_path): - raise ValueError(f"{leading_path} doesn't lead to {path}") - # if leading_path is not provided, just use the parent path, - # and the compression will start from the parent directory - start_path = leading_path if leading_path else os.path.join(path, "..") - - with io.BytesIO() as input_stream: - with zipfile.ZipFile(input_stream, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: - if os.path.realpath(path) != os.path.realpath(start_path): - cur_path = os.path.dirname(path) - while os.path.realpath(cur_path) != os.path.realpath(start_path): - arcname = os.path.relpath(cur_path, start_path) - if not _able_ascii_encode(arcname): - raise ValueError(f"File name {arcname} cannot be encoded using ASCII. Please rename.") - zf.write(cur_path, arcname) - cur_path = os.path.dirname(cur_path) - - if os.path.isdir(path): - for dirpath, _, files in os.walk(path): - # ignore __pycache__ - if ignore_generated_py_file and "__pycache__" in dirpath: - continue - arcname = os.path.relpath(dirpath, start_path) - if not _able_ascii_encode(arcname): - raise ValueError(f"File name {arcname} cannot be encoded using ASCII. Please rename.") - zf.write(dirpath, arcname) - for file in files: - # ignore generated python files - if ignore_generated_py_file and file.endswith(GENERATED_PY_FILE_EXT): - continue - file_path = os.path.join(dirpath, file) - arcname = os.path.relpath(file_path, start_path) - if not _able_ascii_encode(arcname): - raise ValueError(f"File name {arcname} cannot be encoded using ASCII. Please rename.") - zf.write(file_path, arcname) - else: - arcname = os.path.relpath(path, start_path) - if not _able_ascii_encode(arcname): +def make_archive( + target_path: str, + root_dir: Optional[str] = None, + base_dir: Optional[str] = None, + verbose: bool = False, + dry_run: bool = False, + owner: Optional[str] = None, + group: Optional[str] = None, + logger: Optional[logging.Logger] = None, +) -> None: + target_file = pathlib.Path(target_path) + ext = "".join(target_file.suffixes) + basename = str(target_file.parent / target_file.name.replace(ext, "")) + EXT_TO_FORMAT_MAPPING = {".zip": "zip", ".tar": "tar", ".tar.gz": "gztar", ".tar.bz2": "bztar", ".tar.xz": "xztar"} + shutil.make_archive( + basename, + EXT_TO_FORMAT_MAPPING[ext], + root_dir=root_dir, + base_dir=base_dir, + verbose=verbose, + dry_run=dry_run, + owner=owner, + group=group, + logger=logger, + ) + + +def zip_python_package(zipfile_path: str, package_name: str, ignore_generated_py_file: bool = True) -> None: + import importlib_resources + from importlib_resources import abc as importlib_resources_abc + + _, pkg_start_path = get_package_path(package_name) + if os.path.isfile(pkg_start_path): + shutil.copy(pkg_start_path, zipfile_path) + return + + base_dirs = package_name.split(".") + assert len(base_dirs) >= 1, "Invalid package name." + + with zipfile.ZipFile(zipfile_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for i in range(len(base_dirs)): + arcname = pathlib.PurePosixPath(*base_dirs[: i + 1]) + zf.writestr(str(arcname) + "/", "") + base_arcname = pathlib.PurePosixPath(*base_dirs) + + def _add_to_zip( + zf: zipfile.ZipFile, path_info: importlib_resources_abc.Traversable, base_arcname: pathlib.PurePosixPath + ) -> None: + if path_info.is_file(): + if ignore_generated_py_file and path_info.name.endswith(GENERATED_PY_FILE_EXT): + return + arcname = base_arcname / path_info.name + if not _able_ascii_encode(str(arcname)): raise ValueError(f"File name {arcname} cannot be encoded using ASCII. Please rename.") - zf.write(path, arcname) - - yield input_stream - - -@contextlib.contextmanager -def unzip_stream_in_temp_dir(stream: IO[bytes], temp_root: Optional[str] = None) -> Generator[str, None, None]: - """Unzip an IO stream into a temporary directory. + zf.writestr(str(arcname), path_info.read_bytes()) # type: ignore[no-untyped-call] + elif path_info.is_dir(): + arcname = base_arcname / path_info.name + zf.writestr(str(arcname) + "/", "") + for sub_path_info in path_info.iterdir(): # type: ignore[no-untyped-call] + _add_to_zip(zf, sub_path_info, arcname) - Args: - stream: The input stream. - temp_root: The root directory where the temporary directory should created in. Defaults to None. - - Yields: - The path to the created temporary directory. - """ - with tempfile.TemporaryDirectory(dir=temp_root) as tempdir: - with zipfile.ZipFile(stream, mode="r", compression=zipfile.ZIP_DEFLATED) as zf: - zf.extractall(path=tempdir) - yield tempdir + for sub_path_info in importlib_resources.files(package_name).iterdir(): # type: ignore[no-untyped-call] + _add_to_zip(zf, sub_path_info, base_arcname) def hash_directory( @@ -287,7 +247,7 @@ def _create_tar_gz_stream(source_dir: str, arcname: Optional[str] = None) -> Gen def get_package_path(package_name: str, strategy: Literal["first", "last"] = "first") -> Tuple[str, str]: - """Return the path to where a package is defined and its start location. + """[Obsolete]Return the path to where a package is defined and its start location. Example 1: snowflake.ml -> path/to/site-packages/snowflake/ml, path/to/site-packages Example 2: zip_imported_module -> path/to/some/zipfile.zip/zip_imported_module, path/to/some/zipfile.zip @@ -326,40 +286,6 @@ def stage_file_exists( return False -def resolve_zip_import_path(file_path: str) -> str: - """This function resolves a file path when snowml is either loaded as a directory or zip(e.g. in notebook env). - - We first check the snowml package path, if it's a directory, meaning we are not using zip import, then we return - the file_path as is, immediately; if snowml package path is a file, meaning it's a zip, we unzip it to a temp dir, - then reconstruct the correct file path. The reconstruction is needed because if the package is zip-imported, then - the path will be `../path_to_zip.zip/snowflake/ml`, which will cause "file not found" in the downstream. - - Args: - file_path: file path, likely inferred by os.path.dirname(__file__) - - Returns: - Valid file path. - """ - - def _get_unzipped_dir() -> str: - if snowml_start_path in _EXTRACTED_ZIP: - cached_dir = _EXTRACTED_ZIP[snowml_path] - if os.path.exists(cached_dir): - return _EXTRACTED_ZIP[cached_dir] - extract_dir = tempfile.mkdtemp() - with zipfile.ZipFile(os.path.abspath(snowml_start_path), mode="r", compression=zipfile.ZIP_DEFLATED) as zf: - zf.extractall(path=extract_dir) - _EXTRACTED_ZIP[snowml_path] = extract_dir - return extract_dir - - snowml_path, snowml_start_path = get_package_path(_SNOWFLAKE_ML_PKG_NAME, strategy="last") - if not os.path.isfile(snowml_start_path): - return file_path - extract_root = _get_unzipped_dir() - snowml_file_path = os.path.relpath(file_path, snowml_start_path) - return os.path.join(extract_root, *(snowml_file_path.split("/"))) - - def upload_directory_to_stage( session: snowpark.Session, local_path: pathlib.Path, stage_path: pathlib.PurePosixPath ) -> None: diff --git a/snowflake/ml/_internal/file_utils_test.py b/snowflake/ml/_internal/file_utils_test.py index c2ef2bc7..4f0e8b66 100644 --- a/snowflake/ml/_internal/file_utils_test.py +++ b/snowflake/ml/_internal/file_utils_test.py @@ -1,10 +1,8 @@ import importlib import os -import re import shutil import sys import tempfile -import warnings from datetime import datetime from absl.testing import absltest @@ -38,97 +36,63 @@ def test_copytree(self) -> None: [ele[1:] for ele in os.walk(os.path.join(tmpdir, "shutil_copy"))], ) - def test_zip_file_or_directory_to_stream(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - leading_path = os.path.join(tmpdir, "test") - fake_mod_dirpath = os.path.join(leading_path, "snowflake", "fake", "fake_module") + def test_make_archive(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + fake_mod_dirpath = os.path.join(tmpdir, "snowflake", "fake", "fake_module") os.makedirs(fake_mod_dirpath) py_file_path = os.path.join(fake_mod_dirpath, "p.py") with open(py_file_path, "w", encoding="utf-8") as f: f.write(PY_SRC) - zip_module_filename = os.path.join(tmpdir, "fake_module.zip") - with file_utils.zip_file_or_directory_to_stream(py_file_path, leading_path) as input_stream: - with open(zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - sys.path.insert(0, os.path.abspath(zip_module_filename)) + file_utils.make_archive(os.path.join(workspace, "model.zip"), tmpdir) + file_utils.make_archive(os.path.join(workspace, "model.tar"), tmpdir) + file_utils.make_archive(os.path.join(workspace, "model.tar.gz"), tmpdir) + file_utils.make_archive(os.path.join(workspace, "model.tar.bz2"), tmpdir) + file_utils.make_archive(os.path.join(workspace, "model.tar.xz"), tmpdir) - importlib.import_module("snowflake.fake.fake_module.p") + self.assertListEqual( + sorted(os.listdir(workspace)), + sorted(["model.zip", "model.tar", "model.tar.gz", "model.tar.bz2", "model.tar.xz"]), + ) - mod_path, start_path = file_utils.get_package_path("snowflake.fake.fake_module") - self.assertEqual(mod_path, os.path.join(zip_module_filename, "snowflake", "fake", "fake_module")) - self.assertEqual(start_path, zip_module_filename) + def test_zip_python_package(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + fake_mod_dirpath = os.path.join(tmpdir, "snowflake", "fake", "fake_module") + os.makedirs(fake_mod_dirpath) - sys.path.remove(os.path.abspath(zip_module_filename)) + py_file_path = os.path.join(fake_mod_dirpath, "p.py") + with open(py_file_path, "w", encoding="utf-8") as f: + f.write(PY_SRC) - with file_utils.zip_file_or_directory_to_stream(fake_mod_dirpath, leading_path) as input_stream: - with open(zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) + sys.path.insert(0, os.path.abspath(tmpdir)) + importlib.import_module("snowflake.fake.fake_module.p") + zip_module_filename = os.path.join(tmpdir, "fake_module.zip") + file_utils.zip_python_package(zip_module_filename, "snowflake.fake.fake_module") + sys.path.remove(os.path.abspath(tmpdir)) sys.path.insert(0, os.path.abspath(zip_module_filename)) - importlib.import_module("snowflake.fake.fake_module.p") - - mod_path, start_path = file_utils.get_package_path("snowflake.fake.fake_module") - self.assertEqual(mod_path, os.path.join(zip_module_filename, "snowflake", "fake", "fake_module")) - self.assertEqual(start_path, zip_module_filename) - sys.path.remove(os.path.abspath(zip_module_filename)) - with warnings.catch_warnings(): - warnings.simplefilter("error") - with file_utils.zip_file_or_directory_to_stream(fake_mod_dirpath, fake_mod_dirpath) as input_stream: - with open(zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - with warnings.catch_warnings(): - warnings.simplefilter("error") - with file_utils.zip_file_or_directory_to_stream(leading_path, leading_path) as input_stream: - with open(zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - fake_mod_dirpath = os.path.join(leading_path, "❄️", "fake", "fake_module") + with tempfile.TemporaryDirectory() as tmpdir: + fake_mod_dirpath = os.path.join(tmpdir, "snowflake", "fake", "fake_module") os.makedirs(fake_mod_dirpath) py_file_path = os.path.join(fake_mod_dirpath, "p.py") with open(py_file_path, "w", encoding="utf-8") as f: f.write(PY_SRC) - with self.assertRaises(ValueError): - zip_module_filename = os.path.join(tmpdir, "fake_module.zip") - with file_utils.zip_file_or_directory_to_stream(py_file_path, leading_path) as input_stream: - with open(zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - py_file_path = os.path.join(fake_mod_dirpath, "❄️.py") + py_file_path = os.path.join(fake_mod_dirpath, "❄️.txt") with open(py_file_path, "w", encoding="utf-8") as f: f.write(PY_SRC) + sys.path.insert(0, os.path.abspath(tmpdir)) + importlib.import_module("snowflake.fake.fake_module.p") + zip_module_filename = os.path.join(tmpdir, "fake_module.zip") with self.assertRaises(ValueError): - zip_module_filename = os.path.join(tmpdir, "fake_module.zip") - with file_utils.zip_file_or_directory_to_stream(py_file_path, leading_path) as input_stream: - with open(zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - def test_unzip_stream_in_temp_dir(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - leading_path = os.path.join(tmpdir, "test") - fake_mod_dirpath = os.path.join(leading_path, "snowflake", "fake", "fake_module") - os.makedirs(fake_mod_dirpath) - - py_file_path = os.path.join(fake_mod_dirpath, "p.py") - with open(py_file_path, "w", encoding="utf-8") as f: - f.write(PY_SRC) - with warnings.catch_warnings(): - warnings.simplefilter("error") - with file_utils.zip_file_or_directory_to_stream(py_file_path, leading_path) as input_stream: - with file_utils.unzip_stream_in_temp_dir(input_stream, temp_root=tmpdir) as sub_tempdir: - with open( - os.path.join(sub_tempdir, "snowflake", "fake", "fake_module", "p.py"), encoding="utf-8" - ) as f: - self.assertEqual(f.read(), PY_SRC) + file_utils.zip_python_package(zip_module_filename, "snowflake.fake.fake_module") + sys.path.remove(os.path.abspath(tmpdir)) def test_hash_directory(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -243,32 +207,6 @@ def test_able_ascii_encode(self) -> None: self.assertTrue(file_utils._able_ascii_encode("abc")) self.assertFalse(file_utils._able_ascii_encode("❄️")) - def test_resolve_zip_import_path(self) -> None: - # Test when snowml is a directory - snowflake_ml_path = "snowflake/ml/model/_deploy_client/image_builds/inference_server" - input_path = f"/a/b/c/{snowflake_ml_path}" - self.assertEqual(file_utils.resolve_zip_import_path(input_path), input_path) - - # Test when snowml is a zip - snowml_path, snowml_start_path = file_utils.get_package_path("snowflake.ml", strategy="last") - zip_file_name = "snowml.zip" - with tempfile.TemporaryDirectory() as tmpdir: - zipped_snowml_path = os.path.join(tmpdir, zip_file_name) - with open(zipped_snowml_path, "wb") as f: - with file_utils.zip_file_or_directory_to_stream(snowml_path, snowml_start_path) as zip_stream: - f.write(zip_stream.getbuffer()) - try: - sys.path.append(zipped_snowml_path) - tmp_parent_dir = os.path.dirname(tmpdir) - resolved_path = file_utils.resolve_zip_import_path(f"{zipped_snowml_path}/{snowflake_ml_path}") - self.assertTrue(zip_file_name not in resolved_path) - # Note that this is based on the tempfile.TemporaryDirectory, in which the directory name might differ - # between runs. But the suffix/prefix/dir will remain the same. Essentially, the test assumes that if - # zip file is created at /tmp/a/b/snowml,zip, then the unzipped dir will be at /tmp/a/{some_dir} - self.assertTrue(re.match(rf"{tmp_parent_dir}/(\w+)/{snowflake_ml_path}", resolved_path)) - finally: - sys.path.remove(zipped_snowml_path) - if __name__ == "__main__": absltest.main() diff --git a/snowflake/ml/_internal/utils/BUILD.bazel b/snowflake/ml/_internal/utils/BUILD.bazel index f2bdfb12..cf981880 100644 --- a/snowflake/ml/_internal/utils/BUILD.bazel +++ b/snowflake/ml/_internal/utils/BUILD.bazel @@ -211,3 +211,27 @@ py_test( ":log_stream_processor", ], ) + +py_library( + name = "session_token_manager", + srcs = ["session_token_manager.py"], +) + +py_library( + name = "image_registry_http_client", + srcs = ["image_registry_http_client.py"], + deps = [ + ":session_token_manager", + "//snowflake/ml/_internal/exceptions", + "//snowflake/ml/_internal/utils:retryable_http", + ], +) + +py_test( + name = "image_registry_http_client_test", + srcs = ["image_registry_http_client_test.py"], + deps = [ + ":image_registry_http_client", + "//snowflake/ml/test_utils:mock_session", + ], +) diff --git a/snowflake/ml/_internal/utils/identifier.py b/snowflake/ml/_internal/utils/identifier.py index baff014b..2dae0557 100644 --- a/snowflake/ml/_internal/utils/identifier.py +++ b/snowflake/ml/_internal/utils/identifier.py @@ -138,6 +138,12 @@ def concat_names(ids: List[str]) -> str: return final_id +def rename_to_valid_snowflake_identifier(name: str) -> str: + if QUOTED_IDENTIFIER_RE.match(name) is None and UNQUOTED_CASE_SENSITIVE_RE.match(name) is None: + name = get_inferred_name(name) + return name + + def parse_schema_level_object_identifier( path: str, ) -> Tuple[Union[str, Any], Union[str, Any], Union[str, Any], Union[str, Any]]: diff --git a/snowflake/ml/_internal/utils/image_registry_http_client.py b/snowflake/ml/_internal/utils/image_registry_http_client.py new file mode 100644 index 00000000..367cd276 --- /dev/null +++ b/snowflake/ml/_internal/utils/image_registry_http_client.py @@ -0,0 +1,120 @@ +import http +import json +import logging +import time +from typing import Any, Callable, Dict, FrozenSet, Optional +from urllib.parse import urlparse, urlunparse + +import requests + +from snowflake import snowpark +from snowflake.ml._internal.exceptions import ( + error_codes, + exceptions as snowml_exceptions, +) +from snowflake.ml._internal.utils import retryable_http, session_token_manager + +logger = logging.getLogger(__name__) + +_MAX_RETRIES = 5 +_RETRY_DELAY_SECONDS = 1 +_RETRYABLE_HTTP_CODE = frozenset([http.HTTPStatus.UNAUTHORIZED]) + + +def retry_on_error( + http_call_function: Callable[..., requests.Response], + retryable_http_code: FrozenSet[http.HTTPStatus] = _RETRYABLE_HTTP_CODE, +) -> Callable[..., requests.Response]: + def wrapper(*args: Any, **kwargs: Any) -> Any: + retry_delay_seconds = _RETRY_DELAY_SECONDS + for attempt in range(1, _MAX_RETRIES + 1): + resp = http_call_function(*args, **kwargs) + if resp.status_code in retryable_http_code: + logger.warning( + f"Received {resp.status_code} status code. Retrying " f"(attempt {attempt}/{_MAX_RETRIES})..." + ) + time.sleep(retry_delay_seconds) + retry_delay_seconds *= 2 # Increase the retry delay exponentially + if attempt < _MAX_RETRIES: + assert isinstance(args[0], ImageRegistryHttpClient) + args[0]._fetch_bearer_token() + else: + return resp + + if attempt == _MAX_RETRIES: + raise snowml_exceptions.SnowflakeMLException( + error_code=error_codes.INTERNAL_SNOWFLAKE_IMAGE_REGISTRY_ERROR, + original_exception=RuntimeError( + f"Failed to authenticate to registry after max retries {attempt} \n" + f"Status {resp.status_code}," + f"{str(resp.text)}" + ), + ) + + return wrapper + + +class ImageRegistryHttpClient: + """ + An image registry HTTP client utilizes a retryable HTTP client underneath. Its primary function is to facilitate + re-authentication with the image registry by obtaining a new GS token, which is then used to acquire a new bearer + token for subsequent HTTP request authentication. + + Ideally you should not use this client directly. Please use ImageRegistryClient for image registry-specific + operations. For general use of a retryable HTTP client, consider using the "retryable_http" module. + """ + + def __init__(self, *, session: snowpark.Session, repo_url: str) -> None: + self._repo_url = repo_url + self._session_token_manager = session_token_manager.SessionTokenManager(session) + self._retryable_http = retryable_http.get_http_client() + self._bearer_token = "" + + def _with_bearer_token_header(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + if not self._bearer_token: + self._fetch_bearer_token() + assert self._bearer_token + new_headers = {} if not headers else headers.copy() + new_headers["Authorization"] = f"Bearer {self._bearer_token}" + return new_headers + + def _fetch_bearer_token(self) -> None: + resp = self._login() + self._bearer_token = str(json.loads(resp.text)["token"]) + + def _login(self) -> requests.Response: + """Log in to image registry. repo_url is expected to set when _login function is invoked. + + Returns: + Bearer token when login succeeded. + """ + parsed_url = urlparse(self._repo_url) + scheme = parsed_url.scheme + host = parsed_url.netloc + + login_path = "/login" # Construct the login path + url_tuple = (scheme, host, login_path, "", "", "") + login_url = urlunparse(url_tuple) + + base64_encoded_token = self._session_token_manager.get_base64_encoded_token() + return self._retryable_http.get(login_url, headers={"Authorization": f"Basic {base64_encoded_token}"}) + + @retry_on_error + def head(self, api_url: str, *, headers: Optional[Dict[str, str]] = None) -> requests.Response: + return self._retryable_http.head(api_url, headers=self._with_bearer_token_header(headers)) + + @retry_on_error + def get(self, api_url: str, *, headers: Optional[Dict[str, str]] = None) -> requests.Response: + return self._retryable_http.get(api_url, headers=self._with_bearer_token_header(headers)) + + @retry_on_error + def put(self, api_url: str, *, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> requests.Response: + return self._retryable_http.put(api_url, headers=self._with_bearer_token_header(headers), **kwargs) + + @retry_on_error + def post(self, api_url: str, *, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> requests.Response: + return self._retryable_http.post(api_url, headers=self._with_bearer_token_header(headers), **kwargs) + + @retry_on_error + def patch(self, api_url: str, *, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> requests.Response: + return self._retryable_http.patch(api_url, headers=self._with_bearer_token_header(headers), **kwargs) diff --git a/snowflake/ml/_internal/utils/image_registry_http_client_test.py b/snowflake/ml/_internal/utils/image_registry_http_client_test.py new file mode 100644 index 00000000..d6bf0f69 --- /dev/null +++ b/snowflake/ml/_internal/utils/image_registry_http_client_test.py @@ -0,0 +1,137 @@ +import json +from typing import cast + +import requests +from absl.testing import absltest, parameterized +from absl.testing.absltest import mock + +from snowflake.ml._internal.exceptions import exceptions as snowml_exceptions +from snowflake.ml._internal.utils import image_registry_http_client +from snowflake.ml.test_utils import mock_session +from snowflake.snowpark import session + + +class ImageRegistryHttpClientTest(parameterized.TestCase): + def setUp(self) -> None: + super().setUp() + self.m_session = mock_session.MockSession(conn=None, test_case=self) + self.m_repo_url = "https://org-account.registry.snowflakecomputing.com" + + def _get_mock_response(self, *, status_code: int, text: str) -> mock.Mock: + mock_response = mock.Mock(spec=requests.Response) + mock_response.status_code = status_code + mock_response.text = text + return mock_response + + @parameterized.parameters(("head",), ("get",), ("put",), ("post",), ("patch",)) # type: ignore[misc] + def test_http_method_succeed_in_one_request(self, http_method: str) -> None: + http_client = image_registry_http_client.ImageRegistryHttpClient( + session=cast(session.Session, self.m_session), repo_url=self.m_repo_url + ) + api_url = "https://org-account.registry.snowflakecomputing.com/v2/" + + dummy_token = "fake_token" + mock_token_response = self._get_mock_response(status_code=200, text=json.dumps({"token": dummy_token})) + mock_response = self._get_mock_response(status_code=200, text="succeed") + + with mock.patch.object(http_client, "_login", return_value=mock_token_response), mock.patch.object( + http_client._retryable_http, http_method, return_value=mock_response + ): + res = getattr(http_client, http_method)(api_url, headers={}) + self.assertEqual(res, mock_response) + getattr(http_client._retryable_http, http_method).assert_called_once_with( + api_url, headers={"Authorization": f"Bearer {dummy_token}"} + ) + + @parameterized.parameters(("head",), ("get",), ("put",), ("post",), ("patch",)) # type: ignore[misc] + def test_http_method_retry_on_401(self, http_method: str) -> None: + http_client = image_registry_http_client.ImageRegistryHttpClient( + session=cast(session.Session, self.m_session), repo_url=self.m_repo_url + ) + api_url = "https://org-account.registry.snowflakecomputing.com/v2/" + + dummy_token_1 = "fake_token_1" + dummy_token_2 = "fake_token_2" + mock_token_response_1 = self._get_mock_response(status_code=200, text=json.dumps({"token": dummy_token_1})) + mock_token_response_2 = self._get_mock_response(status_code=200, text=json.dumps({"token": dummy_token_2})) + + mock_response_1 = self._get_mock_response(status_code=401, text="401 FAILED") + mock_response_2 = self._get_mock_response(status_code=200, text="Succeed") + + mock_token_responses = [mock_token_response_1, mock_token_response_2] + mock_responses = [mock_response_1, mock_response_2] + + with mock.patch.object(http_client, "_login", side_effect=mock_token_responses), mock.patch.object( + http_client._retryable_http, http_method, side_effect=mock_responses + ): + res = getattr(http_client, http_method)(api_url, headers={}) + self.assertEqual(res, mock_response_2) + getattr(http_client._retryable_http, http_method).assert_has_calls( + [ + mock.call(api_url, headers={"Authorization": f"Bearer {dummy_token_1}"}), + mock.call(api_url, headers={"Authorization": f"Bearer {dummy_token_2}"}), + ], + any_order=False, + ) + + # Running only 1 method to reduce test time; in general other test case already guarantees that each method will + # have decorator @retry_on_401 set. + @parameterized.parameters(("head",)) # type: ignore[misc] + def test_http_method_fail_after_max_retries(self, http_method: str) -> None: + http_client = image_registry_http_client.ImageRegistryHttpClient( + session=cast(session.Session, self.m_session), repo_url=self.m_repo_url + ) + api_url = "https://org-account.registry.snowflakecomputing.com/v2/" + dummy_token = "fake_token" + mock_token_responses = [ + self._get_mock_response(status_code=200, text=json.dumps({"token": f"dummy_token{i}"})) + for i in range(image_registry_http_client._MAX_RETRIES) + ] + mock_responses = [ + self._get_mock_response(status_code=401, text="401 FAILED") + for _ in range(image_registry_http_client._MAX_RETRIES) + ] + + with self.assertRaises(snowml_exceptions.SnowflakeMLException) as context: + with mock.patch.object(http_client, "_login", side_effect=mock_token_responses), mock.patch.object( + http_client._retryable_http, http_method, side_effect=mock_responses + ): + getattr(http_client, http_method)(api_url, headers={}) + + getattr(http_client._retryable_http, http_method).assert_has_calls( + [ + mock.call(api_url, headers={"Authorization": f"Bearer {dummy_token}{i}"}) + for i in range(image_registry_http_client._MAX_RETRIES) + ], + any_order=False, + ) + + expected_error_message = "Failed to authenticate to registry after max retries" + self.assertIn(expected_error_message, str(context.exception)) + + @parameterized.parameters(("head",)) # type: ignore[misc] + def test_should_not_retry_on_non_401(self, http_method: str) -> None: + http_client = image_registry_http_client.ImageRegistryHttpClient( + session=cast(session.Session, self.m_session), repo_url=self.m_repo_url + ) + api_url = "https://org-account.registry.snowflakecomputing.com/v2/" + + dummy_token_1 = "fake_token_1" + mock_token_response = self._get_mock_response(status_code=200, text=json.dumps({"token": dummy_token_1})) + mock_response = self._get_mock_response(status_code=403, text="403 FAILED") + + with mock.patch.object(http_client, "_login", return_value=mock_token_response), mock.patch.object( + http_client._retryable_http, http_method, return_value=mock_response + ): + getattr(http_client, http_method)(api_url, headers={}) + # There should only be a single call for non-401 http code + getattr(http_client._retryable_http, http_method).assert_has_calls( + [ + mock.call(api_url, headers={"Authorization": f"Bearer {dummy_token_1}"}), + ], + any_order=False, + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/_internal/utils/session_token_manager.py b/snowflake/ml/_internal/utils/session_token_manager.py new file mode 100644 index 00000000..cdec61fa --- /dev/null +++ b/snowflake/ml/_internal/utils/session_token_manager.py @@ -0,0 +1,46 @@ +import base64 +import json +from typing import TypedDict + +from snowflake import snowpark + + +class SessionToken(TypedDict): + token: str + expires_in: str + + +class SessionTokenManager: + def __init__(self, session: snowpark.Session) -> None: + self._session = session + + def get_session_token(self) -> SessionToken: + """ + This function retrieves the session token from Snowpark session object. + + Returns: + The session token string value. + """ + ctx = self._session._conn._conn + assert ctx._rest, "SnowflakeRestful is not set in session" + token_data = ctx._rest._token_request("ISSUE") + session_token = token_data["data"]["sessionToken"] + validity_in_seconds = token_data["data"]["validityInSecondsST"] + assert session_token, "session_token is not obtained successfully from the session object" + assert validity_in_seconds, "validityInSecondsST is not obtained successfully from the session object" + return {"token": session_token, "expires_in": validity_in_seconds} + + def get_base64_encoded_token(self, username: str = "0sessiontoken") -> str: + """This function returns the base64 encoded username:password, which is compatible with registry, such as + SnowService image registry, that uses Docker credential helper. In this case, password will be session token. + + Args: + username: username for authentication. + + Returns: + base64 encoded credential string. + + """ + credentials = f"{username}:{json.dumps(self.get_session_token())}" + encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") + return encoded_credentials diff --git a/snowflake/ml/_internal/utils/spcs_image_registry.py b/snowflake/ml/_internal/utils/spcs_image_registry.py index d3a202f2..44875a37 100644 --- a/snowflake/ml/_internal/utils/spcs_image_registry.py +++ b/snowflake/ml/_internal/utils/spcs_image_registry.py @@ -1,3 +1,4 @@ +# TODO[shchen]: Remove this file and use session_token_manager instead. import base64 import contextlib import json diff --git a/snowflake/ml/_internal/utils/sql_identifier.py b/snowflake/ml/_internal/utils/sql_identifier.py index 0d895c3c..2d16ff3f 100644 --- a/snowflake/ml/_internal/utils/sql_identifier.py +++ b/snowflake/ml/_internal/utils/sql_identifier.py @@ -10,21 +10,22 @@ class SqlIdentifier(str): 3. resolved(): this is the state how the identifier stored in database. For example: - 1. user input -> 2. identifier() -> 3. resolved() - SqlIdentifier('abc', True) ABC ABC - SqlIdentifier('"abc"', True) "abc" abc - SqlIdentifier('abc', False) "abc" abc + 1. user input -> 2. identifier() -> 3. resolved() + SqlIdentifier('abc', case_sensitive=False) ABC ABC + SqlIdentifier('"abc"', case_sensitive=False) "abc" abc + SqlIdentifier('abc', case_sensitive=True) "abc" abc """ - def __new__(cls, name: str, quotes_to_preserve_case: bool = True) -> "SqlIdentifier": + def __new__(cls, name: str, *, case_sensitive: bool = False) -> "SqlIdentifier": """Create new instance of sql identifier. Refer to here for more details: https://docs.snowflake.com/en/sql-reference/identifiers-syntax Args: name: A string name. - quotes_to_preserve_case: If true, then double quotes are needed to preserve case. This is the default - mode. When it's false, case are preserved automatically. For instance, This happens when you trying - to construct SqlIdentifier from result of SQL queries. + case_sensitive: If False, then the input string is considered case insensitive and will follow SQL + identifier parsing rule; if True, then the input string is considered case sensitive, so quotes are + automatically added if necessary to make sure the original input's cases are preserved. + Default to False. Raises: ValueError: input name is not a valid identifier. @@ -35,18 +36,20 @@ def __new__(cls, name: str, quotes_to_preserve_case: bool = True) -> "SqlIdentif # TODO (wezhou) add stronger validation to recognize a valid snowflake identifier. if not name: raise ValueError(f"name:`{name}` is not a valid identifier.") - if quotes_to_preserve_case: - return super().__new__(cls, identifier.resolve_identifier(name)) - else: + if case_sensitive: return super().__new__(cls, identifier.get_inferred_name(name)) + else: + return super().__new__(cls, identifier.resolve_identifier(name)) - def __init__(self, name: str, quotes_to_preserve_case: bool = True) -> None: + def __init__(self, name: str, case_sensitive: bool = False) -> None: """Initialize sql identifier. Args: name: A string name. - quotes_to_preserve_case: If true then double quotes are needed to preserve case-sensitivity. - Otherwise, case-sensivitity are preserved automatically. + case_sensitive: If False, then the input string is considered case insensitive and will follow SQL + identifier parsing rule; if True, then the input string is considered case sensitive, so quotes are + automatically added if necessary to make sure the original input's cases are preserved. + Default to False. """ super().__init__() @@ -78,5 +81,5 @@ def __hash__(self) -> int: return super().__hash__() -def to_sql_identifiers(list_of_str: List[str], quotes_to_preserve_case: bool = True) -> List[SqlIdentifier]: - return [SqlIdentifier(val, quotes_to_preserve_case) for val in list_of_str] +def to_sql_identifiers(list_of_str: List[str], *, case_sensitive: bool = False) -> List[SqlIdentifier]: + return [SqlIdentifier(val, case_sensitive=case_sensitive) for val in list_of_str] diff --git a/snowflake/ml/_internal/utils/sql_identifier_test.py b/snowflake/ml/_internal/utils/sql_identifier_test.py index e63fb254..5dba5f3e 100644 --- a/snowflake/ml/_internal/utils/sql_identifier_test.py +++ b/snowflake/ml/_internal/utils/sql_identifier_test.py @@ -5,41 +5,41 @@ class SqlIdentifierTest(absltest.TestCase): def test_sql_identifier(self) -> None: - id = SqlIdentifier("abc", quotes_to_preserve_case=True) + id = SqlIdentifier("abc", case_sensitive=False) self.assertEqual(id.identifier(), "ABC") self.assertEqual(id.resolved(), "ABC") - id = SqlIdentifier('"abc"', quotes_to_preserve_case=True) + id = SqlIdentifier('"abc"', case_sensitive=False) self.assertEqual(id.identifier(), '"abc"') self.assertEqual(id.resolved(), "abc") - id = SqlIdentifier("abc", quotes_to_preserve_case=False) + id = SqlIdentifier("abc", case_sensitive=True) self.assertEqual(id.identifier(), '"abc"') self.assertEqual(id.resolved(), "abc") - id = SqlIdentifier("ABC", quotes_to_preserve_case=False) + id = SqlIdentifier("ABC", case_sensitive=True) self.assertEqual(id.identifier(), "ABC") self.assertEqual(id.resolved(), "ABC") def test_sql_identifier_equality(self) -> None: - id_1 = SqlIdentifier("abc", quotes_to_preserve_case=True) - id_2 = SqlIdentifier("ABC", quotes_to_preserve_case=True) + id_1 = SqlIdentifier("abc", case_sensitive=False) + id_2 = SqlIdentifier("ABC", case_sensitive=False) self.assertEqual(id_1, id_2) - id_1 = SqlIdentifier('"ABC"', quotes_to_preserve_case=True) - id_2 = SqlIdentifier("ABC", quotes_to_preserve_case=True) + id_1 = SqlIdentifier('"ABC"', case_sensitive=False) + id_2 = SqlIdentifier("ABC", case_sensitive=False) self.assertEqual(id_1, id_2) - id_1 = SqlIdentifier("abc", quotes_to_preserve_case=False) - id_2 = SqlIdentifier('"abc"', quotes_to_preserve_case=True) + id_1 = SqlIdentifier("abc", case_sensitive=True) + id_2 = SqlIdentifier('"abc"', case_sensitive=False) self.assertEqual(id_1, id_2) - id_1 = SqlIdentifier("abc", quotes_to_preserve_case=False) - id_2 = SqlIdentifier("abc", quotes_to_preserve_case=False) + id_1 = SqlIdentifier("abc", case_sensitive=True) + id_2 = SqlIdentifier("abc", case_sensitive=True) self.assertEqual(id_1, id_2) - id_1 = SqlIdentifier("ABC", quotes_to_preserve_case=False) - id_2 = SqlIdentifier("abc", quotes_to_preserve_case=False) + id_1 = SqlIdentifier("ABC", case_sensitive=True) + id_2 = SqlIdentifier("abc", case_sensitive=True) self.assertNotEqual(id_1, id_2) diff --git a/snowflake/ml/_internal/utils/string_matcher.py b/snowflake/ml/_internal/utils/string_matcher.py index 0409e1fc..e4517f9b 100644 --- a/snowflake/ml/_internal/utils/string_matcher.py +++ b/snowflake/ml/_internal/utils/string_matcher.py @@ -2,9 +2,12 @@ from typing import Dict, List import sqlparse +from absl.logging import logging from snowflake.ml._internal.utils.formatting import unwrap +logger = logging.getLogger(__name__) + class StringMatcherIgnoreWhitespace: """Matcher that removes all whitespace from strings before comparison.""" @@ -97,18 +100,17 @@ def __eq__(self, other: object) -> bool: # If mismatches have been recorded, output differences and return False. if len(expected_mismatched_tokens) + len(actual_mismatched_tokens) > 0: - print() - print( - unwrap( - f"""---- SQL string mismatch: - actual length {len(actual_tokens)} mismatched {len(actual_mismatched_tokens)} - expected length {len(self._expected_tokens)} mismatched {len(expected_mismatched_tokens)}""" - ) + logger.warn( + f""" +---- SQL string mismatch: +actual length {len(actual_tokens)} mismatched {len(actual_mismatched_tokens)} +expected length {len(self._expected_tokens)} mismatched {len(expected_mismatched_tokens)} + +==== ACTUAL : {self._format_sql_tokens(actual_tokens, actual_mismatched_tokens)} + +==== EXPECTED: {self._format_sql_tokens(self._expected_tokens, expected_mismatched_tokens)} +""" ) - print("==== ACTUAL :", self._format_sql_tokens(actual_tokens, actual_mismatched_tokens)) - print() - print("==== EXPECTED:", self._format_sql_tokens(self._expected_tokens, expected_mismatched_tokens)) - print() return False return True diff --git a/snowflake/ml/_internal/utils/temp_file_utils.py b/snowflake/ml/_internal/utils/temp_file_utils.py index 0d270328..536bd782 100644 --- a/snowflake/ml/_internal/utils/temp_file_utils.py +++ b/snowflake/ml/_internal/utils/temp_file_utils.py @@ -3,6 +3,10 @@ import tempfile from typing import Iterable, Union +from absl.logging import logging + +logger = logging.getLogger(__name__) + def get_temp_file_path() -> str: """Returns a new random temp file path. @@ -43,4 +47,4 @@ def cleanup_temp_files(file_paths: Union[str, Iterable[str]]) -> None: else: os.remove(file_to_delete) except FileNotFoundError: - print(f"Failed to cleanup file {file_to_delete}") + logger.warn(f"Failed to cleanup file {file_to_delete}") diff --git a/snowflake/ml/feature_store/_internal/scripts/install-snowpark-ml-conda.sh b/snowflake/ml/feature_store/_internal/scripts/install-snowpark-ml-conda.sh new file mode 100755 index 00000000..969a28df --- /dev/null +++ b/snowflake/ml/feature_store/_internal/scripts/install-snowpark-ml-conda.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# Setup a conda environment & installs snowpark ML. +# +# Usage +# install-snowpark-ml-conda.sh [-d ] [-n ] [-p 3.8|3.9|3.10] [-h] + +set -o pipefail +set -eu + +PROG=$0 + +function help_pkg() { + pkg_name=$1 + echo "## ${pkg_name} could not be found." + echo "## To install this package on macOS, run:" + echo " brew install ${pkg_name}" + echo "## To install this package on Debian/Ubuntu, run:" + echo " sudo apt install ${pkg_name}" + echo "## To install this package on RHEL/CentOS, run:" + echo " sudo yum install ${pkg_name}" + exit 1 +} + +if ! command -v conda &> /dev/null +then + echo "## conda could not be found. This script is only useful for conda." + exit 1 +fi + +CONDA_ENV_BASE=$(conda run -n base conda info --json | python3 -c "import sys, json; print(json.load(sys.stdin)['envs_dirs'][0])" | tr -d '"') +CHANNEL_HOME="${HOME}/snowpark-ml-local-channel" +PY_VERSION="3.8" +# Needs to be updated every release. Can be moved to snowml repo once that is open sourced. +DEFAULT_FILENAME=$(dirname "$PROG")/snowflake-ml-python-1.0.12-fs-0.2.0-conda.zip + +function help() { + exitcode=$1 && shift + echo "Usage: ${PROG} [-d ] [-n ] [-p 3.8|3.9|3.10] [-h]" + echo " -d OUTPUT_DIR: Optional, default is ${CHANNEL_HOME}" + echo " -p PY_VERSION: Optional, default is 3.8. Options are 3.9, 3.10." + if [ "${CONDA_DEFAULT_ENV-}" ]; then + echo " -n CONDA_ENV_NAME: Optional, default is \`${CONDA_DEFAULT_ENV}\` (current environment). If an existing env provided, it will reuse. It will create otherwise." + else + echo " -n CONDA_ENV_NAME: If an existing env provided, it will reuse. It will create otherwise." + fi + exit ${exitcode} +} + +while (($#)); do + case $1 in + -d) + shift + CHANNEL_HOME=$1 + ;; + -n) + shift + TARGET_CONDA_ENV=$1 + ;; + -p) + shift + if [[ $1 = "3.8" || $1 = "3.9" || $1 == "3.10" ]]; then + PY_VERSION=$1 + else + echo "Invalid python version: $1" + help 1 + fi + ;; + -h|--help) + help 0 + ;; + *) + help 1 + ;; + esac + shift +done + +if [ -z ${TARGET_CONDA_ENV+x} ]; then + if [ "${CONDA_DEFAULT_ENV-}" ]; then + TARGET_CONDA_ENV="${CONDA_DEFAULT_ENV}" + else + help 1 + fi +fi + +echo "## Target conda channel is ${TARGET_CONDA_ENV}" +CONDA_ENV_PATH="${CONDA_ENV_BASE}/${TARGET_CONDA_ENV}" +CONDA_PLATFORM=$(conda info --json | python3 -c "import sys, json; print(json.load(sys.stdin)['platform'])") +CONDA_ALL_ENVS=$(conda info --json | python3 -c "import sys, json; print(json.load(sys.stdin)['envs'])") + +unzip "${DEFAULT_FILENAME}" -d ${CHANNEL_HOME} + +if [[ "$CONDA_ALL_ENVS" == *"$CONDA_ENV_PATH"* ]]; then + echo "## Conda env ${TARGET_CONDA_ENV} exists. Assuming setup correctly." +else + echo "## Creating conda env ${TARGET_CONDA_ENV}" + if [[ "$CONDA_PLATFORM" == 'osx-arm64' ]]; then + echo "## Mac M1 detected. Following special conda treatment as per https://docs.snowflake.com/en/developer-guide/snowpark/python/setup" + CONDA_SUBDIR=osx-64 conda create -p "${CONDA_ENV_PATH}" -y python=${PY_VERSION} numpy pandas --override-channels -c https://repo.anaconda.com/pkgs/snowflake + conda run -p "${CONDA_ENV_PATH}" conda config --env --set subdir osx-64 + else + conda create -p "${CONDA_ENV_PATH}" -y --override-channels -c https://repo.anaconda.com/pkgs/snowflake python=${PY_VERSION} numpy pandas + fi +fi + +if [[ "$CONDA_PLATFORM" == 'osx-arm64' ]]; then + CONDA_SUBDIR=osx-64 conda install -p "${CONDA_ENV_PATH}" -y -c "file://${CHANNEL_HOME}/snowpark-ml-local-channel" -c "https://repo.anaconda.com/pkgs/snowflake/" --override-channels snowflake-ml-python +else + conda install -p "${CONDA_ENV_PATH}" -y -c "file://${CHANNEL_HOME}/snowpark-ml-local-channel" -c "https://repo.anaconda.com/pkgs/snowflake/" --override-channels snowflake-ml-python +fi + +echo "## ALL DONE. Please activate the env by executing \`conda activate ${TARGET_CONDA_ENV}\`" diff --git a/snowflake/ml/feature_store/_internal/scripts/run_synthetic_data_generator.py b/snowflake/ml/feature_store/_internal/scripts/run_synthetic_data_generator.py index b8f9d865..16e77535 100644 --- a/snowflake/ml/feature_store/_internal/scripts/run_synthetic_data_generator.py +++ b/snowflake/ml/feature_store/_internal/scripts/run_synthetic_data_generator.py @@ -13,9 +13,6 @@ table = "tbao_test_data" df = session.table(table) - print(df.to_pandas().describe()) - for s in df.schema: - print(s) generator = SyntheticDataGenerator(session, db, schema, table) generator.trigger(3, 5) @@ -24,6 +21,5 @@ while True: if i > 60: break - print(f"i: {i}") time.sleep(1) i = i + 1 diff --git a/snowflake/ml/feature_store/_internal/scripts/upload_test_datasets.py b/snowflake/ml/feature_store/_internal/scripts/upload_test_datasets.py index d11fee6c..4f815d96 100644 --- a/snowflake/ml/feature_store/_internal/scripts/upload_test_datasets.py +++ b/snowflake/ml/feature_store/_internal/scripts/upload_test_datasets.py @@ -1,6 +1,8 @@ # A helper script cleans open taxi data (https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page) # and store into snowflake database. +from absl.logging import logging + from snowflake.ml._internal.utils import identifier from snowflake.ml.utils.connection_params import SnowflakeLoginOptions from snowflake.snowpark import Session @@ -17,6 +19,8 @@ WINEDATA_NAME = "winequality-red.csv" FILE_LOCAL_PATH = "file://~/Downloads/" +logger = logging.getLogger(__name__) + def create_tripdata(sess: Session, overwrite_mode: str) -> None: sess.file.put(f"{FILE_LOCAL_PATH}/{TRIPDATA_NAME}", sess.get_session_stage()) @@ -28,7 +32,7 @@ def create_tripdata(sess: Session, overwrite_mode: str) -> None: df.write.mode(overwrite_mode).save_as_table(full_table_name) rows_count = sess.sql(f"SELECT COUNT(*) FROM {full_table_name}").collect()[0][0] - print(f"{full_table_name} has total {rows_count} rows.") + logger.info(f"{full_table_name} has total {rows_count} rows.") def create_winedata(sess: Session, overwrite_mode: str) -> None: @@ -59,7 +63,7 @@ def create_winedata(sess: Session, overwrite_mode: str) -> None: df.write.mode(overwrite_mode).save_as_table(full_table_name) rows_count = sess.sql(f"SELECT COUNT(*) FROM {full_table_name}").collect()[0][0] - print(f"{full_table_name} has total {rows_count} rows.") + logger.info(f"{full_table_name} has total {rows_count} rows.") if __name__ == "__main__": @@ -68,4 +72,4 @@ def create_winedata(sess: Session, overwrite_mode: str) -> None: create_tripdata(sess, "overwrite") create_winedata(sess, "overwrite") - print("Script completes successfully.") + logger.info("Script completes successfully.") diff --git a/snowflake/ml/feature_store/_internal/synthetic_data_generator.py b/snowflake/ml/feature_store/_internal/synthetic_data_generator.py index 1646e18d..e2133cc0 100644 --- a/snowflake/ml/feature_store/_internal/synthetic_data_generator.py +++ b/snowflake/ml/feature_store/_internal/synthetic_data_generator.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple, cast +from absl.logging import logging + from snowflake.snowpark import Session from snowflake.snowpark.types import ( DateType, @@ -17,6 +19,8 @@ _NumericType, ) +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class Stats: @@ -91,4 +95,4 @@ def _generate_new_data(self, num_rows: int) -> None: df.write.mode("append").save_as_table( # type:ignore[call-overload] [self._database, self._schema, self._source_table], block=True ) - print(f"Dumped {num_rows} rows to table {self._source_table}.") + logger.info(f"Dumped {num_rows} rows to table {self._source_table}.") diff --git a/snowflake/ml/feature_store/entity.py b/snowflake/ml/feature_store/entity.py index 40088fdb..a1b444a2 100644 --- a/snowflake/ml/feature_store/entity.py +++ b/snowflake/ml/feature_store/entity.py @@ -1,6 +1,5 @@ -from typing import List +from typing import Dict, List -from snowflake.ml._internal.utils.identifier import get_unescaped_names from snowflake.ml._internal.utils.sql_identifier import ( SqlIdentifier, to_sql_identifiers, @@ -29,26 +28,33 @@ def __init__(self, name: str, join_keys: List[str], desc: str = "") -> None: join_keys: join keys associated with a FeatureView, used for feature retrieval. desc: description of the Entity. """ + self._validate(name, join_keys) - self.name: str = name + self.name: SqlIdentifier = SqlIdentifier(name) self.join_keys: List[SqlIdentifier] = to_sql_identifiers(join_keys) self.desc = desc - self._validate() - def _validate(self) -> None: - if len(self.name) > ENTITY_NAME_LENGTH_LIMIT: - raise ValueError(f"Entity name `{self.name}` exceeds maximum length: {ENTITY_NAME_LENGTH_LIMIT}") - if FEATURE_VIEW_ENTITY_TAG_DELIMITER in self.name: + def _validate(self, name: str, join_keys: List[str]) -> None: + if len(name) > ENTITY_NAME_LENGTH_LIMIT: + raise ValueError(f"Entity name `{name}` exceeds maximum length: {ENTITY_NAME_LENGTH_LIMIT}") + if FEATURE_VIEW_ENTITY_TAG_DELIMITER in name: raise ValueError(f"Entity name contains invalid char: `{FEATURE_VIEW_ENTITY_TAG_DELIMITER}`") - if len(set(self.join_keys)) != len(self.join_keys): - raise ValueError(f"Duplicate join keys detected in: {self.join_keys}") - if len(FEATURE_VIEW_ENTITY_TAG_DELIMITER.join(self.join_keys)) > ENTITY_JOIN_KEY_LENGTH_LIMIT: + if len(set(join_keys)) != len(join_keys): + raise ValueError(f"Duplicate join keys detected in: {join_keys}") + if len(FEATURE_VIEW_ENTITY_TAG_DELIMITER.join(join_keys)) > ENTITY_JOIN_KEY_LENGTH_LIMIT: raise ValueError(f"Total length of join keys exceeded maximum length: {ENTITY_JOIN_KEY_LENGTH_LIMIT}") - for k in self.join_keys: + for k in join_keys: if ENTITY_JOIN_KEY_DELIMITER in k: raise ValueError(f"Invalid char `{ENTITY_JOIN_KEY_DELIMITER}` detected in join key {k}") + def _to_dict(self) -> Dict[str, str]: + entity_dict = self.__dict__.copy() + for k, v in entity_dict.items(): + if isinstance(v, SqlIdentifier): + entity_dict[k] = str(v) + return entity_dict + def __repr__(self) -> str: states = (f"{k}={v}" for k, v in vars(self).items()) return f"{type(self).__name__}({', '.join(states)})" @@ -57,8 +63,4 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, Entity): return False - return ( - get_unescaped_names(self.name) == get_unescaped_names(other.name) - and self.desc == other.desc - and self.join_keys == other.join_keys - ) + return self.name == other.name and self.desc == other.desc and self.join_keys == other.join_keys diff --git a/snowflake/ml/feature_store/feature_store.py b/snowflake/ml/feature_store/feature_store.py index 657a3877..34f758a0 100644 --- a/snowflake/ml/feature_store/feature_store.py +++ b/snowflake/ml/feature_store/feature_store.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import json import logging @@ -50,19 +52,9 @@ # TODO: Enable when ASOF join is released. https://snowflakecomputing.atlassian.net/browse/SNOW-780702 _ENABLE_ASOF_JOIN = False -DT_QUERY_PATTERN = re.compile( - r""".*COMMENT\ =\ '(?P.*)'\s* - TAG.*?{entity_tag}\ =\ '(?P.*?)',\n - .*?{ts_col_tag}\ =\ '(?P.*?)',?\n.* - lag\ =\ '(?P.*?)'\ warehouse\ =\ (?P.*?)\s*AS\ (?P.*) - """.format( - entity_tag=FEATURE_VIEW_ENTITY_TAG, ts_col_tag=FEATURE_VIEW_TS_COL_TAG - ), - flags=re.DOTALL | re.IGNORECASE | re.X, -) - -VIEW_QUERY_PATTERN = re.compile( - r""".*COMMENT\ =\ '(?P.*)'\s* +DT_OR_VIEW_QUERY_PATTERN = re.compile( + r"""CREATE\ (?P(DYNAMIC\ TABLE|VIEW))\ .* + COMMENT\ =\ '(?P.*)'\s* TAG.*?{entity_tag}\ =\ '(?P.*?)',\n .*?{ts_col_tag}\ =\ '(?P.*?)',?.*? AS\ (?P.*) @@ -78,24 +70,15 @@ class CreationMode(Enum): CREATE_IF_NOT_EXIST = 2 -@dataclass(frozen=True) +@dataclass class _FeatureStoreConfig: database: SqlIdentifier schema: SqlIdentifier - default_warehouse: SqlIdentifier @property def full_schema_path(self) -> str: return f"{self.database}.{self.schema}" - def to_json(self) -> str: - return json.dumps(self.__dict__) - - @classmethod - def from_json(cls, json_str: str) -> "_FeatureStoreConfig": - json_dict = json.loads(json_str) - return cls(**json_dict) - class FeatureStore: """ @@ -109,7 +92,6 @@ def __init__( session: Session, database: str, name: str, - default_warehouse: str, creation_mode: CreationMode = CreationMode.FAIL_IF_NOT_EXIST, ) -> None: """ @@ -119,26 +101,24 @@ def __init__( session: Snowpark Session to interact with Snowflake backend. database: Database to create the FeatureStore instance. name: Target FeatureStore name, maps to a schema in the database. - default_warehouse: Default warehouse setting to materialize feature pipelines. creation_mode: Create new backend or fail if not exist upon feature store creation. Raises: - SnowflakeMLException: [ValueError] Default_warehouse does not exist. SnowflakeMLException: [ValueError] FAIL_IF_NOT_EXIST is set and feature store not exists. SnowflakeMLException: [RuntimeError] Failed to find resources. SnowflakeMLException: [RuntimeError] Failed to create feature store. """ database = SqlIdentifier(database) name = SqlIdentifier(name) - default_warehouse = SqlIdentifier(default_warehouse) self._telemetry_stmp = telemetry.get_function_usage_statement_params(PROJECT) self._session: Session = session self._config = _FeatureStoreConfig( database=database, schema=name, - default_warehouse=default_warehouse, ) + self._default_warehouse: Optional[SqlIdentifier] = None + # A dict from object name to tuple of search space and object domain. # search space used in query "SHOW LIKE IN " # object domain used in query "TAG_REFERENCE(, )" @@ -152,15 +132,6 @@ def __init__( "WAREHOUSES": (None, None), } - # 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.NOT_FOUND, - original_exception=ValueError(f"Cannot find warehouse {self._config.default_warehouse}"), - ) - if creation_mode == CreationMode.FAIL_IF_NOT_EXIST: schema_result = self._find_object("SCHEMAS", self._config.schema) if len(schema_result) == 0: @@ -176,11 +147,13 @@ def __init__( self._session.sql(f"CREATE SCHEMA IF NOT EXISTS {self._config.full_schema_path}").collect( statement_params=self._telemetry_stmp ) - for tag in [ - FEATURE_VIEW_ENTITY_TAG, - FEATURE_VIEW_TS_COL_TAG, - FEATURE_STORE_OBJECT_TAG, - ]: + for tag in to_sql_identifiers( + [ + FEATURE_VIEW_ENTITY_TAG, + FEATURE_VIEW_TS_COL_TAG, + FEATURE_STORE_OBJECT_TAG, + ] + ): self._session.sql(f"CREATE TAG IF NOT EXISTS {self._get_fully_qualified_name(tag)}").collect( statement_params=self._telemetry_stmp ) @@ -193,6 +166,31 @@ def __init__( logger.info(f"Successfully connected to feature store: {self._config.full_schema_path}.") + @telemetry.send_api_usage_telemetry(project=PROJECT) + @snowpark_utils.private_preview(version="1.0.12") + def set_default_warehouse(self, warehouse_name: str) -> FeatureStore: + """Set default warehouse for feature store. + + Args: + warehouse_name: Name of warehouse. + + Returns: + FeatureStore object with default warehouse. + + Raises: + SnowflakeMLException: If warehouse does not exists. + """ + warehouse = SqlIdentifier(warehouse_name) + warehouse_result = self._find_object("WAREHOUSES", warehouse) + if len(warehouse_result) == 0: + raise snowml_exceptions.SnowflakeMLException( + error_code=error_codes.NOT_FOUND, + original_exception=ValueError(f"Cannot find warehouse {warehouse}"), + ) + + self._default_warehouse = warehouse + return self + @telemetry.send_api_usage_telemetry(project=PROJECT) @snowpark_utils.private_preview(version="1.0.8") def register_entity(self, entity: Entity) -> None: @@ -216,12 +214,12 @@ def register_entity(self, entity: Entity) -> None: ) join_keys_str = ENTITY_JOIN_KEY_DELIMITER.join(entity.join_keys) - tag_name = self._get_fully_qualified_name(tag_name) - self._session.sql(f"CREATE TAG IF NOT EXISTS {tag_name} COMMENT = '{entity.desc}'").collect( + full_tag_name = self._get_fully_qualified_name(tag_name) + self._session.sql(f"CREATE TAG IF NOT EXISTS {full_tag_name} COMMENT = '{entity.desc}'").collect( statement_params=self._telemetry_stmp ) self._session.sql( - f"ALTER SCHEMA {self._config.full_schema_path} SET TAG {tag_name} = '{join_keys_str}'" + f"ALTER SCHEMA {self._config.full_schema_path} SET TAG {full_tag_name} = '{join_keys_str}'" ).collect(statement_params=self._telemetry_stmp) logger.info(f"Registered Entity {entity}.") @@ -266,9 +264,11 @@ def register_feature_view( SnowflakeMLException: [ValueError] FeatureView is already registered, or duplicate name and version are detected. SnowflakeMLException: [ValueError] FeatureView entity has not been registered. + SnowflakeMLException: [ValueError] Warehouse or default warehouse is not specified. SnowflakeMLException: [RuntimeError] Failed to create dynamic table, task, or view. SnowflakeMLException: [RuntimeError] Failed to find resources. """ + version = SqlIdentifier(version) if warehouse is not None: warehouse = SqlIdentifier(warehouse) @@ -310,15 +310,20 @@ def register_feature_view( ) def create_col_desc(col: StructField) -> str: - desc = feature_view.feature_descs.get(col.name, None) + desc = feature_view.feature_descs.get(SqlIdentifier(col.name), None) desc = "" if desc is None else f"COMMENT '{desc}'" return f"{col.name} {desc}" column_descs = ", ".join([f"{create_col_desc(col)}" for col in feature_view.output_schema.fields]) if refresh_freq is not None: + if self._default_warehouse is None and warehouse is None: + raise snowml_exceptions.SnowflakeMLException( + error_code=error_codes.INVALID_ARGUMENT, + original_exception=ValueError("Warehouse cannot be None."), + ) schedule_task = refresh_freq != "DOWNSTREAM" and timeparse(refresh_freq) is None - target_warehouse = self._config.default_warehouse if warehouse is None else warehouse + target_warehouse = cast(SqlIdentifier, self._default_warehouse if warehouse is None else warehouse) self._create_dynamic_table( feature_view_name, feature_view, @@ -396,12 +401,16 @@ def list_feature_views( Returns: List of FeatureViews or in a DataFrame representation. """ + if entity_name is not None: + entity_name = SqlIdentifier(entity_name) + if feature_view_name is not None: + feature_view_name = SqlIdentifier(feature_view_name) + if entity_name is not None: fvs = self._find_feature_views(entity_name, feature_view_name) else: - fv_name = "" if feature_view_name is None else feature_view_name fvs = [] - for row in self._get_backend_representations(f"{fv_name}%"): + for row in self._get_backend_representations(feature_view_name, prefix_match=True): fvs.append(self._compose_feature_view(row)) if as_dataframe: @@ -430,6 +439,9 @@ def get_feature_view(self, name: str, version: str) -> FeatureView: SnowflakeMLException: [ValueError] FeatureView with name and version is not found, or incurred exception when reconstructing the FeatureView object. """ + name = SqlIdentifier(name) + version = SqlIdentifier(version) + fv_name = FeatureView._get_physical_name(name, version) results = self._get_backend_representations(fv_name) if len(results) != 1: @@ -467,6 +479,8 @@ def merge_features( SnowflakeMLException: [ValueError] FeatureView has not been registered. SnowflakeMLException: [ValueError] FeatureView merge failed. """ + name = SqlIdentifier(name) + if len(features) < 2: raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INVALID_ARGUMENT, @@ -676,6 +690,8 @@ def get_entity(self, name: str) -> Entity: SnowflakeMLException: [RuntimeError] Failed to retrieve tag reference information. SnowflakeMLException: [RuntimeError] Failed to find resources. """ + name = SqlIdentifier(name) + full_entity_tag_name = self._get_entity_name(name) prefix_len = len(ENTITY_TAG_PREFIX) + 1 @@ -687,9 +703,7 @@ def get_entity(self, name: str) -> Entity: ) try: - name = self._get_entity_name(name) - unesc_full_entity_tag_name = identifier.get_unescaped_names(name) - + physical_name = self._get_entity_name(name) tag_values = ( qrc.SqlResultValidator( self._session, @@ -702,7 +716,7 @@ def get_entity(self, name: str) -> Entity: 'SCHEMA' ) ) - WHERE TAG_NAME LIKE '{unesc_full_entity_tag_name}' + WHERE TAG_NAME LIKE '{physical_name.resolved()}' AND TAG_DATABASE = '{self._config.database.resolved()}' """, self._telemetry_stmp, @@ -741,6 +755,8 @@ def delete_entity(self, name: str) -> None: SnowflakeMLException: [RuntimeError] Failed to alter schema or drop tag. SnowflakeMLException: [RuntimeError] Failed to find resources. """ + name = SqlIdentifier(name) + if not self._validate_entity_exists(name): raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.NOT_FOUND, @@ -889,7 +905,8 @@ def generate_dataset( original_exception=ValueError(f"materialized_table {materialized_table} contains invalid char `.`"), ) - found_rows = self._find_object("TABLES", materialized_table) + # TODO (wezhou) change materialized_table to SqlIdentifier + found_rows = self._find_object("TABLES", SqlIdentifier(materialized_table)) if save_mode.lower() == "errorifexists" and len(found_rows) > 0: raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.OBJECT_ALREADY_EXISTS, @@ -946,6 +963,27 @@ def generate_dataset( ) return dataset + @telemetry.send_api_usage_telemetry(project=PROJECT) + @snowpark_utils.private_preview(version="1.0.8") + def load_feature_views_from_dataset(self, dataset: Dataset) -> List[Union[FeatureView, FeatureViewSlice]]: + """ + Retrieve FeatureViews used during Dataset construction. + + Args: + dataset: Dataset object created from feature store. + + Returns: + List of FeatureViews used during Dataset construction. + + Raises: + ValueError: if dataset object is not generated from feature store. + """ + serialized_objs = dataset.load_features() + if serialized_objs is None: + raise ValueError(f"Dataset {dataset} does not contain valid feature view information.") + + return self._load_serialized_feature_objects(serialized_objs) + @telemetry.send_api_usage_telemetry(project=PROJECT) @snowpark_utils.private_preview(version="1.0.8") def clear(self) -> None: @@ -969,20 +1007,20 @@ def clear(self) -> None: object_types = ["DYNAMIC TABLES", "TABLES", "VIEWS", "TASKS"] for obj_type in object_types: - all_object_rows = self._find_object(obj_type, "%") + all_object_rows = self._find_object(obj_type, None) for row in all_object_rows: - obj_name = self._get_fully_qualified_name(identifier.get_inferred_name(row["name"])) + obj_name = self._get_fully_qualified_name(SqlIdentifier(row["name"], case_sensitive=True)) self._session.sql(f"DROP {obj_type[:-1]} {obj_name}").collect() logger.info(f"Deleted {obj_type[:-1]}: {obj_name}.") - entity_tags = self._find_object("TAGS", f"{ENTITY_TAG_PREFIX}%") + entity_tags = self._find_object("TAGS", SqlIdentifier(ENTITY_TAG_PREFIX), prefix_match=True) all_tags = [ FEATURE_VIEW_ENTITY_TAG, FEATURE_VIEW_TS_COL_TAG, FEATURE_STORE_OBJECT_TAG, - ] + [row["name"] for row in entity_tags] + ] + [SqlIdentifier(row["name"], case_sensitive=True) for row in entity_tags] for tag_name in all_tags: - obj_name = self._get_fully_qualified_name(identifier.get_inferred_name(tag_name)) + obj_name = self._get_fully_qualified_name(tag_name) self._session.sql(f"DROP TAG IF EXISTS {obj_name}").collect() logger.info(f"Deleted TAG: {obj_name}.") @@ -995,7 +1033,7 @@ def clear(self) -> None: def _create_dynamic_table( self, - feature_view_name: str, + feature_view_name: SqlIdentifier, feature_view: FeatureView, fully_qualified_name: str, column_descs: str, @@ -1063,6 +1101,7 @@ def _create_dynamic_table( f"Dynamic table: `{fully_qualified_name}` will not refresh in INCREMENTAL mode. " + "It will likely incurr bigger computation cost. " + f"The reason is: {found_dts[0]['refresh_mode_reason']}", + stacklevel=2, category=UserWarning, ) @@ -1118,7 +1157,7 @@ def _dump_dataset( original_exception=RuntimeError(f"Failed to create dataset {fully_qualified_name} with merge: {e}."), ) from e - def _validate_version_identifier(self, version: str) -> None: + def _validate_version_identifier(self, version: SqlIdentifier) -> None: if FEATURE_VIEW_NAME_DELIMITER in version: raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INVALID_ARGUMENT, @@ -1127,7 +1166,7 @@ def _validate_version_identifier(self, version: str) -> None: ), ) - def _validate_entity_exists(self, name: str) -> bool: + def _validate_entity_exists(self, name: SqlIdentifier) -> bool: full_entity_tag_name = self._get_entity_name(name) found_rows = self._find_object("TAGS", full_entity_tag_name) return len(found_rows) > 0 @@ -1294,7 +1333,9 @@ def join_cols(cols: List[SqlIdentifier], end_comma: bool, rename: bool, prefix: )""" # Part 4: join original spine table with window table - prefix_f_only_cols = to_sql_identifiers([f"{temp_prefix}{name.resolved()}" for name in f_only_cols], False) + prefix_f_only_cols = to_sql_identifiers( + [f"{temp_prefix}{name.resolved()}" for name in f_only_cols], case_sensitive=True + ) last_select = f""" SELECT {join_keys_str}, @@ -1311,16 +1352,18 @@ def join_cols(cols: List[SqlIdentifier], end_comma: bool, rename: bool, prefix: return complete_query - def _get_entity_name(self, raw_name: str) -> str: - return identifier.concat_names([ENTITY_TAG_PREFIX, raw_name]) + def _get_entity_name(self, raw_name: SqlIdentifier) -> SqlIdentifier: + return SqlIdentifier(identifier.concat_names([ENTITY_TAG_PREFIX, raw_name])) - def _get_fully_qualified_name(self, name: str) -> str: + def _get_fully_qualified_name(self, name: Union[SqlIdentifier, str]) -> str: return f"{self._config.full_schema_path}.{name}" # TODO: SHOW DYNAMIC TABLES is very slow while other show objects are fast, investigate with DT in SNOW-902804. - def _get_backend_representations(self, object_name_pattern: str) -> List[Row]: - dynamic_table_results = self._find_object("DYNAMIC TABLES", object_name_pattern) - view_results = self._find_object("VIEWS", object_name_pattern) + def _get_backend_representations( + self, object_name: Optional[SqlIdentifier], prefix_match: bool = False + ) -> List[Row]: + dynamic_table_results = self._find_object("DYNAMIC TABLES", object_name, prefix_match) + view_results = self._find_object("VIEWS", object_name, prefix_match) return dynamic_table_results + view_results def _update_feature_view_status(self, feature_view: FeatureView, operation: str) -> FeatureView: @@ -1349,11 +1392,13 @@ def _update_feature_view_status(self, feature_view: FeatureView, operation: str) logger.info(f"Successfully {operation} FeatureView {feature_view.name} with version {feature_view.version}.") return feature_view - def _find_feature_views(self, entity_name: str, feature_view_name: Optional[str]) -> List[FeatureView]: + def _find_feature_views( + self, entity_name: SqlIdentifier, feature_view_name: Optional[SqlIdentifier] + ) -> List[FeatureView]: if not self._validate_entity_exists(entity_name): return [] - all_fv_names = [r["name"] for r in self._get_backend_representations("%")] + all_fv_names = [SqlIdentifier(r["name"], case_sensitive=True) for r in self._get_backend_representations(None)] if len(all_fv_names) == 0: return [] @@ -1374,7 +1419,7 @@ def _find_feature_views(self, entity_name: str, feature_view_name: Optional[str] WHERE LEVEL = 'TABLE' AND TAG_NAME = '{FEATURE_VIEW_ENTITY_TAG}' """ - for fv_name in identifier.get_escaped_names(all_fv_names) + for fv_name in all_fv_names ] results = self._session.sql("\nUNION\n".join(queries)).collect(statement_params=self._telemetry_stmp) @@ -1385,24 +1430,31 @@ def _find_feature_views(self, entity_name: str, feature_view_name: Optional[str] ) from e outputs = [] for r in results: - if identifier.get_unescaped_names(entity_name) == identifier.get_unescaped_names(r["TAG_VALUE"]): - fv_name, version = r["OBJECT_NAME"].split(FEATURE_VIEW_NAME_DELIMITER) + if entity_name == SqlIdentifier(r["TAG_VALUE"], case_sensitive=True): + fv_name, version = to_sql_identifiers( + r["OBJECT_NAME"].split(FEATURE_VIEW_NAME_DELIMITER), case_sensitive=True + ) if feature_view_name is not None: - if fv_name == identifier.get_unescaped_names(feature_view_name): + if fv_name == feature_view_name: outputs.append(self.get_feature_view(fv_name, version)) else: continue else: - outputs.append(self.get_feature_view(fv_name, version)) + outputs.append(self.get_feature_view(fv_name.identifier(), version.identifier())) return outputs def _compose_feature_view(self, row: Row) -> FeatureView: - name, version = row["name"].split(FEATURE_VIEW_NAME_DELIMITER) - name = identifier.get_inferred_name(name) - version = identifier.get_inferred_name(version) + name, version = to_sql_identifiers(row["name"].split(FEATURE_VIEW_NAME_DELIMITER), case_sensitive=True) + m = re.match(DT_OR_VIEW_QUERY_PATTERN, row["text"]) + if m is None: + raise snowml_exceptions.SnowflakeMLException( + error_code=error_codes.INTERNAL_SNOWML_ERROR, + original_exception=RuntimeError( + f"Failed to parse query text for FeatureView {name} with version {version}: {row}." + ), + ) - m = re.match(DT_QUERY_PATTERN, row["text"]) - if m is not None: + if m.group("obj_type") == "DYNAMIC TABLE": query = m.group("query") df = self._session.sql(query) desc = m.group("comment") @@ -1419,18 +1471,18 @@ def _compose_feature_view(self, row: Row) -> FeatureView: desc=desc, version=version, status=FeatureViewStatus(row["scheduling_state"]), - feature_descs=self._fetch_column_descs("DYNAMIC TABLE", identifier.get_inferred_name(row["name"])), - refresh_freq=m.group("refresh_freq"), + feature_descs=self._fetch_column_descs( + "DYNAMIC TABLE", SqlIdentifier(row["name"], case_sensitive=True) + ), + refresh_freq=row["target_lag"], database=self._config.database.identifier(), schema=self._config.schema.identifier(), - warehouse=m.group("warehouse"), + warehouse=SqlIdentifier(row["warehouse"], case_sensitive=True).identifier(), refresh_mode=row["refresh_mode"], refresh_mode_reason=row["refresh_mode_reason"], ) return fv - - m = re.match(VIEW_QUERY_PATTERN, row["text"]) - if m is not None: + else: query = m.group("query") df = self._session.sql(query) desc = m.group("comment") @@ -1447,7 +1499,7 @@ def _compose_feature_view(self, row: Row) -> FeatureView: desc=desc, version=version, status=FeatureViewStatus.STATIC, - feature_descs=self._fetch_column_descs("VIEW", identifier.get_inferred_name(row["name"])), + feature_descs=self._fetch_column_descs("VIEW", SqlIdentifier(row["name"], case_sensitive=True)), refresh_freq=None, database=self._config.database.identifier(), schema=self._config.schema.identifier(), @@ -1457,14 +1509,7 @@ def _compose_feature_view(self, row: Row) -> FeatureView: ) return fv - raise snowml_exceptions.SnowflakeMLException( - error_code=error_codes.INTERNAL_SNOWML_ERROR, - original_exception=RuntimeError( - f"Failed to parse query text for FeatureView {name} with version {version}: {row}." - ), - ) - - def _fetch_column_descs(self, obj_type: str, obj_name: str) -> Dict[str, str]: + def _fetch_column_descs(self, obj_type: str, obj_name: SqlIdentifier) -> Dict[str, str]: res = self._session.sql(f"DESC {obj_type} {self._get_fully_qualified_name(obj_name)}").collect( statement_params=self._telemetry_stmp ) @@ -1472,20 +1517,20 @@ def _fetch_column_descs(self, obj_type: str, obj_name: str) -> Dict[str, str]: descs = {} for r in res: if r["comment"] is not None: - descs[r["name"]] = r["comment"] + descs[SqlIdentifier(r["name"], case_sensitive=True).identifier()] = r["comment"] return descs - def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: + def _find_object( + self, object_type: str, object_name: Optional[SqlIdentifier], prefix_match: bool = False + ) -> List[Row]: """Try to find an object by given type and name pattern. Args: 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. + object_name: Name of object. It will match everything of object_type is object_name is None. + prefix_match: Will search all objects with object_name as prefix if set True. Otherwise + will do exact on object_name. Default to false. If object_name is empty and prefix_match is + True, then it will match everything of object_type. Raises: SnowflakeMLException: [RuntimeError] Failed to find resource. @@ -1493,34 +1538,24 @@ def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: Returns: Return a list of rows round. """ - # TODO (wezhou) change type of object_name_pattern to SqlIdentifier. - if isinstance(object_name_pattern, SqlIdentifier): - object_name_pattern = object_name_pattern.identifier() - - if object_name_pattern == "": - return [] - - if object_name_pattern == "%": - 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] + if object_name is None: + match_name = "%" + elif prefix_match: + match_name = object_name.resolved() + "%" else: - unesc_object_name = identifier.get_unescaped_names(object_name_pattern) - object_name = unesc_object_name + match_name = object_name.resolved() search_space, obj_domain = self._obj_search_spaces[object_type] all_rows = [] - fs_objects = [] - tag_free_object_types = ["TAGS", "SCHEMAS"] + fs_tag_objects = [] + tag_free_object_types = ["TAGS", "SCHEMAS", "WAREHOUSES"] try: 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( + all_rows = self._session.sql(f"SHOW {object_type} LIKE '{match_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: + # There could be none-FS objects under FS schema, thus filter on objects with FS special tag. + if object_type not in tag_free_object_types and len(all_rows) > 0: # Note: in TAG_REFERENCES() is case insensitive, # use double quotes to make it case-sensitive. queries = [ @@ -1528,7 +1563,7 @@ def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: SELECT OBJECT_NAME FROM TABLE( {self._config.database}.INFORMATION_SCHEMA.TAG_REFERENCES( - '{self._get_fully_qualified_name(identifier.get_inferred_name(row['name']))}', + '{self._get_fully_qualified_name(SqlIdentifier(row['name'], case_sensitive=True))}', '{obj_domain}' ) ) @@ -1540,19 +1575,18 @@ def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: fs_obj_rows = self._session.sql("\nUNION\n".join(queries)).collect( statement_params=self._telemetry_stmp ) - fs_objects = [row["OBJECT_NAME"] for row in fs_obj_rows] + fs_tag_objects = [row["OBJECT_NAME"] for row in fs_obj_rows] except Exception as e: raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INTERNAL_SNOWPARK_ERROR, - original_exception=RuntimeError(f"Failed to find object {object_name_pattern}: {e}"), + original_exception=RuntimeError(f"Failed to find object : {e}"), ) from e result = [] for row in all_rows: found_name = row["name"] - if found_name.startswith(object_name) and ( - object_name_pattern != "%" or object_type in tag_free_object_types or found_name in fs_objects - ): + prefix = object_name.resolved() if object_name is not None else "" + if found_name.startswith(prefix) and (object_type in tag_free_object_types or found_name in fs_tag_objects): result.append(row) return result diff --git a/snowflake/ml/feature_store/feature_view.py b/snowflake/ml/feature_store/feature_view.py index badd5ab7..83cd6d00 100644 --- a/snowflake/ml/feature_store/feature_view.py +++ b/snowflake/ml/feature_store/feature_view.py @@ -6,7 +6,7 @@ from enum import Enum from typing import Dict, List, Optional -from snowflake.ml._internal.utils.identifier import concat_names, get_unescaped_names +from snowflake.ml._internal.utils.identifier import concat_names from snowflake.ml._internal.utils.sql_identifier import ( SqlIdentifier, to_sql_identifiers, @@ -92,7 +92,7 @@ def __init__( desc: description of the FeatureView. """ - self._name: str = name + self._name: SqlIdentifier = SqlIdentifier(name) self._entities: List[Entity] = entities self._feature_df: DataFrame = feature_df self._timestamp_col: Optional[SqlIdentifier] = ( @@ -100,11 +100,9 @@ def __init__( ) self._desc: str = desc self._query: str = self._get_query() - self._version: Optional[str] = None + self._version: Optional[SqlIdentifier] = None self._status: FeatureViewStatus = FeatureViewStatus.DRAFT - self._feature_desc: OrderedDict[SqlIdentifier, Optional[str]] = OrderedDict( - (f, None) for f in self._get_feature_names() - ) + self._feature_desc: OrderedDict[SqlIdentifier, str] = OrderedDict((f, "") for f in self._get_feature_names()) self._refresh_freq: Optional[str] = None self._database: Optional[SqlIdentifier] = None self._schema: Optional[SqlIdentifier] = None @@ -135,7 +133,7 @@ def slice(self, names: List[str]) -> FeatureViewSlice: res.append(name) return FeatureViewSlice(self, res) - def physical_name(self) -> str: + def physical_name(self) -> SqlIdentifier: """Returns the physical name for this feature in Snowflake. Returns: @@ -144,7 +142,7 @@ def physical_name(self) -> str: Raises: RuntimeError: if the FeatureView is not materialized. """ - if self.status == FeatureViewStatus.DRAFT: + if self.status == FeatureViewStatus.DRAFT or self.version is None: raise RuntimeError(f"FeatureView {self.name} has not been materialized.") return FeatureView._get_physical_name(self.name, self.version) @@ -181,7 +179,7 @@ def attach_feature_desc(self, descs: Dict[str, str]) -> FeatureView: return self @property - def name(self) -> str: + def name(self) -> SqlIdentifier: return self._name @property @@ -205,7 +203,7 @@ def query(self) -> str: return self._query @property - def version(self) -> Optional[str]: + def version(self) -> Optional[SqlIdentifier]: return self._version @property @@ -217,11 +215,8 @@ def feature_names(self) -> List[SqlIdentifier]: return list(self._feature_desc.keys()) @property - def feature_descs(self) -> Dict[str, Optional[str]]: - new_dict = {} - for k, v in self._feature_desc.items(): - new_dict[k.identifier()] = v - return new_dict + def feature_descs(self) -> Dict[SqlIdentifier, str]: + return self._feature_desc @property def refresh_freq(self) -> Optional[str]: @@ -288,7 +283,7 @@ def _validate(self) -> None: def _get_feature_names(self) -> List[SqlIdentifier]: join_keys = [k for e in self._entities for k in e.join_keys] ts_col = [self._timestamp_col] if self._timestamp_col is not None else [] - feature_names = to_sql_identifiers(self._feature_df.columns, False) + feature_names = to_sql_identifiers(self._feature_df.columns, case_sensitive=True) return [c for c in feature_names if c not in join_keys + ts_col] def __repr__(self) -> str: @@ -300,8 +295,8 @@ def __eq__(self, other: object) -> bool: return False return ( - get_unescaped_names(self.name) == get_unescaped_names(other.name) - and get_unescaped_names(self.version) == get_unescaped_names(other.version) + self.name == other.name + and self.version == other.version and self.timestamp_col == other.timestamp_col and self.entities == other.entities and self.desc == other.desc @@ -320,17 +315,26 @@ def _to_dict(self) -> Dict[str, str]: fv_dict = self.__dict__.copy() if "_feature_df" in fv_dict: fv_dict.pop("_feature_df") - fv_dict["_entities"] = [e.__dict__ for e in self._entities] + fv_dict["_entities"] = [e._to_dict() for e in self._entities] fv_dict["_status"] = str(self._status) + fv_dict["_name"] = str(self._name) if self._name is not None else None + fv_dict["_version"] = str(self._version) if self._version is not None else None fv_dict["_database"] = str(self._database) if self._database is not None else None fv_dict["_schema"] = str(self._schema) if self._schema is not None else None fv_dict["_warehouse"] = str(self._warehouse) if self._warehouse is not None else None + fv_dict["_timestamp_col"] = str(self._timestamp_col) if self._timestamp_col is not None else None + + feature_desc_dict = {} + for k, v in self._feature_desc.items(): + feature_desc_dict[k.identifier()] = v + fv_dict["_feature_desc"] = feature_desc_dict + return fv_dict def to_df(self, session: Session) -> DataFrame: values = list(self._to_dict().values()) schema = [x.lstrip("_") for x in list(self._to_dict().keys())] - values.append(self.physical_name()) + values.append(str(self.physical_name())) schema.append("physical_name") return session.create_dataframe([values], schema=schema) @@ -363,13 +367,15 @@ def from_json(cls, json_str: str, session: Session) -> FeatureView: ) @staticmethod - def _get_physical_name(fv_name: Optional[str], fv_version: Optional[str]) -> str: - return concat_names( - [ - fv_name if fv_name is not None else "", - FEATURE_VIEW_NAME_DELIMITER, - fv_version if fv_version is not None else "", - ] + def _get_physical_name(fv_name: SqlIdentifier, fv_version: SqlIdentifier) -> SqlIdentifier: + return SqlIdentifier( + concat_names( + [ + fv_name, + FEATURE_VIEW_NAME_DELIMITER, + fv_version, + ] + ) ) @staticmethod @@ -396,7 +402,7 @@ def _construct_feature_view( timestamp_col=timestamp_col, desc=desc, ) - fv._version = version + fv._version = SqlIdentifier(version) if version is not None else None fv._status = status fv._refresh_freq = refresh_freq fv._database = SqlIdentifier(database) if database is not None else None 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 a2447da8..16ca1bb1 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.2\n", + "Version: 0.2.1\n", "\n", - "Updated date: 10/18/2023" + "Updated date: 11/17/2023" ] }, { @@ -103,6 +103,33 @@ "session.sql(f\"CREATE WAREHOUSE IF NOT EXISTS {FS_DEMO_WH}\").collect()" ] }, + { + "cell_type": "markdown", + "id": "4ece7a2b", + "metadata": {}, + "source": [ + "## Create FeatureStore Client\n", + "\n", + "Let's first create a feature store client.\n", + "\n", + "We can pass in an existing database name, or a new database will be created upon the feature store initialization. Replace `DEMO_DB` and `DEMO_SCHEMA` with your database and schema." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe850ccd", + "metadata": {}, + "outputs": [], + "source": [ + "fs = FeatureStore(\n", + " session=session, \n", + " database=FS_DEMO_DB, \n", + " name=FS_DEMO_SCHEMA, \n", + " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", + ").set_default_warehouse(FS_DEMO_WH)" + ] + }, { "cell_type": "markdown", "id": "b79ba9be", @@ -149,35 +176,8 @@ "df = session.read.options({\"field_delimiter\": \";\", \"skip_header\": 1}) \\\n", " .schema(input_schema) \\\n", " .csv(f\"{session.get_session_stage()}/winequality-red.csv\")\n", - "df.write.mode(\"overwrite\").save_as_table(\"WINE_DATA\")" - ] - }, - { - "cell_type": "markdown", - "id": "4ece7a2b", - "metadata": {}, - "source": [ - "## Create FeatureStore Client\n", - "\n", - "Let's first create a feature store client.\n", - "\n", - "We can pass in an existing database name, or a new database will be created upon the feature store initialization. Replace `DEMO_DB` and `DEMO_SCHEMA` with your database and schema." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fe850ccd", - "metadata": {}, - "outputs": [], - "source": [ - "fs = FeatureStore(\n", - " session=session, \n", - " database=FS_DEMO_DB, \n", - " name=FS_DEMO_SCHEMA, \n", - " default_warehouse=FS_DEMO_WH,\n", - " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", - ")" + "full_table_name = f\"{FS_DEMO_DB}.{TEST_DATASET_SCHEMA}.WINE_DATA\"\n", + "df.write.mode(\"overwrite\").save_as_table(full_table_name)" ] }, { @@ -225,7 +225,7 @@ "metadata": {}, "outputs": [], "source": [ - "source_df = session.table(f\"{FS_DEMO_DB}.{TEST_DATASET_SCHEMA}.WINE_DATA\")\n", + "source_df = session.table(full_table_name)\n", "source_df.show()" ] }, @@ -308,7 +308,7 @@ " feature_df=feature_df, \n", " desc=\"wine features\"\n", ")\n", - "fs.register_feature_view(\n", + "fv = fs.register_feature_view(\n", " feature_view=fv, \n", " version=\"V1\", \n", " refresh_freq=\"1 minute\", \n", @@ -360,7 +360,7 @@ " feature_df=extra_feature_df, \n", " desc=\"extra wine features\"\n", ")\n", - "fs.register_feature_view(\n", + "new_fv = fs.register_feature_view(\n", " feature_view=new_fv, \n", " version=\"V1\", \n", " refresh_freq=\"1 minute\", \n", @@ -401,7 +401,7 @@ "source": [ "full_fv = fs.merge_features(\n", " features=[fv, new_fv], name=\"FULL_WINE_FEATURES\")\n", - "fs.register_feature_view(feature_view=full_fv, version=\"V2\")" + "full_fv = fs.register_feature_view(feature_view=full_fv, version=\"V1\")" ] }, { @@ -472,27 +472,14 @@ "source": [ "## Train a model\n", "\n", - "Now let's training a simple random forest model with sklearn library, and evaluate the prediction accuracy." + "Now let's training a simple random forest model, and evaluate the prediction accuracy." ] }, { - "cell_type": "code", - "execution_count": null, - "id": "29747582", + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "import numpy as np\n", - "from sklearn.model_selection import train_test_split\n", - "from sklearn.ensemble import RandomForestRegressor\n", - "from sklearn.metrics import mean_squared_error\n", - "\n", - "training_pd = training_data.df.to_pandas()\n", - "X = training_pd.drop(\"QUALITY\", axis=1)\n", - "y = training_pd[\"QUALITY\"]\n", - "X_train, X_test, y_train, y_test = train_test_split(\n", - " X, y, test_size=0.2, random_state=42)\n", - "X_train.head()" + "## [Train option 1] Using Sklearn" ] }, { @@ -502,7 +489,19 @@ "metadata": {}, "outputs": [], "source": [ - "def train_model(X_train, X_test, y_train, y_test):\n", + "import numpy as np\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.ensemble import RandomForestRegressor\n", + "from sklearn.metrics import mean_squared_error\n", + "\n", + "def train_model_using_sklearn(training_data):\n", + " training_pd = training_data.df.to_pandas()\n", + " X = training_pd.drop(\"QUALITY\", axis=1)\n", + " y = training_pd[\"QUALITY\"]\n", + " X_train, X_test, y_train, y_test = train_test_split(\n", + " X, y, test_size=0.2, random_state=42)\n", + " X_train.head()\n", + "\n", " rf = RandomForestRegressor(\n", " max_depth=3, n_estimators=20, random_state=42)\n", " rf.fit(X_train, y_train)\n", @@ -513,7 +512,55 @@ " print(f\"MSE: {mse}, Accuracy: {accuracy}\")\n", " return rf\n", " \n", - "rf = train_model(X_train, X_test, y_train, y_test)\n", + "rf = train_model_using_sklearn(training_data)\n", + "print(rf)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## [Train Option 2] Using Snowaprk ML" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from snowflake.ml.modeling.ensemble import RandomForestRegressor\n", + "from snowflake.ml.modeling import metrics as snowml_metrics\n", + "from snowflake.snowpark.functions import abs as sp_abs, mean, col\n", + "\n", + "def train_model_using_snowpark_ml(training_data):\n", + " train, test = training_data.df.random_split([0.8, 0.2], seed=42)\n", + " feature_columns = [col for col in training_data.df.columns if col != \"QUALITY\"]\n", + " label_column = \"QUALITY\"\n", + "\n", + " rf = RandomForestRegressor(\n", + " input_cols=feature_columns, label_cols=[label_column], \n", + " max_depth=3, n_estimators=20, random_state=42\n", + " )\n", + "\n", + " rf.fit(train)\n", + " predictions = rf.predict(test)\n", + "\n", + " mse = snowml_metrics.mean_squared_error(\n", + " df=predictions, \n", + " y_true_col_names=label_column, \n", + " y_pred_col_names=\"OUTPUT_\" + label_column)\n", + "\n", + " accuracy = 100 - snowml_metrics.mean_absolute_percentage_error(\n", + " df=predictions,\n", + " y_true_col_names=label_column,\n", + " y_pred_col_names=\"OUTPUT_\" + label_column\n", + " )\n", + "\n", + " print(f\"MSE: {mse}, Accuracy: {accuracy}\")\n", + " return rf\n", + "\n", + "rf = train_model_using_snowpark_ml(training_data)\n", "print(rf)" ] }, @@ -522,7 +569,7 @@ "id": "1ad8031f", "metadata": {}, "source": [ - "## [Optional 1] Predict with local model\n", + "## [Predict Optional 1] With local model\n", "Now let's predict with the model and the feature values retrieved from feature store. " ] }, @@ -557,9 +604,9 @@ "id": "21b81639", "metadata": {}, "source": [ - "## [Option 2.1] Log model with Model Registry\n", + "## [Predict Option 2] Using Model Registry\n", "\n", - "We can also log the model along with its training dataset metadata into Model Registry." + "### Step 1 : Log the model along with its training dataset metadata into Model Registry" ] }, { @@ -569,7 +616,7 @@ "metadata": {}, "outputs": [], "source": [ - "from snowflake.ml.registry import model_registry, artifact\n", + "from snowflake.ml.registry import model_registry\n", "import time\n", "\n", "registry = model_registry.ModelRegistry(\n", @@ -594,11 +641,10 @@ "metadata": {}, "outputs": [], "source": [ - "artifact_ref = registry.log_artifact(\n", - " artifact_type=artifact.ArtifactType.DATASET,\n", - " artifact_name=\"MY_COOL_DATASET\",\n", - " artifact_spec=training_data.to_json(),\n", - " artifact_version=\"v3\",\n", + "my_dataset = registry.log_artifact(\n", + " artifact=training_data,\n", + " name=\"MY_COOL_DATASET\",\n", + " version=\"V1\",\n", ")" ] }, @@ -624,7 +670,7 @@ " model_version=\"V2\",\n", " model=rf,\n", " tags={\"author\": \"my_rf_with_training_data\"},\n", - " artifacts=[artifact_ref],\n", + " artifacts=[my_dataset],\n", " options={\"embed_local_ml_library\": True},\n", ")" ] @@ -634,7 +680,7 @@ "id": "3ccf2743", "metadata": {}, "source": [ - "## [Optional 2.2] Restore model and predict with features\n", + "### Step 2 : Restore model and predict with features\n", "\n", "We retrieve the training dataset from registry then construct dataframe of latest feature values. Then we restore the model from registry. At last, we can predict with latest feature values." ] @@ -648,10 +694,9 @@ "source": [ "from snowflake.ml.dataset.dataset import Dataset\n", "\n", - "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 = registry.get_artifact(\n", + " my_dataset.name, \n", + " my_dataset.version)\n", "test_df = spine_df.limit(3).select(\"WINE_ID\")\n", "\n", "enriched_df = fs.retrieve_feature_values(\n", 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 59ed9703586623e4332e95c4cc94324f5f86f43c..3a16e13c469e887fc5287d8aea7a1501ffd5cb21 100644 GIT binary patch literal 77415 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�JpL)OP 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 literal 209795 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

s7)IOMq-N7lfa!h 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 diff --git a/snowflake/ml/feature_store/notebooks/customer_demo/Time_Series_Feature_Demo.ipynb b/snowflake/ml/feature_store/notebooks/customer_demo/Time_Series_Feature_Demo.ipynb index 83e2edfd..7e90ab85 100644 --- a/snowflake/ml/feature_store/notebooks/customer_demo/Time_Series_Feature_Demo.ipynb +++ b/snowflake/ml/feature_store/notebooks/customer_demo/Time_Series_Feature_Demo.ipynb @@ -5,9 +5,9 @@ "id": "4f029c96", "metadata": {}, "source": [ - "Notebook version: 0.1.2\n", + "Notebook version: 0.2.1\n", "\n", - "Updated date: 10/18/2023" + "Updated date: 11/17/2023" ] }, { @@ -111,6 +111,33 @@ "session.sql(f\"CREATE WAREHOUSE IF NOT EXISTS {FS_DEMO_WH}\").collect()" ] }, + { + "cell_type": "markdown", + "id": "52162799", + "metadata": {}, + "source": [ + "## Create FeatureStore Client\n", + "\n", + "Let's first create a feature store client.\n", + "\n", + "We can pass in an existing database name, or a new database will be created upon the feature store initialization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c37a635", + "metadata": {}, + "outputs": [], + "source": [ + "fs = FeatureStore(\n", + " session=session, \n", + " database=FS_DEMO_DB, \n", + " name=FS_DEMO_SCHEMA, \n", + " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", + ").set_default_warehouse(FS_DEMO_WH)" + ] + }, { "cell_type": "markdown", "id": "1a67136a", @@ -166,8 +193,7 @@ "metadata": {}, "outputs": [], "source": [ - "source_df = session.table(\n", - " f\"{FS_DEMO_DB}.{TEST_DATASET_SCHEMA}.{table_name}\")\n", + "source_df = session.table(full_table_name)\n", "\n", "# source_df.TPEP_PICKUP_DATETIME.alias(\"PICKUP_TS\"),\n", "# source_df.TPEP_DROPOFF_DATETIME.alias(\"DROPOFF_TS\"),\n", @@ -189,34 +215,6 @@ "source_df.show()" ] }, - { - "cell_type": "markdown", - "id": "52162799", - "metadata": {}, - "source": [ - "## Create FeatureStore Client\n", - "\n", - "Let's first create a feature store client.\n", - "\n", - "We can pass in an existing database name, or a new database will be created upon the feature store initialization." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6c37a635", - "metadata": {}, - "outputs": [], - "source": [ - "fs = FeatureStore(\n", - " session=session, \n", - " database=FS_DEMO_DB, \n", - " name=FS_DEMO_SCHEMA, \n", - " default_warehouse=FS_DEMO_WH,\n", - " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", - ")" - ] - }, { "cell_type": "markdown", "id": "3d052074", @@ -451,7 +449,7 @@ " feature_df=dropoff_df, \n", " timestamp_col=\"TS\"\n", ")\n", - "fs.register_feature_view(\n", + "dropoff_fv = fs.register_feature_view(\n", " feature_view=dropoff_fv, \n", " version=\"V1\", \n", " refresh_freq=\"1 minute\", \n", @@ -486,7 +484,7 @@ "id": "9302cf23", "metadata": {}, "source": [ - "## Generate training data and train a model\n", + "## Generate training data \n", "The training data generation will lookup __point-in-time correct__ feature values and join with the spine dataframe. Optionally, you can also exclude columns in the generated dataset by providing `exclude_columns` argument." ] }, @@ -513,6 +511,22 @@ "training_data.df.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train a model\n", + "\n", + "Now let's training a simple random forest model, and evaluate the prediction accuracy." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## [Train option 1] Using Sklearn" + ] + }, { "cell_type": "code", "execution_count": null, @@ -523,35 +537,106 @@ "import numpy as np\n", "from sklearn.model_selection import train_test_split\n", "from sklearn.linear_model import LinearRegression\n", + "from sklearn.impute import SimpleImputer\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.metrics import mean_squared_error\n", + "\n", + "def train_model_using_sklearn(training_pd): \n", + " X = training_pd.drop([\"FARE_AMOUNT\", \"PICKUP_TS\"], axis=1)\n", + " y = training_pd[\"FARE_AMOUNT\"]\n", + " X_train, X_test, y_train, y_test = train_test_split(\n", + " X, y, test_size=0.2, random_state=42)\n", + " X_train.head()\n", + "\n", + " imp = SimpleImputer(missing_values=np.nan, strategy='mean')\n", + " model = make_pipeline(imp, LinearRegression())\n", + "\n", + " reg = model.fit(X, y)\n", + " r2_score = reg.score(X_test, y_test)\n", + " print(r2_score * 100,'%')\n", + "\n", + " y_pred = reg.predict(X_test)\n", + " print(\"Mean squared error: %.2f\" % mean_squared_error(y_test, y_pred))\n", + "\n", + " return model\n", "\n", "training_pd = training_data.df.to_pandas()\n", - "X = training_pd.drop([\"FARE_AMOUNT\", \"PICKUP_TS\"], axis=1)\n", - "y = training_pd[\"FARE_AMOUNT\"]\n", - "X_train, X_test, y_train, y_test = train_test_split(\n", - " X, y, test_size=0.2, random_state=42)\n", - "X_train.head()" + "estimator = train_model_using_sklearn(training_pd)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## [Train Option 2] Using Snowaprk ML" ] }, { "cell_type": "code", "execution_count": null, - "id": "8f0e6902", "metadata": {}, "outputs": [], "source": [ - "from sklearn.impute import SimpleImputer\n", - "from sklearn.pipeline import make_pipeline\n", - "from sklearn.metrics import mean_squared_error\n", + "from snowflake.ml.modeling.pipeline import Pipeline\n", + "from snowflake.ml.modeling.linear_model import LinearRegression\n", + "from snowflake.ml.modeling.impute import SimpleImputer\n", + "from snowflake.ml.modeling import metrics as snowml_metrics\n", + "from snowflake.snowpark.functions import col, unix_timestamp\n", + "\n", + "def train_model_using_snowpark_ml(training_data):\n", + " training_df = training_data.df\n", + " # preprocess the data\n", + " for col_name in [\"DOLOCATIONID\", \n", + " \"PULOCATIONID\", \n", + " \"COUNT_TRIP_2_HR\", \n", + " \"COUNT_TRIP_5_HR\"]:\n", + " training_df = training_df.withColumn(col_name, col(col_name)\n", + " .cast(\"float\"))\n", + " \n", + " training_df = training_df.withColumn(\n", + " \"PICKUP_TS\", \n", + " unix_timestamp(col(\"PICKUP_TS\")))\n", + "\n", + " train, test = training_df.random_split([0.8, 0.2], seed=42)\n", + " excluded_columns = [\"FARE_AMOUNT\", \"PICKUP_TS\"]\n", + " feature_columns = [col for col in training_df.columns \n", + " if col not in excluded_columns]\n", + " label_column = \"FARE_AMOUNT\"\n", + "\n", + " # Create the pipeline\n", + " steps = [\n", + " ('imputer', SimpleImputer(\n", + " input_cols=feature_columns, \n", + " output_cols=feature_columns, \n", + " drop_input_cols=True, \n", + " strategy=\"most_frequent\")), \n", + " ('linear_regression', LinearRegression(\n", + " input_cols=feature_columns, \n", + " label_cols=[label_column])) \n", + " ] \n", + " pipeline = Pipeline(steps)\n", + "\n", + " model = pipeline.fit(train)\n", + " predictions = model.predict(test)\n", + "\n", + " mse = snowml_metrics.mean_squared_error(\n", + " df=predictions, \n", + " y_true_col_names=label_column, \n", + " y_pred_col_names=\"OUTPUT_\" + label_column\n", + " )\n", "\n", - "imp = SimpleImputer(missing_values=np.nan, strategy='mean')\n", - "estimator = make_pipeline(imp, LinearRegression())\n", + " r2 = snowml_metrics.r2_score(\n", + " df=predictions, \n", + " y_true_col_name=label_column, \n", + " y_pred_col_name=\"OUTPUT_\" + label_column\n", + " )\n", "\n", - "reg = estimator.fit(X, y)\n", - "r2_score = reg.score(X_test, y_test)\n", - "print(r2_score * 100,'%')\n", + " # Display the metrics\n", + " print(f\"Mean squared error: {mse}, R² score: {r2}\")\n", "\n", - "y_pred = reg.predict(X_test)\n", - "print(\"Mean squared error: %.2f\" % mean_squared_error(y_test, y_pred))" + " return model\n", + "\n", + "estimator = train_model_using_snowpark_ml(training_data)" ] }, { @@ -559,7 +644,7 @@ "id": "aceef9e3", "metadata": {}, "source": [ - "## [Optional 1] Predict with local model\n", + "## [Predict Option 1] With local model\n", "Now let's predict with the model and the feature values retrieved from feature store. " ] }, @@ -570,14 +655,15 @@ "metadata": {}, "outputs": [], "source": [ - "# Prepare some source prediction data\n", - "pred_df = training_pd.sample(3, random_state=996)[\n", - " ['PULOCATIONID', 'DOLOCATIONID', 'PICKUP_TS']]\n", - "pred_df = session.create_dataframe(pred_df)\n", - "pred_df = pred_df.select(\n", - " 'PULOCATIONID', \n", - " 'DOLOCATIONID', \n", - " F.cast(pred_df.PICKUP_TS / 1000000, TimestampType()) \\\n", + "# Prepare some source prediction data \n", + "\n", + "pred_df = training_data.df.to_pandas().sample(3, random_state=996)[ \n", + " ['PULOCATIONID', 'DOLOCATIONID', 'PICKUP_TS']] \n", + "pred_df = session.create_dataframe(pred_df) \n", + "pred_df = pred_df.select( \n", + " 'PULOCATIONID', \n", + " 'DOLOCATIONID', \n", + " F.cast(pred_df.PICKUP_TS / 1000000, TimestampType())\n", " .alias('PICKUP_TS'))" ] }, @@ -603,8 +689,8 @@ "id": "0142c25c", "metadata": {}, "source": [ - "## [Optional 2.1] Log model with Model Registry\n", - "We can log the model along with its training dataset metadata with model registry." + "## [Predict Option 2] With Model Registry\n", + "### Step 1 : Log the model along with its training dataset metadata into Model Registry" ] }, { @@ -614,7 +700,7 @@ "metadata": {}, "outputs": [], "source": [ - "from snowflake.ml.registry import model_registry, artifact\n", + "from snowflake.ml.registry import model_registry\n", "import time\n", "\n", "registry = model_registry.ModelRegistry(\n", @@ -639,11 +725,10 @@ "metadata": {}, "outputs": [], "source": [ - "artifact_ref = registry.log_artifact(\n", - " artifact_type=artifact.ArtifactType.DATASET,\n", - " artifact_name=\"MY_COOL_DATASET\",\n", - " artifact_spec=training_data.to_json(),\n", - " artifact_version=\"V5\",\n", + "my_dataset = registry.log_artifact(\n", + " artifact=training_data,\n", + " name=\"MY_COOL_DATASET\",\n", + " version=\"V1\",\n", ")" ] }, @@ -668,7 +753,7 @@ " model_name=model_name,\n", " model_version=\"V1\",\n", " model=estimator,\n", - " artifacts=[artifact_ref],\n", + " artifacts=[my_dataset],\n", ")" ] }, @@ -677,7 +762,7 @@ "id": "e6e0581d", "metadata": {}, "source": [ - "## [Option 2.2] Restore model and predict with features\n", + "### Step 2 : Restore model and predict with features\n", "Retrieve the training dataset from registry and construct dataframe of latest feature values. Then we restore the model from registry. Finally, we can predict with latest feature values." ] }, @@ -691,10 +776,9 @@ "# Enrich source prediction data with features\n", "from snowflake.ml.dataset.dataset import Dataset\n", "\n", - "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 = registry.get_artifact(\n", + " my_dataset.name, \n", + " my_dataset.version)\n", "\n", "enriched_df = fs.retrieve_feature_values(\n", " spine_df=pred_df, \n", @@ -759,7 +843,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.18" + "version": "3.8.17" } }, "nbformat": 4, diff --git a/snowflake/ml/feature_store/notebooks/customer_demo/Time_Series_Feature_Demo.pdf b/snowflake/ml/feature_store/notebooks/customer_demo/Time_Series_Feature_Demo.pdf index 35e6e63db165e31da51e6e795ad41261e214e88f..ec9147def713fd4a7eef7528657cdf05a2e87b6d 100644 GIT binary patch literal 87653 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�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%0A)rjcreaZe4Zlr5MlLuYsyd?jf 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 E51T7J^Z)<= diff --git a/snowflake/ml/feature_store/notebooks/internal_demo/Basic_Feature_Store_Demo.ipynb b/snowflake/ml/feature_store/notebooks/internal_demo/Basic_Feature_Store_Demo.ipynb index 5b883e20..e7ec3be0 100644 --- a/snowflake/ml/feature_store/notebooks/internal_demo/Basic_Feature_Store_Demo.ipynb +++ b/snowflake/ml/feature_store/notebooks/internal_demo/Basic_Feature_Store_Demo.ipynb @@ -262,9 +262,8 @@ " session=session, \n", " database=DEMO_DB, \n", " name=\"AWESOME_FS\", \n", - " default_warehouse=\"PUBLIC\",\n", " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", - ")" + ").set_default_warehouse(\"PUBLIC\")" ] }, { diff --git a/snowflake/ml/feature_store/notebooks/internal_demo/Time_Series_Feature_Demo.ipynb b/snowflake/ml/feature_store/notebooks/internal_demo/Time_Series_Feature_Demo.ipynb index 8777e5b6..d5ba9f6d 100644 --- a/snowflake/ml/feature_store/notebooks/internal_demo/Time_Series_Feature_Demo.ipynb +++ b/snowflake/ml/feature_store/notebooks/internal_demo/Time_Series_Feature_Demo.ipynb @@ -197,9 +197,8 @@ " session=session, \n", " database=DEMO_DB, \n", " name=\"AWESOME_FS\", \n", - " default_warehouse=\"PUBLIC\",\n", " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", - ")" + ").set_default_warehouse(\"PUBLIC\")" ] }, { diff --git a/snowflake/ml/feature_store/tests/BUILD.bazel b/snowflake/ml/feature_store/tests/BUILD.bazel index 69aa7f23..aa0f3eba 100644 --- a/snowflake/ml/feature_store/tests/BUILD.bazel +++ b/snowflake/ml/feature_store/tests/BUILD.bazel @@ -18,7 +18,7 @@ py_test( srcs = [ "feature_store_test.py", ], - shard_count = 8, + shard_count = 16, deps = [ ":common_utils", "//snowflake/ml/feature_store:feature_store_lib", diff --git a/snowflake/ml/feature_store/tests/common_utils.py b/snowflake/ml/feature_store/tests/common_utils.py index b28696f2..8d2f5c13 100644 --- a/snowflake/ml/feature_store/tests/common_utils.py +++ b/snowflake/ml/feature_store/tests/common_utils.py @@ -5,6 +5,7 @@ import pandas as pd from pandas.testing import assert_frame_equal +from snowflake.ml._internal.utils import identifier from snowflake.ml.feature_store.feature_view import FeatureView from snowflake.ml.utils.connection_params import SnowflakeLoginOptions from snowflake.snowpark import Session @@ -19,9 +20,6 @@ # Schema with test datasets used for feature store integration test FS_INTEG_TEST_DATASET_SCHEMA = "TEST_DATASET" -# Warehouse used for feature store integration test -FS_INTEG_TEST_DEFAULT_WAREHOUSE = "FEATURE_STORE_INTEG_TEST" - # Yellow trip dataset FS_INTEG_TEST_YELLOW_TRIP_DATA = "yellow_tripdata_2016_01" @@ -62,3 +60,8 @@ def dispatch(*args: Any) -> Any: session = Session.builder.configs(SnowflakeLoginOptions()).create() session.sql = Mock(side_effect=side_effect(session)) return session + + +def get_test_warehouse_name(session: Session) -> str: + session_warehouse = session.get_current_warehouse() + return identifier._get_unescaped_name(session_warehouse) if session_warehouse else "REGTEST_ML_4XL_MULTI" 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 014e338f..46714830 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 @@ -5,8 +5,8 @@ from common_utils import ( FS_INTEG_TEST_DATASET_SCHEMA, FS_INTEG_TEST_DB, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, create_random_schema, + get_test_warehouse_name, ) from snowflake.ml._internal.utils import identifier @@ -49,6 +49,7 @@ def setUpClass(cls) -> None: cls._session = Session.builder.configs(SnowflakeLoginOptions()).create() cls._active_fs = [] cls._mock_table = cls._create_mock_table("mock_data") + cls._test_warehouse_name = get_test_warehouse_name(cls._session) @classmethod def tearDownClass(cls) -> None: @@ -84,9 +85,8 @@ def test_feature_store_location(self, database: str, schema: str) -> None: session=self._session, database=database, name=schema, - default_warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, - ) + ).set_default_warehouse(self._test_warehouse_name) self._active_fs.append(fs) df = self._session.sql(f"SELECT a, b FROM {self._mock_table}") @@ -103,7 +103,6 @@ def test_feature_store_location(self, database: str, schema: str) -> None: session=self._session, database=database, name=schema, - default_warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.FAIL_IF_NOT_EXIST, ) @@ -117,9 +116,8 @@ def test_warehouse_names(self, warehouse: str) -> None: session=self._session, database=FS_INTEG_TEST_DB, name=current_schema, - default_warehouse=warehouse, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, - ) + ).set_default_warehouse(warehouse) self._active_fs.append(fs) df = self._session.sql(f"SELECT a, b FROM {self._mock_table}") @@ -157,7 +155,6 @@ def generate_unique_name(names: List[str]) -> List[str]: self._session, FS_INTEG_TEST_DB, original_name, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, ) self._active_fs.append(fs) @@ -168,7 +165,6 @@ def generate_unique_name(names: List[str]) -> List[str]: self._session, FS_INTEG_TEST_DB, equi_name, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.FAIL_IF_NOT_EXIST, ) @@ -179,7 +175,6 @@ def generate_unique_name(names: List[str]) -> List[str]: self._session, FS_INTEG_TEST_DB, diff_name, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.FAIL_IF_NOT_EXIST, ) @@ -196,7 +191,6 @@ def test_entity_names(self, equi_names: List[str], diff_names: List[str]) -> Non 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) @@ -241,7 +235,6 @@ def test_join_keys_and_ts_col(self, equi_names: List[str], diff_names: List[str] 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) @@ -280,10 +273,10 @@ def test_join_keys_and_ts_col(self, equi_names: List[str], diff_names: List[str] [ ( [("foo", "bar"), ("foo", "BAR"), ("FOO", "BAR"), ('"FOO"', '"BAR"')], - [('"foo"', "bar"), ("foo", '"BAR"')], + [('"foo"', "bar"), ("foo", '"bar"')], ), ( - [('"abc"', "def"), ('"abc"', '"def"'), ("abc", '"def"')], + [('"abc"', "def"), ('"abc"', "DEF"), ('"abc"', '"DEF"')], [("abc", "def")], ), ] @@ -298,9 +291,8 @@ def test_feature_view_names_and_versions_combination( self._session, FS_INTEG_TEST_DB, current_schema, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, - ) + ).set_default_warehouse(self._test_warehouse_name) self._active_fs.append(fs) df = self._session.create_dataframe([1, 2, 3], schema=["a"]) @@ -371,16 +363,15 @@ def test_find_objects(self, equi_names: List[str], diff_names: List[str]) -> Non 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) + self.assertEqual(len(fs._find_object("SCHEMAS", SqlIdentifier(name))), 1) for name in diff_names: - self.assertEqual(len(fs._find_object("SCHEMAS", name)), 0) + self.assertEqual(len(fs._find_object("SCHEMAS", SqlIdentifier(name))), 0) self._session.sql(f"DROP SCHEMA IF EXISTS {FS_INTEG_TEST_DB}.{equi_names[0]}").collect() 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 1f351f27..240cbc85 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 @@ -6,10 +6,10 @@ from common_utils import ( FS_INTEG_TEST_DATASET_SCHEMA, FS_INTEG_TEST_DB, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, FS_INTEG_TEST_WINE_QUALITY_DATA, FS_INTEG_TEST_YELLOW_TRIP_DATA, create_random_schema, + get_test_warehouse_name, ) from pandas.testing import assert_frame_equal @@ -32,6 +32,7 @@ class FeatureStoreLargeScaleTest(absltest.TestCase): def setUpClass(self) -> None: self._session = Session.builder.configs(SnowflakeLoginOptions()).create() self._active_feature_store = [] + self._test_warehouse_name = get_test_warehouse_name(self._session) @classmethod def tearDownClass(self) -> None: @@ -46,9 +47,8 @@ def _create_feature_store(self, name: Optional[str] = None) -> FeatureStore: self._session, FS_INTEG_TEST_DB, current_schema, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, - ) + ).set_default_warehouse(self._test_warehouse_name) self._active_feature_store.append(fs) return fs @@ -115,12 +115,12 @@ def test_external_table(self) -> None: feature_view=location_features, version="V1", refresh_freq="1 minute", - warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, + warehouse=self._test_warehouse_name, block=True, ) def create_select_query(start: str, end: str) -> str: - return f"""SELECT DISTINCT TO_TIMESTAMP(TPEP_DROPOFF_DATETIME / 1000000) AS DROPOFF_TIME, + return f"""SELECT DISTINCT DATE_TRUNC('second', TO_TIMESTAMP(TPEP_DROPOFF_DATETIME)) AS DROPOFF_TIME, PULOCATIONID, TIP_AMOUNT, TOTAL_AMOUNT FROM {raw_dataset} WHERE DROPOFF_TIME >= '{start}' AND DROPOFF_TIME < '{end}' @@ -157,7 +157,6 @@ def create_select_query(start: str, end: str) -> str: { "database": FS_INTEG_TEST_DB, "schema": current_schema, - "default_warehouse": FS_INTEG_TEST_DEFAULT_WAREHOUSE, }, ) diff --git a/snowflake/ml/feature_store/tests/feature_store_object_test.py b/snowflake/ml/feature_store/tests/feature_store_object_test.py index 5cf630f8..ed7fefd9 100644 --- a/snowflake/ml/feature_store/tests/feature_store_object_test.py +++ b/snowflake/ml/feature_store/tests/feature_store_object_test.py @@ -68,7 +68,7 @@ def test_feature_descs(self) -> None: fv.attach_feature_desc({"e": "foo"}) fv.attach_feature_desc({"b": "foo", "d": "bar"}) - self.assertEqual(fv.feature_descs, {"B": "foo", "C": None, "D": "bar"}) + self.assertEqual(fv.feature_descs, {"B": "foo", "C": "", "D": "bar"}) def test_invalid_timestamp_col(self) -> None: df = self._session.create_dataframe([[1, "bar", 3]], schema=["a", "b", "c"]) diff --git a/snowflake/ml/feature_store/tests/feature_store_test.py b/snowflake/ml/feature_store/tests/feature_store_test.py index 994121da..818b50ee 100644 --- a/snowflake/ml/feature_store/tests/feature_store_test.py +++ b/snowflake/ml/feature_store/tests/feature_store_test.py @@ -5,14 +5,15 @@ from common_utils import ( FS_INTEG_TEST_DATASET_SCHEMA, FS_INTEG_TEST_DB, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, FS_INTEG_TEST_DUMMY_DB, compare_dataframe, compare_feature_views, create_mock_session, create_random_schema, + get_test_warehouse_name, ) +from snowflake.ml.dataset.dataset import Dataset from snowflake.ml.feature_store import ( # type: ignore[attr-defined] CreationMode, Entity, @@ -28,14 +29,13 @@ ) from snowflake.ml.utils.connection_params import SnowflakeLoginOptions from snowflake.snowpark import Session, exceptions as snowpark_exceptions -from snowflake.snowpark.functions import call_udf, udf +from snowflake.snowpark.functions import call_udf, col, udf class FeatureStoreTest(absltest.TestCase): @classmethod def setUpClass(self) -> None: self._session = Session.builder.configs(SnowflakeLoginOptions()).create() - self._warehouse2 = "FEATURE_STORE_INTEG_TEST_2" self._active_feature_store = [] try: @@ -44,12 +44,7 @@ def setUpClass(self) -> None: self._session.sql( f"CREATE SCHEMA IF NOT EXISTS {FS_INTEG_TEST_DB}.{FS_INTEG_TEST_DATASET_SCHEMA}" ).collect() - self._session.sql( - f"CREATE WAREHOUSE IF NOT EXISTS {FS_INTEG_TEST_DEFAULT_WAREHOUSE} WITH WAREHOUSE_SIZE='XSMALL'" - ).collect() - self._session.sql( - f"CREATE WAREHOUSE IF NOT EXISTS {self._warehouse2} WITH WAREHOUSE_SIZE='XSMALL'" - ).collect() + self._test_warehouse_name = get_test_warehouse_name(self._session) self._mock_table = self._create_mock_table("customers") except Exception as e: self.tearDownClass() @@ -91,9 +86,8 @@ def _create_feature_store(self, name: Optional[str] = None) -> FeatureStore: self._session, FS_INTEG_TEST_DB, current_schema, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, - ) + ).set_default_warehouse(self._test_warehouse_name) self._active_feature_store.append(fs) # Intentionally point session to a different database to make sure feature store code is resilient to # session location. @@ -107,30 +101,21 @@ def test_fail_if_not_exist(self) -> None: session=self._session, database=FS_INTEG_TEST_DB, name=name, - default_warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, ) self._create_feature_store(name) fs = FeatureStore( session=self._session, database=FS_INTEG_TEST_DB, name=name, - default_warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, ) self.assertIsNotNone(fs) def test_invalid_warehouse(self) -> None: - schema_name = f"foo_{uuid4().hex.upper()}" + fs = self._create_feature_store() + invalid_warehouse = f"INVALID_WAREHOUSE_{uuid4().hex.upper()}" + with self.assertRaisesRegex(ValueError, "Cannot find warehouse.*"): - FeatureStore( - session=self._session, - database=FS_INTEG_TEST_DB, - name=create_random_schema(self._session, "TEST_INVALID_WAREHOUSE"), - default_warehouse=schema_name, - creation_mode=CreationMode.CREATE_IF_NOT_EXIST, - ) - # No schema should be created if failure happens in the ctor - res = self._session.sql(f"SHOW SCHEMAS LIKE '{schema_name}' in DATABASE {FS_INTEG_TEST_DB}").collect() - self.assertEqual(len(res), 0) + fs.set_default_warehouse(invalid_warehouse) def test_create_if_not_exist_failure(self) -> None: temp_session = create_mock_session( @@ -144,7 +129,6 @@ def test_create_if_not_exist_failure(self) -> None: session=temp_session, database=FS_INTEG_TEST_DB, name=schema_name, - default_warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, ) @@ -164,7 +148,6 @@ def test_create_if_not_exist_system_error(self) -> None: session=mock_session, database=FS_INTEG_TEST_DB, name="foo", - default_warehouse=FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, ) @@ -451,11 +434,12 @@ def test_create_and_delete_feature_views(self) -> None: feature_df=self._session.sql(sql0), desc="my_fv1", ) + alternate_warehouse = "REGTEST_ML_SMALL" fv1 = fs.register_feature_view( feature_view=fv1, version="FIRST", refresh_freq="5 minutes", - warehouse=self._warehouse2, + warehouse=alternate_warehouse, ) compare_feature_views(fs.list_feature_views(as_dataframe=False), [fv0, new_fv0, fv1]) @@ -476,7 +460,7 @@ def test_create_and_delete_feature_views(self) -> None: self.assertEqual(fv.query, sql0) self.assertEqual(fv.status, FeatureViewStatus.RUNNING) self.assertEqual(fv.refresh_freq, "5 minutes") - self.assertEqual(fv.warehouse, self._warehouse2) + self.assertEqual(fv.warehouse, alternate_warehouse) self.assertEqual(fv.desc, "my_fv1") self.assertEqual(fv.timestamp_col, None) @@ -692,7 +676,8 @@ def test_retrieve_feature_values(self) -> None: ) # test retrieve_feature_values with serialized feature objects - dataset = fs.generate_dataset(spine_df, features=[fv1.slice(["name"]), fv2]) + fv1_slice = fv1.slice(["name"]) + dataset = fs.generate_dataset(spine_df, features=[fv1_slice, fv2]) df = fs.retrieve_feature_values(spine_df=spine_df, features=dataset.load_features()) compare_dataframe( actual_df=df.to_pandas(), @@ -703,6 +688,7 @@ def test_retrieve_feature_values(self) -> None: }, sort_cols=["ID"], ) + self.assertEqual([fv1_slice, fv2], fs.load_feature_views_from_dataset(dataset)) def test_invalid_merge_features(self) -> None: fs = self._create_feature_store() @@ -729,11 +715,11 @@ def test_invalid_merge_features(self) -> None: timestamp_col="ts", ) - with self.assertRaisesRegex(ValueError, "FeatureView fv1 has not been registered."): + with self.assertRaisesRegex(ValueError, "FeatureView FV1 has not been registered."): fs.merge_features(features=[fv1, fv2], name="merged_fv") fv1 = fs.register_feature_view(feature_view=fv1, version="v1", refresh_freq="DOWNSTREAM", block=True) - with self.assertRaisesRegex(ValueError, "FeatureView fv2 has not been registered."): + with self.assertRaisesRegex(ValueError, "FeatureView FV2 has not been registered."): fs.merge_features(features=[fv1, fv2], name="merged_fv") # 2. Different Entity @@ -914,6 +900,12 @@ def test_merge_feature_view_slice(self) -> None: sort_cols=["ID"], ) + def test_invalid_load_feature_views_from_dataset(self) -> None: + fs = self._create_feature_store() + dataset = Dataset(self._session, self._session.create_dataframe([1, 2, 3], schema=["foo"])) + with self.assertRaisesRegex(ValueError, "Dataset.*does not contain valid feature view information."): + fs.load_feature_views_from_dataset(dataset) + def test_list_feature_views(self) -> None: fs = self._create_feature_store() @@ -923,15 +915,17 @@ def test_list_feature_views(self) -> None: self.assertEqual(fs.list_feature_views(entity_name="foo", as_dataframe=False), []) # 1. Right side is FeatureViewSlice - sql1 = f"SELECT id, name FROM {self._mock_table}" - fv1 = FeatureView(name="fv1", entities=[e], feature_df=self._session.sql(sql1)) + sql1 = f"SELECT id, name, ts FROM {self._mock_table}" + fv1 = FeatureView(name="fv1", entities=[e], feature_df=self._session.sql(sql1), timestamp_col="ts") + fv1.attach_feature_desc({"name": "this is my name col"}) fv1 = fs.register_feature_view(feature_view=fv1, version="v1", refresh_freq="DOWNSTREAM", block=True) sql2 = f"SELECT id, title, age FROM {self._mock_table}" fv2 = FeatureView(name="fv2", entities=[e], feature_df=self._session.sql(sql2)) fv2 = fs.register_feature_view(feature_view=fv2, version="v1", refresh_freq="DOWNSTREAM", block=True) - - self.assertEqual(fs.list_feature_views(entity_name="Foo", as_dataframe=False), [fv1, fv2]) + self.assertEqual( + sorted(fs.list_feature_views(entity_name="Foo", as_dataframe=False), key=lambda fv: fv.name), [fv1, fv2] + ) self.assertEqual( fs.list_feature_views(entity_name="foo", feature_view_name="fv1", as_dataframe=False), [fv1], @@ -991,7 +985,6 @@ def test_create_and_cleanup_tags(self) -> None: self._session, FS_INTEG_TEST_DB, current_schema, - FS_INTEG_TEST_DEFAULT_WAREHOUSE, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, ) self.assertIsNotNone(fs) @@ -1027,7 +1020,6 @@ def test_generate_dataset(self) -> None: timestamp_col="ts", ) fv2 = fs.register_feature_view(feature_view=fv2, version="v1", refresh_freq="DOWNSTREAM", block=True) - spine_df = self._session.create_dataframe([(1, 101)], schema=["id", "ts"]) # Generate dataset the first time @@ -1048,6 +1040,7 @@ def test_generate_dataset(self) -> None: }, sort_cols=["ID"], ) + self.assertEqual([fv1, fv2], fs.load_feature_views_from_dataset(ds1)) # Re-generate dataset with same source should not cause any duplication ds2 = fs.generate_dataset( @@ -1190,7 +1183,7 @@ def test_clear_feature_store_in_existing_schema(self) -> None: f""" CREATE DYNAMIC TABLE {current_schema}.my_dynamic_table TARGET_LAG='1h' - WAREHOUSE={FS_INTEG_TEST_DEFAULT_WAREHOUSE} + WAREHOUSE={self._test_warehouse_name} AS {sql} """ ).collect() @@ -1271,12 +1264,35 @@ def minus_one(x: int) -> int: entity = Entity("foo", ["name"]) fs.register_entity(entity) - df = self._session.table(self._mock_table).select(call_udf(udf_name, "id").alias("uid"), "name") + df = self._session.table(self._mock_table).select(call_udf(udf_name, col("id")).alias("uid"), "name") fv = FeatureView(name="fv", entities=[entity], feature_df=df) with self.assertWarnsRegex(UserWarning, "Dynamic table: `.*` will not refresh in INCREMENTAL mode"): fs.register_feature_view(feature_view=fv, version="V1", refresh_freq="1h") + def test_missing_warehouse_spec(self) -> None: + current_schema = create_random_schema(self._session, "FS_TEST") + fs = FeatureStore( + self._session, + FS_INTEG_TEST_DB, + current_schema, + creation_mode=CreationMode.CREATE_IF_NOT_EXIST, + ) + self._active_feature_store.append(fs) + + e = Entity("foo", ["id"]) + fs.register_entity(e) + + sql = f"SELECT id, name, title, ts FROM {self._mock_table}" + fv = FeatureView( + name="fv1", + entities=[e], + feature_df=self._session.sql(sql), + timestamp_col="ts", + ) + with self.assertRaisesRegex(ValueError, "Warehouse cannot be None."): + fs.register_feature_view(feature_view=fv, version="v1", refresh_freq="DOWNSTREAM", block=True) + if __name__ == "__main__": absltest.main() diff --git a/snowflake/ml/model/BUILD.bazel b/snowflake/ml/model/BUILD.bazel index b8220dc9..dbb1238e 100644 --- a/snowflake/ml/model/BUILD.bazel +++ b/snowflake/ml/model/BUILD.bazel @@ -59,7 +59,7 @@ py_library( "//snowflake/ml/model/_deploy_client/snowservice:deploy", "//snowflake/ml/model/_deploy_client/warehouse:deploy", "//snowflake/ml/model/_deploy_client/warehouse:infer_template", - "//snowflake/ml/model/_module_model:module_model", + "//snowflake/ml/model/_model_composer:model_composer", "//snowflake/ml/model/_signatures:snowpark_handler", ], ) diff --git a/snowflake/ml/model/_api.py b/snowflake/ml/model/_api.py index 78c83a06..66a4580c 100644 --- a/snowflake/ml/model/_api.py +++ b/snowflake/ml/model/_api.py @@ -19,7 +19,7 @@ deploy as warehouse_deploy, infer_template, ) -from snowflake.ml.model._module_model import module_model +from snowflake.ml.model._model_composer import model_composer from snowflake.ml.model._signatures import snowpark_handler from snowflake.snowpark import DataFrame as SnowparkDataFrame, Session, functions as F @@ -38,14 +38,14 @@ def save_model( ext_modules: Optional[List[ModuleType]] = None, code_paths: Optional[List[str]] = None, options: Optional[model_types.ModelSaveOption] = None, -) -> module_model.ModuleModel: - """Save a model that does not require a signature as module model to a stage path. +) -> model_composer.ModelComposer: + """Save a model that does not require a signature as model to a stage path. Args: name: Name of the model. model: Model object. session: Snowpark connection session. - stage_path: Path to the stage where module model will be saved. + stage_path: Path to the stage where model will be saved. metadata: Model metadata. conda_dependencies: List of Conda package specs. Use "[channel::]package [operator version]" syntax to specify a dependency. It is a recommended way to specify your dependencies using conda. When channel is not @@ -77,14 +77,14 @@ def save_model( ext_modules: Optional[List[ModuleType]] = None, code_paths: Optional[List[str]] = None, options: Optional[model_types.ModelSaveOption] = None, -) -> module_model.ModuleModel: - """Save a model that requires a external signature with user provided signatures as module model to a stage path. +) -> model_composer.ModelComposer: + """Save a model that requires a external signature with user provided signatures as model to a stage path. Args: name: Name of the model. model: Model object. session: Snowpark connection session. - stage_path: Path to the stage where module model will be saved. + stage_path: Path to the stage where model will be saved. signatures: Model data signatures for inputs and output for every target methods. metadata: Model metadata. conda_dependencies: List of Conda package specs. Use "[channel::]package [operator version]" syntax to specify @@ -117,15 +117,15 @@ def save_model( ext_modules: Optional[List[ModuleType]] = None, code_paths: Optional[List[str]] = None, options: Optional[model_types.ModelSaveOption] = None, -) -> module_model.ModuleModel: - """Save a model that requires a external signature as module model to a stage path with signature inferred from a +) -> model_composer.ModelComposer: + """Save a model that requires a external signature as model to a stage path with signature inferred from a sample_input_data. Args: name: Name of the model. model: Model object. session: Snowpark connection session. - stage_path: Path to the stage where module model will be saved. + stage_path: Path to the stage where model will be saved. sample_input: Sample input data to infer the model signatures from. metadata: Model metadata. conda_dependencies: List of Conda package specs. Use "[channel::]package [operator version]" syntax to specify @@ -158,14 +158,14 @@ def save_model( ext_modules: Optional[List[ModuleType]] = None, code_paths: Optional[List[str]] = None, options: Optional[model_types.ModelSaveOption] = None, -) -> module_model.ModuleModel: +) -> model_composer.ModelComposer: """Save the model. Args: name: Name of the model. model: Model object. session: Snowpark connection session. - stage_path: Path to the stage where module model will be saved. + stage_path: Path to the stage where model will be saved. signatures: Model data signatures for inputs and output for every target methods. If it is None, sample_input would be used to infer the signatures if it is a local (non-SnowML modeling model). If not None, sample_input should not be specified. Defaults to None. @@ -186,9 +186,9 @@ def save_model( options: Model specific kwargs. Returns: - Module Model + Model """ - m = module_model.ModuleModel(session=session, stage_path=stage_path) + m = model_composer.ModelComposer(session=session, stage_path=stage_path) m.save( name=name, model=model, @@ -206,35 +206,35 @@ def save_model( @overload -def load_model(*, session: Session, stage_path: str) -> module_model.ModuleModel: +def load_model(*, session: Session, stage_path: str) -> model_composer.ModelComposer: """Load the model into memory from a zip file in the stage. Args: session: Snowflake connection session. - stage_path: Path to the stage where module model will be loaded from. + stage_path: Path to the stage where model will be loaded from. """ ... @overload -def load_model(*, session: Session, stage_path: str, meta_only: Literal[False]) -> module_model.ModuleModel: +def load_model(*, session: Session, stage_path: str, meta_only: Literal[False]) -> model_composer.ModelComposer: """Load the model into memory from a zip file in the stage. Args: session: Snowflake connection session. - stage_path: Path to the stage where module model will be loaded from. + stage_path: Path to the stage where model will be loaded from. meta_only: Flag to indicate that if only load metadata. """ ... @overload -def load_model(*, session: Session, stage_path: str, meta_only: Literal[True]) -> module_model.ModuleModel: +def load_model(*, session: Session, stage_path: str, meta_only: Literal[True]) -> model_composer.ModelComposer: """Load the model into memory from a zip file in the stage with metadata only. Args: session: Snowflake connection session. - stage_path: Path to the stage where module model will be loaded from. + stage_path: Path to the stage where model will be loaded from. meta_only: Flag to indicate that if only load metadata. """ ... @@ -245,19 +245,19 @@ def load_model( session: Session, stage_path: str, meta_only: bool = False, -) -> module_model.ModuleModel: +) -> model_composer.ModelComposer: """Load the model into memory from directory or a zip file in the stage. Args: session: Snowflake connection session. Must be specified when specifying model_stage_file_path. Exclusive with model_dir_path. - stage_path: Path to the stage where module model will be loaded from. + stage_path: Path to the stage where model will be loaded from. meta_only: Flag to indicate that if only load metadata. Returns: - Loaded module model. + Loaded model. """ - m = module_model.ModuleModel(session=session, stage_path=stage_path) + m = model_composer.ModelComposer(session=session, stage_path=stage_path) m.load(meta_only=meta_only) return m @@ -280,7 +280,7 @@ def deploy( platform: Target platform to deploy the model. target_method: The name of the target method to be deployed. Can be omitted if there is only 1 target method in the model. - stage_path: Path to the stage where module model will be deployed. + stage_path: Path to the stage where model will be deployed. options: Additional options when deploying the model. Each target platform will have their own specifications of options. """ @@ -308,7 +308,7 @@ def deploy( platform: Target platform to deploy the model. target_method: The name of the target method to be deployed. Can be omitted if there is only 1 target method in the model. - stage_path: Path to the stage where module model will be deployed. + stage_path: Path to the stage where model will be deployed. deployment_stage_path: Path to stage containing snowpark container service deployment artifacts. options: Additional options when deploying the model. Each target platform will have their own specifications of options. @@ -336,7 +336,7 @@ def deploy( platform: Target platform to deploy the model. target_method: The name of the target method to be deployed. Can be omitted if there is only 1 target method in the model. - stage_path: Path to the stage where module model will be deployed. + stage_path: Path to the stage where model will be deployed. deployment_stage_path: Path to stage containing deployment artifacts. options: Additional options when deploying the model. Each target platform will have their own specifications of options. 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 9d52e4a1..56aa0b57 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/docker_context.py +++ b/snowflake/ml/model/_deploy_client/image_builds/docker_context.py @@ -5,8 +5,11 @@ from abc import ABC from typing import Optional +import importlib_resources + from snowflake.ml._internal import file_utils from snowflake.ml._internal.utils import identifier +from snowflake.ml.model._deploy_client import image_builds from snowflake.ml.model._deploy_client.utils import constants from snowflake.ml.model._packager.model_meta import model_meta from snowflake.snowpark import FileOperation, Session @@ -50,10 +53,12 @@ def build(self) -> None: def _copy_entrypoint_script_to_docker_context(self) -> None: """Copy gunicorn_run.sh entrypoint to docker context directory.""" - path = file_utils.resolve_zip_import_path(os.path.join(os.path.dirname(__file__), constants.ENTRYPOINT_SCRIPT)) - - assert os.path.exists(path), f"Run script file missing at path: {path}" - shutil.copy(path, os.path.join(self.context_dir, constants.ENTRYPOINT_SCRIPT)) + with importlib_resources.as_file( + importlib_resources.files(image_builds).joinpath( # type: ignore[no-untyped-call] + constants.ENTRYPOINT_SCRIPT + ) + ) as path: + shutil.copy(path, os.path.join(self.context_dir, constants.ENTRYPOINT_SCRIPT)) def _copy_model_env_dependency_to_docker_context(self) -> None: """ @@ -66,8 +71,10 @@ def _generate_docker_file(self) -> None: Generates dockerfile based on dockerfile template. """ docker_file_path = os.path.join(self.context_dir, "Dockerfile") - docker_file_template = file_utils.resolve_zip_import_path( - os.path.join(os.path.dirname(__file__), "templates/dockerfile_template") + docker_file_template = ( + importlib_resources.files(image_builds) + .joinpath("templates/dockerfile_template") # type: ignore[no-untyped-call] + .read_text("utf-8") ) if self.model_zip_stage_path is not None: @@ -88,15 +95,13 @@ def _generate_docker_file(self) -> None: copy_model_statement = "" extra_env_statement = "" - with open(docker_file_path, "w", encoding="utf-8") as dockerfile, open( - docker_file_template, encoding="utf-8" - ) as template: + with open(docker_file_path, "w", encoding="utf-8") as dockerfile: base_image = "mambaorg/micromamba:1.4.3" tag = base_image.split(":")[1] assert tag != constants.LATEST_IMAGE_TAG, ( "Base image tag should not be 'latest' as it might cause false" "positive image cache hit" ) - dockerfile_content = string.Template(template.read()).safe_substitute( + dockerfile_content = string.Template(docker_file_template).safe_substitute( { "base_image": "mambaorg/micromamba:1.4.3", "model_env_folder": constants.MODEL_ENV_FOLDER, @@ -117,14 +122,15 @@ def _generate_inference_code(self) -> None: Generates inference code based on the app template and creates a folder named 'server' to house the inference server code. """ - inference_server_folder_path = file_utils.resolve_zip_import_path( - os.path.join(os.path.dirname(__file__), constants.INFERENCE_SERVER_DIR) - ) - - destination_folder_path = os.path.join(self.context_dir, constants.INFERENCE_SERVER_DIR) - ignore_patterns = shutil.ignore_patterns("BUILD.bazel", "*test.py", "*.\\.*", "__pycache__") - file_utils.copytree( - inference_server_folder_path, - destination_folder_path, - ignore=ignore_patterns, - ) + with importlib_resources.as_file( + importlib_resources.files(image_builds).joinpath( # type: ignore[no-untyped-call] + constants.INFERENCE_SERVER_DIR + ) + ) as inference_server_folder_path: + destination_folder_path = os.path.join(self.context_dir, constants.INFERENCE_SERVER_DIR) + ignore_patterns = shutil.ignore_patterns("BUILD.bazel", "*test.py", "*.\\.*", "__pycache__") + file_utils.copytree( + inference_server_folder_path, + destination_folder_path, + ignore=ignore_patterns, + ) diff --git a/snowflake/ml/model/_deploy_client/image_builds/inference_server/main.py b/snowflake/ml/model/_deploy_client/image_builds/inference_server/main.py index eda162e6..77bdc5eb 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/inference_server/main.py +++ b/snowflake/ml/model/_deploy_client/image_builds/inference_server/main.py @@ -5,6 +5,7 @@ import sys import tempfile import threading +import time import traceback import zipfile from enum import Enum @@ -41,6 +42,10 @@ class CustomThread(threading.Thread): def run(self) -> None: try: super().run() + # Keep the daemon thread alive to avoid destroy + # state attached to thread when loading the model. + while True: + time.sleep(60) except Exception: logger.error(traceback.format_exc()) os._exit(arbiter.Arbiter.APP_LOAD_ERROR) @@ -113,7 +118,9 @@ def _run_setup() -> None: ) _LOADED_MODEL = pk.model _LOADED_META = pk.meta - except ImportError: + except ImportError as e: + if e.name and not e.name.startswith("snowflake.ml"): + raise e # Legacy model support from snowflake.ml.model import ( # type: ignore[attr-defined] _model as model_api, @@ -252,7 +259,7 @@ def run_app() -> applications.Starlette: # TODO[shchen]: SNOW-893654. Before SnowService supports Startup probe, or extends support for Readiness probe # with configurable failureThreshold, we will have to load the model in a separate thread in order to prevent # gunicorn worker timeout. - model_loading_worker = CustomThread(target=_run_setup) + model_loading_worker = CustomThread(target=_run_setup, daemon=True) model_loading_worker.start() routes = [ diff --git a/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_test.py b/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_test.py index d6c66844..ca5bc62b 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_test.py +++ b/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_test.py @@ -48,12 +48,27 @@ def predict(self, input: pd.DataFrame) -> pd.DataFrame: sample_input=x, metadata={"author": "halu", "version": "1"}, ) - with file_utils.zip_file_or_directory_to_stream(tmpdir.full_path, leading_path=tmpdir.full_path) as zf: - zf.seek(0) - with open(zip_full_path, "wb") as f: - f.write(zf.getvalue()) + file_utils.make_archive(zip_full_path, tmpdir.full_path) return zip_full_path + def test_setup_import(self) -> None: + with mock.patch.dict( + os.environ, + { + "TARGET_METHOD": "predict", + "MODEL_ZIP_STAGE_PATH": self.model_zip_path, + }, + ): + with mock.patch.object( + model_packager.ModelPackager, + "load", + side_effect=ImportError("Cannot import transformers", name="transformers"), + ): + from main import _run_setup + + with self.assertRaisesRegex(ImportError, "Cannot import transformers"): + _run_setup() + @contextlib.contextmanager def common_helper(self): # type: ignore[no-untyped-def] with mock.patch.dict( diff --git a/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_vllm_test.py b/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_vllm_test.py index 43400f58..645eaed5 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_vllm_test.py +++ b/snowflake/ml/model/_deploy_client/image_builds/inference_server/main_vllm_test.py @@ -55,15 +55,13 @@ def setup_lora_model(self) -> str: model=model, metadata={"author": "halu", "version": "1"}, ) - with file_utils.zip_file_or_directory_to_stream(tmpdir.full_path, leading_path=tmpdir.full_path) as zf: - zf.seek(0) - with open(zip_full_path, "wb") as f: - f.write(zf.getvalue()) + file_utils.make_archive(zip_full_path, tmpdir.full_path) return zip_full_path def setup_pretrain_model(self) -> str: options = llm.LLMOptions( max_batch_size=100, + enable_tp=True, ) model = llm.LLM("facebook/opt-350m", options=options) tmpdir = self.create_tempdir() @@ -74,10 +72,7 @@ def setup_pretrain_model(self) -> str: model=model, metadata={"author": "halu", "version": "1"}, ) - with file_utils.zip_file_or_directory_to_stream(tmpdir.full_path, leading_path=tmpdir.full_path) as zf: - zf.seek(0) - with open(zip_full_path, "wb") as f: - f.write(zf.getvalue()) + file_utils.make_archive(zip_full_path, tmpdir.full_path) return zip_full_path @contextlib.contextmanager 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 964fb9d3..a99a1e51 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 @@ -3,6 +3,7 @@ import posixpath from string import Template +import importlib_resources import yaml from snowflake import snowpark @@ -12,6 +13,7 @@ exceptions as snowml_exceptions, ) from snowflake.ml._internal.utils import identifier +from snowflake.ml.model._deploy_client import image_builds from snowflake.ml.model._deploy_client.image_builds import base_image_builder from snowflake.ml.model._deploy_client.utils import ( constants, @@ -82,8 +84,8 @@ def _build_image_in_remote_job(self) -> None: # TODO[shchen] remove such logic when first-party-image is supported on snowservice registry. # The regular Kaniko image doesn't include a shell; only the debug image comes with a shell. We need a shell # as we use an sh script to launch Kaniko - kaniko_image = "/".join([self.image_repo.rstrip("/"), "kaniko-project/executor:v1.16.0-debug"]) - registry_client = image_registry_client.ImageRegistryClient(self.session) + kaniko_image = "/".join([self.image_repo.rstrip("/"), constants.KANIKO_IMAGE]) + registry_client = image_registry_client.ImageRegistryClient(self.session, kaniko_image) if registry_client.image_exists(kaniko_image): logger.debug(f"Kaniko image already existed at {kaniko_image}, skipping uploading") else: @@ -108,15 +110,15 @@ def _construct_and_upload_docker_entrypoint_script(self, context_tarball_stage_l Args: context_tarball_stage_location: Path context directory stage location. """ - - kaniko_shell_script_template = file_utils.resolve_zip_import_path( - os.path.join(os.path.dirname(__file__), f"templates/{constants.KANIKO_SHELL_SCRIPT_TEMPLATE}") + kaniko_shell_script_template = ( + importlib_resources.files(image_builds) + .joinpath(f"templates/{constants.KANIKO_SHELL_SCRIPT_TEMPLATE}") # type: ignore[no-untyped-call] + .read_text("utf-8") ) + kaniko_shell_file = os.path.join(self.context_dir, constants.KANIKO_SHELL_SCRIPT_NAME) - with open(kaniko_shell_script_template, encoding="utf-8") as template_file, open( - kaniko_shell_file, "w+", encoding="utf-8" - ) as script_file: + with open(kaniko_shell_file, "w+", encoding="utf-8") as script_file: normed_artifact_stage_path = posixpath.normpath(identifier.remove_prefix(self.artifact_stage_location, "@")) params = { # Remove @ in the beginning, append "/" to denote root directory. @@ -129,7 +131,7 @@ def _construct_and_upload_docker_entrypoint_script(self, context_tarball_stage_l "cache_repo": f"{self.image_repo.rstrip('/')}/cache", "image_destination": self.full_image_name, } - template = Template(template_file.read()) + template = Template(kaniko_shell_script_template) script = template.safe_substitute(params) script_file.write(script) logger.debug(f"script content: \n\n {script}") @@ -164,21 +166,21 @@ def _construct_and_upload_job_spec(self, base_image: str, kaniko_shell_script_st "@" ), f"stage path should start with @, actual: {kaniko_shell_script_stage_location}" - spec_template_path = file_utils.resolve_zip_import_path( - os.path.join(os.path.dirname(__file__), f"templates/{constants.IMAGE_BUILD_JOB_SPEC_TEMPLATE}") + spec_template = ( + importlib_resources.files(image_builds) + .joinpath(f"templates/{constants.IMAGE_BUILD_JOB_SPEC_TEMPLATE}") # type: ignore[no-untyped-call] + .read_text("utf-8") ) spec_file_path = os.path.join( os.path.dirname(self.context_dir), f"{constants.IMAGE_BUILD_JOB_SPEC_TEMPLATE}.yaml" ) - with open(spec_template_path, encoding="utf-8") as template_file, open( - spec_file_path, "w+", encoding="utf-8" - ) as spec_file: + with open(spec_file_path, "w+", encoding="utf-8") as spec_file: assert self.artifact_stage_location.startswith("@") normed_artifact_stage_path = posixpath.normpath(identifier.remove_prefix(self.artifact_stage_location, "@")) (db, schema, stage, path) = identifier.parse_schema_level_object_identifier(normed_artifact_stage_path) - content = Template(template_file.read()).substitute( + content = Template(spec_template).substitute( { "base_image": base_image, "container_name": constants.KANIKO_CONTAINER_NAME, diff --git a/snowflake/ml/model/_deploy_client/image_builds/templates/kaniko_shell_script_template b/snowflake/ml/model/_deploy_client/image_builds/templates/kaniko_shell_script_template index e177da93..9d83cc24 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/templates/kaniko_shell_script_template +++ b/snowflake/ml/model/_deploy_client/image_builds/templates/kaniko_shell_script_template @@ -42,6 +42,7 @@ run_kaniko() { echo "Starting Kaniko command..." # Set cache ttl to a large value as snowservice registry doesn't support deleting cache anyway. + # Compression level set to 1 for fastest compression/decompression speed at the cost of compression ration. /kaniko/executor \ --dockerfile Dockerfile \ --context ${context_dir} \ @@ -56,6 +57,8 @@ run_kaniko() { --cache-ttl=8760h \ --push-retry=3 \ --image-fs-extract-retry=5 \ + --compression=zstd \ + --compression-level=1 \ --log-timestamp } diff --git a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/kaniko_shell_script_fixture.sh b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/kaniko_shell_script_fixture.sh index 0dd8796e..e212c39f 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/kaniko_shell_script_fixture.sh +++ b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/kaniko_shell_script_fixture.sh @@ -42,6 +42,7 @@ run_kaniko() { echo "Starting Kaniko command..." # Set cache ttl to a large value as snowservice registry doesn't support deleting cache anyway. + # Compression level set to 1 for fastest compression/decompression speed at the cost of compression ration. /kaniko/executor \ --dockerfile Dockerfile \ --context dir:///stage/models/id/context \ @@ -56,6 +57,8 @@ run_kaniko() { --cache-ttl=8760h \ --push-retry=3 \ --image-fs-extract-retry=5 \ + --compression=zstd \ + --compression-level=1 \ --log-timestamp } diff --git a/snowflake/ml/model/_deploy_client/snowservice/deploy.py b/snowflake/ml/model/_deploy_client/snowservice/deploy.py index 4482bd5c..413f479d 100644 --- a/snowflake/ml/model/_deploy_client/snowservice/deploy.py +++ b/snowflake/ml/model/_deploy_client/snowservice/deploy.py @@ -5,10 +5,10 @@ import string import tempfile import time -from abc import ABC from contextlib import contextmanager -from typing import Any, Dict, Generator, Optional, Tuple, cast +from typing import Any, Dict, Generator, Optional, cast +import importlib_resources import yaml from typing_extensions import Unpack @@ -19,6 +19,7 @@ ) from snowflake.ml._internal.utils import identifier, query_result_checker from snowflake.ml.model import type_hints +from snowflake.ml.model._deploy_client import snowservice from snowflake.ml.model._deploy_client.image_builds import ( base_image_builder, client_image_builder, @@ -97,11 +98,25 @@ def _deploy( snowflake_connector_logger = logging.getLogger("snowflake.connector") snowpark_log_level = snowpark_logger.level snowflake_connector_log_level = snowflake_connector_logger.level + + query_result = ( + query_result_checker.SqlResultValidator( + session, + query="SHOW PARAMETERS LIKE 'PYTHON_CONNECTOR_QUERY_RESULT_FORMAT' IN SESSION", + ) + .has_dimensions(expected_rows=1) + .validate() + ) + prev_format = query_result[0].value + try: # Setting appropriate log level to prevent console from being polluted by vast amount of snowpark and snowflake # connector logging. snowpark_logger.setLevel(logging.WARNING) snowflake_connector_logger.setLevel(logging.WARNING) + + # Query format change is needed to ensure session token obtained from the session object is valid. + session.sql("ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'json'").collect() if not model_id: raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INVALID_ARGUMENT, @@ -181,6 +196,7 @@ def _deploy( ) return ss_deployment.deploy() finally: + session.sql(f"ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = '{prev_format}'").collect() # Preserve the original logging level. snowpark_logger.setLevel(snowpark_log_level) snowflake_connector_logger.setLevel(snowflake_connector_log_level) @@ -278,7 +294,7 @@ def _sanitize_dns_url(url: str) -> str: ) from e -class SnowServiceDeployment(ABC): +class SnowServiceDeployment: """ Class implementation that encapsulates image build and workflow deployment to SnowService """ @@ -334,8 +350,7 @@ def deploy(self) -> type_hints.SnowparkContainerServiceDeployDetails: """ if self.options.prebuilt_snowflake_image: logger.warning(f"Skipped image build. Use prebuilt image: {self.options.prebuilt_snowflake_image}") - full_image_name = self.options.prebuilt_snowflake_image - (service_spec, service_function_sql) = self._deploy_workflow(self.options.prebuilt_snowflake_image) + service_function_sql = self._deploy_workflow(self.options.prebuilt_snowflake_image) else: with _debug_aware_tmp_directory(debug_dir=self.debug_dir) as context_dir: extra_kwargs = {} @@ -354,7 +369,7 @@ def deploy(self) -> type_hints.SnowparkContainerServiceDeployDetails: self.session, service_func_name=self.service_func_name, image_repo=self.options.image_repo ) full_image_name = self._get_full_image_name(image_repo=image_repo, context_dir=context_dir) - registry_client = image_registry_client.ImageRegistryClient(self.session) + registry_client = image_registry_client.ImageRegistryClient(self.session, full_image_name) if not self.options.force_image_build and registry_client.image_exists(full_image_name=full_image_name): logger.warning( @@ -390,10 +405,12 @@ def deploy(self) -> type_hints.SnowparkContainerServiceDeployDetails: except Exception as e: # Proceed to the deployment with a warning message. logger.warning(f"Failed to add tag {self.model_meta.name} to image {full_image_name}: {str(e)}") - (service_spec, service_function_sql) = self._deploy_workflow(full_image_name) + service_function_sql = self._deploy_workflow(full_image_name) + + rows = self.session.sql(f"DESCRIBE SERVICE {self._service_name}").collect() + service_info = rows[0].as_dict() if rows and rows[0] else None return type_hints.SnowparkContainerServiceDeployDetails( - image_name=full_image_name, - service_spec=service_spec, + service_info=service_info, service_function_sql=service_function_sql, ) @@ -446,30 +463,29 @@ def _build_and_upload_image(self, context_dir: str, image_repo: str, full_image_ ) image_builder.build_and_upload_image() - def _prepare_and_upload_artifacts_to_stage(self, image: str) -> str: + def _prepare_and_upload_artifacts_to_stage(self, image: str) -> None: """Constructs and upload service spec to stage. Args: image: Name of the image to create SnowService container from. - - Returns: - Service spec string. """ if self.options.model_in_image: - spec_template_path = file_utils.resolve_zip_import_path( - os.path.join(os.path.dirname(__file__), "templates/service_spec_template_with_model") + spec_template = ( + importlib_resources.files(snowservice) + .joinpath("templates/service_spec_template_with_model") # type: ignore[no-untyped-call] + .read_text("utf-8") ) else: - spec_template_path = file_utils.resolve_zip_import_path( - os.path.join(os.path.dirname(__file__), "templates/service_spec_template") + spec_template = ( + importlib_resources.files(snowservice) + .joinpath("templates/service_spec_template") # type: ignore[no-untyped-call] + .read_text("utf-8") ) with _debug_aware_tmp_directory(self.debug_dir) as dir_path: spec_file_path = os.path.join(dir_path, f"{constants.SERVICE_SPEC}.yaml") - with open(spec_template_path, encoding="utf-8") as template, open( - spec_file_path, "w+", encoding="utf-8" - ) as spec_file: + with open(spec_file_path, "w+", encoding="utf-8") as spec_file: assert self.model_zip_stage_path.startswith("@") norm_stage_path = posixpath.normpath(identifier.remove_prefix(self.model_zip_stage_path, "@")) # Ensure model stage path has root prefix as stage mount will it mount it to root. @@ -484,11 +500,12 @@ def _prepare_and_upload_artifacts_to_stage(self, image: str) -> str: "target_method": self.target_method, "num_workers": self.options.num_workers, "use_gpu": self.options.use_gpu, + "enable_ingress": self.options.enable_ingress, } if self.options.model_in_image: del substitutes["model_stage"] del substitutes["model_zip_stage_path"] - content = string.Template(template.read()).substitute(substitutes) + content = string.Template(spec_template).substitute(substitutes) content_dict = yaml.safe_load(content) if self.options.use_gpu: container = content_dict["spec"]["container"][0] @@ -507,9 +524,7 @@ def _prepare_and_upload_artifacts_to_stage(self, image: str) -> str: container["env"]["_CONCURRENT_REQUESTS_MAX"] = 1 yaml.dump(content_dict, spec_file) - spec_file.seek(0) - spec_file_yaml_string = spec_file.read() - logger.debug(f"Create service spec: \n {spec_file_yaml_string}") + logger.debug("Create service spec: \n, %s", content_dict) self.session.file.put( local_file_name=spec_file_path, @@ -520,7 +535,6 @@ def _prepare_and_upload_artifacts_to_stage(self, image: str) -> str: logger.debug( f"Uploaded spec file {os.path.basename(spec_file_path)} " f"to {self._model_artifact_stage_location}" ) - return spec_file_yaml_string def _get_max_batch_rows(self) -> Optional[int]: # To avoid too large batch in HF LLM case @@ -543,17 +557,17 @@ def _get_max_batch_rows(self) -> Optional[int]: max_batch_rows = min(batch_size, max_batch_rows) return max_batch_rows - def _deploy_workflow(self, image: str) -> Tuple[str, str]: + def _deploy_workflow(self, image: str) -> str: """This function handles workflow deployment to SnowService with the given image. Args: image: Name of the image to create SnowService container from. Returns: - Tuple of (service spec, service function sql). + service function sql """ - service_spec_string = self._prepare_and_upload_artifacts_to_stage(image) + self._prepare_and_upload_artifacts_to_stage(image) client = snowservice_client.SnowServiceClient(self.session) spec_stage_location = posixpath.join( self._model_artifact_stage_location.rstrip("/"), f"{constants.SERVICE_SPEC}.yaml" @@ -578,4 +592,4 @@ def _deploy_workflow(self, image: str) -> Tuple[str, str]: max_batch_rows=self._get_max_batch_rows(), ) logger.info(f"Service function {self.service_func_name} is created. Deployment completed successfully!") - return service_spec_string, service_function_sql + return service_function_sql diff --git a/snowflake/ml/model/_deploy_client/snowservice/deploy_options.py b/snowflake/ml/model/_deploy_client/snowservice/deploy_options.py index 0cbe700d..b96ea8c5 100644 --- a/snowflake/ml/model/_deploy_client/snowservice/deploy_options.py +++ b/snowflake/ml/model/_deploy_client/snowservice/deploy_options.py @@ -26,6 +26,7 @@ def __init__( force_image_build: Optional[bool] = False, model_in_image: Optional[bool] = False, debug_mode: Optional[bool] = False, + enable_ingress: Optional[bool] = False, ) -> None: """Initialization @@ -53,6 +54,8 @@ def __init__( model_in_image: When set to True, image would container full model weights. The default if False, which means image without model weights and we do stage mount to access weights. debug_mode: When set to True, deployment artifacts will be persisted in a local temp directory. + enable_ingress: When set to True, will expose HTTP endpoint for access to the predict method of the created + service. Default to False. """ self.compute_pool = compute_pool @@ -66,6 +69,7 @@ def __init__( self.force_image_build = force_image_build self.model_in_image = model_in_image self.debug_mode = debug_mode + self.enable_ingress = enable_ingress if self.num_workers is None and self.use_gpu: logger.info("num_workers has been defaulted to 1 when using GPU.") diff --git a/snowflake/ml/model/_deploy_client/snowservice/deploy_test.py b/snowflake/ml/model/_deploy_client/snowservice/deploy_test.py index d9380525..20c5132c 100644 --- a/snowflake/ml/model/_deploy_client/snowservice/deploy_test.py +++ b/snowflake/ml/model/_deploy_client/snowservice/deploy_test.py @@ -3,6 +3,7 @@ from absl.testing import absltest from absl.testing.absltest import mock +from snowflake import snowpark from snowflake.ml.model._deploy_client.snowservice import deploy_options from snowflake.ml.model._deploy_client.snowservice.deploy import ( SnowServiceDeployment, @@ -30,6 +31,16 @@ def setUp(self) -> None: "image_repo": "mock_image_repo", } + self.m_session.add_mock_sql( + query="SHOW PARAMETERS LIKE 'PYTHON_CONNECTOR_QUERY_RESULT_FORMAT' IN SESSION", + result=mock_data_frame.MockDataFrame([row.Row(value="arrow")]), + ) + + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'json'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) + def _get_mocked_compute_pool_res( self, state: Optional[str] = "IDLE", @@ -63,6 +74,11 @@ def test_deploy_with_model_id(self, m_deployment_class: mock.MagicMock, m_model_ result=self._get_mocked_compute_pool_res(), ) + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) + _deploy( session=cast(session.Session, self.m_session), model_id="provided_model_id", @@ -100,6 +116,12 @@ def test_deploy_with_not_ready_compute_pool( query=f"DESC COMPUTE POOL {self.options['compute_pool']}", result=self._get_mocked_compute_pool_res(state="STARTING"), ) + + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) + with exception_utils.assert_snowml_exceptions(self, expected_original_error_type=RuntimeError): _deploy( session=cast(session.Session, self.m_session), @@ -130,6 +152,11 @@ def test_deploy_with_compute_pool_in_suspended_state_with_auto_resume( result=self._get_mocked_compute_pool_res(state="SUSPENDED", auto_resume=True), ) + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) + _deploy( session=cast(session.Session, self.m_session), model_id="provided_model_id", @@ -150,6 +177,11 @@ def test_deploy_with_empty_model_id( ) -> None: m_model_meta = m_model_meta_class.return_value with exception_utils.assert_snowml_exceptions(self, expected_original_error_type=ValueError): + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) + _deploy( session=cast(session.Session, self.m_session), service_func_name="mock_service_func", @@ -173,6 +205,10 @@ def test_deploy_with_missing_required_options( self, expected_original_error_type=ValueError, expected_regex="compute_pool" ): options: Dict[str, Any] = {} + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) _deploy( session=cast(session.Session, self.m_session), service_func_name="mock_service_func", @@ -199,7 +235,10 @@ def test_deploy_with_over_requested_gpus( query=f"DESC COMPUTE POOL {self.options['compute_pool']}", result=self._get_mocked_compute_pool_res(instance_family="GPU_3"), ) - + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) _deploy( session=cast(session.Session, self.m_session), service_func_name="mock_service_func", @@ -226,8 +265,8 @@ def test_deploy_with_over_requested_gpus_no_cuda( expected_regex="You are requesting GPUs for models that do not use a GPU or does not have CUDA version set", ): self.m_session.add_mock_sql( - query=f"DESC COMPUTE POOL {self.options['compute_pool']}", - result=self._get_mocked_compute_pool_res(instance_family="GPU_7"), + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), ) _deploy( session=cast(session.Session, self.m_session), @@ -260,6 +299,10 @@ def test_deploy_with_gpu_validation_and_unknown_instance_type( query=f"DESC COMPUTE POOL {self.options['compute_pool']}", result=self._get_mocked_compute_pool_res(instance_family=unknown_instance_type), ) + self.m_session.add_mock_sql( + query="ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'", + result=mock_data_frame.MockDataFrame(collect_result=[]), + ) with self.assertLogs(level="INFO") as cm: _deploy( session=cast(session.Session, self.m_session), @@ -297,6 +340,12 @@ def test_deploy_with_gpu_validation_and_unknown_instance_type( ) m_deployment.deploy.assert_called_once() + +class TestImageRepoCreate(absltest.TestCase): + def setUp(self) -> None: + super().setUp() + self.m_session = mock_session.MockSession(conn=None, test_case=self) + @mock.patch( "snowflake.ml.model._deploy_client.snowservice.deploy." "snowservice_client.SnowServiceClient" ) # type: ignore[misc] @@ -369,7 +418,7 @@ class SnowServiceDeploymentTestCase(absltest.TestCase): @mock.patch("snowflake.ml.model._deploy_client.snowservice.deploy.model_meta.ModelMetadata") # type: ignore[misc] def setUp(self, m_model_meta_class: mock.MagicMock) -> None: super().setUp() - self.m_session = cast(session.Session, mock_session.MockSession(conn=None, test_case=self)) + self.m_session = mock_session.MockSession(conn=None, test_case=self) self.m_model_id = "provided_model_id" self.m_service_func_name = "mock_db.mock_schema.provided_service_func_name" self.m_model_zip_stage_path = "@provided_model_zip_stage_path/model.zip" @@ -385,7 +434,7 @@ def setUp(self, m_model_meta_class: mock.MagicMock) -> None: } self.deployment = SnowServiceDeployment( - self.m_session, + cast(session.Session, self.m_session), model_id=self.m_model_id, service_func_name=self.m_service_func_name, model_meta=self.m_model_meta, @@ -395,19 +444,25 @@ def setUp(self, m_model_meta_class: mock.MagicMock) -> None: options=deploy_options.SnowServiceDeployOptions.from_dict(self.m_options), ) + self.m_session.add_mock_sql( + query=f"DESCRIBE SERVICE {self.deployment._service_name}", + result=mock_data_frame.MockDataFrame( + collect_result=[snowpark.Row(**{"name": self.deployment._service_name})] + ), + ) + def test_service_name(self) -> None: self.assertEqual(self.deployment._service_name, "mock_db.mock_schema.service_provided_model_id") @mock.patch( "snowflake.ml.model._deploy_client.snowservice.deploy.image_registry_client.ImageRegistryClient" - ".add_tag_to_remote_image" - ) # type: ignore[misc] - @mock.patch( - "snowflake.ml.model._deploy_client.snowservice.deploy.image_registry_client.ImageRegistryClient" ".image_exists" ) # type: ignore[misc] - def test_deploy(self, m_image_exists_class: mock.MagicMock, m_add_tag_class: mock.MagicMock) -> None: - m_image_exists_class.return_value = False - m_add_tag_class.return_value = None + def test_deploy(self, m_image_registry_client: mock.MagicMock) -> None: + m_image_registry_client.return_value = mock.MagicMock() + m_client = m_image_registry_client.return_value + m_client.image_exists.return_value = False + m_client.add_tag_to_remote_image.return_value = None + with mock.patch.object( self.deployment, "_build_and_upload_image" ) as m_build_and_upload_image, mock.patch.object( @@ -440,20 +495,19 @@ def test_deploy(self, m_image_exists_class: mock.MagicMock, m_add_tag_class: moc ], ) - m_add_tag_class.assert_called_once_with(original_full_image_name=full_image_name, new_tag=self.model_name) + m_client.add_tag_to_remote_image.assert_called_once_with( + original_full_image_name=full_image_name, new_tag=self.model_name + ) @mock.patch( "snowflake.ml.model._deploy_client.snowservice.deploy.image_registry_client.ImageRegistryClient" - ".add_tag_to_remote_image" ) # type: ignore[misc] - @mock.patch( - "snowflake.ml.model._deploy_client.snowservice.deploy.image_registry_client.ImageRegistryClient.image_exists" - ) # type: ignore[misc] - def test_deploy_with_image_already_exists_in_registry( - self, m_image_exists_class: mock.MagicMock, m_add_tag_class: mock.MagicMock - ) -> None: - m_image_exists_class.return_value = True - m_add_tag_class.return_value = None + def test_deploy_with_image_already_exists_in_registry(self, m_image_registry_client: mock.MagicMock) -> None: + m_image_registry_client.return_value = mock.MagicMock() + m_client = m_image_registry_client.return_value + m_client.image_exists.return_value = True + m_client.add_tag_to_remote_image.return_value = None + with mock.patch.object( self.deployment, "_build_and_upload_image" ) as m_build_and_upload_image, mock.patch.object( @@ -464,6 +518,7 @@ def test_deploy_with_image_already_exists_in_registry( full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" m_get_full_image_name.return_value = full_image_name m_deploy_workflow.return_value = ("service_spec", "sql") + with self.assertLogs(level="WARNING") as cm: self.deployment.deploy() m_build_and_upload_image.assert_not_called() @@ -479,7 +534,9 @@ def test_deploy_with_image_already_exists_in_registry( ) ], ) - m_add_tag_class.assert_called_once_with(original_full_image_name=full_image_name, new_tag=self.model_name) + m_client.add_tag_to_remote_image.assert_called_once_with( + original_full_image_name=full_image_name, new_tag=self.model_name + ) if __name__ == "__main__": diff --git a/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template b/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template index cb043a2f..ce9d2fdd 100644 --- a/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template +++ b/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template @@ -18,6 +18,7 @@ spec: endpoint: - name: ${predict_endpoint_name} port: 5000 + public: ${enable_ingress} volume: - name: vol1 source: local # only local emptyDir volume is supported 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 1035d80a..66e8bc1c 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 @@ -15,6 +15,7 @@ spec: endpoint: - name: ${predict_endpoint_name} port: 5000 + public: ${enable_ingress} volume: - name: vol1 source: local # only local emptyDir volume is supported diff --git a/snowflake/ml/model/_deploy_client/utils/BUILD.bazel b/snowflake/ml/model/_deploy_client/utils/BUILD.bazel index d7548e7b..d5fa0a02 100644 --- a/snowflake/ml/model/_deploy_client/utils/BUILD.bazel +++ b/snowflake/ml/model/_deploy_client/utils/BUILD.bazel @@ -24,7 +24,7 @@ py_library( deps = [ ":imagelib", "//snowflake/ml/_internal/exceptions", - "//snowflake/ml/_internal/utils:spcs_image_registry", + "//snowflake/ml/_internal/utils:image_registry_http_client", ], ) @@ -32,17 +32,7 @@ py_library( name = "imagelib", srcs = ["imagelib.py"], deps = [ - ":image_auth_manager", - "//snowflake/ml/_internal/utils:retryable_http", - "//snowflake/ml/_internal/utils:spcs_image_registry", - ], -) - -py_library( - name = "image_auth_manager", - srcs = ["image_auth_manager.py"], - deps = [ - "//snowflake/ml/_internal/utils:retryable_http", + "//snowflake/ml/_internal/utils:image_registry_http_client", ], ) diff --git a/snowflake/ml/model/_deploy_client/utils/constants.py b/snowflake/ml/model/_deploy_client/utils/constants.py index bab32324..4762df71 100644 --- a/snowflake/ml/model/_deploy_client/utils/constants.py +++ b/snowflake/ml/model/_deploy_client/utils/constants.py @@ -49,3 +49,4 @@ class ResourceStatus(Enum): KANIKO_SHELL_SCRIPT_NAME = "kaniko_shell_script_fixture.sh" KANIKO_CONTAINER_NAME = "kaniko" LATEST_IMAGE_TAG = "latest" +KANIKO_IMAGE = "kaniko-project/executor:v1.16.0-debug" diff --git a/snowflake/ml/model/_deploy_client/utils/image_auth_manager.py b/snowflake/ml/model/_deploy_client/utils/image_auth_manager.py deleted file mode 100644 index 92de074a..00000000 --- a/snowflake/ml/model/_deploy_client/utils/image_auth_manager.py +++ /dev/null @@ -1,50 +0,0 @@ -import dataclasses -from abc import abstractmethod -from typing import Optional - -from snowflake.ml._internal.utils import retryable_http - -http = retryable_http.get_http_client() - - -class AuthManager: - """ - An interface to support fetching tokens for registries. - This can be subclassed to provide custom implementations. - """ - - @abstractmethod - def get_auth_token(self, spcs_token: Optional[str] = None) -> str: - """Returns a bearer token for the registry. Use the spcs registry cred to authenticate. - The details of generation and lifetime management/caching are left to the - implementation. - - Args: - spcs_token: session token from SPCS image registry. - - """ - pass - - -@dataclasses.dataclass -class SnowflakeAuthManager(AuthManager): - """ - Implements authentication against Snowflake image registry. - """ - - registry_host: str - - def get_auth_token(self, spcs_token: Optional[str] = None) -> str: - """Get bearer token by authenticating to registry with the given spcs session token. - - Args: - spcs_token: session token from SPCS image registry. - - Returns: - str: A bearer token from Docker API. - - """ - assert spcs_token is not None - resp = http.get(url=f"https://{self.registry_host}/login", headers={"authorization": f"Basic {spcs_token}"}) - assert resp.status_code == 200, f"login failed with code {resp.status_code} and message {str(resp.content)}" - return str(resp.json()["token"]) diff --git a/snowflake/ml/model/_deploy_client/utils/image_registry_client.py b/snowflake/ml/model/_deploy_client/utils/image_registry_client.py index 097ec1d6..df8775b6 100644 --- a/snowflake/ml/model/_deploy_client/utils/image_registry_client.py +++ b/snowflake/ml/model/_deploy_client/utils/image_registry_client.py @@ -1,72 +1,43 @@ import http -import json import logging from typing import Dict, Optional, cast -from urllib.parse import urlparse, urlunparse +from urllib.parse import urlunparse from snowflake.ml._internal.exceptions import ( error_codes, exceptions as snowml_exceptions, ) -from snowflake.ml._internal.utils import retryable_http, spcs_image_registry -from snowflake.ml.model._deploy_client.utils import image_auth_manager, imagelib +from snowflake.ml._internal.utils import image_registry_http_client +from snowflake.ml.model._deploy_client.utils import imagelib from snowflake.snowpark import Session from snowflake.snowpark._internal import utils as snowpark_utils -MANIFEST_V1_HEADER = "application/vnd.oci.image.manifest.v1+json" -MANIFEST_V2_HEADER = "application/vnd.docker.distribution.manifest.v2+json" +_MANIFEST_V1_HEADER = "application/vnd.oci.image.manifest.v1+json" +_MANIFEST_V2_HEADER = "application/vnd.docker.distribution.manifest.v2+json" +_SUPPORTED_MANIFEST_HEADERS = [_MANIFEST_V1_HEADER, _MANIFEST_V2_HEADER] logger = logging.getLogger(__name__) class ImageRegistryClient: """ - A simple SPCS image registry HTTP client partial implementation. This client exists due to current unavailability - of registry "list image" system function and lack of registry SDK. + A partial implementation of an SPCS image registry client. The client utilizes the ImageRegistryHttpClient under + the hood, incorporating a retry mechanism to handle intermittent 401 errors from the SPCS image registry. """ - def __init__(self, session: Session) -> None: + def __init__(self, session: Session, full_dest_image_name: str) -> None: """Initialization Args: session: Snowpark session + full_dest_image_name: Based on dest image name, repo url can be inferred. """ - self.session = session - self.http = retryable_http.get_http_client() - - def login(self, repo_url: str, registry_cred: str) -> str: - """Log in to image registry - - Args: - repo_url: image repo url. - registry_cred: registry basic auth credential. - - Returns: - Bearer token when login succeeded. + self.image_registry_http_client = image_registry_http_client.ImageRegistryHttpClient( + session=session, + repo_url=self._convert_to_v2_manifests_url(full_image_name=full_dest_image_name), + ) - Raises: - SnowflakeMLException: when login failed. - """ - parsed_url = urlparse(repo_url) - scheme = parsed_url.scheme - host = parsed_url.netloc - - login_path = "/login" # Construct the login path - url_tuple = (scheme, host, login_path, "", "", "") - login_url = urlunparse(url_tuple) - - resp = self.http.get(login_url, headers={"Authorization": f"Basic {registry_cred}"}) - if resp.status_code != http.HTTPStatus.OK: - raise snowml_exceptions.SnowflakeMLException( - error_code=error_codes.INTERNAL_SNOWFLAKE_IMAGE_REGISTRY_ERROR, - original_exception=RuntimeError( - f"Failed to login to the repository. Status {resp.status_code}," f"{str(resp.text)}" - ), - ) - - return str(json.loads(resp.text)["token"]) - - def convert_to_v2_manifests_url(self, full_image_name: str) -> str: + def _convert_to_v2_manifests_url(self, full_image_name: str) -> str: """Converts a full image name to a Docker Registry HTTP API V2 URL: https://docs.docker.com/registry/spec/api/#existing-manifests @@ -92,6 +63,12 @@ def convert_to_v2_manifests_url(self, full_image_name: str) -> str: url_tuple = (scheme, domain, path, "", "", "") return urlunparse(url_tuple) + def _get_accept_headers(self) -> Dict[str, str]: + # Depending on the built image, the media type of the image manifest might be either + # application/vnd.oci.image.manifest.v1+json or application/vnd.docker.distribution.manifest.v2+json + # Hence we need to check for both, otherwise it could result in false negative. + return {"Accept": ",".join(_SUPPORTED_MANIFEST_HEADERS)} + def image_exists(self, full_image_name: str) -> bool: """Check whether image already exists in the registry. @@ -106,39 +83,17 @@ def image_exists(self, full_image_name: str) -> bool: # unable to fetch session token needed to authenticate to SPCS image registry. if snowpark_utils.is_in_stored_procedure(): # type: ignore[no-untyped-call] return False + v2_api_url = self._convert_to_v2_manifests_url(full_image_name) + headers = self._get_accept_headers() + status = self.image_registry_http_client.head(v2_api_url, headers=headers).status_code + return status == http.HTTPStatus.OK - with spcs_image_registry.generate_image_registry_credential(self.session) as registry_cred: - v2_api_url = self.convert_to_v2_manifests_url(full_image_name) - bearer_login = self.login(v2_api_url, registry_cred) - - headers_v1 = { - "Authorization": f"Bearer {bearer_login}", - "Accept": MANIFEST_V1_HEADER, - } - - headers_v2 = { - "Authorization": f"Bearer {bearer_login}", - "Accept": MANIFEST_V2_HEADER, - } - # Depending on the built image, the media type of the image manifest might be either - # application/vnd.oci.image.manifest.v1+json or application/vnd.docker.distribution.manifest.v2+json - # Hence we need to check for both, otherwise it could result in false negative. - if self.http.head(v2_api_url, headers=headers_v2).status_code == http.HTTPStatus.OK: - return True - elif self.http.head(v2_api_url, headers=headers_v1).status_code == http.HTTPStatus.OK: - return True - return False - - def _get_manifest( - self, full_image_name: str, header_v1: Dict[str, str], header_v2: Dict[str, str] - ) -> Dict[str, str]: + def _get_manifest(self, full_image_name: str) -> Dict[str, str]: """Retrieve image manifest file. Given Docker manifest comes with two versions, and for each version the corresponding request header is required for a successful HTTP response. Args: full_image_name: Full image name. - header_v1: Docker manifest v1 header. - header_v2: Docker manifest v2 header. Returns: Full manifest content as a python dict. @@ -147,19 +102,15 @@ def _get_manifest( SnowflakeMLException: when failed to retrieve manifest. """ - v2_api_url = self.convert_to_v2_manifests_url(full_image_name) - res1 = self.http.get(v2_api_url, headers=header_v2) - if res1.status_code == http.HTTPStatus.OK: - return cast(Dict[str, str], res1.json()) - res2 = self.http.get(v2_api_url, headers=header_v1) - if res2.status_code == http.HTTPStatus.OK: - return cast(Dict[str, str], res2.json()) + v2_api_url = self._convert_to_v2_manifests_url(full_image_name) + res = self.image_registry_http_client.get(v2_api_url, headers=self._get_accept_headers()) + if res.status_code == http.HTTPStatus.OK: + return cast(Dict[str, str], res.json()) raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INTERNAL_SNOWFLAKE_IMAGE_REGISTRY_ERROR, original_exception=ValueError( - f"Failed to retrieve manifest for {full_image_name}. Two requests filed: \n" - f"HTTP status code 1: {res1.status_code}. Full response 1: {res1.text}. \n" - f"HTTP status code 2: {res2.status_code}. Full response 2: {res2.text}" + f"Failed to retrieve manifest for {full_image_name}. \n" + f"HTTP status code: {res.status_code}. Full response: {res.text}." ), ) @@ -180,52 +131,42 @@ def add_tag_to_remote_image(self, original_full_image_name: str, new_tag: str) - if snowpark_utils.is_in_stored_procedure(): # type: ignore[no-untyped-call] return None - with spcs_image_registry.generate_image_registry_credential(self.session) as registry_cred: - full_image_name_parts = original_full_image_name.split(":") - assert len(full_image_name_parts) == 2, "full image name should include both image name and tag" - new_full_image_name = ":".join([full_image_name_parts[0], new_tag]) - if self.image_exists(new_full_image_name): - # Early return if image with the associated tag already exists. - return - api_url = self.convert_to_v2_manifests_url(new_full_image_name) - # Login again to avoid token timeout issue. - bearer_login = self.login(api_url, registry_cred) - header_v1 = { - "Authorization": f"Bearer {bearer_login}", - "Accept": MANIFEST_V1_HEADER, - } - header_v2 = {"Authorization": f"Bearer {bearer_login}", "Accept": MANIFEST_V2_HEADER} - - manifest = self._get_manifest( - full_image_name=original_full_image_name, header_v1=header_v1, header_v2=header_v2 - ) - manifest_copy = manifest.copy() - manifest_copy["tag"] = new_tag - - put_header_v1 = { - **header_v1, - "Content-Type": MANIFEST_V1_HEADER, - } - put_header_v2 = { - **header_v2, - "Content-Type": MANIFEST_V2_HEADER, - } - - res1 = self.http.put(api_url, headers=put_header_v2, json=manifest_copy) - if res1.status_code != http.HTTPStatus.CREATED: - res2 = self.http.put(api_url, headers=put_header_v1, json=manifest_copy) - if res2.status_code != http.HTTPStatus.CREATED: - raise snowml_exceptions.SnowflakeMLException( - error_code=error_codes.INTERNAL_SNOWFLAKE_IMAGE_REGISTRY_ERROR, - original_exception=ValueError( - f"Failed to push manifest for {new_full_image_name}. Two requests filed: \n" - f"HTTP status code 1: {res1.status_code}. Full response 1: {res1.text}. \n" - f"HTTP status code 2: {res2.status_code}. Full response 2: {res2.text}" - ), - ) - assert self.image_exists(new_full_image_name), ( - f"{new_full_image_name} should exist in image repo after a" f"successful manifest update" - ) + full_image_name_parts = original_full_image_name.split(":") + assert len(full_image_name_parts) == 2, "full image name should include both image name and tag" + new_full_image_name = ":".join([full_image_name_parts[0], new_tag]) + if self.image_exists(new_full_image_name): + # Early return if image with the associated tag already exists. + return + api_url = self._convert_to_v2_manifests_url(new_full_image_name) + manifest = self._get_manifest(full_image_name=original_full_image_name) + manifest_copy = manifest.copy() + manifest_copy["tag"] = new_tag + headers = self._get_accept_headers() + # Http Content-Type does not support multi-value, hence need to construct separate header. + put_header_v1 = { + **headers, + "Content-Type": _MANIFEST_V1_HEADER, + } + put_header_v2 = { + **headers, + "Content-Type": _MANIFEST_V2_HEADER, + } + + res1 = self.image_registry_http_client.put(api_url, headers=put_header_v1, json=manifest_copy) + if res1.status_code != http.HTTPStatus.CREATED: + res2 = self.image_registry_http_client.put(api_url, headers=put_header_v2, json=manifest_copy) + if res2.status_code != http.HTTPStatus.CREATED: + raise snowml_exceptions.SnowflakeMLException( + error_code=error_codes.INTERNAL_SNOWFLAKE_IMAGE_REGISTRY_ERROR, + original_exception=ValueError( + f"Failed to push manifest for {new_full_image_name}. Two requests filed: \n" + f"HTTP status code 1: {res1.status_code}. Full response 1: {res1.text}. \n" + f"HTTP status code 2: {res2.status_code}. Full response 2: {res2.text}" + ), + ) + assert self.image_exists( + new_full_image_name + ), f"{new_full_image_name} should exist in image repo after a successful manifest update" def copy_image( self, @@ -255,7 +196,9 @@ def copy_image( dest_image = imagelib.convert_to_image_descriptor( dest_image_with_tag, with_tag=True, - creds_manager=image_auth_manager.SnowflakeAuthManager(dest_image_with_tag.split("/")[0]), ) - imagelib.copy_image(src_image=src_image, dest_image=dest_image, arch=arch, session=self.session) + # TODO[shchen]: Remove the imagelib, instead rely on the copy image system function later. + imagelib.copy_image( + src_image=src_image, dest_image=dest_image, arch=arch, retryable_http=self.image_registry_http_client + ) logger.info("Image copy completed successfully") diff --git a/snowflake/ml/model/_deploy_client/utils/image_registry_client_test.py b/snowflake/ml/model/_deploy_client/utils/image_registry_client_test.py index 71bb3e13..4a1f3430 100644 --- a/snowflake/ml/model/_deploy_client/utils/image_registry_client_test.py +++ b/snowflake/ml/model/_deploy_client/utils/image_registry_client_test.py @@ -4,7 +4,7 @@ from absl.testing.absltest import mock from snowflake.ml.model._deploy_client.utils import image_registry_client -from snowflake.ml.test_utils import exception_utils, mock_session +from snowflake.ml.test_utils import mock_session from snowflake.snowpark import session @@ -13,160 +13,36 @@ def setUp(self) -> None: super().setUp() self.m_session = mock_session.MockSession(conn=None, test_case=self) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.retryable_http.get_http_client" - ) - def test_successful_login(self, mock_get_http_client: mock.MagicMock) -> None: - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) - mock_response = mock.MagicMock(status_code=200, text='{"token": "dummy_token"}') - mock_get_http_client.return_value.get.return_value = mock_response - repo_url = "https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo" - registry_cred = "dummy_credentials" - token = client.login(repo_url, registry_cred) - self.assertEqual(token, "dummy_token") - mock_get_http_client.return_value.get.assert_called_once_with( - "https://org-account.registry.snowflakecomputing.com/login", - headers={"Authorization": f"Basic {registry_cred}"}, - ) - - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.retryable_http.get_http_client" - ) - def test_failed_login(self, mock_get_http_client: mock.MagicMock) -> None: - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) - mock_response = mock.MagicMock(status_code=401, text="Unauthorized") - mock_get_http_client.return_value.get.return_value = mock_response - repo_url = "https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo" - registry_cred = "dummy_credentials" - - with exception_utils.assert_snowml_exceptions(self, expected_original_error_type=RuntimeError): - client.login(repo_url, registry_cred) - - mock_get_http_client.return_value.get.assert_called_once_with( - "https://org-account.registry.snowflakecomputing.com/login", - headers={"Authorization": f"Basic {registry_cred}"}, - ) - def test_convert_to_v2_head_manifests_url(self) -> None: - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" - actual = client.convert_to_v2_manifests_url(full_image_name=full_image_name) + client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session), full_image_name) + actual = client._convert_to_v2_manifests_url(full_image_name=full_image_name) expected = "https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo/image/manifests/latest" self.assertEqual(actual, expected) def test_convert_to_v2_head_manifests_url_with_invalid_full_image_name(self) -> None: - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) image_name_without_tag = "org-account.registry.snowflakecomputing.com/db/schema/repo/image" with self.assertRaises(AssertionError): - client.convert_to_v2_manifests_url(full_image_name=image_name_without_tag) + image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session), image_name_without_tag) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client." "ImageRegistryClient.login" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.retryable_http.get_http_client" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.spcs_image_registry" - ) - def test_image_exists( - self, mock_spcs_image_registry: mock.MagicMock, mock_get_http_client: mock.MagicMock, mock_login: mock.MagicMock - ) -> None: - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) - mock_response = mock.MagicMock(status_code=200) - mock_get_http_client.return_value.head.return_value = mock_response - mock_bearer_token = "dummy_bearer_token" - mock_registry_cred = "dummy_registry_cred" - mock_login.return_value = mock_bearer_token - - with mock.patch.object(mock_spcs_image_registry, "generate_image_registry_credential") as m_generate: - m_generate.return_value.__enter__.return_value = mock_registry_cred - full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" - url = "https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo/image/manifests/latest" - self.assertEqual(client.image_exists(full_image_name=full_image_name), True) - mock_login.assert_called_once_with(url, mock_registry_cred) - mock_get_http_client.return_value.head.assert_called_once_with( - url, - headers={ - "Authorization": f"Bearer {mock_bearer_token}", - "Accept": image_registry_client.MANIFEST_V2_HEADER, - }, - ) - - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client" ".ImageRegistryClient.login" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.retryable_http.get_http_client" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.spcs_image_registry" - ) - def test_image_exists_with_two_head_requests( - self, mock_spcs_image_registry: mock.MagicMock, mock_get_http_client: mock.MagicMock, mock_login: mock.MagicMock - ) -> None: - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) - mock_get_http_client.return_value.head.side_effect = [ - mock.Mock(status_code=404), - mock.Mock(status_code=200), - ] - - mock_bearer_token = "dummy_bearer_token" - mock_registry_cred = "dummy_registry_cred" - mock_login.return_value = mock_bearer_token + def test_image_exists(self) -> None: + full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" + client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session), full_image_name) + url = client._convert_to_v2_manifests_url(full_image_name) - with mock.patch.object(mock_spcs_image_registry, "generate_image_registry_credential") as m_generate: - m_generate.return_value.__enter__.return_value = mock_registry_cred - full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" - url = "https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo/image/manifests/latest" + with mock.patch.object(client.image_registry_http_client, "head", return_value=mock.MagicMock(status_code=200)): self.assertEqual(client.image_exists(full_image_name=full_image_name), True) - mock_login.assert_called_once_with(url, mock_registry_cred) - - # Modify the assertion to check for two calls to head - self.assertEqual(mock_get_http_client.return_value.head.call_count, 2) - - # Modify the expected calls to match both head requests - expected_calls = [ - mock.call( - url, - headers={ - "Authorization": f"Bearer {mock_bearer_token}", - "Accept": image_registry_client.MANIFEST_V2_HEADER, - }, - ), - mock.call( - url, - headers={ - "Authorization": f"Bearer {mock_bearer_token}", - "Accept": image_registry_client.MANIFEST_V1_HEADER, - }, - ), - ] - # Assert that the expected calls were made - mock_get_http_client.return_value.head.assert_has_calls(expected_calls) + client.image_registry_http_client.head.assert_called_once_with( # type: ignore[attr-defined] + url, headers=client._get_accept_headers() + ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client." "ImageRegistryClient.login" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.retryable_http.get_http_client" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client." "ImageRegistryClient.image_exists" - ) - @mock.patch( # type: ignore[misc] - "snowflake.ml.model._deploy_client.utils.image_registry_client.spcs_image_registry" - ) - def test_add_tag_to_remote_image( - self, - mock_spcs_image_registry: mock.MagicMock, - mock_image_exists: mock.MagicMock, - mock_get_http_client: mock.MagicMock, - mock_login: mock.MagicMock, - ) -> None: - mock_image_exists.side_effect = [False, True] - client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session)) + def test_add_tag_to_remote_image(self) -> None: + # Test case for updating the tag on an image that initially doesn't exist. + # Retrieves the manifest, updates it with a new tag, and pushes the manifest to add the tag. + # Covers the scenario where it takes 2 put requests to update the tag. + full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" + client = image_registry_client.ImageRegistryClient(cast(session.Session, self.m_session), full_image_name) test_manifest = { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", @@ -184,44 +60,39 @@ def test_add_tag_to_remote_image( ], "tag": "tag-v1", } - mock_get_response = mock.Mock(status_code=200, json=lambda: test_manifest) - mock_put_response = mock.Mock(status_code=201) - mock_get_http_client.return_value.get.return_value = mock_get_response - mock_get_http_client.return_value.put.return_value = mock_put_response - - mock_bearer_token = "dummy_bearer_token" - mock_registry_cred = "dummy_registry_cred" - mock_login.return_value = mock_bearer_token - - with mock.patch.object(mock_spcs_image_registry, "generate_image_registry_credential") as m_generate: - m_generate.return_value.__enter__.return_value = mock_registry_cred + with mock.patch.object(client, "image_exists", side_effect=[False, True]), mock.patch.object( + client, "_get_manifest", return_value=test_manifest + ), mock.patch.object( + client.image_registry_http_client, + "put", + side_effect=[mock.Mock(status_code=400), mock.Mock(status_code=201)], + ): new_tag = "new_tag" - full_image_name = "org-account.registry.snowflakecomputing.com/db/schema/repo/image:latest" new_image_name = f"org-account.registry.snowflakecomputing.com/db/schema/repo/image:{new_tag}" - url_old_tag = "https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo/image/manifests/latest" - url = f"https://org-account.registry.snowflakecomputing.com/v2/db/schema/repo/image/manifests/{new_tag}" client.add_tag_to_remote_image(original_full_image_name=full_image_name, new_tag=new_tag) - - headers = { - "Authorization": f"Bearer {mock_bearer_token}", - "Accept": image_registry_client.MANIFEST_V2_HEADER, + headers = client._get_accept_headers() + put_header_v1 = { + **headers, + "Content-Type": image_registry_client._MANIFEST_V1_HEADER, } - mock_login.assert_called_once_with(url, mock_registry_cred) - mock_get_http_client.return_value.get.assert_called_once_with(url_old_tag, headers=headers) - test_manifest_copy = test_manifest.copy() - test_manifest_copy["tag"] = new_tag - - put_headers = { + put_header_v2 = { **headers, - "Content-Type": image_registry_client.MANIFEST_V2_HEADER, + "Content-Type": image_registry_client._MANIFEST_V2_HEADER, } + url = client._convert_to_v2_manifests_url(new_image_name) - mock_get_http_client.return_value.put.assert_called_once_with( - url, headers=put_headers, json=test_manifest_copy + test_manifest_copy = test_manifest.copy() + test_manifest_copy["tag"] = new_tag + client.image_registry_http_client.put.assert_has_calls( # type: ignore[attr-defined] + [ + mock.call(url, headers=put_header_v1, json=test_manifest_copy), + mock.call(url, headers=put_header_v2, json=test_manifest_copy), + ], + any_order=False, ) # First call to check existence before adding tag; second call to validate tag indeed added. - mock_image_exists.assert_has_calls( + client.image_exists.assert_has_calls( # type: ignore[attr-defined] [ mock.call(new_image_name), mock.call(new_image_name), diff --git a/snowflake/ml/model/_deploy_client/utils/imagelib.py b/snowflake/ml/model/_deploy_client/utils/imagelib.py index c1bf6cb7..8d0d9115 100644 --- a/snowflake/ml/model/_deploy_client/utils/imagelib.py +++ b/snowflake/ml/model/_deploy_client/utils/imagelib.py @@ -23,9 +23,7 @@ import requests -from snowflake import snowpark -from snowflake.ml._internal.utils import retryable_http, spcs_image_registry -from snowflake.ml.model._deploy_client.utils import image_auth_manager +from snowflake.ml._internal.utils import image_registry_http_client # Common HTTP headers _CONTENT_LENGTH_HEADER = "content-length" @@ -54,8 +52,6 @@ logger = logging.getLogger(__name__) -http = retryable_http.get_http_client() - @dataclasses.dataclass class ImageDescriptor: @@ -66,7 +62,6 @@ class ImageDescriptor: repository_name: the name of the repository like kaniko-project/executor tag: the tag of the image like v1.6.0 digest: the sha256 digest of the image like sha256:b8c0... - creds_manager: the credentials manager to use, defaults to None protocol: the protocol to use, defaults to https Only a tag or a digest must be specified, not both. @@ -76,7 +71,6 @@ class ImageDescriptor: repository_name: str tag: Optional[str] = None digest: Optional[str] = None - creds_manager: Optional[image_auth_manager.AuthManager] = None protocol: str = "https" def __baseurl(self) -> str: @@ -156,7 +150,7 @@ class BlobTransfer: src_image: ImageDescriptor dest_image: ImageDescriptor manifest: Manifest - session: snowpark.Session + image_registry_http_client: image_registry_http_client.ImageRegistryHttpClient def upload_all_blobs(self) -> None: blob_digests = self.manifest.get_blob_digests() @@ -173,7 +167,7 @@ def _should_upload(self, blob_digest: str) -> bool: """ Check if the blob already exists in the destination registry. """ - resp = http.head(self.dest_image.blob_link(blob_digest), headers={}) + resp = self.image_registry_http_client.head(self.dest_image.blob_link(blob_digest), headers={}) return resp.status_code != 200 def _fetch_blob(self, blob_digest: str) -> Tuple[io.BytesIO, int]: @@ -182,7 +176,7 @@ def _fetch_blob(self, blob_digest: str) -> Tuple[io.BytesIO, int]: """ src_blob_link = self.src_image.blob_link(blob_digest) headers = {_CONTENT_LENGTH_HEADER: "0"} - resp = http.get(src_blob_link, headers=headers) + resp = self.image_registry_http_client.get(src_blob_link, headers=headers) assert resp.status_code == 200, f"Blob GET failed with code {resp.status_code}" assert _CONTENT_LENGTH_HEADER in resp.headers, f"Blob does not contain {_CONTENT_LENGTH_HEADER}" @@ -193,13 +187,11 @@ def _get_upload_url(self) -> str: """ Obtain the upload URL from the destination registry. """ - with spcs_image_registry.generate_image_registry_credential(self.session) as token: - headers = add_token({}, self.dest_image, token) - response = http.post(self.dest_image.blob_upload_link(), headers=headers) - assert ( - response.status_code == 202 - ), f"Failed to get the upload URL to destination. Status {response.status_code}. {str(response.content)}" - return str(response.headers[_LOCATION_HEADER]) + response = self.image_registry_http_client.post(self.dest_image.blob_upload_link()) + assert ( + response.status_code == 202 + ), f"Failed to get the upload URL to destination. Status {response.status_code}. {str(response.content)}" + return str(response.headers[_LOCATION_HEADER]) def _upload_blob(self, blob_digest: str, blob_data: io.BytesIO, content_length: int) -> None: """ @@ -210,31 +202,27 @@ def _upload_blob(self, blob_digest: str, blob_data: io.BytesIO, content_length: _CONTENT_TYPE_HEADER: "application/octet-stream", } - with spcs_image_registry.generate_image_registry_credential(self.session) as token: - # Use chunked transfer - # This can be optimized to use a single PUT request for small blobs - next_loc = upload_url - start_byte = 0 - while start_byte < content_length: - add_token(headers, self.dest_image, token) - chunk = blob_data.read(self.chunk_size_bytes) - chunk_length = len(chunk) - end_byte = start_byte + chunk_length - 1 + # Use chunked transfer + # This can be optimized to use a single PUT request for small blobs + next_loc = upload_url + start_byte = 0 + while start_byte < content_length: + chunk = blob_data.read(self.chunk_size_bytes) + chunk_length = len(chunk) + end_byte = start_byte + chunk_length - 1 - headers[_CONTENT_RANGE_HEADER] = f"{start_byte}-{end_byte}" - headers[_CONTENT_LENGTH_HEADER] = str(chunk_length) + headers[_CONTENT_RANGE_HEADER] = f"{start_byte}-{end_byte}" + headers[_CONTENT_LENGTH_HEADER] = str(chunk_length) - resp = http.patch(next_loc, headers=headers, data=chunk) - assert resp.status_code == 202, f"Blob PATCH failed with code {resp.status_code}" + resp = self.image_registry_http_client.patch(next_loc, headers=headers, data=chunk) + assert resp.status_code == 202, f"Blob PATCH failed with code {resp.status_code}" - next_loc = resp.headers[_LOCATION_HEADER] - start_byte += chunk_length + next_loc = resp.headers[_LOCATION_HEADER] + start_byte += chunk_length - with spcs_image_registry.generate_image_registry_credential(self.session) as token: - # Finalize the upload - headers = add_token({}, self.dest_image, token) - resp = http.put(f"{next_loc}&digest={blob_digest}", headers=headers) - assert resp.status_code == 201, f"Blob PUT failed with code {resp.status_code}" + # Finalize the upload + resp = self.image_registry_http_client.put(f"{next_loc}&digest={blob_digest}") + assert resp.status_code == 201, f"Blob PUT failed with code {resp.status_code}" def _transfer(self, blob_digest: str) -> None: """ @@ -268,24 +256,15 @@ def get_bytes_with_sha_verification(resp: requests.Response, sha256_digest: str) return content, calculated_digest -def add_token( - headers: Dict[str, str], image_descriptor: ImageDescriptor, spcs_token: Optional[str] = None -) -> Dict[str, str]: - if image_descriptor.creds_manager is not None: - token = image_descriptor.creds_manager.get_auth_token(spcs_token) - headers[_AUTHORIZATION_HEADER] = f"Bearer {token}" - return headers - - def get_manifest( - image_descriptor: ImageDescriptor, - arch: _Arch, + image_descriptor: ImageDescriptor, arch: _Arch, retryable_http: image_registry_http_client.ImageRegistryHttpClient ) -> Manifest: """Get the manifest of an image from the remote registry. Args: image_descriptor: the image descriptor arch: the architecture to filter for if it's a multi-arch image + retryable_http: a retryable http client. Returns: Manifest object. @@ -295,7 +274,7 @@ def get_manifest( headers = {_ACCEPT_HEADER: ",".join(ALL_SUPPORTED_MEDIA_TYPES)} - response = http.get(image_descriptor.manifest_link(), headers=headers) + response = retryable_http.get(image_descriptor.manifest_link(), headers=headers) assert response.status_code == 200, f"Manifest GET failed with code {response.status_code}, {response.text}" assert image_descriptor.digest @@ -331,47 +310,49 @@ def get_manifest( repository_name=image_descriptor.repository_name, digest=manifest_digest, tag=None, - creds_manager=image_descriptor.creds_manager, ) # Supports only one level of manifest list nesting to avoid infinite recursion - return get_manifest(descriptor_copy, arch) + return get_manifest(descriptor_copy, arch, retryable_http) return Manifest(manifest_bytes, manifest_digest) -def put_manifest(image_descriptor: ImageDescriptor, manifest: Manifest, session: snowpark.Session) -> None: +def put_manifest( + image_descriptor: ImageDescriptor, + manifest: Manifest, + retryable_http: image_registry_http_client.ImageRegistryHttpClient, +) -> None: """ Upload the given manifest to the destination registry. """ assert image_descriptor.tag is not None, "Tag must be specified for manifest upload" + headers = {_CONTENT_TYPE_HEADER: manifest.media_type} + url = image_descriptor.manifest_upload_link(image_descriptor.tag) + logger.debug(f"Uploading manifest to {url}") + response = retryable_http.put(url, headers=headers, data=manifest.manifest_bytes) + assert response.status_code == 201, f"Manifest PUT failed with code {response.status_code}" - with spcs_image_registry.generate_image_registry_credential(session) as token: - headers = {_CONTENT_TYPE_HEADER: manifest.media_type} - add_token(headers, image_descriptor, token) - - url = image_descriptor.manifest_upload_link(image_descriptor.tag) - logger.debug(f"Uploading manifest to {url}") - - response = http.put(url, headers=headers, data=manifest.manifest_bytes) - assert response.status_code == 201, f"Manifest PUT failed with code {response.status_code}" - - -def copy_image(src_image: ImageDescriptor, dest_image: ImageDescriptor, arch: _Arch, session: snowpark.Session) -> None: +def copy_image( + src_image: ImageDescriptor, + dest_image: ImageDescriptor, + arch: _Arch, + retryable_http: image_registry_http_client.ImageRegistryHttpClient, +) -> None: logger.debug(f"Pulling image manifest for {src_image}") # 1. Get the manifest - manifest = get_manifest(src_image, arch) + manifest = get_manifest(src_image, arch, retryable_http) logger.debug(f"Manifest pulled for {src_image} with digest {manifest.manifest_digest}") # 2: Retrieve all blob digests from manifest; fetch blob based on blob digest, then upload blob. - blob_transfer = BlobTransfer(src_image, dest_image, manifest, session=session) + blob_transfer = BlobTransfer(src_image, dest_image, manifest, image_registry_http_client=retryable_http) blob_transfer.upload_all_blobs() # 3. Upload the manifest logger.debug(f"All blobs copied successfully. Copying manifest for {src_image} to {dest_image}") - put_manifest(dest_image, manifest, session) + put_manifest(dest_image, manifest, retryable_http) logger.debug(f"Image {src_image} copied to {dest_image}") @@ -380,7 +361,6 @@ def convert_to_image_descriptor( image_name: str, with_digest: bool = False, with_tag: bool = False, - creds_manager: Optional[image_auth_manager.AuthManager] = None, ) -> ImageDescriptor: """Convert a full image name to a ImageDescriptor object. @@ -388,7 +368,6 @@ def convert_to_image_descriptor( image_name: name of image. with_digest: boolean to specify whether a digest is included in the image name with_tag: boolean to specify whether a tag is included in the image name. - creds_manager: optional credential manager, used for authentication to registry. Returns: An ImageDescriptor instance @@ -403,5 +382,4 @@ def convert_to_image_descriptor( repository_name="/".join(parts[1:-1] + [parts[-1].split(sep)[0]]), digest=tag_digest if with_digest else None, tag=tag_digest if with_tag else None, - creds_manager=creds_manager, ) diff --git a/snowflake/ml/model/_deploy_client/utils/snowservice_client.py b/snowflake/ml/model/_deploy_client/utils/snowservice_client.py index 6db99bca..5205ef95 100644 --- a/snowflake/ml/model/_deploy_client/utils/snowservice_client.py +++ b/snowflake/ml/model/_deploy_client/utils/snowservice_client.py @@ -1,5 +1,6 @@ import json import logging +import textwrap import time from typing import Optional @@ -52,14 +53,16 @@ def create_or_replace_service( """ stage, path = uri.get_stage_and_path(spec_stage_location) self._drop_service_if_exists(service_name) - sql = f""" + sql = textwrap.dedent( + f""" CREATE SERVICE {service_name} IN COMPUTE POOL {compute_pool} FROM {stage} SPEC = '{path}' MIN_INSTANCES={min_instances} MAX_INSTANCES={max_instances} - """ + """ + ) logger.info(f"Creating service {service_name}") logger.debug(f"Create service with SQL: \n {sql}") self.session.sql(sql).collect() @@ -76,12 +79,14 @@ def create_job(self, compute_pool: str, spec_stage_location: str) -> None: """ stage, path = uri.get_stage_and_path(spec_stage_location) - sql = f""" + sql = textwrap.dedent( + f""" EXECUTE SERVICE IN COMPUTE POOL {compute_pool} FROM {stage} SPEC = '{path}' - """ + """ + ) logger.debug(f"Create job with SQL: \n {sql}") cur = self.session._conn._conn.cursor() cur.execute_async(sql) @@ -129,7 +134,8 @@ def create_or_replace_service_function( if max_batch_rows: max_batch_rows_sql = f"MAX_BATCH_ROWS = {max_batch_rows}" - sql = f""" + sql = textwrap.dedent( + f""" CREATE OR REPLACE FUNCTION {service_func_name}(input OBJECT) RETURNS OBJECT SERVICE={service_name} @@ -137,6 +143,7 @@ def create_or_replace_service_function( {max_batch_rows_sql} AS '/{path_at_service_endpoint}' """ + ) logger.debug(f"Create service function with SQL: \n {sql}") self.session.sql(sql).collect() logger.debug(f"Successfully created service function: {service_func_name}") diff --git a/snowflake/ml/model/_deploy_client/warehouse/infer_template.py b/snowflake/ml/model/_deploy_client/warehouse/infer_template.py index 0e8c9f9c..34d2e044 100644 --- a/snowflake/ml/model/_deploy_client/warehouse/infer_template.py +++ b/snowflake/ml/model/_deploy_client/warehouse/infer_template.py @@ -62,7 +62,9 @@ def __exit__( model = pk.model meta = pk.meta -except ImportError: +except ImportError as e: + if e.name and not e.name.startswith("snowflake.ml"): + raise e # Support Legacy model from snowflake.ml.model import _model # Backward for <= 1.0.5 diff --git a/snowflake/ml/model/_module_model/BUILD.bazel b/snowflake/ml/model/_model_composer/BUILD.bazel similarity index 74% rename from snowflake/ml/model/_module_model/BUILD.bazel rename to snowflake/ml/model/_model_composer/BUILD.bazel index 552d138d..83f4c4e1 100644 --- a/snowflake/ml/model/_module_model/BUILD.bazel +++ b/snowflake/ml/model/_model_composer/BUILD.bazel @@ -3,23 +3,24 @@ load("//bazel:py_rules.bzl", "py_library", "py_test") package(default_visibility = ["//visibility:public"]) py_library( - name = "module_model", - srcs = ["module_model.py"], + name = "model_composer", + srcs = ["model_composer.py"], deps = [ "//snowflake/ml/_internal:env", "//snowflake/ml/_internal:env_utils", "//snowflake/ml/_internal:file_utils", "//snowflake/ml/model:model_signature", "//snowflake/ml/model:type_hints", + "//snowflake/ml/model/_model_composer/model_manifest", "//snowflake/ml/model/_packager:model_packager", ], ) py_test( - name = "module_model_test", - srcs = ["module_model_test.py"], + name = "model_composer_test", + srcs = ["model_composer_test.py"], deps = [ - ":module_model", + ":model_composer", "//snowflake/ml/_internal:env_utils", "//snowflake/ml/_internal:file_utils", "//snowflake/ml/modeling/linear_model:linear_regression", diff --git a/snowflake/ml/model/_module_model/module_model.py b/snowflake/ml/model/_model_composer/model_composer.py similarity index 82% rename from snowflake/ml/model/_module_model/module_model.py rename to snowflake/ml/model/_model_composer/model_composer.py index a998fb07..c842410f 100644 --- a/snowflake/ml/model/_module_model/module_model.py +++ b/snowflake/ml/model/_model_composer/model_composer.py @@ -10,25 +10,24 @@ from snowflake.ml._internal import env as snowml_env, env_utils, file_utils from snowflake.ml.model import model_signature, type_hints as model_types +from snowflake.ml.model._model_composer.model_manifest import model_manifest from snowflake.ml.model._packager import model_packager from snowflake.snowpark import Session from snowflake.snowpark._internal import utils as snowpark_utils -class ModuleModel: - """Top-level class to construct and represent contents in a MODEL object in SQL. +class ModelComposer: + """Top-level class to construct contents in a MODEL object in SQL. Attributes: session: The Snowpark Session. stage_path: A stage path representing the base directory where the content of a MODEL object will exist. workspace_path: A local path which is the exact mapping to the `stage_path` - (TODO) manifest: A ModuleManifest object managing the MANIFEST file generation. - (TODO) runtimes: A list of ModuleRuntime objects managing the runtimes and environment in the MODEL object. - (TODO) methods: A list of ModuleMethod objects managing the method we registered to the MODEL object. + manifest: A ModelManifest object managing the MANIFEST file generation. packager: A ModelPackager object managing the (un)packaging of a Snowflake Native Model in the MODEL object. - _packager_workspace_path: A local path created from packager where it will dump all files there and ModuleModel + _packager_workspace_path: A local path created from packager where it will dump all files there and ModelModel will zip it. This would not required if we make directory import work. """ @@ -42,6 +41,7 @@ def __init__(self, session: Session, stage_path: str) -> None: self._packager_workspace = tempfile.TemporaryDirectory() self.packager = model_packager.ModelPackager(local_dir_path=str(self._packager_workspace_path)) + self.manifest = model_manifest.ModelManifest(workspace_path=self.workspace_path) def __del__(self) -> None: self._workspace.cleanup() @@ -57,11 +57,11 @@ def _packager_workspace_path(self) -> pathlib.Path: @property def model_stage_path(self) -> str: - return (self.stage_path / ModuleModel.MODEL_FILE_REL_PATH).as_posix() + return (self.stage_path / ModelComposer.MODEL_FILE_REL_PATH).as_posix() @property def model_local_path(self) -> str: - return str(self.workspace_path / ModuleModel.MODEL_FILE_REL_PATH) + return str(self.workspace_path / ModelComposer.MODEL_FILE_REL_PATH) def save( self, @@ -108,13 +108,17 @@ def save( code_paths=code_paths, options=options, ) - with file_utils.zip_file_or_directory_to_stream( - str(self._packager_workspace_path), - leading_path=str(self._packager_workspace_path), - ) as zf: - with open(self.model_local_path, "wb") as f: - f.write(zf.getbuffer()) - f.flush() + + assert self.packager.meta is not None + + file_utils.make_archive(self.model_local_path, str(self._packager_workspace_path)) + + self.manifest.save( + session=self.session, + model_meta=self.packager.meta, + model_file_rel_path=pathlib.PurePosixPath(ModelComposer.MODEL_FILE_REL_PATH), + options=options, + ) file_utils.upload_directory_to_stage(self.session, local_path=self.workspace_path, stage_path=self.stage_path) @@ -130,7 +134,7 @@ def load( # TODO (Server-side Model Rollout): Remove this section. model_zip_path = pathlib.Path(glob.glob(str(self.workspace_path / "*.zip"))[0]) - ModuleModel.MODEL_FILE_REL_PATH = str(model_zip_path.relative_to(self.workspace_path)) + ModelComposer.MODEL_FILE_REL_PATH = str(model_zip_path.relative_to(self.workspace_path)) with zipfile.ZipFile(self.model_local_path, mode="r", compression=zipfile.ZIP_DEFLATED) as zf: zf.extractall(path=self._packager_workspace_path) diff --git a/snowflake/ml/model/_model_composer/model_composer_test.py b/snowflake/ml/model/_model_composer/model_composer_test.py new file mode 100644 index 00000000..5cb3cdb5 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_composer_test.py @@ -0,0 +1,62 @@ +from typing import cast +from unittest import mock + +import numpy as np +import pandas as pd +from absl.testing import absltest +from sklearn import linear_model + +from snowflake.ml._internal import env_utils +from snowflake.ml.model._model_composer import model_composer +from snowflake.ml.modeling.linear_model import ( # type:ignore[attr-defined] + LinearRegression, +) +from snowflake.ml.test_utils import mock_session +from snowflake.snowpark import FileOperation, Session + + +class ModelInterfaceTest(absltest.TestCase): + def test_save_interface(self) -> None: + m_session = mock_session.MockSession(conn=None, test_case=self) + c_session = cast(Session, m_session) + + stage_path = '@"db"."schema"."stage"' + arr = np.array([[1, 2, 3], [4, 2, 5]]) + d = pd.DataFrame(arr, columns=["c1", "c2", "c3"]) + + mock_pk = mock.MagicMock() + mock_pk.meta = mock.MagicMock() + mock_pk.meta.signatures = mock.MagicMock() + m = model_composer.ModelComposer(session=c_session, stage_path=stage_path) + m.packager = mock_pk + with mock.patch.object(m.packager, "save") as mock_save: + with mock.patch.object(m.manifest, "save") as mock_manifest_save: + with mock.patch.object(FileOperation, "put", return_value=None) as mock_put_stream: + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + m.save( + name="model1", + model=LinearRegression(), + ) + mock_save.assert_called_once() + mock_manifest_save.assert_called_once() + + m = model_composer.ModelComposer(session=c_session, stage_path=stage_path) + m.packager = mock_pk + with mock.patch.object(m.packager, "save") as mock_save: + with mock.patch.object(m.manifest, "save") as mock_manifest_save: + with mock.patch.object(FileOperation, "put", return_value=None) as mock_put_stream: + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + m.save( + name="model1", + model=linear_model.LinearRegression(), + sample_input=d, + ) + mock_put_stream.assert_called_once_with(mock.ANY, stage_path, auto_compress=False, overwrite=False) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/model/_model_composer/model_manifest/BUILD.bazel b/snowflake/ml/model/_model_composer/model_manifest/BUILD.bazel new file mode 100644 index 00000000..0b0d9706 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_manifest/BUILD.bazel @@ -0,0 +1,34 @@ +load("//bazel:py_rules.bzl", "py_library", "py_test") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "model_manifest", + srcs = ["model_manifest.py"], + deps = [ + ":model_manifest_schema", + "//snowflake/ml/model/_model_composer/model_method", + "//snowflake/ml/model/_model_composer/model_method:function_generator", + "//snowflake/ml/model/_model_composer/model_runtime", + "//snowflake/ml/model/_packager/model_meta", + ], +) + +py_library( + name = "model_manifest_schema", + srcs = ["model_manifest_schema.py"], +) + +py_test( + name = "model_manifest_test", + srcs = ["model_manifest_test.py"], + data = ["//snowflake/ml/model/_model_composer/model_method:function_fixtures"], + deps = [ + ":model_manifest", + "//snowflake/ml/_internal:env_utils", + "//snowflake/ml/model:model_signature", + "//snowflake/ml/model:type_hints", + "//snowflake/ml/model/_packager/model_meta", + "//snowflake/ml/model/_packager/model_meta:model_blob_meta", + ], +) diff --git a/snowflake/ml/model/_model_composer/model_manifest/model_manifest.py b/snowflake/ml/model/_model_composer/model_manifest/model_manifest.py new file mode 100644 index 00000000..98b24565 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_manifest/model_manifest.py @@ -0,0 +1,86 @@ +import pathlib +from typing import List, Optional + +import yaml + +from snowflake.ml.model import type_hints +from snowflake.ml.model._model_composer.model_manifest import model_manifest_schema +from snowflake.ml.model._model_composer.model_method import ( + function_generator, + model_method, +) +from snowflake.ml.model._model_composer.model_runtime import model_runtime +from snowflake.ml.model._packager.model_meta import model_meta as model_meta_api +from snowflake.snowpark import Session + + +class ModelManifest: + """Class to construct MANIFEST.yml file for Model + + Attributes: + workspace_path: A local path where model related files should be dumped to. + runtimes: A list of ModelRuntime objects managing the runtimes and environment in the MODEL object. + methods: A list of ModelMethod objects managing the method we registered to the MODEL object. + """ + + MANIFEST_FILE_REL_PATH = "MANIFEST.yml" + _DEFAULT_RUNTIME_NAME = "python_runtime" + + def __init__(self, workspace_path: pathlib.Path) -> None: + self.workspace_path = workspace_path + + def save( + self, + session: Session, + model_meta: model_meta_api.ModelMetadata, + model_file_rel_path: pathlib.PurePosixPath, + options: Optional[type_hints.ModelSaveOption] = None, + ) -> None: + if options is None: + options = {} + self.runtimes = [ + model_runtime.ModelRuntime( + session=session, + name=ModelManifest._DEFAULT_RUNTIME_NAME, + model_meta=model_meta, + imports=[model_file_rel_path], + ) + ] + self.function_generator = function_generator.FunctionGenerator(model_file_rel_path=model_file_rel_path) + self.methods: List[model_method.ModelMethod] = [] + _seen_method_names: List[str] = [] + for target_method in model_meta.signatures.keys(): + method = model_method.ModelMethod( + model_meta=model_meta, + target_method=target_method, + runtime_name=self.runtimes[0].name, + function_generator=self.function_generator, + options=model_method.get_model_method_options_from_options(options, target_method), + ) + if method.method_name in _seen_method_names: + raise ValueError( + f"Found duplicate method named resolved as {method.method_name} in the model. " + "This might because you have methods with same letters but different cases. " + "In this case, set case_sensitive as True for those methods to distinguish them" + ) + else: + _seen_method_names.append(method.method_name) + + self.methods.append(method) + + manifest_dict = model_manifest_schema.ModelManifestDict( + manifest_version=model_manifest_schema.MODEL_MANIFEST_VERSION, + runtimes={runtime.name: runtime.save(self.workspace_path) for runtime in self.runtimes}, + methods=[ + method.save( + self.workspace_path, + options=function_generator.get_function_generate_options_from_options( + options, method.target_method + ), + ) + for method in self.methods + ], + ) + + with (self.workspace_path / ModelManifest.MANIFEST_FILE_REL_PATH).open("w", encoding="utf-8") as f: + yaml.safe_dump(manifest_dict, f) diff --git a/snowflake/ml/model/_model_composer/model_manifest/model_manifest_schema.py b/snowflake/ml/model/_model_composer/model_manifest/model_manifest_schema.py new file mode 100644 index 00000000..efc5126a --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_manifest/model_manifest_schema.py @@ -0,0 +1,45 @@ +# This files contains schema definition of what will be written into MANIFEST.yml + +from typing import Dict, List, Literal, TypedDict + +from typing_extensions import NotRequired, Required + +MODEL_MANIFEST_VERSION = "1.0" + + +class ModelRuntimeDependenciesDict(TypedDict): + conda: Required[str] + + +class ModelRuntimeDict(TypedDict): + language: Required[Literal["PYTHON"]] + version: Required[str] + imports: Required[List[str]] + dependencies: Required[ModelRuntimeDependenciesDict] + + +class ModelMethodSignatureField(TypedDict): + type: Required[str] + + +class ModelMethodSignatureFieldWithName(ModelMethodSignatureField): + name: Required[str] + + +class ModelFunctionMethodDict(TypedDict): + name: Required[str] + runtime: Required[str] + type: Required[Literal["FUNCTION"]] + handler: Required[str] + inputs: Required[List[ModelMethodSignatureFieldWithName]] + outputs: Required[List[ModelMethodSignatureField]] + + +ModelMethodDict = ModelFunctionMethodDict + + +class ModelManifestDict(TypedDict): + manifest_version: Required[str] + runtimes: Required[Dict[str, ModelRuntimeDict]] + methods: Required[List[ModelMethodDict]] + user_data: NotRequired[Dict[str, str]] diff --git a/snowflake/ml/model/_model_composer/model_manifest/model_manifest_test.py b/snowflake/ml/model/_model_composer/model_manifest/model_manifest_test.py new file mode 100644 index 00000000..cf1109c6 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_manifest/model_manifest_test.py @@ -0,0 +1,243 @@ +import os +import pathlib +import tempfile +from unittest import mock + +import importlib_resources +import yaml +from absl.testing import absltest + +from snowflake.ml._internal import env_utils +from snowflake.ml.model import model_signature, type_hints +from snowflake.ml.model._model_composer.model_manifest import model_manifest +from snowflake.ml.model._packager.model_meta import model_blob_meta, model_meta + +_DUMMY_SIG = { + "predict": model_signature.ModelSignature( + inputs=[ + model_signature.FeatureSpec(dtype=model_signature.DataType.FLOAT, name="input"), + ], + outputs=[model_signature.FeatureSpec(name="output", dtype=model_signature.DataType.FLOAT)], + ) +} + + +_DUMMY_BLOB = model_blob_meta.ModelBlobMeta( + name="model1", model_type="custom", path="mock_path", handler_version="version_0" +) + + +class ModelManifestTest(absltest.TestCase): + def setUp(self) -> None: + self.m_session = mock.MagicMock() + + def test_model_manifest_1(self) -> None: + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + mm = model_manifest.ModelManifest(pathlib.Path(workspace)) + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mm.save(self.m_session, meta, pathlib.PurePosixPath("model.zip")) + with open(os.path.join(workspace, "MANIFEST.yml"), encoding="utf-8") as f: + loaded_manifest = yaml.safe_load(f) + self.assertDictEqual( + loaded_manifest, + { + "manifest_version": "1.0", + "runtimes": { + "python_runtime": { + "language": "PYTHON", + "version": meta.env.python_version, + "imports": ["model.zip"], + "dependencies": {"conda": "runtimes/python_runtime/env/conda.yml"}, + } + }, + "methods": [ + { + "name": "PREDICT", + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.predict.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + } + ], + }, + ) + with open(pathlib.Path(workspace, "functions", "predict.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files("snowflake.ml.model._model_composer.model_method") + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_1.py_fixture") + .read_text() + ), + f.read(), + ) + + def test_model_manifest_2(self) -> None: + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + mm = model_manifest.ModelManifest(pathlib.Path(workspace)) + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures={"__call__": _DUMMY_SIG["predict"]}, + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mm.save( + self.m_session, + meta, + pathlib.PurePosixPath("model.zip"), + options=type_hints.BaseModelSaveOption( + method_options={"__call__": type_hints.ModelMethodSaveOptions(max_batch_size=10)} + ), + ) + with open(os.path.join(workspace, "MANIFEST.yml"), encoding="utf-8") as f: + loaded_manifest = yaml.safe_load(f) + self.assertDictEqual( + loaded_manifest, + { + "manifest_version": "1.0", + "runtimes": { + "python_runtime": { + "language": "PYTHON", + "version": meta.env.python_version, + "imports": ["model.zip"], + "dependencies": {"conda": "runtimes/python_runtime/env/conda.yml"}, + } + }, + "methods": [ + { + "name": "__CALL__", + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.__call__.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + } + ], + }, + ) + with open(pathlib.Path(workspace, "functions", "__call__.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files("snowflake.ml.model._model_composer.model_method") + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_2.py_fixture") + .read_text() + ), + f.read(), + ) + + def test_model_manifest_mix(self) -> None: + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + mm = model_manifest.ModelManifest(pathlib.Path(workspace)) + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures={"predict": _DUMMY_SIG["predict"], "__call__": _DUMMY_SIG["predict"]}, + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=None + ): + mm.save( + self.m_session, + meta, + pathlib.PurePosixPath("model.zip"), + options=type_hints.BaseModelSaveOption( + method_options={ + "predict": type_hints.ModelMethodSaveOptions(case_sensitive=True), + "__call__": type_hints.ModelMethodSaveOptions(max_batch_size=10), + } + ), + ) + with open(os.path.join(workspace, "MANIFEST.yml"), encoding="utf-8") as f: + loaded_manifest = yaml.safe_load(f) + self.assertDictEqual( + loaded_manifest, + { + "manifest_version": "1.0", + "runtimes": { + "python_runtime": { + "language": "PYTHON", + "version": meta.env.python_version, + "imports": ["model.zip", "runtimes/python_runtime/snowflake-ml-python.zip"], + "dependencies": {"conda": "runtimes/python_runtime/env/conda.yml"}, + } + }, + "methods": [ + { + "name": '"predict"', + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.predict.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + }, + { + "name": "__CALL__", + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.__call__.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + }, + ], + }, + ) + with open(pathlib.Path(workspace, "functions", "predict.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files("snowflake.ml.model._model_composer.model_method") + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_1.py_fixture") + .read_text() + ), + f.read(), + ) + with open(pathlib.Path(workspace, "functions", "__call__.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files("snowflake.ml.model._model_composer.model_method") + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_2.py_fixture") + .read_text() + ), + f.read(), + ) + + def test_model_manifest_bad(self) -> None: + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + mm = model_manifest.ModelManifest(pathlib.Path(workspace)) + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures={"predict": _DUMMY_SIG["predict"], "PREDICT": _DUMMY_SIG["predict"]}, + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + with self.assertRaisesRegex( + ValueError, "Found duplicate method named resolved as PREDICT in the model." + ): + mm.save( + self.m_session, + meta, + pathlib.PurePosixPath("model.zip"), + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/model/_model_composer/model_method/BUILD.bazel b/snowflake/ml/model/_model_composer/model_method/BUILD.bazel new file mode 100644 index 00000000..23d180f5 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_method/BUILD.bazel @@ -0,0 +1,60 @@ +load("//bazel:py_rules.bzl", "py_library", "py_test") + +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "function_fixtures", + srcs = [ + "fixtures/function_fixture_1.py_fixture", + "fixtures/function_fixture_2.py_fixture", + ], +) + +py_library( + name = "function_generator", + srcs = ["function_generator.py"], + data = [ + "infer_function.py_template", + ], + deps = [ + "//snowflake/ml/model:type_hints", + ], +) + +py_test( + name = "function_generator_test", + srcs = ["function_generator_test.py"], + data = [ + ":function_fixtures", + ], + deps = [ + ":function_generator", + ], +) + +py_library( + name = "model_method", + srcs = ["model_method.py"], + deps = [ + ":function_generator", + "//snowflake/ml/_internal/utils:sql_identifier", + "//snowflake/ml/model:type_hints", + "//snowflake/ml/model/_model_composer/model_manifest:model_manifest_schema", + "//snowflake/ml/model/_packager/model_meta", + ], +) + +py_test( + name = "model_method_test", + srcs = ["model_method_test.py"], + data = [ + ":function_fixtures", + ], + deps = [ + ":function_generator", + ":model_method", + "//snowflake/ml/model:model_signature", + "//snowflake/ml/model/_packager/model_meta", + "//snowflake/ml/model/_packager/model_meta:model_blob_meta", + ], +) diff --git a/snowflake/ml/model/_module_model/module_method/fixtures/handler_fixture_1.py_fixture b/snowflake/ml/model/_model_composer/model_method/fixtures/function_fixture_1.py_fixture similarity index 99% rename from snowflake/ml/model/_module_model/module_method/fixtures/handler_fixture_1.py_fixture rename to snowflake/ml/model/_model_composer/model_method/fixtures/function_fixture_1.py_fixture index e6c64872..d51dc6c8 100644 --- a/snowflake/ml/model/_module_model/module_method/fixtures/handler_fixture_1.py_fixture +++ b/snowflake/ml/model/_model_composer/model_method/fixtures/function_fixture_1.py_fixture @@ -70,7 +70,7 @@ input_cols = [feature.name for feature in features] dtype_map = {feature.name: feature.as_dtype() for feature in features} -# Actual handler +# Actual function @vectorized(input=pd.DataFrame, max_batch_size=MAX_BATCH_SIZE) def infer(df: pd.DataFrame) -> dict: input_df = pd.json_normalize(df[0]).astype(dtype=dtype_map) diff --git a/snowflake/ml/model/_module_model/module_method/fixtures/handler_fixture_2.py_fixture b/snowflake/ml/model/_model_composer/model_method/fixtures/function_fixture_2.py_fixture similarity index 99% rename from snowflake/ml/model/_module_model/module_method/fixtures/handler_fixture_2.py_fixture rename to snowflake/ml/model/_model_composer/model_method/fixtures/function_fixture_2.py_fixture index 3058fa50..fa356184 100644 --- a/snowflake/ml/model/_module_model/module_method/fixtures/handler_fixture_2.py_fixture +++ b/snowflake/ml/model/_model_composer/model_method/fixtures/function_fixture_2.py_fixture @@ -70,7 +70,7 @@ input_cols = [feature.name for feature in features] dtype_map = {feature.name: feature.as_dtype() for feature in features} -# Actual handler +# Actual function @vectorized(input=pd.DataFrame, max_batch_size=MAX_BATCH_SIZE) def infer(df: pd.DataFrame) -> dict: input_df = pd.json_normalize(df[0]).astype(dtype=dtype_map) diff --git a/snowflake/ml/model/_model_composer/model_method/function_generator.py b/snowflake/ml/model/_model_composer/model_method/function_generator.py new file mode 100644 index 00000000..192480fa --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_method/function_generator.py @@ -0,0 +1,52 @@ +import pathlib +from typing import Optional, TypedDict + +import importlib_resources +from typing_extensions import NotRequired + +from snowflake.ml.model import type_hints + + +class FunctionGenerateOptions(TypedDict): + max_batch_size: NotRequired[Optional[int]] + + +def get_function_generate_options_from_options( + options: type_hints.ModelSaveOption, target_method: str +) -> FunctionGenerateOptions: + method_option = options.get("method_options", {}).get(target_method, {}) + return FunctionGenerateOptions(max_batch_size=method_option.get("max_batch_size", None)) + + +class FunctionGenerator: + FUNCTION_NAME = "infer" + + def __init__( + self, + model_file_rel_path: pathlib.PurePosixPath, + ) -> None: + self.model_file_rel_path = model_file_rel_path + + def generate( + self, + function_file_path: pathlib.Path, + target_method: str, + options: Optional[FunctionGenerateOptions] = None, + ) -> None: + if options is None: + options = {} + function_template = ( + importlib_resources.files("snowflake.ml.model._model_composer.model_method") + .joinpath("infer_function.py_template") # type: ignore[no-untyped-call] + .read_text() + ) + + udf_code = function_template.format( + model_file_name=self.model_file_rel_path.name, + target_method=target_method, + max_batch_size=options.get("max_batch_size", None), + function_name=FunctionGenerator.FUNCTION_NAME, + ) + with open(function_file_path, "w", encoding="utf-8") as f: + f.write(udf_code) + f.flush() diff --git a/snowflake/ml/model/_module_model/module_method/handler_generator_test.py b/snowflake/ml/model/_model_composer/model_method/function_generator_test.py similarity index 65% rename from snowflake/ml/model/_module_model/module_method/handler_generator_test.py rename to snowflake/ml/model/_model_composer/model_method/function_generator_test.py index 0336680a..c10b06d0 100644 --- a/snowflake/ml/model/_module_model/module_method/handler_generator_test.py +++ b/snowflake/ml/model/_model_composer/model_method/function_generator_test.py @@ -4,38 +4,38 @@ import importlib_resources from absl.testing import absltest -from snowflake.ml.model._module_model.module_method import handler_generator +from snowflake.ml.model._model_composer.model_method import function_generator -class HandlerGeneratorTest(absltest.TestCase): - def test_handler_generator(self) -> None: - hg = handler_generator.HandlerGenerator(pathlib.PurePosixPath("@a.b.c/abc/model.zip")) +class FunctionGeneratorTest(absltest.TestCase): + def test_function_generator(self) -> None: + fg = function_generator.FunctionGenerator(pathlib.PurePosixPath("@a.b.c/abc/model.zip")) with tempfile.TemporaryDirectory() as tmpdir: - hg.generate( + fg.generate( pathlib.Path(tmpdir, "handler.py"), "predict", ) with open(pathlib.Path(tmpdir, "handler.py"), encoding="utf-8") as f: self.assertEqual( ( - importlib_resources.files("snowflake.ml.model._module_model.module_method") + importlib_resources.files("snowflake.ml.model._model_composer.model_method") .joinpath("fixtures") # type: ignore[no-untyped-call] - .joinpath("handler_fixture_1.py_fixture") + .joinpath("function_fixture_1.py_fixture") .read_text() ), f.read(), ) - hg.generate( + fg.generate( pathlib.Path(tmpdir, "another_handler.py"), "__call__", - options=handler_generator.HandlerGenerateOptions(max_batch_size=10), + options=function_generator.FunctionGenerateOptions(max_batch_size=10), ) with open(pathlib.Path(tmpdir, "another_handler.py"), encoding="utf-8") as f: self.assertEqual( ( - importlib_resources.files("snowflake.ml.model._module_model.module_method") + importlib_resources.files("snowflake.ml.model._model_composer.model_method") .joinpath("fixtures") # type: ignore[no-untyped-call] - .joinpath("handler_fixture_2.py_fixture") + .joinpath("function_fixture_2.py_fixture") .read_text() ), f.read(), diff --git a/snowflake/ml/model/_module_model/module_method/infer_handler.py_template b/snowflake/ml/model/_model_composer/model_method/infer_function.py_template similarity index 97% rename from snowflake/ml/model/_module_model/module_method/infer_handler.py_template rename to snowflake/ml/model/_model_composer/model_method/infer_function.py_template index 14c721d5..bd041620 100644 --- a/snowflake/ml/model/_module_model/module_method/infer_handler.py_template +++ b/snowflake/ml/model/_model_composer/model_method/infer_function.py_template @@ -70,9 +70,9 @@ input_cols = [feature.name for feature in features] dtype_map = {{feature.name: feature.as_dtype() for feature in features}} -# Actual handler +# Actual function @vectorized(input=pd.DataFrame, max_batch_size=MAX_BATCH_SIZE) -def {handler_name}(df: pd.DataFrame) -> dict: +def {function_name}(df: pd.DataFrame) -> dict: input_df = pd.json_normalize(df[0]).astype(dtype=dtype_map) predictions_df = runner(input_df[input_cols]) return predictions_df.to_dict("records") diff --git a/snowflake/ml/model/_model_composer/model_method/model_method.py b/snowflake/ml/model/_model_composer/model_method/model_method.py new file mode 100644 index 00000000..94026777 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_method/model_method.py @@ -0,0 +1,90 @@ +import pathlib +from typing import Optional, TypedDict + +from typing_extensions import NotRequired + +from snowflake.ml._internal.utils import sql_identifier +from snowflake.ml.model import type_hints +from snowflake.ml.model._model_composer.model_manifest import model_manifest_schema +from snowflake.ml.model._model_composer.model_method import function_generator +from snowflake.ml.model._packager.model_meta import model_meta as model_meta_api + + +class ModelMethodOptions(TypedDict): + """Options when creating model method. + + case_sensitive: Specify when the name of the method should be considered as case sensitive when registered to SQL. + """ + + case_sensitive: NotRequired[bool] + + +def get_model_method_options_from_options( + options: type_hints.ModelSaveOption, target_method: str +) -> ModelMethodOptions: + method_option = options.get("method_options", {}).get(target_method, {}) + return ModelMethodOptions(case_sensitive=method_option.get("case_sensitive", False)) + + +class ModelMethod: + """A class that is responsible to create the method information in the model manifest file and call generator to + create the function file for the method. + + Attributes: + model_meta: Model Metadata. + target_method: Original target method name to call with the model. + method_name: The actual method name registered in manifest and used in SQL. + + function_generator: Function file generator. + runtime_name: Name of the Model Runtime to run the method. + + options: Model Method Options. + """ + + FUNCTIONS_DIR_REL_PATH = "functions" + + def __init__( + self, + model_meta: model_meta_api.ModelMetadata, + target_method: str, + runtime_name: str, + function_generator: function_generator.FunctionGenerator, + options: Optional[ModelMethodOptions] = None, + ) -> None: + self.model_meta = model_meta + self.target_method = target_method + self.function_generator = function_generator + self.runtime_name = runtime_name + self.options = options or {} + try: + self.method_name = sql_identifier.SqlIdentifier( + target_method, case_sensitive=self.options.get("case_sensitive", False) + ) + except ValueError as e: + raise ValueError( + f"Your target method {self.target_method} cannot be resolved as valid SQL identifier. " + "Try specify `case_sensitive` as True." + ) from e + + if self.target_method not in self.model_meta.signatures.keys(): + raise ValueError(f"Target method {self.target_method} is not available in the signatures of the model.") + + def save( + self, workspace_path: pathlib.Path, options: Optional[function_generator.FunctionGenerateOptions] = None + ) -> model_manifest_schema.ModelMethodDict: + (workspace_path / ModelMethod.FUNCTIONS_DIR_REL_PATH).mkdir(parents=True, exist_ok=True) + self.function_generator.generate( + workspace_path / ModelMethod.FUNCTIONS_DIR_REL_PATH / f"{self.target_method}.py", + self.target_method, + options=options, + ) + return model_manifest_schema.ModelFunctionMethodDict( + name=self.method_name.identifier(), + runtime=self.runtime_name, + type="FUNCTION", + handler=".".join( + [ModelMethod.FUNCTIONS_DIR_REL_PATH, self.target_method, self.function_generator.FUNCTION_NAME] + ), + inputs=[model_manifest_schema.ModelMethodSignatureFieldWithName(name="tmp_input", type="OBJECT")], + outputs=[model_manifest_schema.ModelMethodSignatureField(type="OBJECT")], + ) diff --git a/snowflake/ml/model/_model_composer/model_method/model_method_test.py b/snowflake/ml/model/_model_composer/model_method/model_method_test.py new file mode 100644 index 00000000..6ce76d98 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_method/model_method_test.py @@ -0,0 +1,173 @@ +import pathlib +import tempfile + +import importlib_resources +from absl.testing import absltest + +from snowflake.ml.model import model_signature +from snowflake.ml.model._model_composer import model_method as model_method_pkg +from snowflake.ml.model._model_composer.model_method import ( + function_generator, + model_method, +) +from snowflake.ml.model._packager.model_meta import model_blob_meta, model_meta + +_DUMMY_SIG = { + "predict": model_signature.ModelSignature( + inputs=[ + model_signature.FeatureSpec(dtype=model_signature.DataType.FLOAT, name="input"), + ], + outputs=[model_signature.FeatureSpec(name="output", dtype=model_signature.DataType.FLOAT)], + ) +} + +_DUMMY_BLOB = model_blob_meta.ModelBlobMeta( + name="model1", model_type="custom", path="mock_path", handler_version="version_0" +) + + +class ModelMethodTest(absltest.TestCase): + def test_model_method(self) -> None: + fg = function_generator.FunctionGenerator(pathlib.PurePosixPath("@a.b.c/abc/model.zip")) + + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + mm = model_method.ModelMethod( + meta, + "predict", + "python_runtime", + fg, + ) + method_dict = mm.save(pathlib.Path(workspace)) + with open(pathlib.Path(workspace, "functions", "predict.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files(model_method_pkg) + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_1.py_fixture") + .read_text() + ), + f.read(), + ) + self.assertDictEqual( + method_dict, + { + "name": "PREDICT", + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.predict.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + }, + ) + + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures={"__call__": _DUMMY_SIG["predict"]}, + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + mm = model_method.ModelMethod( + meta, + "__call__", + "python_runtime", + fg, + ) + method_dict = mm.save( + pathlib.Path(workspace), function_generator.FunctionGenerateOptions(max_batch_size=10) + ) + with open(pathlib.Path(workspace, "functions", "__call__.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files(model_method_pkg) + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_2.py_fixture") + .read_text() + ), + f.read(), + ) + self.assertDictEqual( + method_dict, + { + "name": "__CALL__", + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.__call__.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + }, + ) + + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + with self.assertRaisesRegex( + ValueError, "Your target method идентификатор cannot be resolved as valid SQL identifier." + ): + mm = model_method.ModelMethod( + meta, + "идентификатор", + "python_runtime", + fg, + ) + + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + with self.assertRaisesRegex( + ValueError, "Target method predict_proba is not available in the signatures of the model." + ): + mm = model_method.ModelMethod( + meta, + "predict_proba", + "python_runtime", + fg, + ) + + with tempfile.TemporaryDirectory() as workspace, tempfile.TemporaryDirectory() as tmpdir: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + mm = model_method.ModelMethod( + meta, + "predict", + "python_runtime", + fg, + options=model_method.ModelMethodOptions(case_sensitive=True), + ) + method_dict = mm.save(pathlib.Path(workspace)) + with open(pathlib.Path(workspace, "functions", "predict.py"), encoding="utf-8") as f: + self.assertEqual( + ( + importlib_resources.files(model_method_pkg) + .joinpath("fixtures") # type: ignore[no-untyped-call] + .joinpath("function_fixture_1.py_fixture") + .read_text() + ), + f.read(), + ) + self.assertDictEqual( + method_dict, + { + "name": '"predict"', + "runtime": "python_runtime", + "type": "FUNCTION", + "handler": "functions.predict.infer", + "inputs": [{"name": "tmp_input", "type": "OBJECT"}], + "outputs": [{"type": "OBJECT"}], + }, + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/model/_model_composer/model_runtime/BUILD.bazel b/snowflake/ml/model/_model_composer/model_runtime/BUILD.bazel new file mode 100644 index 00000000..b696cafc --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_runtime/BUILD.bazel @@ -0,0 +1,47 @@ +load("//bazel:py_rules.bzl", "py_genrule", "py_library", "py_test") + +package(default_visibility = ["//visibility:public"]) + +GEN_RUNTIME_REQ_CMD = "$(location //bazel/requirements:parse_and_generate_requirements) $(location //:requirements.yml) --schema $(location //bazel/requirements:requirements.schema.json) --mode version_requirements --format python --filter_by_tag udf_inference > $@" + +py_genrule( + name = "gen_runtime_requirements", + srcs = [ + "//:requirements.yml", + "//bazel/requirements:requirements.schema.json", + ], + outs = ["_runtime_requirements.py"], + cmd = GEN_RUNTIME_REQ_CMD, + tools = ["//bazel/requirements:parse_and_generate_requirements"], +) + +py_library( + name = "_runtime_requirements", + srcs = [":gen_runtime_requirements"], +) + +py_library( + name = "model_runtime", + srcs = ["model_runtime.py"], + deps = [ + ":_runtime_requirements", + "//snowflake/ml/_internal:env", + "//snowflake/ml/_internal:env_utils", + "//snowflake/ml/_internal:file_utils", + "//snowflake/ml/model/_model_composer/model_manifest:model_manifest_schema", + "//snowflake/ml/model/_packager/model_env", + "//snowflake/ml/model/_packager/model_meta", + ], +) + +py_test( + name = "model_runtime_test", + srcs = ["model_runtime_test.py"], + deps = [ + ":model_runtime", + "//snowflake/ml/_internal:env_utils", + "//snowflake/ml/model:model_signature", + "//snowflake/ml/model/_packager/model_meta", + "//snowflake/ml/model/_packager/model_meta:model_blob_meta", + ], +) diff --git a/snowflake/ml/model/_model_composer/model_runtime/model_runtime.py b/snowflake/ml/model/_model_composer/model_runtime/model_runtime.py new file mode 100644 index 00000000..1e07ec68 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_runtime/model_runtime.py @@ -0,0 +1,91 @@ +import copy +import pathlib +from typing import List, Optional + +from packaging import requirements + +from snowflake.ml._internal import env as snowml_env, env_utils, file_utils +from snowflake.ml.model._model_composer.model_manifest import model_manifest_schema +from snowflake.ml.model._model_composer.model_runtime import _runtime_requirements +from snowflake.ml.model._packager.model_env import model_env +from snowflake.ml.model._packager.model_meta import model_meta as model_meta_api +from snowflake.snowpark import session + +_UDF_INFERENCE_DEPENDENCIES = _runtime_requirements.REQUIREMENTS + + +class ModelRuntime: + """Class to represent runtime in a model, which controls the runtime and version, imports and dependencies. + + Attributes: + model_meta: Model Metadata. + runtime_env: ModelEnv object representing the actual environment when deploying. The environment is based on + the environment from the packaged model with additional dependencies required to deploy. + imports: List of files to be imported in the created functions. At least packed model should be imported. + If the required Snowpark ML library is not available in the server-side, we will automatically pack the + local version as well as "snowflake-ml-python.zip" and added into the imports. + """ + + RUNTIME_DIR_REL_PATH = "runtimes" + + def __init__( + self, + session: session.Session, + name: str, + model_meta: model_meta_api.ModelMetadata, + imports: Optional[List[pathlib.PurePosixPath]] = None, + ) -> None: + self.name = name + self.model_meta = model_meta + self.runtime_env = copy.deepcopy(self.model_meta.env) + self.imports = imports or [] + + snowml_pkg_spec = f"{env_utils.SNOWPARK_ML_PKG_NAME}=={self.runtime_env.snowpark_ml_version}" + if self.runtime_env._snowpark_ml_version.local: + self.embed_local_ml_library = True + else: + snowml_server_availability = env_utils.validate_requirements_in_snowflake_conda_channel( + session=session, + reqs=[requirements.Requirement(snowml_pkg_spec)], + python_version=snowml_env.PYTHON_VERSION, + ) + self.embed_local_ml_library = snowml_server_availability is None + + if self.embed_local_ml_library: + self.runtime_env.include_if_absent( + [ + model_env.ModelDependency(requirement=dep, pip_name=requirements.Requirement(dep).name) + for dep in _UDF_INFERENCE_DEPENDENCIES + ], + check_local_version=True, + ) + else: + self.runtime_env.include_if_absent( + [ + model_env.ModelDependency(requirement=dep, pip_name=requirements.Requirement(dep).name) + for dep in _UDF_INFERENCE_DEPENDENCIES + [snowml_pkg_spec] + ], + check_local_version=True, + ) + + def save(self, workspace_path: pathlib.Path) -> model_manifest_schema.ModelRuntimeDict: + runtime_base_path = workspace_path / ModelRuntime.RUNTIME_DIR_REL_PATH / self.name + runtime_base_path.mkdir(parents=True, exist_ok=True) + + if self.embed_local_ml_library: + snowpark_ml_lib_path = runtime_base_path / "snowflake-ml-python.zip" + file_utils.zip_python_package(str(snowpark_ml_lib_path), "snowflake.ml") + snowpark_ml_lib_rel_path = pathlib.PurePosixPath( + snowpark_ml_lib_path.relative_to(workspace_path).as_posix() + ) + self.imports.append(snowpark_ml_lib_rel_path) + + env_dict = self.runtime_env.save_as_dict(runtime_base_path) + return model_manifest_schema.ModelRuntimeDict( + language="PYTHON", + version=self.runtime_env.python_version, + imports=list(map(str, self.imports)), + dependencies=model_manifest_schema.ModelRuntimeDependenciesDict( + conda=(runtime_base_path / env_dict["conda"]).relative_to(workspace_path).as_posix() + ), + ) diff --git a/snowflake/ml/model/_model_composer/model_runtime/model_runtime_test.py b/snowflake/ml/model/_model_composer/model_runtime/model_runtime_test.py new file mode 100644 index 00000000..5ede2068 --- /dev/null +++ b/snowflake/ml/model/_model_composer/model_runtime/model_runtime_test.py @@ -0,0 +1,274 @@ +import os +import pathlib +import tempfile +from importlib import metadata as importlib_metadata +from unittest import mock + +import yaml +from absl.testing import absltest +from packaging import requirements + +from snowflake.ml._internal import env_utils +from snowflake.ml.model import model_signature +from snowflake.ml.model._model_composer.model_runtime import model_runtime +from snowflake.ml.model._packager.model_meta import model_blob_meta, model_meta + +_DUMMY_SIG = { + "predict": model_signature.ModelSignature( + inputs=[ + model_signature.FeatureSpec(dtype=model_signature.DataType.FLOAT, name="input"), + ], + outputs=[model_signature.FeatureSpec(name="output", dtype=model_signature.DataType.FLOAT)], + ) +} + +_DUMMY_BLOB = model_blob_meta.ModelBlobMeta( + name="model1", model_type="custom", path="mock_path", handler_version="version_0" +) + +_BASIC_DEPENDENCIES_TARGET = list( + sorted( + map( + lambda x: str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement(x))), + model_runtime._UDF_INFERENCE_DEPENDENCIES, + ) + ) +) + +_BASIC_DEPENDENCIES_TARGET_WITH_SNOWML = list( + sorted( + map( + lambda x: str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement(x))), + model_runtime._UDF_INFERENCE_DEPENDENCIES + [env_utils.SNOWPARK_ML_PKG_NAME], + ) + ) +) + + +class ModelRuntimeTest(absltest.TestCase): + def setUp(self) -> None: + self.m_session = mock.MagicMock() + + def test_model_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + returned_dict = mr.save(pathlib.Path(workspace)) + + self.assertDictEqual( + returned_dict, + { + "language": "PYTHON", + "version": meta.env.python_version, + "imports": ["model.zip"], + "dependencies": {"conda": "runtimes/python_runtime/env/conda.yml"}, + }, + ) + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(_BASIC_DEPENDENCIES_TARGET_WITH_SNOWML, dependencies["dependencies"]) + + def test_model_runtime_local_snowml(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, name="model1", model_type="custom", signatures=_DUMMY_SIG + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=None + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + returned_dict = mr.save(pathlib.Path(workspace)) + + self.assertDictEqual( + returned_dict, + { + "language": "PYTHON", + "version": meta.env.python_version, + "imports": ["model.zip", "runtimes/python_runtime/snowflake-ml-python.zip"], + "dependencies": {"conda": "runtimes/python_runtime/env/conda.yml"}, + }, + ) + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(_BASIC_DEPENDENCIES_TARGET, dependencies["dependencies"]) + + def test_model_runtime_dup_basic_dep(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + conda_dependencies=["pandas"], + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + dep_target = _BASIC_DEPENDENCIES_TARGET_WITH_SNOWML[:] + dep_target.remove(f"pandas=={importlib_metadata.version('pandas')}") + dep_target.append("pandas") + dep_target.sort() + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + _ = mr.save(pathlib.Path(workspace)) + + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(dep_target, dependencies["dependencies"]) + + def test_model_runtime_dup_basic_dep_other_channel(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + conda_dependencies=["conda-forge::pandas"], + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + dep_target = _BASIC_DEPENDENCIES_TARGET_WITH_SNOWML[:] + dep_target.remove(f"pandas=={importlib_metadata.version('pandas')}") + dep_target.append("conda-forge::pandas") + dep_target.sort() + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + _ = mr.save(pathlib.Path(workspace)) + + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(dep_target, dependencies["dependencies"]) + + def test_model_runtime_dup_basic_dep_pip(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + pip_requirements=["pandas"], + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + dep_target = _BASIC_DEPENDENCIES_TARGET_WITH_SNOWML[:] + dep_target.remove(f"pandas=={importlib_metadata.version('pandas')}") + dep_target.sort() + + with mock.patch.object(env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""]): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + _ = mr.save(pathlib.Path(workspace)) + + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(dep_target, dependencies["dependencies"]) + + def test_model_runtime_additional_conda_dep(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + conda_dependencies=["pytorch"], + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + dep_target = _BASIC_DEPENDENCIES_TARGET_WITH_SNOWML[:] + dep_target.append("pytorch") + dep_target.sort() + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + _ = mr.save(pathlib.Path(workspace)) + + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(dep_target, dependencies["dependencies"]) + + def test_model_runtime_additional_pip_dep(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + pip_requirements=["torch"], + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + dep_target = _BASIC_DEPENDENCIES_TARGET_WITH_SNOWML[:] + dep_target.sort() + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + _ = mr.save(pathlib.Path(workspace)) + + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(dep_target, dependencies["dependencies"]) + + def test_model_runtime_additional_dep_both(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as workspace: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + conda_dependencies=["pytorch"], + pip_requirements=["torch"], + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + dep_target = _BASIC_DEPENDENCIES_TARGET_WITH_SNOWML[:] + dep_target.append("pytorch") + dep_target.sort() + + with mock.patch.object( + env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] + ): + mr = model_runtime.ModelRuntime( + self.m_session, "python_runtime", meta, [pathlib.PurePosixPath("model.zip")] + ) + _ = mr.save(pathlib.Path(workspace)) + + with open(os.path.join(workspace, "runtimes/python_runtime/env/conda.yml"), encoding="utf-8") as f: + dependencies = yaml.safe_load(f) + + self.assertContainsSubset(dep_target, dependencies["dependencies"]) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/model/_module_model/module_manifest/BUILD.bazel b/snowflake/ml/model/_module_model/module_manifest/BUILD.bazel deleted file mode 100644 index f2afd157..00000000 --- a/snowflake/ml/model/_module_model/module_manifest/BUILD.bazel +++ /dev/null @@ -1,8 +0,0 @@ -load("//bazel:py_rules.bzl", "py_library") - -package(default_visibility = ["//visibility:public"]) - -py_library( - name = "module_manifest", - srcs = ["module_manifest.py"], -) diff --git a/snowflake/ml/model/_module_model/module_manifest/module_manifest.py b/snowflake/ml/model/_module_model/module_manifest/module_manifest.py deleted file mode 100644 index 3a795fd9..00000000 --- a/snowflake/ml/model/_module_model/module_manifest/module_manifest.py +++ /dev/null @@ -1,2 +0,0 @@ -class ModuleManifest: - ... diff --git a/snowflake/ml/model/_module_model/module_method/BUILD.bazel b/snowflake/ml/model/_module_model/module_method/BUILD.bazel deleted file mode 100644 index b75316f1..00000000 --- a/snowflake/ml/model/_module_model/module_method/BUILD.bazel +++ /dev/null @@ -1,28 +0,0 @@ -load("//bazel:py_rules.bzl", "py_library", "py_test") - -package(default_visibility = ["//visibility:public"]) - -py_library( - name = "handler_generator", - srcs = ["handler_generator.py"], - data = [ - "infer_handler.py_template", - ], -) - -py_test( - name = "handler_generator_test", - srcs = ["handler_generator_test.py"], - data = [ - "fixtures/handler_fixture_1.py_fixture", - "fixtures/handler_fixture_2.py_fixture", - ], - deps = [ - ":handler_generator", - ], -) - -py_library( - name = "module_method", - srcs = ["module_method.py"], -) diff --git a/snowflake/ml/model/_module_model/module_method/handler_generator.py b/snowflake/ml/model/_module_model/module_method/handler_generator.py deleted file mode 100644 index 90af240e..00000000 --- a/snowflake/ml/model/_module_model/module_method/handler_generator.py +++ /dev/null @@ -1,43 +0,0 @@ -import pathlib -from typing import Optional, TypedDict - -import importlib_resources -from typing_extensions import NotRequired - - -class HandlerGenerateOptions(TypedDict): - max_batch_size: NotRequired[int] - - -class HandlerGenerator: - HANDLER_NAME = "infer" - - def __init__( - self, - model_file_stage_path: pathlib.PurePosixPath, - ) -> None: - self.model_file_stage_path = model_file_stage_path - - def generate( - self, - handler_file_path: pathlib.Path, - target_method: str, - options: Optional[HandlerGenerateOptions] = None, - ) -> None: - if options is None: - options = {} - handler_template = ( - importlib_resources.files("snowflake.ml.model._module_model.module_method") - .joinpath("infer_handler.py_template") # type: ignore[no-untyped-call] - .read_text() - ) - - udf_code = handler_template.format( - model_file_name=self.model_file_stage_path.name, - target_method=target_method, - max_batch_size=options.get("max_batch_size", None), - handler_name=HandlerGenerator.HANDLER_NAME, - ) - with open(handler_file_path, "w", encoding="utf-8") as f: - f.write(udf_code) - f.flush() diff --git a/snowflake/ml/model/_module_model/module_method/module_method.py b/snowflake/ml/model/_module_model/module_method/module_method.py deleted file mode 100644 index c72d7b65..00000000 --- a/snowflake/ml/model/_module_model/module_method/module_method.py +++ /dev/null @@ -1,2 +0,0 @@ -class ModuleMethod: - ... diff --git a/snowflake/ml/model/_module_model/module_model_test.py b/snowflake/ml/model/_module_model/module_model_test.py deleted file mode 100644 index d7c2a607..00000000 --- a/snowflake/ml/model/_module_model/module_model_test.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import cast -from unittest import mock - -import numpy as np -import pandas as pd -from absl.testing import absltest -from sklearn import linear_model - -from snowflake.ml._internal import env_utils -from snowflake.ml.model._module_model import module_model -from snowflake.ml.modeling.linear_model import ( # type:ignore[attr-defined] - LinearRegression, -) -from snowflake.ml.test_utils import mock_session -from snowflake.snowpark import FileOperation, Session - - -class ModuleInterfaceTest(absltest.TestCase): - def test_save_interface(self) -> None: - m_session = mock_session.MockSession(conn=None, test_case=self) - c_session = cast(Session, m_session) - - stage_path = '@"db"."schema"."stage"' - arr = np.array([[1, 2, 3], [4, 2, 5]]) - d = pd.DataFrame(arr, columns=["c1", "c2", "c3"]) - - mock_pk = mock.MagicMock() - mock_pk.meta = mock.MagicMock() - mock_pk.meta.signatures = mock.MagicMock() - m = module_model.ModuleModel(session=c_session, stage_path=stage_path) - with mock.patch.object(m.packager, "save") as mock_save: - with mock.patch.object(FileOperation, "put", return_value=None) as mock_put_stream: - with mock.patch.object( - env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] - ): - m.save( - name="model1", - model=LinearRegression(), - ) - mock_save.assert_called_once() - - m = module_model.ModuleModel(session=c_session, stage_path=stage_path) - with mock.patch.object(m.packager, "save") as mock_save: - with mock.patch.object(FileOperation, "put", return_value=None) as mock_put_stream: - with mock.patch.object( - env_utils, "validate_requirements_in_snowflake_conda_channel", return_value=[""] - ): - m.save( - name="model1", - model=linear_model.LinearRegression(), - sample_input=d, - ) - mock_put_stream.assert_called_once_with(mock.ANY, stage_path, auto_compress=False, overwrite=False) - - -if __name__ == "__main__": - absltest.main() diff --git a/snowflake/ml/model/_module_model/module_runtime/BUILD.bazel b/snowflake/ml/model/_module_model/module_runtime/BUILD.bazel deleted file mode 100644 index 975eb45f..00000000 --- a/snowflake/ml/model/_module_model/module_runtime/BUILD.bazel +++ /dev/null @@ -1,8 +0,0 @@ -load("//bazel:py_rules.bzl", "py_library") - -package(default_visibility = ["//visibility:public"]) - -py_library( - name = "module_runtime", - srcs = ["module_runtime.py"], -) diff --git a/snowflake/ml/model/_module_model/module_runtime/module_runtime.py b/snowflake/ml/model/_module_model/module_runtime/module_runtime.py deleted file mode 100644 index a90a8628..00000000 --- a/snowflake/ml/model/_module_model/module_runtime/module_runtime.py +++ /dev/null @@ -1,2 +0,0 @@ -class ModuleRuntime: - ... diff --git a/snowflake/ml/model/_packager/model_env/model_env.py b/snowflake/ml/model/_packager/model_env/model_env.py index f9e6fe0f..40f83d4f 100644 --- a/snowflake/ml/model/_packager/model_env/model_env.py +++ b/snowflake/ml/model/_packager/model_env/model_env.py @@ -119,20 +119,19 @@ def include_if_absent(self, pkgs: List[ModelDependency], check_local_version: bo pkgs: A list of ModelDependency namedtuple to be appended. check_local_version: Flag to indicate if it is required to pin to local version. Defaults to False. """ - conda_reqs_str, pip_names_str = tuple(zip(*pkgs)) - pip_names = env_utils.validate_pip_requirement_string_list(list(pip_names_str)) - conda_reqs = env_utils.validate_conda_dependency_string_list(list(conda_reqs_str)) - for conda_req, pip_name in zip(conda_reqs[env_utils.DEFAULT_CHANNEL_NAME], pip_names): + for conda_req_str, pip_name in pkgs: + conda_req_channel, conda_req = env_utils._validate_conda_dependency_string(conda_req_str) if check_local_version: - req_to_check = requirements.Requirement(f"{pip_name.name}{conda_req.specifier}") + req_to_check = requirements.Requirement(f"{pip_name}{conda_req.specifier}") req_to_add = env_utils.get_local_installed_version_of_pip_package(req_to_check) req_to_add.name = conda_req.name else: req_to_add = conda_req - added_in_pip = False - for added_pip_req in self._pip_requirements: - if added_pip_req.name == pip_name.name: + show_warning_message = conda_req_channel == env_utils.DEFAULT_CHANNEL_NAME + + if any(added_pip_req.name == pip_name for added_pip_req in self._pip_requirements): + if show_warning_message: warnings.warn( ( f"Basic dependency {req_to_add.name} specified from PIP requirements." @@ -141,24 +140,39 @@ def include_if_absent(self, pkgs: List[ModelDependency], check_local_version: bo category=UserWarning, stacklevel=2, ) - added_in_pip = True - if added_in_pip: continue + try: - env_utils.append_conda_dependency( - self._conda_dependencies, (env_utils.DEFAULT_CHANNEL_NAME, req_to_add) - ) + env_utils.append_conda_dependency(self._conda_dependencies, (conda_req_channel, req_to_add)) except env_utils.DuplicateDependencyError: pass except env_utils.DuplicateDependencyInMultipleChannelsError: - warnings.warn( - ( - f"Basic dependency {req_to_add.name} specified from non-Snowflake channel." - + " This may prevent model deploying to Snowflake Warehouse." - ), - category=UserWarning, - stacklevel=2, - ) + if show_warning_message: + warnings.warn( + ( + f"Basic dependency {req_to_add.name} specified from non-Snowflake channel." + + " This may prevent model deploying to Snowflake Warehouse." + ), + category=UserWarning, + stacklevel=2, + ) + + def include_if_absent_pip(self, pkgs: List[str], check_local_version: bool = False) -> None: + """Append pip requirements into model env if absent. + + Args: + pkgs: A list of string to be appended in pip requirement. + check_local_version: Flag to indicate if it is required to pin to local version. Defaults to False. + """ + + pip_reqs = env_utils.validate_pip_requirement_string_list(pkgs) + for pip_req in pip_reqs: + if check_local_version: + pip_req = env_utils.get_local_installed_version_of_pip_package(pip_req) + try: + env_utils.append_requirement_list(self._pip_requirements, pip_req) + except env_utils.DuplicateDependencyError: + pass def generate_env_for_cuda(self) -> None: if self.cuda_version is None: @@ -175,26 +189,23 @@ def generate_env_for_cuda(self) -> None: ) if not cuda_spec: - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - ("nvidia", requirements.Requirement(f"cuda=={self.cuda_version}.*")), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass + self.include_if_absent( + [ModelDependency(requirement=f"nvidia::cuda=={self.cuda_version}.*", pip_name="cuda")], + check_local_version=False, + ) xgboost_spec = env_utils.find_dep_spec( self._conda_dependencies, self._pip_requirements, conda_pkg_name="xgboost", remove_spec=True ) if xgboost_spec: - xgboost_spec.name = "py-xgboost-gpu" - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - ("conda-forge", xgboost_spec), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass + self.include_if_absent( + [ + ModelDependency( + requirement=f"conda-forge::py-xgboost-gpu{xgboost_spec.specifier}", pip_name="xgboost" + ) + ], + check_local_version=False, + ) pytorch_spec = env_utils.find_dep_spec( self._conda_dependencies, @@ -216,67 +227,38 @@ def generate_env_for_cuda(self) -> None: " dependencies or pip requirements." ) if pytorch_spec: - pytorch_spec.name = "pytorch" - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - ("pytorch", pytorch_spec), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass + self.include_if_absent( + [ModelDependency(requirement=f"pytorch::pytorch{pytorch_spec.specifier}", pip_name="torch")], + check_local_version=False, + ) if not pytorch_cuda_spec: - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - p_chan_dep=("pytorch", requirements.Requirement(f"pytorch-cuda=={self.cuda_version}.*")), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass + self.include_if_absent( + [ModelDependency(requirement=f"pytorch::pytorch-cuda=={self.cuda_version}.*", pip_name="torch")], + check_local_version=False, + ) tf_spec = env_utils.find_dep_spec( self._conda_dependencies, self._pip_requirements, conda_pkg_name="tensorflow", remove_spec=True ) if tf_spec: - tf_spec.name = "tensorflow-gpu" - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - ("conda-forge", tf_spec), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass + self.include_if_absent( + [ModelDependency(requirement=f"conda-forge::tensorflow-gpu{tf_spec.specifier}", pip_name="tensorflow")], + check_local_version=False, + ) transformers_spec = env_utils.find_dep_spec( self._conda_dependencies, self._pip_requirements, conda_pkg_name="transformers", remove_spec=False ) if transformers_spec: - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - ("conda-forge", requirements.Requirement("accelerate>=0.22.0")), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass - - # Required by bitsandbytes - try: - env_utils.append_conda_dependency( - self._conda_dependencies, - ( - env_utils.DEFAULT_CHANNEL_NAME, - env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("scipy")), - ), - ) - except (env_utils.DuplicateDependencyError, env_utils.DuplicateDependencyInMultipleChannelsError): - pass + self.include_if_absent( + [ + ModelDependency(requirement="conda-forge::accelerate>=0.22.0", pip_name="accelerate"), + ModelDependency(requirement="scipy>=1.9", pip_name="scipy"), + ], + check_local_version=False, + ) - try: - env_utils.append_requirement_list( - self._pip_requirements, - requirements.Requirement("bitsandbytes>=0.41.0"), - ) - except env_utils.DuplicateDependencyError: - pass + self.include_if_absent_pip(["bitsandbytes>=0.41.0"], check_local_version=False) def relax_version(self) -> None: """Relax the version requirements for both conda dependencies and pip requirements. diff --git a/snowflake/ml/model/_packager/model_env/model_env_test.py b/snowflake/ml/model/_packager/model_env/model_env_test.py index 5594c800..e5db33dd 100644 --- a/snowflake/ml/model/_packager/model_env/model_env_test.py +++ b/snowflake/ml/model/_packager/model_env/model_env_test.py @@ -2,6 +2,7 @@ import os import pathlib import tempfile +import warnings import yaml from absl.testing import absltest @@ -153,6 +154,28 @@ def test_include_if_absent(self) -> None: self.assertListEqual(env.conda_dependencies, []) self.assertListEqual(env.pip_requirements, ["some-package==1.0.1"]) + env = model_env.ModelEnv() + env.conda_dependencies = ["channel::some-package==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent( + [model_env.ModelDependency(requirement="channel::some-package", pip_name="some-package")] + ) + self.assertListEqual(env.conda_dependencies, ["channel::some-package==1.0.1"]) + self.assertListEqual(env.pip_requirements, []) + + env = model_env.ModelEnv() + env.pip_requirements = ["some-package==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent( + [model_env.ModelDependency(requirement="channel::some-package", pip_name="some-package")] + ) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["some-package==1.0.1"]) + def test_include_if_absent_check_local(self) -> None: env = model_env.ModelEnv() env.conda_dependencies = [] @@ -291,6 +314,160 @@ def test_include_if_absent_check_local(self) -> None: self.assertListEqual(env.conda_dependencies, []) self.assertListEqual(env.pip_requirements, ["numpy==1.0.1"]) + env = model_env.ModelEnv() + env.conda_dependencies = ["channel::numpy==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent( + [model_env.ModelDependency(requirement="channel::numpy", pip_name="numpy")], check_local_version=True + ) + self.assertListEqual(env.conda_dependencies, ["channel::numpy==1.0.1"]) + self.assertListEqual(env.pip_requirements, []) + + env = model_env.ModelEnv() + env.pip_requirements = ["numpy==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent( + [model_env.ModelDependency(requirement="channel::numpy", pip_name="numpy")], check_local_version=True + ) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["numpy==1.0.1"]) + + def test_include_if_absent_pip(self) -> None: + env = model_env.ModelEnv() + env.conda_dependencies = ["some-package==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent_pip(["some-package"]) + self.assertListEqual(env.conda_dependencies, ["some-package==1.0.1"]) + self.assertListEqual(env.pip_requirements, ["some-package"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["some-package==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent_pip(["some-package"]) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["some-package==1.0.1"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["some-package==1.0.1"] + + env.include_if_absent_pip(["some-package==1.0.2"]) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["some-package==1.0.1"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["some-package==1.0.1"] + + env.include_if_absent_pip(["some-package>=1.0,<2"]) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["some-package==1.0.1"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["some-package==1.0.1"] + + env.include_if_absent_pip(["another-package>=1.0,<2"]) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["another-package<2,>=1.0", "some-package==1.0.1"]) + + def test_include_if_absent_pip_check_local(self) -> None: + env = model_env.ModelEnv() + env.include_if_absent_pip(["numpy"], check_local_version=True) + self.assertListEqual( + env.conda_dependencies, + [], + ) + self.assertListEqual( + env.pip_requirements, + [str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("numpy")))], + ) + + env = model_env.ModelEnv() + env.include_if_absent_pip(["numpy>=1.0"], check_local_version=True) + self.assertListEqual( + env.conda_dependencies, + [], + ) + self.assertListEqual( + env.pip_requirements, + [str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("numpy")))], + ) + + env = model_env.ModelEnv() + env.include_if_absent_pip(["numpy<1.0"], check_local_version=True) + self.assertListEqual( + env.conda_dependencies, + [], + ) + self.assertListEqual(env.pip_requirements, ["numpy<1.0"]) + + env = model_env.ModelEnv() + env.include_if_absent_pip(["invalid-package"], check_local_version=True) + self.assertListEqual( + env.conda_dependencies, + [], + ) + self.assertListEqual(env.pip_requirements, ["invalid-package"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["numpy==1.0.1"] + + env.include_if_absent_pip(["numpy"], check_local_version=True) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["numpy==1.0.1"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["numpy==1.0.1"] + env.include_if_absent_pip(["numpy==1.0.2"], check_local_version=True) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["numpy==1.0.1"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["numpy==1.0.1"] + env.include_if_absent_pip(["numpy>=1.0,<2"], check_local_version=True) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["numpy==1.0.1"]) + + env = model_env.ModelEnv() + env.pip_requirements = ["numpy==1.0.1"] + + env.include_if_absent_pip(["torch>=1.0"], check_local_version=True) + self.assertListEqual( + env.conda_dependencies, + [], + ) + self.assertListEqual( + env.pip_requirements, + [ + "numpy==1.0.1", + str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("torch"))), + ], + ) + + env = model_env.ModelEnv() + env.conda_dependencies = ["numpy==1.0.1"] + env.include_if_absent_pip(["numpy>=1.0"], check_local_version=True) + self.assertListEqual(env.conda_dependencies, ["numpy==1.0.1"]) + self.assertListEqual( + env.pip_requirements, + [str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("numpy")))], + ) + + env = model_env.ModelEnv() + env.pip_requirements = ["numpy==1.0.1"] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.include_if_absent_pip(["numpy>=1.0"], check_local_version=True) + self.assertListEqual(env.conda_dependencies, []) + self.assertListEqual(env.pip_requirements, ["numpy==1.0.1"]) + def test_generate_conda_env_for_cuda(self) -> None: env = model_env.ModelEnv() env.conda_dependencies = ["somepackage==1.0.0", "another_channel::another_package==1.0.0"] @@ -545,7 +722,7 @@ def test_generate_conda_env_for_cuda(self) -> None: "nvidia::cuda==11.7.*", "pytorch::pytorch-cuda==11.7.*", "pytorch::pytorch==1.0.0", - str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("scipy"))), + "scipy>=1.9", "transformers==1.0.0", ], ) @@ -583,7 +760,7 @@ def test_generate_conda_env_for_cuda(self) -> None: "conda-forge::accelerate==1.0.0", "conda-forge::transformers==1.0.0", "nvidia::cuda==11.7.*", - str(env_utils.get_local_installed_version_of_pip_package(requirements.Requirement("scipy"))), + "scipy>=1.9", ], ) diff --git a/snowflake/ml/model/_packager/model_handlers/BUILD.bazel b/snowflake/ml/model/_packager/model_handlers/BUILD.bazel index fa431296..585d7cba 100644 --- a/snowflake/ml/model/_packager/model_handlers/BUILD.bazel +++ b/snowflake/ml/model/_packager/model_handlers/BUILD.bazel @@ -202,7 +202,6 @@ py_library( srcs = ["llm.py"], deps = [ ":_base", - "//snowflake/ml/_internal:env_utils", "//snowflake/ml/_internal:file_utils", "//snowflake/ml/model:custom_model", "//snowflake/ml/model:model_signature", diff --git a/snowflake/ml/model/_packager/model_handlers/llm.py b/snowflake/ml/model/_packager/model_handlers/llm.py index 86840a71..cfede8ef 100644 --- a/snowflake/ml/model/_packager/model_handlers/llm.py +++ b/snowflake/ml/model/_packager/model_handlers/llm.py @@ -4,10 +4,9 @@ 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._internal import file_utils from snowflake.ml.model import custom_model, model_signature, type_hints as model_types from snowflake.ml.model._packager.model_env import model_env from snowflake.ml.model._packager.model_handlers import _base @@ -109,14 +108,7 @@ def save_model( ] model_meta.env.include_if_absent(pkgs_requirements, check_local_version=True) # Recent peft versions are only available in PYPI. - env_utils.append_requirement_list( - model_meta.env._pip_requirements, - requirements.Requirement("peft==0.5.0"), - ) - env_utils.append_requirement_list( - model_meta.env._pip_requirements, - requirements.Requirement("vllm==0.2.1.post1"), - ) + model_meta.env.include_if_absent_pip(["peft==0.5.0", "vllm==0.2.1.post1"]) model_meta.env.cuda_version = kwargs.get("cuda_version", model_env.DEFAULT_CUDA_VERSION) @@ -170,100 +162,95 @@ def _memory_stats(self, msg: str) -> None: logger.warning(f"Torch VRAM {torch.cuda.memory_allocated()/1024**2} MB allocated.") logger.warning(f"Torch VRAM {torch.cuda.memory_reserved()/1024**2} MB reserved.") - def _merge_model(self, local_dir_path: str): # type: ignore[no-untyped-def] - import peft - + def _prepare_for_pretrain(self) -> None: hub_kwargs = { "revision": raw_model.revision, "token": raw_model.token, } model_dir_path = raw_model.model_id_or_path - 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, + model_dir_path, padding_side="right", use_fast=False, **hub_kwargs, ) if not tokenizer.pad_token: tokenizer.pad_token = tokenizer.eos_token - tokenizer.save_pretrained(local_dir_path) - logger.warning(f"Tokenizer state is saved to {local_dir_path}.") - hf_model = peft.AutoPeftModelForCausalLM.from_pretrained( # type: ignore[attr-defined] + tokenizer.save_pretrained(self.local_model_dir) + hf_model = transformers.AutoModelForCausalLM.from_pretrained( model_dir_path, device_map="auto", torch_dtype="auto", **hub_kwargs, ) hf_model.eval() - hf_model = hf_model.merge_and_unload() - hf_model.save_pretrained(local_dir_path) - logger.warning(f"Merged model state is saved to {local_dir_path}.") - return hf_model + hf_model.save_pretrained(self.local_model_dir) + logger.warning(f"Model state is saved to {self.local_model_dir}.") + del tokenizer + del hf_model + gc.collect() + torch.cuda.empty_cache() + self._memory_stats("After GC on model.") + + def _prepare_for_lora(self) -> None: + self._memory_stats("Before model load & merge.") + import peft - def _init_engine_for_remote_pretrain(self) -> None: hub_kwargs = { "revision": raw_model.revision, "token": raw_model.token, } model_dir_path = raw_model.model_id_or_path + 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( - model_dir_path, + base_model_path, padding_side="right", use_fast=False, **hub_kwargs, ) if not tokenizer.pad_token: tokenizer.pad_token = tokenizer.eos_token - t = tempfile.TemporaryDirectory() - local_dir_path = t.name - tokenizer.save_pretrained(local_dir_path) - hf_model = transformers.AutoModelForCausalLM.from_pretrained( + tokenizer.save_pretrained(self.local_model_dir) + logger.warning(f"Tokenizer state is saved to {self.local_model_dir}.") + hf_model = peft.AutoPeftModelForCausalLM.from_pretrained( # type: ignore[attr-defined] model_dir_path, device_map="auto", torch_dtype="auto", **hub_kwargs, ) hf_model.eval() - hf_model.save_pretrained(local_dir_path) - logger.warning(f"Model state is saved to {local_dir_path}.") - del tokenizer - del hf_model - gc.collect() - torch.cuda.empty_cache() - self._memory_stats("After GC on model.") - self.llm_engine = vllm.LLM( - model=t.name, - # TODO(halu): Update if raylet issued resolved. - tensor_parallel_size=1, - ) - - def _init_engine_for_lora(self) -> None: - t = tempfile.TemporaryDirectory() - self._memory_stats("Before model load & merge.") - hf_model = self._merge_model(t.name) + hf_model = hf_model.merge_and_unload() + hf_model.save_pretrained(self.local_model_dir) + logger.warning(f"Merged model state is saved to {self.local_model_dir}.") self._memory_stats("After model load & merge.") del hf_model gc.collect() torch.cuda.empty_cache() self._memory_stats("After GC on model.") - tp_size = torch.cuda.device_count() if raw_model.enable_tp else 1 - self.llm_engine = vllm.LLM(model=t.name, tensor_parallel_size=tp_size) - logger.warning(f"vLLM engine init is done. tp: {tp_size}") - def __init__(self, context: custom_model.ModelContext) -> None: + self.local_tmp_holder = tempfile.TemporaryDirectory() + self.local_model_dir = self.local_tmp_holder.name if raw_model.mode == llm.LLM.Mode.LOCAL_LORA: - self._init_engine_for_lora() + self._prepare_for_lora() elif raw_model.mode == llm.LLM.Mode.REMOTE_PRETRAIN: - self._init_engine_for_remote_pretrain() - + self._prepare_for_pretrain() self.sampling_params = vllm.SamplingParams( temperature=raw_model.temperature, top_p=raw_model.top_p, max_tokens=raw_model.max_tokens, ) + self._init_engine() + + # This has to have same lifetime as main thread + # in order to avoid pre-maturely terminate ray. + def _init_engine(self) -> None: + tp_size = torch.cuda.device_count() if raw_model.enable_tp else 1 + self.llm_engine = vllm.LLM( + model=self.local_model_dir, + tensor_parallel_size=tp_size, + ) @custom_model.inference_api def infer(self, X: pd.DataFrame) -> pd.DataFrame: diff --git a/snowflake/ml/model/_packager/model_handlers/snowmlmodel.py b/snowflake/ml/model/_packager/model_handlers/snowmlmodel.py index 9d84233a..cbafe76f 100644 --- a/snowflake/ml/model/_packager/model_handlers/snowmlmodel.py +++ b/snowflake/ml/model/_packager/model_handlers/snowmlmodel.py @@ -6,7 +6,7 @@ import pandas as pd from typing_extensions import TypeGuard, Unpack -from snowflake.ml._internal import env as snowml_env, env_utils, type_utils +from snowflake.ml._internal import type_utils from snowflake.ml.model import custom_model, model_signature, type_hints as model_types from snowflake.ml.model._packager.model_env import model_env from snowflake.ml.model._packager.model_handlers import _base, _utils as handlers_utils @@ -125,13 +125,6 @@ def get_prediction( for dep in model_dependencies: pkg_name = dep.split("==")[0] _include_if_absent_pkgs.append(model_env.ModelDependency(requirement=pkg_name, pip_name=pkg_name)) - if not model_meta.env._snowpark_ml_version.local: - _include_if_absent_pkgs.append( - model_env.ModelDependency( - requirement=f"{env_utils.SNOWPARK_ML_PKG_NAME}=={snowml_env.VERSION}", - pip_name=env_utils.SNOWPARK_ML_PKG_NAME, - ) - ) model_meta.env.include_if_absent(_include_if_absent_pkgs, check_local_version=True) @classmethod diff --git a/snowflake/ml/model/_packager/model_meta/model_meta.py b/snowflake/ml/model/_packager/model_meta/model_meta.py index e787e0e1..73785d2e 100644 --- a/snowflake/ml/model/_packager/model_meta/model_meta.py +++ b/snowflake/ml/model/_packager/model_meta/model_meta.py @@ -11,7 +11,7 @@ import cloudpickle import yaml -from packaging import version +from packaging import requirements, version from snowflake.ml._internal import env as snowml_env, env_utils, file_utils from snowflake.ml.model import model_signature, type_hints as model_types @@ -154,13 +154,16 @@ def _create_env_for_model_metadata( env.snowpark_ml_version = snowml_env.VERSION if embed_local_ml_library: env.include_if_absent( - [model_env.ModelDependency(requirement=dep, pip_name=dep) for dep in _PACKAGING_CORE_DEPENDENCIES], + [ + model_env.ModelDependency(requirement=dep, pip_name=requirements.Requirement(dep).name) + for dep in _PACKAGING_CORE_DEPENDENCIES + ], check_local_version=True, ) else: env.include_if_absent( [ - model_env.ModelDependency(requirement=dep, pip_name=dep) + model_env.ModelDependency(requirement=dep, pip_name=requirements.Requirement(dep).name) for dep in _PACKAGING_CORE_DEPENDENCIES + [env_utils.SNOWPARK_ML_PKG_NAME] ], check_local_version=True, @@ -301,9 +304,9 @@ def _validate_model_metadata(loaded_meta: Any) -> model_meta_schema.ModelMetadat loaded_meta = migrator_plans.migrate_metadata(loaded_meta) loaded_meta_min_snowpark_ml_version = loaded_meta.get("min_snowpark_ml_version", None) - if not loaded_meta_min_snowpark_ml_version or version.parse( - loaded_meta_min_snowpark_ml_version - ) < version.parse(snowml_env.VERSION): + if not loaded_meta_min_snowpark_ml_version or ( + version.parse(loaded_meta_min_snowpark_ml_version) > version.parse(snowml_env.VERSION) + ): raise RuntimeError( f"The minimal version required to load the model is {loaded_meta_min_snowpark_ml_version}," f"while current version of Snowpark ML library is {snowml_env.VERSION}." diff --git a/snowflake/ml/model/_packager/model_meta/model_meta_test.py b/snowflake/ml/model/_packager/model_meta/model_meta_test.py index 85f0390f..7d9993cf 100644 --- a/snowflake/ml/model/_packager/model_meta/model_meta_test.py +++ b/snowflake/ml/model/_packager/model_meta/model_meta_test.py @@ -273,6 +273,25 @@ def test_model_meta_check(self) -> None: with self.assertRaisesRegex(ValueError, "Unable to get the version of the metadata file."): model_meta.ModelMetadata.load(tmpdir) + def test_model_meta_check_min_version(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + with model_meta.create_model_metadata( + model_dir_path=tmpdir, + name="model1", + model_type="custom", + signatures=_DUMMY_SIG, + metadata={"foo": "bar"}, + ) as meta: + meta.models["model1"] = _DUMMY_BLOB + current_version = version.parse(snowml_env.VERSION) + + meta.min_snowpark_ml_version = ( + f"{current_version.major}.{current_version.minor}.{current_version.micro+1}" + ) + + with self.assertRaisesRegex(RuntimeError, "The minimal version required to load the model is"): + model_meta.ModelMetadata.load(tmpdir) + def test_model_meta_cuda(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: with model_meta.create_model_metadata( diff --git a/snowflake/ml/model/_packager/model_packager.py b/snowflake/ml/model/_packager/model_packager.py index 71127327..22b66db2 100644 --- a/snowflake/ml/model/_packager/model_packager.py +++ b/snowflake/ml/model/_packager/model_packager.py @@ -100,8 +100,8 @@ def save( if signatures is None: logging.info(f"Model signatures are auto inferred as:\n\n{meta.signatures}") - self.model = model - self.meta = meta + self.model = model + self.meta = meta def load( self, diff --git a/snowflake/ml/model/_packager/model_packager_test.py b/snowflake/ml/model/_packager/model_packager_test.py index 60dbfbae..70387fde 100644 --- a/snowflake/ml/model/_packager/model_packager_test.py +++ b/snowflake/ml/model/_packager/model_packager_test.py @@ -129,12 +129,9 @@ def test_model_save_validation(self) -> None: ) def test_zipimport_snowml(self) -> None: - snowml_path, snowml_start_path = file_utils.get_package_path("snowflake.ml", strategy="last") with tempfile.TemporaryDirectory() as workspace: zipped_snowml_path = os.path.join(workspace, "snowml.zip") - with open(zipped_snowml_path, "wb") as f: - with file_utils.zip_file_or_directory_to_stream(snowml_path, snowml_start_path) as zip_stream: - f.write(zip_stream.getbuffer()) + file_utils.zip_python_package(zipped_snowml_path, "snowflake.ml") sys.path.append(zipped_snowml_path) try: diff --git a/snowflake/ml/model/_signatures/pandas_handler.py b/snowflake/ml/model/_signatures/pandas_handler.py index fedc86cb..9ce26d5b 100644 --- a/snowflake/ml/model/_signatures/pandas_handler.py +++ b/snowflake/ml/model/_signatures/pandas_handler.py @@ -62,7 +62,7 @@ def validate(data: pd.DataFrame) -> None: for df_col, df_col_dtype in zip(df_cols, df_col_dtypes): if df_col_dtype == np.dtype("O"): # Check if all objects have the same type - if not all(isinstance(data_row, type(data[df_col][0])) for data_row in data[df_col]): + if not all(isinstance(data_row, type(data[df_col].iloc[0])) for data_row in data[df_col]): raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INVALID_DATA, original_exception=ValueError( @@ -70,8 +70,8 @@ def validate(data: pd.DataFrame) -> None: ), ) - if isinstance(data[df_col][0], list): - arr = utils.convert_list_to_ndarray(data[df_col][0]) + if isinstance(data[df_col].iloc[0], list): + arr = utils.convert_list_to_ndarray(data[df_col].iloc[0]) arr_dtype = core.DataType.from_numpy_type(arr.dtype) converted_data_list = [utils.convert_list_to_ndarray(data_row) for data_row in data[df_col]] @@ -88,8 +88,8 @@ def validate(data: pd.DataFrame) -> None: ), ) - elif isinstance(data[df_col][0], np.ndarray): - arr_dtype = core.DataType.from_numpy_type(data[df_col][0].dtype) + elif isinstance(data[df_col].iloc[0], np.ndarray): + arr_dtype = core.DataType.from_numpy_type(data[df_col].iloc[0].dtype) if not all(core.DataType.from_numpy_type(data_row.dtype) == arr_dtype for data_row in data[df_col]): raise snowml_exceptions.SnowflakeMLException( @@ -99,7 +99,7 @@ def validate(data: pd.DataFrame) -> None: + f"Inconsistent type of element in object found in column data {data[df_col]}." ), ) - elif not isinstance(data[df_col][0], (str, bytes)): + elif not isinstance(data[df_col].iloc[0], (str, bytes)): raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.INVALID_DATA, original_exception=ValueError( @@ -124,10 +124,10 @@ def infer_signature(data: pd.DataFrame, role: Literal["input", "output"]) -> Seq specs = [] for df_col, df_col_dtype, ft_name in zip(df_cols, df_col_dtypes, ft_names): if df_col_dtype == np.dtype("O"): - if isinstance(data[df_col][0], list): - arr = utils.convert_list_to_ndarray(data[df_col][0]) + if isinstance(data[df_col].iloc[0], list): + arr = utils.convert_list_to_ndarray(data[df_col].iloc[0]) arr_dtype = core.DataType.from_numpy_type(arr.dtype) - ft_shape = np.shape(data[df_col][0]) + ft_shape = np.shape(data[df_col].iloc[0]) converted_data_list = [utils.convert_list_to_ndarray(data_row) for data_row in data[df_col]] @@ -135,17 +135,17 @@ def infer_signature(data: pd.DataFrame, role: Literal["input", "output"]) -> Seq ft_shape = (-1,) specs.append(core.FeatureSpec(dtype=arr_dtype, name=ft_name, shape=ft_shape)) - elif isinstance(data[df_col][0], np.ndarray): - arr_dtype = core.DataType.from_numpy_type(data[df_col][0].dtype) - ft_shape = np.shape(data[df_col][0]) + elif isinstance(data[df_col].iloc[0], np.ndarray): + arr_dtype = core.DataType.from_numpy_type(data[df_col].iloc[0].dtype) + ft_shape = np.shape(data[df_col].iloc[0]) if not all(np.shape(data_row) == ft_shape for data_row in data[df_col]): ft_shape = (-1,) specs.append(core.FeatureSpec(dtype=arr_dtype, name=ft_name, shape=ft_shape)) - elif isinstance(data[df_col][0], str): + elif isinstance(data[df_col].iloc[0], str): specs.append(core.FeatureSpec(dtype=core.DataType.STRING, name=ft_name)) - elif isinstance(data[df_col][0], bytes): + elif isinstance(data[df_col].iloc[0], bytes): specs.append(core.FeatureSpec(dtype=core.DataType.BYTES, name=ft_name)) else: specs.append(core.FeatureSpec(dtype=core.DataType.from_numpy_type(df_col_dtype), name=ft_name)) diff --git a/snowflake/ml/model/_signatures/pandas_test.py b/snowflake/ml/model/_signatures/pandas_test.py index 53975f3e..28a4581a 100644 --- a/snowflake/ml/model/_signatures/pandas_test.py +++ b/snowflake/ml/model/_signatures/pandas_test.py @@ -283,6 +283,12 @@ def test_convert_to_df_pd_DataFrame(self) -> None: df2 = pd.DataFrame([[1, li], [2, li]], columns=["a", "b"]) pd.testing.assert_frame_equal(pandas_handler.PandasDataFrameHandler.convert_to_df(df1), df2) + def test_infer_signature_pd_DataFrame_with_random_row_labels(self) -> None: + df = pd.DataFrame({"input": ["1", "2", "3", "4"]}) + df.index = [10, 11, 12, 13] + df["input"] = df["input"].astype(np.dtype("O")) + pandas_handler.PandasDataFrameHandler.validate(df) + if __name__ == "__main__": absltest.main() diff --git a/snowflake/ml/model/_signatures/snowpark_handler.py b/snowflake/ml/model/_signatures/snowpark_handler.py index 4b000b87..4ada0262 100644 --- a/snowflake/ml/model/_signatures/snowpark_handler.py +++ b/snowflake/ml/model/_signatures/snowpark_handler.py @@ -6,6 +6,7 @@ from typing_extensions import TypeGuard import snowflake.snowpark +import snowflake.snowpark.functions as F import snowflake.snowpark.types as spt from snowflake.ml._internal.exceptions import ( error_codes, @@ -89,10 +90,6 @@ def convert_from_df( session: snowflake.snowpark.Session, df: pd.DataFrame, keep_order: bool = False ) -> snowflake.snowpark.DataFrame: # This method is necessary to create the Snowpark Dataframe in correct schema. - # Snowpark ignore the schema argument when providing a pandas DataFrame. - # However, in this case, if a cell of the original Dataframe is some array type, - # they will be inferred as VARIANT. - # To make sure Snowpark get the correct schema, we have to provide in a list of records. # However, in this case, the order could not be preserved. Thus, a _ID column has to be added, # if keep_order is True. # Although in this case, the column with array type can get correct ARRAY type, however, the element @@ -106,7 +103,9 @@ def convert_from_df( ) features = pandas_handler.PandasDataFrameHandler.infer_signature(df, role="input") # Role will be no effect on the column index. That is to say, the feature name is the actual column name. - schema_list = [] + sp_df = session.create_dataframe(df) + column_names = [] + columns = [] for feature in features: if isinstance(feature, core.FeatureGroupSpec): raise snowml_exceptions.SnowflakeMLException( @@ -114,21 +113,12 @@ def convert_from_df( original_exception=NotImplementedError("FeatureGroupSpec is not supported."), ) assert isinstance(feature, core.FeatureSpec), "Invalid feature kind." - schema_list.append( - spt.StructField( - identifier.get_inferred_name(feature.name), - feature.as_snowpark_type(), - nullable=df[feature.name].isnull().any(), - ) - ) + column_names.append(identifier.get_inferred_name(feature.name)) + columns.append(F.col(identifier.get_inferred_name(feature.name)).cast(feature.as_snowpark_type())) + + sp_df = sp_df.with_columns(column_names, columns) - data = df.rename(columns=identifier.get_inferred_name).to_dict("records") if keep_order: - for idx, data_item in enumerate(data): - data_item[infer_template._KEEP_ORDER_COL_NAME] = idx - schema_list.append(spt.StructField(infer_template._KEEP_ORDER_COL_NAME, spt.LongType(), nullable=False)) - sp_df = session.create_dataframe( - data, # To make sure the schema can be used, otherwise, array will become variant. - spt.StructType(schema_list), - ) + sp_df = sp_df.with_column(infer_template._KEEP_ORDER_COL_NAME, F.monotonically_increasing_id()) + return sp_df diff --git a/snowflake/ml/model/model_signature.py b/snowflake/ml/model/model_signature.py index 3d8a230b..c8f89683 100644 --- a/snowflake/ml/model/model_signature.py +++ b/snowflake/ml/model/model_signature.py @@ -61,6 +61,7 @@ def _truncate_data( """ ), category=UserWarning, + stacklevel=1, ) return handler.truncate(data) raise snowml_exceptions.SnowflakeMLException( @@ -72,7 +73,7 @@ def _truncate_data( def _infer_signature( - data: model_types.SupportedLocalDataType, role: Literal["input", "output"] + data: model_types.SupportedLocalDataType, role: Literal["input", "output"], use_snowflake_identifiers: bool = False ) -> Sequence[core.BaseFeatureSpec]: """Infer the inputs/outputs signature given a data that could be dataframe, numpy array or list. Dispatching is used to separate logic for different types. @@ -81,6 +82,8 @@ def _infer_signature( Args: data: The data that we want to infer signature from. role: a flag indicating that if this is to infer an input or output feature. + use_snowflake_identifiers: a flag indicating whether to ensure the signature names are + valid snowflake identifiers. Raises: SnowflakeMLException: NotImplementedError: Raised when an unsupported data type is provided. @@ -88,16 +91,38 @@ def _infer_signature( Returns: A sequence of feature specifications and feature group specifications. """ + signature = None for handler in _ALL_DATA_HANDLERS: if handler.can_handle(data): handler.validate(data) - return handler.infer_signature(data, role) - raise snowml_exceptions.SnowflakeMLException( - error_code=error_codes.NOT_IMPLEMENTED, - original_exception=NotImplementedError( - f"Unable to infer model signature: Un-supported type provided {type(data)} for X type inference." - ), - ) + signature = handler.infer_signature(data, role) + break + + if signature is None: + raise snowml_exceptions.SnowflakeMLException( + error_code=error_codes.NOT_IMPLEMENTED, + original_exception=NotImplementedError( + f"Unable to infer model signature: Un-supported type provided {type(data)} for X type inference." + ), + ) + + if use_snowflake_identifiers: + signature = _rename_signature_with_snowflake_identifiers(signature) + + return signature + + +def _rename_signature_with_snowflake_identifiers( + signature: Sequence[core.BaseFeatureSpec], +) -> Sequence[core.BaseFeatureSpec]: + inferred_names = [] + for feature_spec in signature: + name = identifier.rename_to_valid_snowflake_identifier(feature_spec.name) + inferred_names.append(name) + + signature = utils.rename_features(signature, inferred_names) + + return signature def _validate_numpy_array(arr: model_types._SupportedNumpyArray, feature_type: core.DataType) -> bool: @@ -311,6 +336,7 @@ def _validate_snowpark_data(data: snowflake.snowpark.DataFrame, features: Sequen f"Warn in feature {ft_name}: Nullable column {field.name} provided," + " inference might fail if there is null value.", category=RuntimeWarning, + stacklevel=1, ) if isinstance(feature, core.FeatureGroupSpec): raise snowml_exceptions.SnowflakeMLException( @@ -332,6 +358,7 @@ def _validate_snowpark_data(data: snowflake.snowpark.DataFrame, features: Sequen warnings.warn( f"Warn in feature {ft_name}: Feature is a array feature, type validation cannot happen.", category=RuntimeWarning, + stacklevel=1, ) else: if feature._shape: diff --git a/snowflake/ml/model/model_signature_test.py b/snowflake/ml/model/model_signature_test.py index 749d2a6e..ae27d7c0 100644 --- a/snowflake/ml/model/model_signature_test.py +++ b/snowflake/ml/model/model_signature_test.py @@ -72,6 +72,14 @@ def test_infer_signature(self) -> None: ], ) + self.assertListEqual( + model_signature._infer_signature(lt4, role="input", use_snowflake_identifiers=True), + [ + model_signature.FeatureSpec('"input_feature_0"', model_signature.DataType.INT64), + model_signature.FeatureSpec('"input_feature_1"', model_signature.DataType.INT64), + ], + ) + tf_tensor = tf.constant([1, 2, 3, 4], dtype=tf.int64) lt5 = [tf_tensor, tf_tensor] self.assertListEqual( diff --git a/snowflake/ml/model/models/llm.py b/snowflake/ml/model/models/llm.py index c6bf036e..7ad9f42f 100644 --- a/snowflake/ml/model/models/llm.py +++ b/snowflake/ml/model/models/llm.py @@ -29,8 +29,6 @@ class LLMOptions: revision: Optional[str] = field(default=None) token: Optional[str] = field(default=None) max_batch_size: int = field(default=1) - # TODO(halu): Debug raylet die issue. - # TP on vLLM is not supported yet. enable_tp: bool = field(default=False) # TODO(halu): Below could be per query call param instead. temperature: float = field(default=0.01) diff --git a/snowflake/ml/model/type_hints.py b/snowflake/ml/model/type_hints.py index eef1a9e7..5e757e81 100644 --- a/snowflake/ml/model/type_hints.py +++ b/snowflake/ml/model/type_hints.py @@ -1,5 +1,15 @@ # mypy: disable-error-code="import" -from typing import TYPE_CHECKING, Literal, Sequence, TypedDict, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Literal, + Optional, + Sequence, + TypedDict, + TypeVar, + Union, +) import numpy.typing as npt from typing_extensions import NotRequired, Required @@ -158,6 +168,11 @@ class SnowparkContainerServiceDeployOptions(DeployOptions): enable_remote_image_build: When set to True, will enable image build on a remote SnowService job. Default is True. force_image_build: When set to True, an image rebuild will occur. The default is False, which means the system will automatically check whether a previously built image can be reused + model_in_image: When set to True, image would container full model weights. The default if False, which + means image without model weights and we do stage mount to access weights. + debug_mode: When set to True, deployment artifacts will be persisted in a local temp directory. + enable_ingress: When set to True, will expose HTTP endpoint for access to the predict method of the created + service. """ compute_pool: str @@ -171,6 +186,12 @@ class SnowparkContainerServiceDeployOptions(DeployOptions): force_image_build: NotRequired[bool] model_in_image: NotRequired[bool] debug_mode: NotRequired[bool] + enable_ingress: NotRequired[bool] + + +class ModelMethodSaveOptions(TypedDict): + case_sensitive: NotRequired[bool] + max_batch_size: NotRequired[int] class BaseModelSaveOption(TypedDict): @@ -180,6 +201,7 @@ class BaseModelSaveOption(TypedDict): """ embed_local_ml_library: NotRequired[bool] + method_options: NotRequired[Dict[str, ModelMethodSaveOptions]] class CustomModelSaveOption(BaseModelSaveOption): @@ -256,13 +278,11 @@ class ModelLoadOption(TypedDict): class SnowparkContainerServiceDeployDetails(TypedDict): """ Attributes: - image_name: Full image name. - service_spec: YAML service spec. + service_info: A snowpark row containing the result of "describe service" service_function_sql: SQL for service function creation. """ - image_name: str - service_spec: str + service_info: Optional[Dict[str, Any]] service_function_sql: str diff --git a/snowflake/ml/modeling/_internal/estimator_protocols.py b/snowflake/ml/modeling/_internal/estimator_protocols.py index d6cf59a9..155c10e3 100644 --- a/snowflake/ml/modeling/_internal/estimator_protocols.py +++ b/snowflake/ml/modeling/_internal/estimator_protocols.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Protocol, Union +from typing import List, Optional, Protocol, Union import pandas as pd from sklearn import model_selection @@ -70,6 +70,18 @@ def score_snowpark( # TODO: Add more specific entities to type hint estimators instead of using `object`. class CVHandlers(Protocol): + def fit_snowpark( + self, + dataset: DataFrame, + session: Session, + estimator: object, + dependencies: List[str], + input_cols: List[str], + label_cols: List[str], + sample_weight_col: Optional[str], + ) -> object: + raise NotImplementedError + def fit_pandas( self, dataset: pd.DataFrame, @@ -119,7 +131,7 @@ def score_snowpark( def fit_search_snowpark( self, - param_list: Union[Dict[str, Any], List[Dict[str, Any]]], + param_grid: Union[model_selection.ParameterGrid, model_selection.ParameterSampler], dataset: DataFrame, session: Session, estimator: Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV], diff --git a/snowflake/ml/modeling/_internal/estimator_utils.py b/snowflake/ml/modeling/_internal/estimator_utils.py index dbe6b567..e08306d5 100644 --- a/snowflake/ml/modeling/_internal/estimator_utils.py +++ b/snowflake/ml/modeling/_internal/estimator_utils.py @@ -110,7 +110,7 @@ def check(self: BaseTransformer) -> TypeGuard[Callable[..., object]]: return check -def if_single_node(session: Session) -> bool: +def is_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 diff --git a/snowflake/ml/modeling/_internal/snowpark_handlers.py b/snowflake/ml/modeling/_internal/snowpark_handlers.py index 2ab5f328..79cbe5b1 100644 --- a/snowflake/ml/modeling/_internal/snowpark_handlers.py +++ b/snowflake/ml/modeling/_internal/snowpark_handlers.py @@ -45,10 +45,10 @@ StringType, StructField, StructType, - VariantType, ) cp.register_pickle_by_value(inspect.getmodule(get_temp_file_path)) +cp.register_pickle_by_value(inspect.getmodule(identifier.get_inferred_name)) _PROJECT = "ModelDevelopment" @@ -693,7 +693,7 @@ def score_wrapper_sproc( def fit_search_snowpark( self, - param_list: Union[Dict[str, Any], List[Dict[str, Any]]], + param_grid: Union[model_selection.ParameterGrid, model_selection.ParameterSampler], dataset: DataFrame, session: Session, estimator: Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV], @@ -706,7 +706,7 @@ def fit_search_snowpark( from itertools import product import cachetools - from sklearn.base import is_classifier + from sklearn.base import clone, is_classifier from sklearn.calibration import check_cv # Create one stage for data and for estimators. @@ -723,13 +723,20 @@ def fit_search_snowpark( 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 + original_refit = estimator.refit + # Create a temp file and dump the estimator to that file. estimator_file_name = get_temp_file_path() + params_to_evaluate = [] + for param_to_eval in list(param_grid): + for k, v in param_to_eval.items(): + param_to_eval[k] = [v] + params_to_evaluate.append([param_to_eval]) + 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) + cp.dump(dict(estimator=estimator, param_grid=params_to_evaluate), local_estimator_file_obj) stage_estimator_file_name = posixpath.join(temp_stage_name, os.path.basename(estimator_file_name)) sproc_statement_params = telemetry.get_function_usage_statement_params( project=_PROJECT, @@ -787,10 +794,9 @@ def _distributed_search( input_cols: List[str], label_cols: List[str], ) -> str: - import copy import os import time - from typing import Iterator, List + from typing import Iterator import cloudpickle as cp import pandas as pd @@ -811,6 +817,7 @@ def _distributed_search( for file_name in data_files ] df = pd.concat(partial_df, ignore_index=True) + df.columns = [identifier.get_inferred_name(col) for col in df.columns] X = df[input_cols] y = df[label_cols].squeeze() @@ -822,7 +829,7 @@ def _distributed_search( local_estimator_file_name, os.listdir(local_estimator_file_name)[0] ) with open(local_estimator_file_path, mode="r+b") as local_estimator_file_obj: - estimator = cp.load(local_estimator_file_obj) + estimator = cp.load(local_estimator_file_obj)["estimator"] cv_orig = check_cv(estimator.cv, y, classifier=is_classifier(estimator.estimator)) indices = [test for _, test in cv_orig.split(X, y)] @@ -842,11 +849,6 @@ def _distributed_search( 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[ @@ -854,6 +856,7 @@ def _load_data_into_udf() -> Tuple[ Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV], pd.DataFrame, int, + List[Dict[str, Any]], ]: import pyarrow.parquet as pq @@ -867,13 +870,16 @@ def _load_data_into_udf() -> Tuple[ for file_name in data_files ] df = pd.concat(partial_df, ignore_index=True) + df.columns = [identifier.get_inferred_name(col) for col in df.columns] # 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) + estimator_objects = cp.load(local_estimator_file_obj) + estimator = estimator_objects["estimator"] + params_to_evaluate = estimator_objects["param_grid"] # load indices local_indices_file_path = os.path.join( @@ -891,23 +897,22 @@ def _load_data_into_udf() -> Tuple[ 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) + return args, estimator, indices, len(df), params_to_evaluate class SearchCV: def __init__(self) -> None: - args, estimator, indices, data_length = _load_data_into_udf() + args, estimator, indices, data_length, params_to_evaluate = _load_data_into_udf() self.args = args self.estimator = estimator self.indices = indices self.data_length = data_length + self.params_to_evaluate = params_to_evaluate - def process( - self, params: List[dict], idx: int # type:ignore[type-arg] - ) -> Iterator[Tuple[str]]: + def process(self, params_idx: int, idx: int) -> Iterator[Tuple[str]]: if hasattr(estimator, "param_grid"): - self.estimator.param_grid = params + self.estimator.param_grid = self.params_to_evaluate[params_idx] else: - self.estimator.param_distributions = params + self.estimator.param_distributions = self.params_to_evaluate[params_idx] full_indices = np.array([i for i in range(self.data_length)]) test_indice = self.indices[idx] train_indice = np.setdiff1d(full_indices, test_indice) @@ -926,7 +931,7 @@ def end_partition(self) -> None: session.udtf.register( SearchCV, output_schema=StructType([StructField("CV_RESULTS", StringType())]), - input_types=[VariantType(), IntegerType()], + input_types=[IntegerType(), IntegerType()], name=random_udtf_name, packages=required_deps, # type: ignore[arg-type] replace=True, @@ -938,17 +943,17 @@ def end_partition(self) -> None: HP_TUNING = F.table_function(random_udtf_name) idx_length = int(indices_len) - params_length = len(params_to_evaluate) + params_length = len(param_grid) 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, training_indices = [], [] + for param_idx, cv_idx in product([param_index for param_index in range(params_length)], idxs): param_indices.append(param_idx) + training_indices.append(cv_idx) pd_df = pd.DataFrame( { - "PARAMS": params, - "TRAIN_IND": param_indices, + "PARAMS": param_indices, + "TRAIN_IND": training_indices, "PARAM_INDEX": [i for i in range(idx_length * params_length)], } ) @@ -961,6 +966,7 @@ def end_partition(self) -> None: # cv_result maintains the original order multimetric = False cv_results_ = dict() + scorers = set() for i, val in enumerate(results.select("CV_RESULTS").sort(col("PARAM_INDEX")).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 @@ -970,11 +976,11 @@ def end_partition(self) -> None: for k, v in each_cv_result.items(): cur_cv = i % idx_length key = k - if "split0_test" in k: + if "split0_test_" in k: # For multi-metric evaluation, the scores for all the scorers are available in the # cv_results_ dict at the keys ending with that scorer’s name ('_') # instead of '_score'. - multimetric = True if k.split("_")[-1] != "score" else False + scorers.add(k[len("split0_test_") :]) key = k.replace("split0_test", f"split{cur_cv}_test") elif k.startswith("param"): if cur_cv != 0: @@ -985,32 +991,47 @@ def end_partition(self) -> None: else: cv_results_[key] = np.concatenate([cv_results_[key], v]) + multimetric = len(scorers) > 1 # 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, + # Generally speaking, reshape all the results into the (scorers+2, 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 + scores = [ + np.reshape( + np.concatenate([cv_results_[f"split{cur_cv}_test_{score}"] for cur_cv in range(idx_length)]), + (idx_length, -1), + ) + for score in scorers + ] + 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_fit_time"], (idx_length, -1)), 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), - ), - ) + ] + + scores ) + 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] + for idx, score in enumerate(scorers): + cv_results_[f"std_test_{score}"] = std_fit_score_test_matrix[idx + 2] + cv_results_[f"mean_test_{score}"] = mean_fit_score_test_matrix[idx + 2] + # re-compute the ranking again with mean_test_. + cv_results_[f"rank_test_{score}"] = rankdata(-cv_results_[f"mean_test_{score}"], method="min") + # The best param is the highest ranking (which is 1) and we choose the first time ranking 1 appeared. + # If all scores are `nan`, `rankdata` will also produce an array of `nan` values. + # In that case, default to first index. + best_param_index = ( + np.where(cv_results_[f"rank_test_{score}"] == 1)[0][0] + if not np.isnan(cv_results_[f"rank_test_{score}"]).all() + else 0 + ) + estimator.cv_results_ = cv_results_ estimator.multimetric_ = multimetric @@ -1023,29 +1044,30 @@ def end_partition(self) -> None: else: scorers = _check_multimetric_scoring(estimator.estimator, estimator.scoring) estimator._check_refit_for_multimetric(scorers) - refit_metric = estimator.refit + refit_metric = original_refit estimator.scorer_ = scorers # check refit_metric now for a callabe scorer that is multimetric if callable(estimator.scoring) and estimator.multimetric_: - refit_metric = estimator.refit + refit_metric = original_refit # For multi-metric evaluation, store the best_index_, best_params_ and # best_score_ iff refit is one of the scorer names # In single metric evaluation, refit_metric is "score" - if estimator.refit or not estimator.multimetric_: - estimator.best_index_ = estimator._select_best_index(estimator.refit, refit_metric, cv_results_) - if not callable(estimator.refit): + if original_refit or not estimator.multimetric_: + estimator.best_index_ = estimator._select_best_index(original_refit, refit_metric, cv_results_) + if not callable(original_refit): # With a non-custom callable, we can select the best score # based on the best index estimator.best_score_ = cv_results_[f"mean_test_{refit_metric}"][estimator.best_index_] estimator.best_params_ = cv_results_["params"][best_param_index] - if refit_bool: - estimator.best_estimator_ = copy.deepcopy( - copy.deepcopy(estimator.estimator).set_params(**estimator.best_params_) + if original_refit: + estimator.best_estimator_ = clone(estimator.estimator).set_params( + **clone(estimator.best_params_, safe=False) ) + # Let the sproc use all cores to refit. estimator.n_jobs = -1 if not estimator.n_jobs else estimator.n_jobs @@ -1057,7 +1079,7 @@ def end_partition(self) -> None: 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 + estimator.refit = original_refit refit_start_time = time.time() estimator.best_estimator_.fit(**args) refit_end_time = time.time() diff --git a/snowflake/ml/modeling/framework/base.py b/snowflake/ml/modeling/framework/base.py index 6fb52d8a..df2680c3 100644 --- a/snowflake/ml/modeling/framework/base.py +++ b/snowflake/ml/modeling/framework/base.py @@ -53,11 +53,9 @@ def __init__(self) -> None: self.output_cols: List[str] = [] self.label_cols: List[str] = [] - @abstractmethod def _create_unfitted_sklearn_object(self) -> Any: raise NotImplementedError() - @abstractmethod def _create_sklearn_object(self) -> Any: raise NotImplementedError() diff --git a/snowflake/ml/modeling/model_selection/BUILD.bazel b/snowflake/ml/modeling/model_selection/BUILD.bazel index 13435714..cca563ec 100644 --- a/snowflake/ml/modeling/model_selection/BUILD.bazel +++ b/snowflake/ml/modeling/model_selection/BUILD.bazel @@ -1,11 +1,44 @@ -load("//codegen:codegen_rules.bzl", "autogen_estimators", "autogen_init_file_for_module") -load(":estimators_info.bzl", "estimator_info_list") +load("//bazel:py_rules.bzl", "py_library", "py_package") package(default_visibility = ["//visibility:public"]) -autogen_init_file_for_module(module = "sklearn.model_selection") +py_package( + name = "model_selection_pkg", + packages = ["snowflake.ml"], + deps = [ + ":grid_search_cv", + ":randomized_search_cv", + ], +) + +py_library( + name = "init", + srcs = [ + "__init__.py", + ], + deps = [ + "//snowflake/ml/_internal:init_utils", + ], +) + +py_library( + name = "grid_search_cv", + srcs = ["grid_search_cv.py"], + deps = [ + ":init", + "//snowflake/ml/_internal:telemetry", + "//snowflake/ml/_internal/exceptions", + "//snowflake/ml/modeling/_internal:snowpark_handlers", + ], +) -autogen_estimators( - estimator_info_list = estimator_info_list, - module = "sklearn.model_selection", +py_library( + name = "randomized_search_cv", + srcs = ["randomized_search_cv.py"], + deps = [ + ":init", + "//snowflake/ml/_internal:telemetry", + "//snowflake/ml/_internal/exceptions", + "//snowflake/ml/modeling/_internal:snowpark_handlers", + ], ) diff --git a/snowflake/ml/modeling/model_selection/_internal/__init__.py b/snowflake/ml/modeling/model_selection/__init__.py similarity index 100% rename from snowflake/ml/modeling/model_selection/_internal/__init__.py rename to snowflake/ml/modeling/model_selection/__init__.py diff --git a/snowflake/ml/modeling/model_selection/_internal/BUILD.bazel b/snowflake/ml/modeling/model_selection/_internal/BUILD.bazel deleted file mode 100644 index 9c39b361..00000000 --- a/snowflake/ml/modeling/model_selection/_internal/BUILD.bazel +++ /dev/null @@ -1,44 +0,0 @@ -load("//bazel:py_rules.bzl", "py_library", "py_package") - -package(default_visibility = ["//visibility:public"]) - -py_package( - name = "_internal_pkg", - packages = ["snowflake.ml"], - deps = [ - ":_grid_search_cv", - ":_randomized_search_cv", - ], -) - -py_library( - name = "init", - srcs = [ - "__init__.py", - ], - deps = [ - "//snowflake/ml/_internal:init_utils", - ], -) - -py_library( - name = "_grid_search_cv", - srcs = ["_grid_search_cv.py"], - deps = [ - ":init", - "//snowflake/ml/_internal:telemetry", - "//snowflake/ml/_internal/exceptions", - "//snowflake/ml/modeling/_internal:snowpark_handlers", - ], -) - -py_library( - name = "_randomized_search_cv", - srcs = ["_randomized_search_cv.py"], - deps = [ - ":init", - "//snowflake/ml/_internal:telemetry", - "//snowflake/ml/_internal/exceptions", - "//snowflake/ml/modeling/_internal:snowpark_handlers", - ], -) diff --git a/snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py b/snowflake/ml/modeling/model_selection/grid_search_cv.py similarity index 97% rename from snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py rename to snowflake/ml/modeling/model_selection/grid_search_cv.py index bf7c9e47..05181abe 100644 --- a/snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py +++ b/snowflake/ml/modeling/model_selection/grid_search_cv.py @@ -2,7 +2,7 @@ # 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 typing import Dict, Iterable, List, Optional, Set, Union from uuid import uuid4 import numpy as np @@ -25,7 +25,7 @@ from snowflake.ml.modeling._internal.estimator_protocols import CVHandlers from snowflake.ml.modeling._internal.estimator_utils import ( gather_dependencies, - if_single_node, + is_single_node, original_estimator_has_callable, transform_snowml_obj_to_sklearn_obj, validate_sklearn_args, @@ -207,6 +207,7 @@ class GridSearchCV(BaseTransformer): drop_input_cols: Optional[bool], default=False If set, the response of predict(), transform() methods will not contain input columns. """ + _ENABLE_DISTRIBUTED = True def __init__( # type: ignore[no-untyped-def] self, @@ -341,8 +342,8 @@ def _fit_snowpark(self, dataset: DataFrame) -> None: dataset = dataset.select(selected_cols) assert self._sklearn_object is not None - single_node = if_single_node(session) - if not single_node: + is_distributed = not is_single_node(session) and self._ENABLE_DISTRIBUTED is True + if is_distributed: # 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 [ @@ -351,7 +352,7 @@ def _fit_snowpark(self, dataset: DataFrame) -> None: ]: 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), + param_grid=ParameterGrid(self._sklearn_object.param_grid), dataset=dataset, session=session, estimator=self._sklearn_object, @@ -549,8 +550,8 @@ def predict(self, dataset: Union[DataFrame, pd.DataFrame]) -> Union[DataFrame, p super()._check_dataset_type(dataset) if isinstance(dataset, DataFrame): expected_type_inferred = "" - # when it is classifier, infer the datatype from label columns - if expected_type_inferred == "" and "predict" in self.model_signatures: + # infer the datatype from label columns + if "predict" in self.model_signatures: expected_type_inferred = convert_sp_to_sf_type( self.model_signatures["predict"].outputs[0].as_snowpark_type() ) @@ -593,19 +594,10 @@ def transform(self, dataset: Union[DataFrame, pd.DataFrame]) -> Union[DataFrame, """ super()._check_dataset_type(dataset) if isinstance(dataset, DataFrame): - expected_dtype = "" - if False: # is child of _BaseHeterogeneousEnsemble - # transform() method of HeterogeneousEnsemble estimators return responses of varying shapes - # from (n_samples, n_estimators) to (n_samples, n_estimators * n_classes) (and everything in between) - # based on init param values. We will convert that to pandas dataframe of shape (n_samples, 1) with - # each row containing a list of values. - expected_dtype = "ARRAY" - output_df = self._batch_inference( dataset=dataset, inference_method="transform", expected_output_cols_list=self.output_cols, - expected_output_cols_type=expected_dtype, ) elif isinstance(dataset, pd.DataFrame): output_df = self._sklearn_inference( @@ -864,9 +856,8 @@ def model_signatures(self) -> Dict[str, ModelSignature]: ) return self._model_signature_dict - def to_sklearn(self) -> Any: - if self._sklearn_object is None: - self._sklearn_object = self._create_sklearn_object() + def to_sklearn(self) -> sklearn.model_selection.GridSearchCV: + assert self._sklearn_object is not None return self._sklearn_object def _get_dependencies(self) -> List[str]: diff --git a/snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py b/snowflake/ml/modeling/model_selection/randomized_search_cv.py similarity index 97% rename from snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py rename to snowflake/ml/modeling/model_selection/randomized_search_cv.py index f92fe2c9..0a0ac606 100644 --- a/snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py +++ b/snowflake/ml/modeling/model_selection/randomized_search_cv.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Iterable, List, Optional, Set, Union +from typing import Dict, Iterable, List, Optional, Set, Union from uuid import uuid4 import numpy as np @@ -22,7 +22,7 @@ from snowflake.ml.modeling._internal.estimator_protocols import CVHandlers from snowflake.ml.modeling._internal.estimator_utils import ( gather_dependencies, - if_single_node, + is_single_node, original_estimator_has_callable, transform_snowml_obj_to_sklearn_obj, validate_sklearn_args, @@ -215,6 +215,7 @@ class RandomizedSearchCV(BaseTransformer): drop_input_cols: Optional[bool], default=False If set, the response of predict(), transform() methods will not contain input columns. """ + _ENABLE_DISTRIBUTED = True def __init__( # type: ignore[no-untyped-def] self, @@ -353,8 +354,8 @@ def _fit_snowpark(self, dataset: DataFrame) -> None: dataset = dataset.select(selected_cols) assert self._sklearn_object is not None - single_node = if_single_node(session) - if not single_node: + is_distributed = not is_single_node(session) and self._ENABLE_DISTRIBUTED is True + if is_distributed: # 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 [ @@ -363,7 +364,7 @@ def _fit_snowpark(self, dataset: DataFrame) -> None: ]: self._sklearn_object.estimator.n_jobs = DEFAULT_UDTF_NJOBS self._sklearn_object = self._handlers.fit_search_snowpark( - param_list=ParameterSampler( + param_grid=ParameterSampler( self._sklearn_object.param_distributions, n_iter=self._sklearn_object.n_iter, random_state=self._sklearn_object.random_state, @@ -564,8 +565,8 @@ def predict(self, dataset: Union[DataFrame, pd.DataFrame]) -> Union[DataFrame, p super()._check_dataset_type(dataset) if isinstance(dataset, DataFrame): expected_type_inferred = "" - # when it is classifier, infer the datatype from label columns - if expected_type_inferred == "" and "predict" in self.model_signatures: + # infer the datatype from label columns + if "predict" in self.model_signatures: expected_type_inferred = convert_sp_to_sf_type( self.model_signatures["predict"].outputs[0].as_snowpark_type() ) @@ -608,19 +609,10 @@ def transform(self, dataset: Union[DataFrame, pd.DataFrame]) -> Union[DataFrame, """ super()._check_dataset_type(dataset) if isinstance(dataset, DataFrame): - expected_dtype = "" - if False: # is child of _BaseHeterogeneousEnsemble - # transform() method of HeterogeneousEnsemble estimators return responses of varying shapes - # from (n_samples, n_estimators) to (n_samples, n_estimators * n_classes) (and everything in between) - # based on init param values. We will convert that to pandas dataframe of shape (n_samples, 1) with - # each row containing a list of values. - expected_dtype = "ARRAY" - output_df = self._batch_inference( dataset=dataset, inference_method="transform", expected_output_cols_list=self.output_cols, - expected_output_cols_type=expected_dtype, ) elif isinstance(dataset, pd.DataFrame): output_df = self._sklearn_inference( @@ -879,9 +871,8 @@ def model_signatures(self) -> Dict[str, ModelSignature]: ) return self._model_signature_dict - def to_sklearn(self) -> Any: - if self._sklearn_object is None: - self._sklearn_object = self._create_sklearn_object() + def to_sklearn(self) -> sklearn.model_selection.RandomizedSearchCV: + assert self._sklearn_object is not None return self._sklearn_object def _get_dependencies(self) -> List[str]: diff --git a/snowflake/ml/modeling/parameters/BUILD.bazel b/snowflake/ml/modeling/parameters/BUILD.bazel new file mode 100644 index 00000000..45de007f --- /dev/null +++ b/snowflake/ml/modeling/parameters/BUILD.bazel @@ -0,0 +1,35 @@ +load("//bazel:py_rules.bzl", "py_library", "py_package", "py_test") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "disable_distributed_hpo", + srcs = [ + "disable_distributed_hpo.py", + ], + deps = [ + "//snowflake/ml/modeling/model_selection:grid_search_cv", + "//snowflake/ml/modeling/model_selection:randomized_search_cv", + ], +) + +py_test( + name = "disable_distributed_hpo_test", + srcs = [ + "disable_distributed_hpo_test.py", + ], + deps = [ + ":disable_distributed_hpo", + "//snowflake/ml/modeling/model_selection:grid_search_cv", + "//snowflake/ml/modeling/model_selection:randomized_search_cv", + "//snowflake/ml/modeling/xgboost:xgb_classifier", + ], +) + +py_package( + name = "parameters_pkg", + packages = ["snowflake.ml"], + deps = [ + ":disable_distributed_hpo", + ], +) diff --git a/snowflake/ml/modeling/parameters/disable_distributed_hpo.py b/snowflake/ml/modeling/parameters/disable_distributed_hpo.py new file mode 100644 index 00000000..bea0113b --- /dev/null +++ b/snowflake/ml/modeling/parameters/disable_distributed_hpo.py @@ -0,0 +1,8 @@ +"""Disables the distributed implementation of Grid Search and Randomized Search CV""" +from snowflake.ml.modeling.model_selection.grid_search_cv import GridSearchCV +from snowflake.ml.modeling.model_selection.randomized_search_cv import ( + RandomizedSearchCV, +) + +GridSearchCV._ENABLE_DISTRIBUTED = False +RandomizedSearchCV._ENABLE_DISTRIBUTED = False diff --git a/snowflake/ml/modeling/parameters/disable_distributed_hpo_test.py b/snowflake/ml/modeling/parameters/disable_distributed_hpo_test.py new file mode 100644 index 00000000..aee4de00 --- /dev/null +++ b/snowflake/ml/modeling/parameters/disable_distributed_hpo_test.py @@ -0,0 +1,144 @@ +from typing import List, Optional, Union +from unittest import mock + +import pandas as pd +from absl.testing import absltest +from sklearn import model_selection +from snowflake.ml.modeling.xgboost.xgb_classifier import XGBClassifier + +from snowflake.ml.modeling.model_selection.grid_search_cv import GridSearchCV +from snowflake.ml.modeling.model_selection.randomized_search_cv import ( + RandomizedSearchCV, +) +from snowflake.snowpark import DataFrame, Session + + +class MockHandlers: + def fit_pandas( + self, + dataset: pd.DataFrame, + estimator: object, + input_cols: List[str], + label_cols: Optional[List[str]], + sample_weight_col: Optional[str], + ) -> object: + raise NotImplementedError + + def batch_inference( + self, + dataset: DataFrame, + session: Session, + estimator: object, + dependencies: List[str], + inference_method: str, + input_cols: List[str], + pass_through_columns: List[str], + expected_output_cols_list: List[str], + expected_output_cols_type: str = "", + ) -> DataFrame: + raise NotImplementedError + + def score_pandas( + self, + dataset: pd.DataFrame, + estimator: object, + input_cols: List[str], + label_cols: List[str], + sample_weight_col: Optional[str], + ) -> float: + raise NotImplementedError + + def score_snowpark( + self, + dataset: DataFrame, + session: Session, + estimator: object, + dependencies: List[str], + score_sproc_imports: List[str], + input_cols: List[str], + label_cols: List[str], + sample_weight_col: Optional[str], + ) -> float: + raise NotImplementedError + + def fit_snowpark( + self, + dataset: DataFrame, + session: Session, + estimator: object, + dependencies: List[str], + input_cols: List[str], + label_cols: List[str], + sample_weight_col: Optional[str], + ) -> object: + response_obj = mock.Mock(spec=model_selection.GridSearchCV) + response_obj.function = "FIT_SNOWPARK" + return response_obj + + def fit_search_snowpark( + self, + param_grid: Union[model_selection.ParameterGrid, model_selection.ParameterSampler], + 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]: + response_obj = mock.Mock(spec=model_selection.GridSearchCV) + response_obj.function = "FIT_SEARCH" + return response_obj + + +class DisableDistributedHPOTest(absltest.TestCase): + @mock.patch( + "snowflake.ml.modeling.model_selection.grid_search_cv.pkg_version_utils" + ".get_valid_pkg_versions_supported_in_snowflake_conda_channel" + ) + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_disable_distributed_hpo(self, is_single_node_mock: mock.Mock, pkg_version_mock: mock.Mock) -> None: + is_single_node_mock.return_value = False + pkg_version_mock.return_value = [] + mock_session = mock.MagicMock(spec=Session) + mock_dataframe = mock.MagicMock(spec=DataFrame) + mock_dataframe._session = mock_session + + estimator = XGBClassifier() + grid_search_cv = GridSearchCV(estimator=estimator, param_grid=dict(fake=[1, 2])) + grid_search_cv._handlers = MockHandlers() + + randomized_search_cv = RandomizedSearchCV(estimator=estimator, param_distributions=dict(fake=[1, 2])) + randomized_search_cv._handlers = MockHandlers() + + grid_search_cv._fit_snowpark(mock_dataframe) + randomized_search_cv._fit_snowpark(mock_dataframe) + + assert grid_search_cv._sklearn_object is not None + assert randomized_search_cv._sklearn_object is not None + self.assertTrue(grid_search_cv._sklearn_object.function, "FIT_SEARCH") + self.assertEqual(randomized_search_cv._sklearn_object.function, "FIT_SEARCH") + + # Disable distributed HPO + import snowflake.ml.modeling.parameters.disable_distributed_hpo # noqa: F401 + + self.assertFalse(GridSearchCV._ENABLE_DISTRIBUTED) + self.assertFalse(RandomizedSearchCV._ENABLE_DISTRIBUTED) + + grid_search_cv = GridSearchCV(estimator=estimator, param_grid=dict(fake=[1, 2])) + grid_search_cv._handlers = MockHandlers() + randomized_search_cv = RandomizedSearchCV(estimator=estimator, param_distributions=dict(fake=[1, 2])) + randomized_search_cv._handlers = MockHandlers() + + grid_search_cv._fit_snowpark(mock_dataframe) + randomized_search_cv._fit_snowpark(mock_dataframe) + + assert grid_search_cv._sklearn_object is not None + assert randomized_search_cv._sklearn_object is not None + self.assertTrue(grid_search_cv._sklearn_object.function, "FIT_SNOWPARK") + self.assertEqual(randomized_search_cv._sklearn_object.function, "FIT_SNOWPARK") + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/modeling/pipeline/pipeline.py b/snowflake/ml/modeling/pipeline/pipeline.py index e7327a5b..c57871f0 100644 --- a/snowflake/ml/modeling/pipeline/pipeline.py +++ b/snowflake/ml/modeling/pipeline/pipeline.py @@ -596,7 +596,7 @@ def _get_model_signatures(self, dataset: Union[snowpark.DataFrame, pd.DataFrame] self._model_signature_dict = dict() input_columns = self._get_sanitized_list_of_columns(dataset.columns) - inputs_signature = _infer_signature(dataset[input_columns], "input") + inputs_signature = _infer_signature(dataset[input_columns], "input", use_snowflake_identifiers=True) estimator_step = self._get_estimator() if estimator_step: diff --git a/snowflake/ml/modeling/preprocessing/ordinal_encoder.py b/snowflake/ml/modeling/preprocessing/ordinal_encoder.py index 760715a8..595c26b7 100644 --- a/snowflake/ml/modeling/preprocessing/ordinal_encoder.py +++ b/snowflake/ml/modeling/preprocessing/ordinal_encoder.py @@ -499,12 +499,6 @@ def _transform_snowpark(self, dataset: snowpark.DataFrame) -> snowpark.DataFrame .drop(identifier.concat_names([input_col, suffix])) ) - # in case of duplicate column, filter them - output_cols = transformed_dataset.columns - if output_col not in output_cols: - output_cols.append(output_col) - transformed_dataset = transformed_dataset[output_cols] - batch_table_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.TABLE) transformed_dataset.write.save_as_table( # type: ignore[call-overload] batch_table_name, diff --git a/snowflake/ml/packages.bzl b/snowflake/ml/packages.bzl index 1cf3b42d..8441381a 100644 --- a/snowflake/ml/packages.bzl +++ b/snowflake/ml/packages.bzl @@ -1,9 +1,11 @@ "Defines packages to be included in the releasing wheel file." PACKAGES = [ + "//snowflake/cortex:cortex_pkg", "//snowflake/ml/modeling/impute:impute_pkg", "//snowflake/ml/modeling/metrics:metrics_pkg", - "//snowflake/ml/modeling/model_selection/_internal:_internal_pkg", + "//snowflake/ml/modeling/model_selection:model_selection_pkg", + "//snowflake/ml/modeling/parameters:parameters_pkg", "//snowflake/ml/modeling/pipeline:pipeline_pkg", "//snowflake/ml/modeling/preprocessing:preprocessing_pkg", "//snowflake/ml/monitoring:monitoring_pkg", @@ -31,7 +33,6 @@ PACKAGES = [ "//snowflake/ml/modeling/lightgbm:lightgbm_pkg", "//snowflake/ml/modeling/manifold:sklearn_manifold_pkg", "//snowflake/ml/modeling/mixture:sklearn_mixture_pkg", - "//snowflake/ml/modeling/model_selection:sklearn_model_selection_pkg", "//snowflake/ml/modeling/multiclass:sklearn_multiclass_pkg", "//snowflake/ml/modeling/multioutput:sklearn_multioutput_pkg", "//snowflake/ml/modeling/naive_bayes:sklearn_naive_bayes_pkg", diff --git a/snowflake/ml/registry/model_registry.py b/snowflake/ml/registry/model_registry.py index 71bd92d5..31c89529 100644 --- a/snowflake/ml/registry/model_registry.py +++ b/snowflake/ml/registry/model_registry.py @@ -748,7 +748,6 @@ def _get_model_path( Raises: DataError: When the model cannot be found or not be restored. - NotImplementedError: For models that span multiple files. """ statement_params = self._get_statement_params(inspect.currentframe()) selected_models = self._list_selected_models(id=id, model_name=model_name, model_version=model_version) @@ -769,8 +768,6 @@ def _get_model_path( model_file_list = self._session.sql(f"LIST @{model_stage_path}").collect(statement_params=statement_params) if len(model_file_list) == 0: raise connector.DataError(f"No files in model artifact for id {id} located at {model_uri}.") - if len(model_file_list) > 1: - raise NotImplementedError("Restoring models consisting of multiple files is currently not supported.") return f"{_STAGE_PREFIX}{model_stage_path}" def _log_model_path( @@ -1400,7 +1397,7 @@ def log_model( stage_path = f"{_STAGE_PREFIX}{fully_qualified_model_stage_name}" model = cast(model_types.SupportedModelType, model) try: - module_model = model_api.save_model( # type: ignore[call-overload, misc] + model_composer = model_api.save_model( # type: ignore[call-overload, misc] name=model_name, session=self._session, stage_path=stage_path, @@ -1424,7 +1421,7 @@ def log_model( model_name=model_name, model_version=model_version, model_id=model_id, - type=module_model.packager.meta.model_type, + type=model_composer.packager.meta.model_type, uri=uri.get_uri_from_snowflake_stage_path(stage_path), description=description, tags=tags, @@ -1994,37 +1991,37 @@ def predict(self, deployment_name: str, data: Any) -> "pd.DataFrame": session=self._registry._session, deployment=di, X=data, statement_params=statement_params ) - try: - # Mypy enforce to refer to the registry for calling the function - deployment = self._registry.get_deployment( - self._model_name, self._model_version, deployment_name=deployment_name - ).collect(statement_params=statement_params)[0] - platform = deploy_platforms.TargetPlatform(deployment["TARGET_PLATFORM"]) - target_method = deployment["TARGET_METHOD"] - signature = model_signature.ModelSignature.from_dict(json.loads(deployment["SIGNATURE"])) - options_dict = cast(Dict[str, Any], json.loads(deployment["OPTIONS"])) - platform_options = { - deploy_platforms.TargetPlatform.WAREHOUSE: model_types.WarehouseDeployOptions, - deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES: ( - model_types.SnowparkContainerServiceDeployOptions - ), - } - - if platform not in platform_options: - raise ValueError(f"Unsupported target Platform: {platform}") - options = platform_options[platform](options_dict) - di = model_types.Deployment( - name=self._registry._fully_qualified_deployment_name(deployment_name), - platform=platform, - target_method=target_method, - signature=signature, - options=options, - ) - return model_api.predict( - session=self._registry._session, deployment=di, X=data, statement_params=statement_params - ) - except KeyError: + # Mypy enforce to refer to the registry for calling the function + deployment_collect = self._registry.get_deployment( + self._model_name, self._model_version, deployment_name=deployment_name + ).collect(statement_params=statement_params) + if not deployment_collect: raise ValueError(f"The deployment with name {deployment_name} haven't been deployed") + deployment = deployment_collect[0] + platform = deploy_platforms.TargetPlatform(deployment["TARGET_PLATFORM"]) + target_method = deployment["TARGET_METHOD"] + signature = model_signature.ModelSignature.from_dict(json.loads(deployment["SIGNATURE"])) + options_dict = cast(Dict[str, Any], json.loads(deployment["OPTIONS"])) + platform_options = { + deploy_platforms.TargetPlatform.WAREHOUSE: model_types.WarehouseDeployOptions, + deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES: ( + model_types.SnowparkContainerServiceDeployOptions + ), + } + + if platform not in platform_options: + raise ValueError(f"Unsupported target Platform: {platform}") + options = platform_options[platform](options_dict) + di = model_types.Deployment( + name=self._registry._fully_qualified_deployment_name(deployment_name), + platform=platform, + target_method=target_method, + signature=signature, + options=options, + ) + return model_api.predict( + session=self._registry._session, deployment=di, X=data, statement_params=statement_params + ) @telemetry.send_api_usage_telemetry( diff --git a/snowflake/ml/registry/model_registry_test.py b/snowflake/ml/registry/model_registry_test.py index 5a63380b..ba539850 100644 --- a/snowflake/ml/registry/model_registry_test.py +++ b/snowflake/ml/registry/model_registry_test.py @@ -1128,11 +1128,11 @@ def test_log_model(self) -> None: ) as mock_path: mock_model = absltest.mock.MagicMock() mock_type = absltest.mock.MagicMock() - mock_module_model = absltest.mock.MagicMock( + mock_model_composer = absltest.mock.MagicMock( packager=absltest.mock.MagicMock(meta=absltest.mock.MagicMock(model_type=mock_type)) ) with absltest.mock.patch.object( - target=_api, attribute="save_model", return_value=mock_module_model + target=_api, attribute="save_model", return_value=mock_model_composer ) as mock_save: with absltest.mock.patch.object( target=model_registry, attribute="_register_model_with_id", return_value=None 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 66669f72..06ce6bed 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 @@ -101,7 +101,8 @@ "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 database SHULIN_DB_TEST already exists. Skipping creation.\n" + "WARNING:absl:The database SHULIN_DB already exists. Skipping creation.\n", + "WARNING:absl:The schema SHULIN_DB.SHULIN_SCHEMA already exists. Skipping creation.\n" ] } ], @@ -136,8 +137,22 @@ "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" + "WARNING:snowflake.snowpark:ModelRegistry.list_models() is in private preview since 0.2.0. Do not use it in production. \n", + "/Users/shchen/micromamba/envs/snowml_1.0.12/lib/python3.10/site-packages/snowflake/ml/_internal/env_utils.py:217: UserWarning: Package requirement snowflake-snowpark-python<2,>=1.8.0 specified, while version 1.6.1 is installed. Local version will be ignored to conform to package requirement.\n", + " warnings.warn(\n", + "/Users/shchen/micromamba/envs/snowml_1.0.12/lib/python3.10/site-packages/snowflake/ml/_internal/env_utils.py:217: UserWarning: Package requirement snowflake-snowpark-python<2,>=1.8.0 specified, while version 1.6.1 is installed. Local version will be ignored to conform to package requirement.\n", + " warnings.warn(\n" ] + }, + { + "data": { + "text/plain": [ + "'\\nIf your model has been logged and you want to reference it, you can use:\\nmodel_ref = model_registry.ModelReference(\\n registry=registry, model_name=model_name, model_version=model_version\\n)\\n'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ @@ -150,7 +165,14 @@ " model_version=model_version,\n", " model=logistic_model,\n", " sample_input_data=test_features,\n", - ")" + ")\n", + "\n", + "\"\"\"\n", + "If your model has been logged and you want to reference it, you can use:\n", + "model_ref = model_registry.ModelReference(\n", + " registry=registry, model_name=model_name, model_version=model_version\n", + ")\n", + "\"\"\"" ] }, { @@ -176,7 +198,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 6, "id": "72ff114f", "metadata": {}, "outputs": [ @@ -184,14 +206,152 @@ "name": "stderr", "output_type": "stream", "text": [ - "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Similar environment detected. Using existing image sfengineering-mlplatformtest.registry.snowflakecomputing.com/shulin_db_test/shulin_db_schema/snowml_repo/d3e4770e34443205e0d53f9f84c602ba7fc2876b:latest to skip image build. To disable this feature, set 'force_image_build=True' in deployment options\n", - "WARNING:snowflake.ml.model._deploy_client.utils.snowservice_client:Best-effort log streaming from SPCS will be enabled when python logging level is set to INFO.Alternatively, you can also query the logs by running the query 'CALL SYSTEM$GET_SERVICE_LOGS('SHULIN_DB_TEST.SHULIN_DB_SCHEMA.service_919991487d4211ee92415ac3f3b698df', '0', 'inference-server')'\n" + "WARNING:snowflake.snowpark:ModelRegistry.deploy() is in private preview since 0.2.0. Do not use it in production. \n", + "INFO:snowflake.connector.cursor:query: [SHOW TABLES LIKE '_SYSTEM_REGISTRY_SCHEMA_VERSION' IN SHULIN_DB.SHULIN_SCHEMA]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 1\n", + "INFO:snowflake.connector.cursor:query: [SELECT MAX(VERSION) AS MAX_VERSION FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 1\n", + "INFO:snowflake.connector.cursor:query: [CREATE STAGE IF NOT EXISTS SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_DEPLOYMENTS_...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 1\n", + "INFO:snowflake.connector.cursor:query: [SELECT * FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_MODELS_VIEW]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 0\n", + "INFO:snowflake.connector.cursor:query: [SELECT * FROM (SELECT * FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_MODELS_V...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 1\n", + "INFO:snowflake.connector.cursor:query: [LIST @SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 9\n", + "INFO:snowflake.connector.cursor:query: [SELECT * FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_MODELS_VIEW]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 0\n", + "INFO:snowflake.connector.cursor:query: [SELECT * FROM (SELECT * FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_MODELS_V...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 1\n", + "INFO:snowflake.connector.cursor:query: [ls @SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 9\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/MANI...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/mode...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/modu...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/modu...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/modu...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/modu...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/runt...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/runt...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [GET '@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/runt...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Similar environment detected. Using existing image sfengineering-mlplatformtest.registry.snowflakecomputing.com/shulin_db/shulin_schema/snowml_repo/260e5812c5d0c81981b30ab72d53a291a894a505:latest to skip image build. To disable this feature, set 'force_image_build=True' in deployment options\n", + "INFO:snowflake.ml.model._deploy_client.utils.snowservice_client:Creating service SHULIN_DB.SHULIN_SCHEMA.service_704eb1ee858011ee9dc05ac3f3b698e0\n", + "INFO:snowflake.ml.model._deploy_client.snowservice.deploy:Wait for service SHULIN_DB.SHULIN_SCHEMA.service_704eb1ee858011ee9dc05ac3f3b698e0 to become ready...\n", + "WARNING:snowflake.ml.model._deploy_client.utils.snowservice_client:Best-effort log streaming from SPCS will be enabled when python logging level is set to INFO.Alternatively, you can also query the logs by running the query 'CALL SYSTEM$GET_SERVICE_LOGS('SHULIN_DB.SHULIN_SCHEMA.service_704eb1ee858011ee9dc05ac3f3b698e0', '0', 'inference-server')'\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:Number of CPU cores: 4\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:Setting number of workers to 9\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [1] [INFO] Starting gunicorn 21.2.0\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1)\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [1] [INFO] Using worker: uvicorn.workers.UvicornWorker\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [20] [INFO] Booting worker with pid: 20\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [21] [INFO] Booting worker with pid: 21\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [22] [INFO] Booting worker with pid: 22\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [23] [INFO] Booting worker with pid: 23\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [24] [INFO] Booting worker with pid: 24\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [26] [INFO] Booting worker with pid: 26\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [32] [INFO] Booting worker with pid: 32\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:41 +0000] [37] [INFO] Booting worker with pid: 37\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [39] [INFO] Booting worker with pid: 39\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [20] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [20] [INFO] Started server process [20]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [20] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [20] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [20] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmpb0qnwx1b/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [22] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [21] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [23] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [22] [INFO] Started server process [22]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [22] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [22] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [21] [INFO] Started server process [21]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [21] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [21] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [21] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmp7gqnk72j/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [22] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmpybmydbd8/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [23] [INFO] Started server process [23]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [23] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [23] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [23] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmplvgh7e5w/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [32] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [20] [INFO] Loading model from /tmp/tmpb0qnwx1b/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [37] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [32] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmpmfhs5e6d/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [23] [INFO] Loading model from /tmp/tmplvgh7e5w/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [37] [INFO] Started server process [37]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [37] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [32] [INFO] Started server process [32]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [32] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [32] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:42 +0000] [37] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [37] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmp756rou0h/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [21] [INFO] Loading model from /tmp/tmp7gqnk72j/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [22] [INFO] Loading model from /tmp/tmpybmydbd8/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [26] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [37] [INFO] Loading model from /tmp/tmp756rou0h/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [26] [INFO] Started server process [26]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [26] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [26] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [26] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmps97gvf92/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [32] [INFO] Loading model from /tmp/tmpmfhs5e6d/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:43 +0000] [26] [INFO] Loading model from /tmp/tmps97gvf92/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [24] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [39] [INFO] ENV: environ({'SERVICE_SERVICE_HOST': '10.102.169.166', 'KUBERNETES_SERVICE_PORT_HTTPS': '443', 'KUBERNETES_SERVICE_PORT': '443', 'ENV_NAME': 'base', 'MAMBA_USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PROTO': 'tcp', 'HOSTNAME': 'statefulset-0', 'stage_uid': '1000', 'NUM_WORKERS': 'None', 'SNOWFLAKE_PORT': '443', 'PWD': '/tmp', 'CONDA_PREFIX': '/opt/conda', 'SERVICE_SERVICE_PORT_PREDICT': '5000', 'MAMBA_ROOT_PREFIX': '/opt/conda', 'SNOWFLAKE_ACCOUNT': 'FAB02971', 'SNOWFLAKE_DATABASE': 'SHULIN_DB', 'TARGET_METHOD': 'predict', 'vol1_gid': '0', 'vol1_uid': '0', 'HOME': '/home/mambauser', 'SERVICE_PORT_5000_TCP_ADDR': '10.102.169.166', 'LANG': 'C.UTF-8', 'KUBERNETES_PORT_443_TCP': 'tcp://10.96.0.1:443', 'CONDA_PROMPT_MODIFIER': '(base) ', 'SNOWML_USE_GPU': 'false', 'SNOWFLAKE_SCHEMA': 'SHULIN_SCHEMA', 'SERVICE_PORT_5000_TCP': 'tcp://10.102.169.166:5000', 'stage_gid': '1000', 'MAMBA_EXE': '/bin/micromamba', 'SNOWFLAKE_HOST': 'snowflake.prod3.us-west-2.aws.snowflakecomputing.com', 'USER': 'mambauser', 'SERVICE_PORT_5000_TCP_PORT': '5000', 'CONDA_SHLVL': '1', 'SHLVL': '0', 'SERVICE_SERVICE_PORT': '5000', 'KUBERNETES_PORT_443_TCP_PROTO': 'tcp', 'KUBERNETES_PORT_443_TCP_ADDR': '10.96.0.1', 'CONDA_DEFAULT_ENV': 'base', 'KUBERNETES_SERVICE_HOST': '10.96.0.1', 'LC_ALL': 'C.UTF-8', 'KUBERNETES_PORT': 'tcp://10.96.0.1:443', 'KUBERNETES_PORT_443_TCP_PORT': '443', 'SERVICE_PORT': 'tcp://10.102.169.166:5000', 'PATH': '/opt/conda/bin:/opt/conda/condabin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'MODEL_ZIP_STAGE_PATH': '/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip', 'SERVER_SOFTWARE': 'gunicorn/21.2.0'})\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [24] [INFO] Started server process [24]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [24] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [24] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [24] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmp0xbrnt1_/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [39] [INFO] Started server process [39]\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [39] [INFO] Waiting for application startup.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [39] [INFO] Application startup complete.\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [39] [INFO] Extracting model zip from /SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip to /tmp/tmp_f0jegpx/extracted_model_dir\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [39] [INFO] Loading model from /tmp/tmp_f0jegpx/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:44 +0000] [24] [INFO] Loading model from /tmp/tmp0xbrnt1_/extracted_model_dir into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [26] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [21] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [23] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [20] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [32] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [22] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:46 +0000] [37] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:47 +0000] [24] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:[2023-11-17 19:35:47 +0000] [39] [INFO] Successfully loaded model into memory\n", + "INFO:snowflake.ml._internal.utils.log_stream_processor:\n", + "INFO:snowflake.ml.model._deploy_client.snowservice.deploy:Service SHULIN_DB.SHULIN_SCHEMA.service_704eb1ee858011ee9dc05ac3f3b698e0 is ready. Creating service function...\n", + "INFO:snowflake.ml.model._deploy_client.snowservice.deploy:Service function SHULIN_DB.SHULIN_SCHEMA.LOGISTIC_FUNC is created. Deployment completed successfully!\n", + "INFO:snowflake.connector.cursor:query: [INSERT INTO SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_DEPLOYMENTS ( CREATION_TIME...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:query: [SELECT * FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_MODELS_VIEW]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 0\n", + "INFO:snowflake.connector.cursor:query: [SELECT * FROM (SELECT * FROM SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_MODELS_V...]\n", + "INFO:snowflake.connector.cursor:query execution done\n", + "INFO:snowflake.connector.cursor:Number of results in first chunk: 1\n", + "INFO:snowflake.connector.cursor:query: [INSERT INTO SHULIN_DB.SHULIN_SCHEMA._SYSTEM_REGISTRY_METADATA ( ATTRIBUTE_NAME,E...]\n", + "INFO:snowflake.connector.cursor:query execution done\n" ] }, { "data": { "text/plain": [ - "{'name': 'SHULIN_DB_TEST.SHULIN_DB_SCHEMA.LOGISTIC_FUNC',\n", + "{'name': 'SHULIN_DB.SHULIN_SCHEMA.LOGISTIC_FUNC',\n", " 'platform': ,\n", " 'target_method': 'predict',\n", " 'signature': ModelSignature(\n", @@ -209,13 +369,25 @@ " \t\tFeatureSpec(dtype=DataType.DOUBLE, name='PREDICTED_TARGET')\n", " ]\n", " ),\n", - " 'options': {'compute_pool': 'REGTEST_INFERENCE_CPU_POOL'},\n", - " 'details': {'image_name': 'sfengineering-mlplatformtest.registry.snowflakecomputing.com/shulin_db_test/shulin_db_schema/snowml_repo/d3e4770e34443205e0d53f9f84c602ba7fc2876b:latest',\n", - " 'service_spec': \"spec:\\n container:\\n - env:\\n MODEL_ZIP_STAGE_PATH: SHULIN_DB_TEST.SHULIN_DB_SCHEMA.SNOWML_MODEL_919991487D4211EE92415AC3F3B698DF/model.zip\\n NUM_WORKERS: None\\n SNOWML_USE_GPU: false\\n TARGET_METHOD: predict\\n image: sfengineering-mlplatformtest.registry.snowflakecomputing.com/shulin_db_test/shulin_db_schema/snowml_repo/d3e4770e34443205e0d53f9f84c602ba7fc2876b:latest\\n name: inference-server\\n readinessProbe:\\n path: /health\\n port: 5000\\n volumeMounts:\\n - mountPath: /local/user/vol1\\n name: vol1\\n - mountPath: SHULIN_DB_TEST.SHULIN_DB_SCHEMA.SNOWML_MODEL_919991487D4211EE92415AC3F3B698DF\\n name: stage\\n endpoint:\\n - name: predict\\n port: 5000\\n volume:\\n - name: vol1\\n source: local\\n - gid: 1000\\n name: stage\\n source: '@SHULIN_DB_TEST.SHULIN_DB_SCHEMA.SNOWML_MODEL_919991487D4211EE92415AC3F3B698DF'\\n uid: 1000\\n\",\n", - " 'service_function_sql': \"\\n CREATE OR REPLACE FUNCTION SHULIN_DB_TEST.SHULIN_DB_SCHEMA.LOGISTIC_FUNC(input OBJECT)\\n RETURNS OBJECT\\n SERVICE=SHULIN_DB_TEST.SHULIN_DB_SCHEMA.service_919991487d4211ee92415ac3f3b698df\\n ENDPOINT=predict\\n \\n AS '/predict'\\n \"}}" + " 'options': {'compute_pool': 'DEV_INFERENCE_CPU_POOL', 'enable_ingress': True},\n", + " 'details': {'service_info': {'name': 'SERVICE_704EB1EE858011EE9DC05AC3F3B698E0',\n", + " 'database_name': 'SHULIN_DB',\n", + " 'schema_name': 'SHULIN_SCHEMA',\n", + " 'owner': 'ENGINEER',\n", + " 'compute_pool': 'DEV_INFERENCE_CPU_POOL',\n", + " 'spec': '---\\nspec:\\n containers:\\n - name: \"inference-server\"\\n image: \"sfengineering-mlplatformtest.registry.snowflakecomputing.com/shulin_db/shulin_schema/snowml_repo/260e5812c5d0c81981b30ab72d53a291a894a505:latest\"\\n env:\\n MODEL_ZIP_STAGE_PATH: \"/SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0/model.zip\"\\n NUM_WORKERS: \"None\"\\n SNOWML_USE_GPU: \"false\"\\n TARGET_METHOD: \"predict\"\\n readinessProbe:\\n port: 5000\\n path: \"/health\"\\n volumeMounts:\\n - name: \"vol1\"\\n mountPath: \"/local/user/vol1\"\\n - name: \"stage\"\\n mountPath: \"SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0\"\\n volumes:\\n - name: \"vol1\"\\n source: \"local\"\\n - name: \"stage\"\\n source: \"@SHULIN_DB.SHULIN_SCHEMA.SNOWML_MODEL_704EB1EE858011EE9DC05AC3F3B698E0\"\\n uid: 1000\\n gid: 1000\\n endpoints:\\n - name: \"predict\"\\n port: 5000\\n public: true\\n',\n", + " 'dns_name': 'service-704eb1ee858011ee9dc05ac3f3b698e0.shulin-schema.shulin-db.snowflakecomputing.internal',\n", + " 'public_endpoints': 'Endpoints provisioning in progress... check back in a few minutes',\n", + " 'min_instances': 1,\n", + " 'max_instances': 1,\n", + " 'auto_resume': 'true',\n", + " 'created_on': datetime.datetime(2023, 11, 17, 11, 35, 39, 754000, tzinfo=),\n", + " 'updated_on': datetime.datetime(2023, 11, 17, 11, 35, 40, 289000, tzinfo=),\n", + " 'comment': None},\n", + " 'service_function_sql': \"\\nCREATE OR REPLACE FUNCTION SHULIN_DB.SHULIN_SCHEMA.LOGISTIC_FUNC(input OBJECT)\\n RETURNS OBJECT\\n SERVICE=SHULIN_DB.SHULIN_SCHEMA.service_704eb1ee858011ee9dc05ac3f3b698e0\\n ENDPOINT=predict\\n\\n AS '/predict'\\n\"}}" ] }, - "execution_count": 10, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -224,23 +396,26 @@ "from snowflake.ml.model import deploy_platforms\n", "from snowflake import snowpark\n", "\n", - "compute_pool = \"REGTEST_INFERENCE_CPU_POOL\" # Pre-created compute pool\n", + "compute_pool = \"DEV_INFERENCE_CPU_POOL\" # Pre-created compute pool\n", "deployment_name = \"LOGISTIC_FUNC\" # Name of the resulting UDF\n", "\n", - "model_ref.deploy(\n", + "deployment_info = 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", + " \"enable_ingress\": True,\n", " #num_gpus: 1 # Specify the number of GPUs for GPU inferenc\n", " }\n", - ")" + ")\n", + "\n", + "deployment_info" ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 7, "id": "8709ee24-f7c0-458a-bc54-a2b78d5cc2cb", "metadata": {}, "outputs": [], @@ -260,10 +435,19 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 8, "id": "a5c02328", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:snowflake.snowpark:ModelReference.predict() is in private preview since 0.2.0. Do not use it in production. \n", + "WARNING:snowflake.snowpark:ModelRegistry.get_deployment() is in private preview since 1.0.1. Do not use it in production. \n", + "WARNING:snowflake.snowpark:ModelRegistry.list_deployments() is in private preview since 1.0.1. Do not use it in production. \n" + ] + }, { "data": { "text/html": [ @@ -391,7 +575,7 @@ "9 4.9 3.1 1.5 0.1 0.0" ] }, - "execution_count": 12, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -400,20 +584,130 @@ "model_ref.predict(deployment_name, test_features)" ] }, + { + "cell_type": "markdown", + "id": "add87e4c-986c-4757-a3f5-5109f66d94c6", + "metadata": {}, + "source": [ + "## Invoke Service Public Endpoint on Snowpark Container Service" + ] + }, + { + "cell_type": "markdown", + "id": "829eac06-b760-443f-aaee-0915b0208005", + "metadata": {}, + "source": [ + "### Prerequisites:\n", + "- For Limited Private Preview, the ACCOUNTADMIN of your Snowflake account must execute the following command:\n", + "```\n", + "CREATE SECURITY INTEGRATION SNOWSERVICES_INGRESS_OAUTH\n", + "TYPE=oauth\n", + "OAUTH_CLIENT=snowservices_ingress\n", + "ENABLED=true;\n", + "```\n", + "\n", + "### Notes:\n", + "- Because Snowpark Containers uses Snowflake OAuth to enable ingress, the default role of the user cannot be any of the privileged roles, including ACCOUNTADMIN, SECURITYADMIN, and ORGADMIN. For more information, see Blocking Specific Roles from Using the Integration.\n", + "\n", + "- Not everyone can access the public endpoints a service exposes. Only users in the same Snowflake account having a role with USAGE privilege on a service can access the public endpoints of the service.\n", + "\n", + "For more details, please refers to https://docs.snowflake.com/LIMITEDACCESS/snowpark-containers/working-with-services#ingress-using-a-service-from-outside-snowflake for detailed setup to enable public endpoint on Snowpark Container Service.\n", + "\n", + "\n", + "\n" + ] + }, { "cell_type": "code", "execution_count": 9, - "id": "12991f07", + "id": "5d35ef90-0a5d-4c2f-80a9-ca6d6c2eb60c", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "def get_service_endpoint():\n", + " service_info = deployment_info[\"details\"][\"service_info\"]\n", + " rows = session.sql(f'DESCRIBE SERVICE {service_info[\"database_name\"]}.{service_info[\"schema_name\"]}.{service_info[\"name\"]}').collect()\n", + " res = rows[0][\"public_endpoints\"]\n", + " if \"provisioning in progress\" in res:\n", + " raise Valuere(\"Endpoints provisioning in progress. Please retry in a few seconds\") \n", + " res_json = json.loads(res)\n", + " target_method = deployment_info[\"target_method\"]\n", + " return res_json[target_method]\n", + "\n", + "def get_session_token(session) -> str:\n", + " \"\"\"\n", + " Gets session token from Snowflake client.\n", + " \"\"\"\n", + " return session._conn._conn._rest._token_request(\"ISSUE\")[\"data\"][\"sessionToken\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "5f2c6442-1026-4761-b1cd-64a6a8bf3fa8", "metadata": {}, + "outputs": [], + "source": [ + "data = {\"data\": [[index, {\"_ID\": index, **row.to_dict()}] for index, row in test_features.iterrows()]}" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "5d90c42c-678b-449c-9b11-4ca34fa23d6b", + "metadata": { + "scrolled": true + }, "outputs": [ { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:snowflake.snowpark:ModelRegistry.delete_deployment() is in private preview since 1.0.1. Do not use it in production. \n" + "ename": "NameError", + "evalue": "name 'Valuere' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[11], line 12\u001b[0m\n\u001b[1;32m 6\u001b[0m session_token \u001b[38;5;241m=\u001b[39m get_session_token(session)\n\u001b[1;32m 7\u001b[0m headers \u001b[38;5;241m=\u001b[39m {\n\u001b[1;32m 8\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mAuthorization\u001b[39m\u001b[38;5;124m\"\u001b[39m: \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mSnowflake Token=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00msession_token\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m'\u001b[39m,\n\u001b[1;32m 9\u001b[0m }\n\u001b[0;32m---> 12\u001b[0m api_endpoint \u001b[38;5;241m=\u001b[39m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mhttps://\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[43mget_service_endpoint\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m/predict\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 14\u001b[0m res \u001b[38;5;241m=\u001b[39m requests\u001b[38;5;241m.\u001b[39mpost(api_endpoint, json\u001b[38;5;241m=\u001b[39mdata, headers\u001b[38;5;241m=\u001b[39mheaders)\n\u001b[1;32m 16\u001b[0m session\u001b[38;5;241m.\u001b[39msql(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = \u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124marrow\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mcollect()\n", + "Cell \u001b[0;32mIn[9], line 7\u001b[0m, in \u001b[0;36mget_service_endpoint\u001b[0;34m()\u001b[0m\n\u001b[1;32m 5\u001b[0m res \u001b[38;5;241m=\u001b[39m rows[\u001b[38;5;241m0\u001b[39m][\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mpublic_endpoints\u001b[39m\u001b[38;5;124m\"\u001b[39m]\n\u001b[1;32m 6\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mprovisioning in progress\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;129;01min\u001b[39;00m res:\n\u001b[0;32m----> 7\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[43mValuere\u001b[49m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mEndpoints provisioning in progress. Please retry in a few seconds\u001b[39m\u001b[38;5;124m\"\u001b[39m) \n\u001b[1;32m 8\u001b[0m res_json \u001b[38;5;241m=\u001b[39m json\u001b[38;5;241m.\u001b[39mloads(res)\n\u001b[1;32m 9\u001b[0m target_method \u001b[38;5;241m=\u001b[39m deployment_info[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mtarget_method\u001b[39m\u001b[38;5;124m\"\u001b[39m]\n", + "\u001b[0;31mNameError\u001b[0m: name 'Valuere' is not defined" ] } ], + "source": [ + "import requests\n", + "\n", + "# Temporarily reset PYTHON_CONNECTOR_QUERY_RESULT_FORMAT needed for obtaining session token. \n", + "session.sql(\"ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'json'\").collect()\n", + "\n", + "session_token = get_session_token(session)\n", + "headers = {\n", + " \"Authorization\": f'Snowflake Token=\"{session_token}\"',\n", + "}\n", + "\n", + "\n", + "api_endpoint = f\"https://{get_service_endpoint()}/predict\"\n", + "\n", + "res = requests.post(api_endpoint, json=data, headers=headers)\n", + "\n", + "session.sql(\"ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'\").collect()\n", + "\n", + "res.json()[\"da\"]\n" + ] + }, + { + "cell_type": "markdown", + "id": "2b45f922-7b36-4555-a148-e78af0a4cf5d", + "metadata": {}, + "source": [ + "## Cleanup " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12991f07", + "metadata": {}, + "outputs": [], "source": [ "model_ref.delete_deployment(deployment_name=deployment_name)" ] diff --git a/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb b/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb index 8bc24c03..2a65e89d 100644 --- a/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb +++ b/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb @@ -2,233 +2,45 @@ "cells": [ { "cell_type": "markdown", - "id": "fa0e355f", + "id": "a91e831d-5778-4321-87c2-2a4f3550b189", "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" + "# LLM Pretrain or Finetune Model Workflow for Model Registry" ] }, { - "cell_type": "code", - "execution_count": 4, - "id": "255a02dd-9208-4489-9468-fb98231e859b", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[33mWARNING: Ignoring invalid distribution -ackaging (/opt/conda/envs/pytorch/lib/python3.10/site-packages)\u001b[0m\u001b[33m\n", - "\u001b[0mCollecting snowflake-snowpark-python==1.8.0\n", - " Using cached snowflake_snowpark_python-1.8.0-py3-none-any.whl (326 kB)\n", - "Collecting setuptools>=40.6.0 (from snowflake-snowpark-python==1.8.0)\n", - " Using cached setuptools-68.2.2-py3-none-any.whl (807 kB)\n", - "Collecting wheel (from snowflake-snowpark-python==1.8.0)\n", - " Using cached wheel-0.41.3-py3-none-any.whl (65 kB)\n", - "Collecting snowflake-connector-python<4.0.0,>=3.2.0 (from snowflake-snowpark-python==1.8.0)\n", - " Using cached snowflake_connector_python-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (26.6 MB)\n", - "Collecting pyyaml (from snowflake-snowpark-python==1.8.0)\n", - " Using cached PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (705 kB)\n", - "Collecting cloudpickle<=2.0.0,>=1.6.0 (from snowflake-snowpark-python==1.8.0)\n", - " Using cached cloudpickle-2.0.0-py3-none-any.whl (25 kB)\n", - "Collecting asn1crypto<2.0.0,>0.24.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached asn1crypto-1.5.1-py2.py3-none-any.whl (105 kB)\n", - "Collecting cffi<2.0.0,>=1.9 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (443 kB)\n", - "Collecting cryptography<42.0.0,>=3.1.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl (4.4 MB)\n", - "Collecting oscrypto<2.0.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached oscrypto-1.3.0-py2.py3-none-any.whl (194 kB)\n", - "Collecting pyOpenSSL<24.0.0,>=16.2.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached pyOpenSSL-23.3.0-py3-none-any.whl (58 kB)\n", - "Collecting pycryptodomex!=3.5.0,<4.0.0,>=3.2 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached pycryptodomex-3.19.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.1 MB)\n", - "Collecting pyjwt<3.0.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached PyJWT-2.8.0-py3-none-any.whl (22 kB)\n", - "Collecting pytz (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached pytz-2023.3.post1-py2.py3-none-any.whl (502 kB)\n", - "Collecting requests<3.0.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached requests-2.31.0-py3-none-any.whl (62 kB)\n", - "Collecting packaging (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached packaging-23.2-py3-none-any.whl (53 kB)\n", - "Collecting charset-normalizer<4,>=2 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (142 kB)\n", - "Collecting idna<4,>=2.5 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached idna-3.4-py3-none-any.whl (61 kB)\n", - "Collecting urllib3<1.27,>=1.21.1 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached urllib3-1.26.18-py2.py3-none-any.whl (143 kB)\n", - "Collecting certifi>=2017.4.17 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached certifi-2023.7.22-py3-none-any.whl (158 kB)\n", - "Collecting typing-extensions<5,>=4.3 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached typing_extensions-4.8.0-py3-none-any.whl (31 kB)\n", - "Collecting filelock<4,>=3.5 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached filelock-3.13.1-py3-none-any.whl (11 kB)\n", - "Collecting sortedcontainers>=2.4.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached sortedcontainers-2.4.0-py2.py3-none-any.whl (29 kB)\n", - "Collecting platformdirs<4.0.0,>=2.6.0 (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached platformdirs-3.11.0-py3-none-any.whl (17 kB)\n", - "Collecting tomlkit (from snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached tomlkit-0.12.1-py3-none-any.whl (37 kB)\n", - "Collecting pycparser (from cffi<2.0.0,>=1.9->snowflake-connector-python<4.0.0,>=3.2.0->snowflake-snowpark-python==1.8.0)\n", - " Using cached pycparser-2.21-py2.py3-none-any.whl (118 kB)\n", - "\u001b[33mWARNING: Ignoring invalid distribution -ackaging (/opt/conda/envs/pytorch/lib/python3.10/site-packages)\u001b[0m\u001b[33m\n", - "\u001b[0mInstalling collected packages: sortedcontainers, pytz, asn1crypto, wheel, urllib3, typing-extensions, tomlkit, setuptools, pyyaml, pyjwt, pycryptodomex, pycparser, platformdirs, packaging, oscrypto, idna, filelock, cloudpickle, charset-normalizer, certifi, requests, cffi, cryptography, pyOpenSSL, snowflake-connector-python, snowflake-snowpark-python\n", - " Attempting uninstall: sortedcontainers\n", - " Found existing installation: sortedcontainers 2.4.0\n", - " Uninstalling sortedcontainers-2.4.0:\n", - " Successfully uninstalled sortedcontainers-2.4.0\n", - " Attempting uninstall: pytz\n", - " Found existing installation: pytz 2023.3.post1\n", - " Uninstalling pytz-2023.3.post1:\n", - " Successfully uninstalled pytz-2023.3.post1\n", - " Attempting uninstall: asn1crypto\n", - " Found existing installation: asn1crypto 1.5.1\n", - " Uninstalling asn1crypto-1.5.1:\n", - " Successfully uninstalled asn1crypto-1.5.1\n", - " Attempting uninstall: wheel\n", - " Found existing installation: wheel 0.41.3\n", - " Uninstalling wheel-0.41.3:\n", - " Successfully uninstalled wheel-0.41.3\n", - " Attempting uninstall: urllib3\n", - " Found existing installation: urllib3 1.26.18\n", - " Uninstalling urllib3-1.26.18:\n", - " Successfully uninstalled urllib3-1.26.18\n", - " Attempting uninstall: typing-extensions\n", - " Found existing installation: typing_extensions 4.8.0\n", - " Uninstalling typing_extensions-4.8.0:\n", - " Successfully uninstalled typing_extensions-4.8.0\n", - " Attempting uninstall: tomlkit\n", - " Found existing installation: tomlkit 0.12.1\n", - " Uninstalling tomlkit-0.12.1:\n", - " Successfully uninstalled tomlkit-0.12.1\n", - " Attempting uninstall: setuptools\n", - " Found existing installation: setuptools 68.2.2\n", - " Uninstalling setuptools-68.2.2:\n", - " Successfully uninstalled setuptools-68.2.2\n", - " Attempting uninstall: pyyaml\n", - " Found existing installation: PyYAML 6.0.1\n", - " Uninstalling PyYAML-6.0.1:\n", - " Successfully uninstalled PyYAML-6.0.1\n", - " Attempting uninstall: pyjwt\n", - " Found existing installation: PyJWT 2.8.0\n", - " Uninstalling PyJWT-2.8.0:\n", - " Successfully uninstalled PyJWT-2.8.0\n", - " Attempting uninstall: pycryptodomex\n", - " Found existing installation: pycryptodomex 3.19.0\n", - " Uninstalling pycryptodomex-3.19.0:\n", - " Successfully uninstalled pycryptodomex-3.19.0\n", - " Attempting uninstall: pycparser\n", - " Found existing installation: pycparser 2.21\n", - " Uninstalling pycparser-2.21:\n", - " Successfully uninstalled pycparser-2.21\n", - " Attempting uninstall: platformdirs\n", - " Found existing installation: platformdirs 3.11.0\n", - " Uninstalling platformdirs-3.11.0:\n", - " Successfully uninstalled platformdirs-3.11.0\n", - " Attempting uninstall: packaging\n", - " Found existing installation: packaging 23.2\n", - " Uninstalling packaging-23.2:\n", - " Successfully uninstalled packaging-23.2\n", - " Attempting uninstall: oscrypto\n", - " Found existing installation: oscrypto 1.3.0\n", - " Uninstalling oscrypto-1.3.0:\n", - " Successfully uninstalled oscrypto-1.3.0\n", - " Attempting uninstall: idna\n", - " Found existing installation: idna 3.4\n", - " Uninstalling idna-3.4:\n", - " Successfully uninstalled idna-3.4\n", - " Attempting uninstall: filelock\n", - " Found existing installation: filelock 3.12.2\n", - " Uninstalling filelock-3.12.2:\n", - " Successfully uninstalled filelock-3.12.2\n", - " Attempting uninstall: cloudpickle\n", - " Found existing installation: cloudpickle 2.0.0\n", - " Uninstalling cloudpickle-2.0.0:\n", - " Successfully uninstalled cloudpickle-2.0.0\n", - " Attempting uninstall: charset-normalizer\n", - " Found existing installation: charset-normalizer 3.1.0\n", - " Uninstalling charset-normalizer-3.1.0:\n", - " Successfully uninstalled charset-normalizer-3.1.0\n", - " Attempting uninstall: certifi\n", - " Found existing installation: certifi 2023.5.7\n", - " Uninstalling certifi-2023.5.7:\n", - " Successfully uninstalled certifi-2023.5.7\n", - " Attempting uninstall: requests\n", - " Found existing installation: requests 2.31.0\n", - " Uninstalling requests-2.31.0:\n", - " Successfully uninstalled requests-2.31.0\n", - " Attempting uninstall: cffi\n", - " Found existing installation: cffi 1.15.1\n", - " Uninstalling cffi-1.15.1:\n", - " Successfully uninstalled cffi-1.15.1\n", - " Attempting uninstall: cryptography\n", - " Found existing installation: cryptography 39.0.2\n", - " Uninstalling cryptography-39.0.2:\n", - " Successfully uninstalled cryptography-39.0.2\n", - " Attempting uninstall: pyOpenSSL\n", - " Found existing installation: pyOpenSSL 23.2.0\n", - " Uninstalling pyOpenSSL-23.2.0:\n", - " Successfully uninstalled pyOpenSSL-23.2.0\n", - " Attempting uninstall: snowflake-connector-python\n", - " Found existing installation: snowflake-connector-python 3.3.1\n", - " Uninstalling snowflake-connector-python-3.3.1:\n", - " Successfully uninstalled snowflake-connector-python-3.3.1\n", - " Attempting uninstall: snowflake-snowpark-python\n", - " Found existing installation: snowflake-snowpark-python 1.9.0\n", - " Uninstalling snowflake-snowpark-python-1.9.0:\n", - " Successfully uninstalled snowflake-snowpark-python-1.9.0\n", - "\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\n", - "triton 2.0.0 requires cmake, which is not installed.\n", - "triton 2.0.0 requires lit, which is not installed.\n", - "awscli 1.27.151 requires botocore==1.29.151, but you have botocore 1.31.17 which is incompatible.\n", - "awscli 1.27.151 requires PyYAML<5.5,>=3.10, but you have pyyaml 6.0.1 which is incompatible.\n", - "sagemaker 2.164.0 requires cloudpickle==2.2.1, but you have cloudpickle 2.0.0 which is incompatible.\n", - "sagemaker 2.164.0 requires PyYAML==6.0, but you have pyyaml 6.0.1 which is incompatible.\u001b[0m\u001b[31m\n", - "\u001b[0mSuccessfully installed asn1crypto-1.5.1 certifi-2023.7.22 cffi-1.16.0 charset-normalizer-3.3.2 cloudpickle-2.0.0 cryptography-41.0.5 filelock-3.13.1 idna-3.4 oscrypto-1.3.0 packaging-23.2 platformdirs-3.11.0 pyOpenSSL-23.3.0 pycparser-2.21 pycryptodomex-3.19.0 pyjwt-2.8.0 pytz-2023.3.post1 pyyaml-6.0.1 requests-2.31.0 setuptools-68.2.2 snowflake-connector-python-3.3.1 snowflake-snowpark-python-1.8.0 sortedcontainers-2.4.0 tomlkit-0.12.1 typing-extensions-4.8.0 urllib3-1.26.18 wheel-0.41.3\n" - ] - } - ], + "cell_type": "markdown", + "id": "024a8eb0-8306-4220-b25d-209aac880586", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "markdown", + "id": "fa0e355f", + "metadata": {}, "source": [ - "!pip install --upgrade --force-reinstall snowflake-snowpark-python==1.8.0" + "* Create a python3.8 conda env\n", + "`conda create --name {your_preferred_env_name} python=3.8`\n", + "* And, then install the latest snowparkML python package(minimum 1.0.12)" ] }, { "cell_type": "code", - "execution_count": 45, + "execution_count": null, "id": "1ed66db9", "metadata": { "scrolled": true }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[33mWARNING: Ignoring invalid distribution -ackaging (/opt/conda/envs/pytorch/lib/python3.10/site-packages)\u001b[0m\u001b[33m\n", - "\u001b[0mProcessing /home/ubuntu/snowml/bazel-bin/snowflake/ml/snowflake_ml_python-1.0.12-py3-none-any.whl\n", - "Installing collected packages: snowflake-ml-python\n", - " Attempting uninstall: snowflake-ml-python\n", - " Found existing installation: snowflake-ml-python 1.0.12\n", - " Uninstalling snowflake-ml-python-1.0.12:\n", - " Successfully uninstalled snowflake-ml-python-1.0.12\n", - "Successfully installed snowflake-ml-python-1.0.12\n" - ] - } - ], + "outputs": [], "source": [ "!pip install --force-reinstall --no-deps /home/ubuntu/snowml/bazel-bin/snowflake/ml/snowflake_ml_python-1.0.12-py3-none-any.whl" ] }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "292e9f48", "metadata": {}, "outputs": [ @@ -255,32 +67,17 @@ }, { "cell_type": "code", - "execution_count": 2, - "id": "4c6b1310-9941-4ba3-b126-6e58c01fb613", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[33mWARNING: Ignoring invalid distribution -ackaging (/opt/conda/envs/pytorch/lib/python3.10/site-packages)\u001b[0m\u001b[33m\n", - "\u001b[0msnowflake-snowpark-python 1.8.0\n" - ] - } - ], - "source": [ - "! pip list | grep snowpark" - ] - }, - { - "cell_type": "code", - "execution_count": 3, + "execution_count": 23, "id": "7585077b", "metadata": {}, "outputs": [], "source": [ "from snowflake.snowpark import Session\n", - "from snowflake.ml.utils.connection_params import SnowflakeLoginOptions" + "from snowflake.ml.utils.connection_params import SnowflakeLoginOptions\n", + "import pandas as pd\n", + "from snowflake.ml.model.models import llm\n", + "from snowflake.ml.registry import model_registry\n", + "from IPython.display import JSON" ] }, { @@ -293,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "f876232e", "metadata": {}, "outputs": [ @@ -311,30 +108,19 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "c6aee8c9", "metadata": { "scrolled": true }, - "outputs": [ - { - "data": { - "text/plain": [ - "('\"HALU_FT\"', '\"PUBLIC\"')" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "session.get_current_database(), session.get_current_schema()" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 9, "id": "72c16c14", "metadata": {}, "outputs": [], @@ -345,7 +131,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 10, "id": "c420807b", "metadata": {}, "outputs": [ @@ -360,7 +146,6 @@ } ], "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", @@ -370,26 +155,59 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "7d104673-c6fa-4eff-bec1-230c1d783881", + "metadata": {}, + "source": [ + "# Registry opertions" + ] + }, + { + "cell_type": "markdown", + "id": "6956d692-92c2-474f-9b5b-d69c2ef4e364", + "metadata": {}, + "source": [ + "## Define the model" + ] + }, + { + "cell_type": "markdown", + "id": "83ca550b-971d-46ba-a332-8218bc75ae00", + "metadata": {}, + "source": [ + "### Case1: Local Lora Finetune Weights\n", + "Lora finetune weights by huggingface PEFT library is supported." + ] + }, { "cell_type": "code", - "execution_count": 47, - "id": "0adc9637", + "execution_count": 14, + "id": "3b1d6e92-cb09-4576-a534-18522a040390", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "adapter_config.json adapter_model.bin\thalu_peft_ft training_args.bin\n" + ] + } + ], "source": [ - "from snowflake.ml.model.models import llm" + "!ls /home/ubuntu/projects/test_ft_weights" ] }, { "cell_type": "code", - "execution_count": 48, + "execution_count": 36, "id": "18323af6", "metadata": {}, "outputs": [], "source": [ "options = llm.LLMOptions(\n", " token=\"...\",\n", - " max_batch_size=20,\n", + " max_batch_size=100,\n", ")\n", "model = llm.LLM(\n", " model_id_or_path=\"/home/ubuntu/projects/test_ft_weights\",\n", @@ -397,24 +215,67 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "01883785-576f-435e-a5cb-dfd3243b75c6", + "metadata": {}, + "source": [ + "### Case2: Pretrain models" + ] + }, { "cell_type": "code", - "execution_count": 50, - "id": "dac3fc56", + "execution_count": 17, + "id": "b55dd0d5-b76e-48c8-bd89-f1f2ebe559de", + "metadata": {}, + "outputs": [], + "source": [ + "options = llm.LLMOptions(\n", + " token=\"...\",\n", + " max_batch_size=100, \n", + ")\n", + "model = llm.LLM(\n", + " model_id_or_path=\"meta-llama/Llama-2-7b-chat-hf\",\n", + " options=options\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "23dcb9cf-975d-49b6-90c7-119969d94f9d", "metadata": {}, + "source": [ + "## Log model" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "dac3fc56", + "metadata": { + "scrolled": true + }, "outputs": [], "source": [ "svc_model = registry.log_model(\n", - " model_name='build_demo_1101',\n", - " model_version='v5',\n", + " model_name='llm_notebook_ft',\n", + " model_version='v1',\n", " model=model,\n", " options={\"embed_local_ml_library\": True},\n", ")" ] }, + { + "cell_type": "markdown", + "id": "c725ec11-8a30-467f-813f-261971ec65fd", + "metadata": {}, + "source": [ + "## Deploy" + ] + }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 45, "id": "b17b1fbb", "metadata": { "scrolled": true @@ -424,40 +285,12 @@ "name": "stderr", "output_type": "stream", "text": [ - "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Debug model is enabled, deployment artifacts will be available in /tmp/tmpqkmmoahf\n", - "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Building the Docker image and deploying to Snowpark Container Service. This process may take a few minutes.\n", - "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Image successfully built! For future model deployments, the image will be reused if possible, saving model deployment time. To enforce using the same image, include 'prebuilt_snowflake_image': 'sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/50e52d564ecc126d1f53452aa4dd734efa4e3a0a:latest' in the deploy() function's options.\n", - "WARNING:urllib3.connectionpool:Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'ProtocolError('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))': /login\n" + "/opt/conda/envs/pytorch/lib/python3.10/site-packages/snowflake/ml/model/_packager/model_env/model_env.py:353: UserWarning: Found dependencies specified as pip requirements. This may prevent model deploying to Snowflake Warehouse.\n", + " warnings.warn(\n", + "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Debug model is enabled, deployment artifacts will be available in /tmp/tmpyp2rz595\n", + "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/c125a958091b70d924d69b379b55ee20cbd8157e:latest to skip image build. To disable this feature, set 'force_image_build=True' in deployment options\n", + "WARNING:snowflake.ml.model._deploy_client.utils.snowservice_client:Best-effort log streaming from SPCS will be enabled when python logging level is set to INFO.Alternatively, you can also query the logs by running the query 'CALL SYSTEM$GET_SERVICE_LOGS('HALU_MR.PUBLIC.service_1a0ec2427e5511eea17e06f9498c0da3', '0', 'inference-server')'\n" ] - }, - { - "data": { - "text/plain": [ - "{'name': 'HALU_MR.PUBLIC.build_demo_1101_4',\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", - " 'debug_mode': True},\n", - " 'details': {'image_name': 'sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/50e52d564ecc126d1f53452aa4dd734efa4e3a0a:latest',\n", - " 'service_spec': 'spec:\\n container:\\n - env:\\n NUM_WORKERS: 1\\n SNOWML_USE_GPU: true\\n TARGET_METHOD: infer\\n _CONCURRENT_REQUESTS_MAX: 1\\n image: sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/50e52d564ecc126d1f53452aa4dd734efa4e3a0a: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.build_demo_1101_4(input OBJECT)\\n RETURNS OBJECT\\n SERVICE=HALU_MR.PUBLIC.service_3b9880c078d711ee861c06f9498c0da3\\n ENDPOINT=predict\\n MAX_BATCH_ROWS = 20\\n AS '/predict'\\n \"}}" - ] - }, - "execution_count": 51, - "metadata": {}, - "output_type": "execute_result" } ], "source": [ @@ -468,31 +301,28 @@ " \"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", " \"debug_mode\": True,\n", - " #'prebuilt_snowflake_image': 'sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/93d14fc687640746235f8f880a6af8c730ce3eaf:latest'\n", "}\n", " \n", "deploy_info = svc_model.deploy(\n", - " deployment_name=\"build_demo_1101_4\",\n", + " deployment_name=\"llm_notebook_ft_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": null, - "id": "f14753df-6fdc-422e-864d-10d93fbc05a6", + "cell_type": "markdown", + "id": "4a80b8bb-2191-4342-acc4-f44f817271c3", "metadata": {}, - "outputs": [], - "source": [] + "source": [ + "## Prediction" + ] }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 20, "id": "9475d6cd-5222-4bcb-9883-9d8924354d6a", "metadata": {}, "outputs": [], @@ -516,43 +346,42 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "484f44fe-bf03-497d-97b3-147fcb4074c3", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 28, - "id": "b25baf1c", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 26, - "id": "2a84b44b", + "execution_count": 21, + "id": "d067a009-567d-4869-9e8a-44694e169cc0", "metadata": {}, "outputs": [], "source": [ - "import pandas as pd" + "df = pd.read_json('/home/ubuntu/projects/v8.jsonl', lines=True)" ] }, { "cell_type": "code", "execution_count": 27, - "id": "d067a009-567d-4869-9e8a-44694e169cc0", + "id": "8ca2e858-fa83-44d9-aca5-8a64ccc78975", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "{'language': 'EN',\n", + " 'transcript': \"caller: Hello!\\nfrosty: Well, hello! Who's spreading holiday cheer with me today?\\ncaller: I'm Max from Sydney.\\nfrosty: Hello, Max! Can you tell me what's on your wish list this holiday?\\ncaller: Hmm, I am not sure. I guess I like cars.\\nfrosty: We have a fun Bluey car. It's very cool. And also, there's a Teenage Mutant Ninja Turtles pizza delivery van! It's really fun.\\ncaller: Oh, the bluey car sounds cool.\\nfrosty: Great choice, Max! By the way, how do you plan to celebrate the holiday season with your family?\\ncaller: We're going to the beach! It's summer here in Sydney.\\nfrosty: Oh, that sounds wonderful, Max. So, we will put the Bluey car on your holiday wish list, okay?\\ncaller: Yes, please!\\nfrosty: It’s all done. I hope your holiday is filled with joy and fun!\",\n", + " 'name': 'Max',\n", + " 'location': 'Sydney',\n", + " 'toy_list': ['Bluey Convertible and Figures']}" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "df = pd.read_json('/home/ubuntu/projects/v8.jsonl', lines=True)" + "df.iloc[0].to_dict()" ] }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 28, "id": "fdbfa6ef-e179-44e1-898b-8c103cf09d4d", "metadata": {}, "outputs": [], @@ -562,7 +391,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 29, "id": "c484ec44-672d-4269-830b-42ec037cef13", "metadata": {}, "outputs": [], @@ -572,7 +401,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 30, "id": "e8377d97-a70d-4709-b1c7-ea638634a557", "metadata": {}, "outputs": [], @@ -582,131 +411,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 40, "id": "3477c070-f067-471e-83b1-302dfec392b9", "metadata": {}, "outputs": [], "source": [ "res = svc_model.predict(\n", - " deployment_name='build_demo_1101_2',\n", - " data=input_df\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3df9106f-30f4-4de5-b731-68d3d71901e9", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ac42369c", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9e0da3cd-c983-4b12-b7ca-bc038a75ff9b", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7fb574d1-2671-46d4-baa4-659951b1f4cc", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d4afd2ce-aaec-43d1-8961-a484964e2997", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "702e9749-e8e3-432c-9fb6-d083794fbe0b", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "65f84287-550f-4190-94c0-59b16aa64880", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5babe0a7-1272-4ca0-8f23-d9c57da21fce", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d2b93fa3-97ae-422a-949d-5f3d72037ad9", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4a4f591b-48d8-4187-8ffa-1ba747800ee1", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a0ac832c-e8c9-4083-9429-e8050dd2b215", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "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", + " deployment_name='llm_notebook_ft_1',\n", " data=input_df\n", ")" ] }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 42, "id": "f32e6498", "metadata": {}, "outputs": [], @@ -716,7 +434,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 43, "id": "a467afb6", "metadata": {}, "outputs": [ @@ -747,28 +465,63 @@ " \n", " \n", " 0\n", - " {\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}\n", + " {\"toy_list\": [\"Bluey Convertible and Figures\", \"Teenage Mutant Ninja Turtles: Mutant Mayhem Pizza Fire Delivery Van\"], \"location\": \"Sydney\"}\n", " \n", " \n", " 1\n", - " {\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}\n", + " {\"toy_list\": [\"Furby interactive plush toy\", \"Transformers Rise of the Beasts Beast-Mode Bumblebee\"], \"location\": \"London\"}\n", " \n", " \n", " 2\n", - " {\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}\n", + " {\"toy_list\": [\"Teenage Mutant Ninja Turtles: Mutant Mayhem Pizza Fire Delivery Van\"], \"location\": \"Auckland\"}\n", + " \n", + " \n", + " 3\n", + " {\"toy_list\": [\"Transformers Rise of the Beasts Beast-Mode Bumblebee\"], \"location\": \"Denver\"}\n", + " \n", + " \n", + " 4\n", + " {\"toy_list\": [\"Fingerlings\", \"Barbie Dreamhouse 2023\"], \"location\": \"Sydney\"}\n", + " \n", + " \n", + " 5\n", + " {\"toy_list\": [\"Barbie Science Lab Playset\", \"Furby interactive plush toy\"], \"location\": \"Houston, Texas\"}\n", + " \n", + " \n", + " 6\n", + " {\"toy_list\": [\"Star Wars LOLA animatronic droid\", \"Bluey Convertible and Figures\"], \"location\": \"Sydney\"}\n", + " \n", + " \n", + " 7\n", + " {\"toy_list\": [\"Teenage Mutant Ninja Turtles: Mutant Mayhem Pizza Fire Delivery Van\", \"Bitzee interactive pet\"], \"location\": \"Dublin\"}\n", + " \n", + " \n", + " 8\n", + " {\"toy_list\": [\"Barbie Science Lab Playset\"], \"location\": \"Melbourne, Australia\"}\n", + " \n", + " \n", + " 9\n", + " {\"toy_list\": [\"Sesame Street Monster Meditation Elmo\"], \"location\": \"Toronto\"}\n", " \n", " \n", "\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\"}" + " generated_text\n", + "0 {\"toy_list\": [\"Bluey Convertible and Figures\", \"Teenage Mutant Ninja Turtles: Mutant Mayhem Pizza Fire Delivery Van\"], \"location\": \"Sydney\"}\n", + "1 {\"toy_list\": [\"Furby interactive plush toy\", \"Transformers Rise of the Beasts Beast-Mode Bumblebee\"], \"location\": \"London\"}\n", + "2 {\"toy_list\": [\"Teenage Mutant Ninja Turtles: Mutant Mayhem Pizza Fire Delivery Van\"], \"location\": \"Auckland\"}\n", + "3 {\"toy_list\": [\"Transformers Rise of the Beasts Beast-Mode Bumblebee\"], \"location\": \"Denver\"}\n", + "4 {\"toy_list\": [\"Fingerlings\", \"Barbie Dreamhouse 2023\"], \"location\": \"Sydney\"}\n", + "5 {\"toy_list\": [\"Barbie Science Lab Playset\", \"Furby interactive plush toy\"], \"location\": \"Houston, Texas\"}\n", + "6 {\"toy_list\": [\"Star Wars LOLA animatronic droid\", \"Bluey Convertible and Figures\"], \"location\": \"Sydney\"}\n", + "7 {\"toy_list\": [\"Teenage Mutant Ninja Turtles: Mutant Mayhem Pizza Fire Delivery Van\", \"Bitzee interactive pet\"], \"location\": \"Dublin\"}\n", + "8 {\"toy_list\": [\"Barbie Science Lab Playset\"], \"location\": \"Melbourne, Australia\"}\n", + "9 {\"toy_list\": [\"Sesame Street Monster Meditation Elmo\"], \"location\": \"Toronto\"}" ] }, - "execution_count": 33, + "execution_count": 43, "metadata": {}, "output_type": "execute_result" } @@ -776,6 +529,16 @@ "source": [ "res" ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "dcb3b9b1-16a2-4ed8-9f81-399660f2f530", + "metadata": {}, + "outputs": [], + "source": [ + "svc_model.delete_deployment(deployment_name='llm_notebook_ft_1')" + ] } ], "metadata": { diff --git a/snowflake/ml/requirements.bzl b/snowflake/ml/requirements.bzl index 403a547b..0761d610 100755 --- a/snowflake/ml/requirements.bzl +++ b/snowflake/ml/requirements.bzl @@ -11,15 +11,13 @@ EXTRA_REQUIREMENTS = { "tensorflow>=2.9,<3,!=2.12.0", "tokenizers>=0.10,<1", "torchdata>=0.4,<1", - "transformers>=4.32.1,<5", - "vllm>=0.2.1.post1,<1" + "transformers>=4.32.1,<5" ], "lightgbm": [ "lightgbm==3.3.5" ], "llm": [ - "peft>=0.5.0,<1", - "vllm>=0.2.1.post1,<1" + "peft>=0.5.0,<1" ], "mlflow": [ "mlflow>=2.1.0,<2.4" @@ -56,7 +54,7 @@ REQUIREMENTS = [ "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", + "snowflake-snowpark-python>=1.8.0,<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 abf8795b..bac1a040 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.12" +VERSION = "1.1.0" diff --git a/tests/integ/snowflake/ml/_internal/BUILD.bazel b/tests/integ/snowflake/ml/_internal/BUILD.bazel index d907170a..94d7fdb9 100644 --- a/tests/integ/snowflake/ml/_internal/BUILD.bazel +++ b/tests/integ/snowflake/ml/_internal/BUILD.bazel @@ -20,32 +20,6 @@ py_test( ], ) -py_test( - name = "grid_search_integ_test", - timeout = "long", - srcs = ["grid_search_integ_test.py"], - shard_count = 3, - deps = [ - "//snowflake/ml/modeling/ensemble:random_forest_classifier", - "//snowflake/ml/modeling/model_selection/_internal:_grid_search_cv", - "//snowflake/ml/modeling/svm:svr", - "//snowflake/ml/modeling/xgboost:xgb_classifier", - "//snowflake/ml/utils:connection_params", - ], -) - -py_test( - name = "randomized_search_integ_test", - timeout = "long", - srcs = ["randomized_search_integ_test.py"], - shard_count = 2, - deps = [ - "//snowflake/ml/modeling/ensemble:random_forest_classifier", - "//snowflake/ml/modeling/model_selection/_internal:_randomized_search_cv", - "//snowflake/ml/utils:connection_params", - ], -) - py_test( name = "snowpark_handlers_test", timeout = "long", @@ -57,31 +31,3 @@ py_test( "//tests/integ/snowflake/ml/test_utils:common_test_base", ], ) - -py_test( - name = "grid_search_pipeline_test", - srcs = ["grid_search_pipeline_test.py"], - deps = [ - "//snowflake/ml/modeling/compose:column_transformer", - "//snowflake/ml/modeling/linear_model:logistic_regression", - "//snowflake/ml/modeling/model_selection/_internal:_grid_search_cv", - "//snowflake/ml/modeling/pipeline", - "//snowflake/ml/modeling/preprocessing:label_encoder", - "//snowflake/ml/modeling/preprocessing:min_max_scaler", - "//snowflake/ml/modeling/preprocessing:one_hot_encoder", - "//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/file_utils_integ_test.py b/tests/integ/snowflake/ml/_internal/file_utils_integ_test.py index 8492f9e5..580d6271 100644 --- a/tests/integ/snowflake/ml/_internal/file_utils_integ_test.py +++ b/tests/integ/snowflake/ml/_internal/file_utils_integ_test.py @@ -1,22 +1,18 @@ +import importlib import os +import sys import tempfile -from absl.testing import absltest +from absl.testing import absltest, parameterized from snowflake.ml._internal import file_utils from tests.integ.snowflake.ml.test_utils import common_test_base class FileUtilsIntegTest(common_test_base.CommonTestBase): - def setUp(self) -> None: - """Creates Snowpark and Snowflake environments for testing.""" - super().setUp() - - def tearDown(self) -> None: - super().tearDown() - @common_test_base.CommonTestBase.sproc_test() - def test_copytree(self) -> None: + @parameterized.parameters({"content": "hello"}, {"content": "snowflake"}) # type: ignore[misc] + def test_copytree(self, content: str) -> None: with tempfile.TemporaryDirectory() as tmpdir: leading_path = os.path.join(tmpdir, "test") fake_mod_dirpath = os.path.join(leading_path, "snowflake", "fake", "fake_module") @@ -24,7 +20,7 @@ def test_copytree(self) -> None: py_file_path = os.path.join(fake_mod_dirpath, "p.py") with open(py_file_path, "w", encoding="utf-8") as f: - f.write("Hello World") + f.write(content) file_utils.copy_file_or_tree(leading_path, os.path.join(tmpdir, "my_copy")) @@ -33,6 +29,32 @@ def test_copytree(self) -> None: [(["snowflake"], []), (["fake"], []), (["fake_module"], []), ([], ["p.py"])], ) + @common_test_base.CommonTestBase.sproc_test() + def test_zip_python_package_1(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + zip_module_filename = os.path.join(tmpdir, "snowml.zip") + file_utils.zip_python_package(zip_module_filename, "snowflake.ml") + sys.path.insert(0, os.path.abspath(zip_module_filename)) + + mod = importlib.reload(file_utils) + + self.assertIn(zip_module_filename, mod.__file__) + sys.path.remove(zip_module_filename) + mod = importlib.reload(file_utils) + + @common_test_base.CommonTestBase.sproc_test() + def test_zip_python_package_2(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + zip_module_filename = os.path.join(tmpdir, "snowml.zip") + file_utils.zip_python_package(zip_module_filename, "snowflake.ml._internal") + sys.path.insert(0, os.path.abspath(zip_module_filename)) + + mod = importlib.reload(file_utils) + + self.assertIn(zip_module_filename, mod.__file__) + sys.path.remove(zip_module_filename) + mod = importlib.reload(file_utils) + if __name__ == "__main__": absltest.main() diff --git a/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py b/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py deleted file mode 100644 index a3f6f974..00000000 --- a/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py +++ /dev/null @@ -1,142 +0,0 @@ -from unittest import mock - -import inflection -import numpy as np -from absl.testing import absltest, parameterized -from sklearn.datasets import load_diabetes, load_iris -from sklearn.model_selection import GridSearchCV as SkGridSearchCV -from sklearn.svm import SVR as SkSVR -from xgboost import XGBClassifier as SkXGBClassifier - -from snowflake.ml.modeling.model_selection._internal import GridSearchCV -from snowflake.ml.modeling.svm import SVR -from snowflake.ml.modeling.xgboost import XGBClassifier -from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import Session - - -class GridSearchCVTest(parameterized.TestCase): - def setUp(self): - """Creates Snowpark and Snowflake environments for testing.""" - self._session = Session.builder.configs(SnowflakeLoginOptions()).create() - - def tearDown(self): - self._session.close() - - def _compare_cv_results(self, cv_result_1, cv_result_2) -> None: - # compare the keys - self.assertEqual(cv_result_1.keys(), cv_result_2.keys()) - # compare the values - for k, v in cv_result_1.items(): - if isinstance(v, np.ndarray): - if k.startswith("param_"): # compare the masked array - self.assertTrue(np.ma.allequal(v, cv_result_2[k])) - elif k == "params": # compare the parameter combination - self.assertItemsEqual(v.tolist(), cv_result_2[k]) - elif ("test_") in k: # compare the test score - np.testing.assert_allclose(v, cv_result_2[k], rtol=1.0e-1, atol=1.0e-2) - # Do not compare the fit time - - @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")] - 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) - - 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) - - 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].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) - - # 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) - - @parameterized.parameters({"is_single_node": True}, {"is_single_node": False}) - @mock.patch("snowflake.ml.modeling.model_selection._internal._grid_search_cv.if_single_node") - def test_fit_xgboost_multimetric_and_compare_results(self, mock_if_single_node, is_single_node) -> None: - mock_if_single_node.return_value = is_single_node - 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")] - 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) - - sk_estimator = SkXGBClassifier(seed=42, n_jobs=1) - parameters = { - "max_depth": [2, 6], - "learning_rate": [0.1, 0.01], - } - scoring = ["accuracy", "f1_macro"] - - sklearn_reg = SkGridSearchCV( - estimator=sk_estimator, param_grid=parameters, scoring=scoring, refit="f1_macro", verbose=True - ) - sklearn_reg.fit(X=input_df_pandas[input_cols], y=input_df_pandas[label_col].squeeze()) - - estimator = XGBClassifier(seed=42, n_jobs=1) - reg = GridSearchCV(estimator=estimator, param_grid=parameters, scoring=scoring, refit="f1_macro", 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) - - # the result of SnowML grid search cv should behave the same as sklearn's - sk_obj = reg.to_sklearn() - np.testing.assert_allclose(sk_obj.best_score_, sklearn_reg.best_score_) - self._compare_cv_results(sk_obj.cv_results_, sklearn_reg.cv_results_) - self.assertEqual(sk_obj.best_params_, sklearn_reg.best_params_) - self.assertEqual(sk_obj.multimetric_, sklearn_reg.multimetric_) - self.assertEqual(sklearn_reg.multimetric_, True) - self.assertEqual(sk_obj.best_index_, sklearn_reg.best_index_) - - # n_features_in_ is available because `refit` is set to `True`. - self.assertEqual(sk_obj.n_features_in_, sklearn_reg.n_features_in_) - - # classes are available because this is a classifier - for idx, class_ in enumerate(sk_obj.classes_): - self.assertEqual(class_, sklearn_reg.classes_[idx]) - - 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(actual_arr.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) - - # Test predict_proba - actual_inference_result = ( - reg.predict_proba(input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") - ) - 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() - sklearn_predict_prob_array = sklearn_reg.predict_proba(input_df_pandas[input_cols]) - np.testing.assert_allclose(actual_inference_result.flatten(), sklearn_predict_prob_array.flatten()) - - # Test score - actual_score = reg.score(input_df) - sklearn_score = sklearn_reg.score(input_df_pandas[input_cols], input_df_pandas[label_col]) - np.testing.assert_allclose(actual_score, sklearn_score, rtol=1.0e-1, atol=1.0e-2) - - -if __name__ == "__main__": - absltest.main() diff --git a/tests/integ/snowflake/ml/_internal/grid_search_pipeline_test.py b/tests/integ/snowflake/ml/_internal/grid_search_pipeline_test.py deleted file mode 100644 index d9215b70..00000000 --- a/tests/integ/snowflake/ml/_internal/grid_search_pipeline_test.py +++ /dev/null @@ -1,117 +0,0 @@ -import numpy as np -from absl.testing.absltest import TestCase, main -from sklearn.compose import ColumnTransformer as SkColumnTransformer -from sklearn.linear_model import LogisticRegression as SkLogisticRegression -from sklearn.model_selection import GridSearchCV as SkGridSearchCV -from sklearn.pipeline import Pipeline as SkPipeline -from sklearn.preprocessing import ( - MinMaxScaler as SkMinMaxScaler, - OneHotEncoder as SkOneHotEncoder, -) -from snowflake.ml.modeling.linear_model.logistic_regression import LogisticRegression - -from snowflake.ml.modeling.compose import ColumnTransformer -from snowflake.ml.modeling.model_selection._internal import GridSearchCV -from snowflake.ml.modeling.pipeline import Pipeline -from snowflake.ml.modeling.preprocessing import MinMaxScaler, OneHotEncoder -from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import Column, Session - -categorical_columns = [ - "AGE", - "CAMPAIGN", - "CONTACT", - "DAY_OF_WEEK", - "EDUCATION", - "HOUSING", - "JOB", - "LOAN", - "MARITAL", - "MONTH", - "POUTCOME", -] -numerical_columns = [ - "CONS_CONF_IDX", - "CONS_PRICE_IDX", - "DURATION", - "EMP_VAR_RATE", - "EURIBOR3M", - "NR_EMPLOYED", - "PDAYS", - "PREVIOUS", -] -label_column = ["LABEL"] -feature_cols = categorical_columns + numerical_columns - - -class GridSearchCVTest(TestCase): - def setUp(self): - """Creates Snowpark and Snowflake environments for testing.""" - self._session = Session.builder.configs(SnowflakeLoginOptions()).create() - - def tearDown(self): - self._session.close() - - def test_fit_and_compare_results(self) -> None: - raw_data = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1, 0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ).drop(Column("Y")) - - pipeline = Pipeline( - steps=[ - ( - "preprocessing", - ColumnTransformer( - transformers=[ - ("OHE", OneHotEncoder(handle_unknown="ignore", sparse=False), categorical_columns), - ("MMS", MinMaxScaler(clip=True), numerical_columns), - ] - ), - ), - ("CLF", LogisticRegression(solver="saga", label_cols=label_column)), - ] - ) - - gs = GridSearchCV( - estimator=pipeline, - param_grid={"CLF__penalty": ["l1", "l2"]}, - input_cols=feature_cols, - label_cols=label_column, - drop_input_cols=True, - ) - gs.fit(raw_data) - predicted = gs.predict(raw_data).to_pandas() - - raw_data_pd = raw_data.to_pandas() - sk_pipeline = SkPipeline( - steps=[ - ( - "preprocessing", - SkColumnTransformer( - transformers=[ - ("OHE", SkOneHotEncoder(handle_unknown="ignore", sparse=False), categorical_columns), - ("MMS", SkMinMaxScaler(clip=True), numerical_columns), - ] - ), - ), - ("CLF", SkLogisticRegression(solver="saga")), - ] - ) - sk_gs = SkGridSearchCV( - estimator=sk_pipeline, - param_grid={"CLF__penalty": ["l1", "l2"]}, - ) - sk_gs.fit(raw_data_pd[feature_cols], raw_data_pd[label_column]) - sk_predicted = sk_gs.predict(raw_data_pd[feature_cols]) - - assert gs._sklearn_object.best_params_ == sk_gs.best_params_ - np.testing.assert_allclose(gs._sklearn_object.best_score_, sk_gs.best_score_) - np.testing.assert_allclose( - predicted["OUTPUT_LABEL"].to_numpy().flatten(), sk_predicted.flatten(), rtol=1.0e-1, atol=1.0e-2 - ) - - -if __name__ == "__main__": - main() diff --git a/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py b/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py deleted file mode 100644 index ac4fd9b0..00000000 --- a/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py +++ /dev/null @@ -1,115 +0,0 @@ -from unittest import mock - -import inflection -import numpy as np -from absl.testing import absltest, parameterized -from scipy.stats import randint -from sklearn.datasets import load_iris -from sklearn.ensemble import RandomForestClassifier as SkRandomForestClassifier -from sklearn.model_selection import RandomizedSearchCV as SkRandomizedSearchCV - -from snowflake.ml.modeling.ensemble import RandomForestClassifier -from snowflake.ml.modeling.model_selection._internal import RandomizedSearchCV -from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import Session - - -class RandomizedSearchCVTest(parameterized.TestCase): - def setUp(self): - """Creates Snowpark and Snowflake environments for testing.""" - self._session = Session.builder.configs(SnowflakeLoginOptions()).create() - - def tearDown(self): - self._session.close() - - def _compare_cv_results(self, cv_result_1, cv_result_2) -> None: - # compare the keys - self.assertEqual(cv_result_1.keys(), cv_result_2.keys()) - # compare the values - for k, v in cv_result_1.items(): - if isinstance(v, np.ndarray): - if k.startswith("param_"): # compare the masked array - self.assertTrue(np.ma.allequal(v, cv_result_2[k])) - elif k == "params": # compare the parameter combination - self.assertItemsEqual(v.tolist(), cv_result_2[k]) - elif ("test_") in k: # compare the test score - np.testing.assert_allclose(v, cv_result_2[k], rtol=1.0e-1, atol=1.0e-2) - # Do not compare the fit time - - @parameterized.parameters({"is_single_node": True}, {"is_single_node": False}) - @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, is_single_node) -> None: - mock_if_single_node.return_value = is_single_node - 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) - param_distribution = { - "n_estimators": [50, 200], - "max_depth": randint(3, 8), - } - - sklearn_reg = SkRandomizedSearchCV( - estimator=SkRandomForestClassifier(random_state=0), - param_distributions=param_distribution, - random_state=0, - ) - - reg = RandomizedSearchCV( - estimator=RandomForestClassifier(random_state=0), - param_distributions=param_distribution, - random_state=0, - ) - 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()) - 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_) - self.assertEqual(sk_obj.best_params_, sklearn_reg.best_params_) - self.assertEqual(sk_obj.multimetric_, sklearn_reg.multimetric_) - self.assertEqual(sklearn_reg.multimetric_, False) - self.assertEqual(sk_obj.best_index_, sklearn_reg.best_index_) - self._compare_cv_results(sk_obj.cv_results_, sklearn_reg.cv_results_) - - 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(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) - - # Test predict_proba - actual_inference_result = ( - reg.predict_proba(input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") - ) - 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() - - sklearn_predict_prob_array = sklearn_reg.predict_proba(input_df_pandas[input_cols]) - np.testing.assert_allclose(actual_inference_result.flatten(), sklearn_predict_prob_array.flatten()) - - # Test predict_log_proba - actual_log_proba_result = ( - reg.predict_log_proba(input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") - ) - actual_log_proba_result = actual_log_proba_result[actual_output_cols].to_numpy() - sklearn_log_prob_array = sklearn_reg.predict_log_proba(input_df_pandas[input_cols]) - np.testing.assert_allclose(actual_log_proba_result.flatten(), sklearn_log_prob_array.flatten()) - - # Test score - actual_score = reg.score(input_df) - sklearn_score = sklearn_reg.score(input_df_pandas[input_cols], input_df_pandas[label_col]) - np.testing.assert_allclose(actual_score, sklearn_score, rtol=1.0e-1, atol=1.0e-2) - - -if __name__ == "__main__": - absltest.main() diff --git a/tests/integ/snowflake/ml/_internal/snowpark_handlers_test.py b/tests/integ/snowflake/ml/_internal/snowpark_handlers_test.py index ae617c9d..d61c8039 100644 --- a/tests/integ/snowflake/ml/_internal/snowpark_handlers_test.py +++ b/tests/integ/snowflake/ml/_internal/snowpark_handlers_test.py @@ -23,9 +23,6 @@ def setUp(self) -> None: class_name="test", subproject="subproject", wrapper_provider=SklearnWrapperProvider() ) - def tearDown(self) -> None: - super().tearDown() - def _get_test_dataset(self) -> Tuple[pd.DataFrame, List[str], List[str]]: """Constructs input dataset to be used in the integration test. diff --git a/tests/integ/snowflake/ml/extra_tests/BUILD.bazel b/tests/integ/snowflake/ml/extra_tests/BUILD.bazel index 623b67eb..6a8711d5 100644 --- a/tests/integ/snowflake/ml/extra_tests/BUILD.bazel +++ b/tests/integ/snowflake/ml/extra_tests/BUILD.bazel @@ -37,6 +37,7 @@ py_test( py_test( name = "grid_search_on_pipeline_test", srcs = ["grid_search_on_pipeline_test.py"], + data = ["//tests/integ/snowflake/ml/test_data:UCI_BANK_MARKETING_20COLUMNS.csv"], deps = [ "//snowflake/ml/modeling/compose:column_transformer", "//snowflake/ml/modeling/linear_model:logistic_regression", @@ -63,6 +64,7 @@ py_test( name = "pipeline_with_ohe_and_xgbr_test", timeout = "long", srcs = ["pipeline_with_ohe_and_xgbr_test.py"], + data = ["//tests/integ/snowflake/ml/test_data:UCI_BANK_MARKETING_20COLUMNS.csv"], shard_count = 4, deps = [ "//snowflake/ml/modeling/framework", @@ -124,6 +126,18 @@ py_test( ], ) +py_test( + name = "fit_predict_test", + srcs = ["fit_predict_test.py"], + shard_count = 3, + deps = [ + "//snowflake/ml/modeling/cluster:agglomerative_clustering", + "//snowflake/ml/modeling/cluster:dbscan", + "//snowflake/ml/modeling/cluster:optics", + "//snowflake/ml/utils:connection_params", + ], +) + py_test( name = "decimal_type_test", srcs = ["decimal_type_test.py"], diff --git a/tests/integ/snowflake/ml/extra_tests/fit_predict_test.py b/tests/integ/snowflake/ml/extra_tests/fit_predict_test.py new file mode 100644 index 00000000..1d828a1f --- /dev/null +++ b/tests/integ/snowflake/ml/extra_tests/fit_predict_test.py @@ -0,0 +1,64 @@ +import numpy as np +import pandas as pd +from absl.testing.absltest import TestCase, main +from sklearn.cluster import ( + DBSCAN as SKDBSCAN, + OPTICS as SKOPTICS, + AgglomerativeClustering as SKAgglomerativeClustering, +) + +from snowflake.ml.modeling.cluster import DBSCAN, OPTICS, AgglomerativeClustering +from snowflake.ml.utils.connection_params import SnowflakeLoginOptions +from snowflake.snowpark import Session + + +class FitPredictTest(TestCase): + def setUp(self): + """Creates Snowpark and Snowflake environments for testing.""" + self._session = Session.builder.configs(SnowflakeLoginOptions()).create() + + def tearDown(self): + self._session.close() + + def test_aggolomerative(self): + sample_data = np.array([[1, 2], [1, 4], [1, 0], [4, 2], [4, 4], [4, 0]]) + pd_df = pd.DataFrame(sample_data) + pd_df.columns = [str(c) for c in pd_df.columns] + sp_df = self._session.create_dataframe(pd_df) + agg = AgglomerativeClustering(input_cols=sp_df.columns) + sk_agg = SKAgglomerativeClustering() + + return_label = agg.fit_predict(sp_df) + sk_label = sk_agg.fit_predict(sample_data) + + np.testing.assert_allclose(return_label, sk_label, rtol=1.0e-1, atol=1.0e-2) + + def test_dbscan(self): + sample_data = np.array([[1, 2], [2, 2], [2, 3], [8, 7], [8, 8], [25, 80]]) + pd_df = pd.DataFrame(sample_data) + pd_df.columns = [str(c) for c in pd_df.columns] + sp_df = self._session.create_dataframe(pd_df) + dbs = DBSCAN(input_cols=sp_df.columns, eps=3, min_samples=2) + sk_dbs = SKDBSCAN(eps=3, min_samples=2) + + return_label = dbs.fit_predict(sp_df) + sk_label = sk_dbs.fit_predict(sample_data) + + np.testing.assert_allclose(return_label, sk_label, rtol=1.0e-1, atol=1.0e-2) + + def test_optics(self): + sample_data = np.array([[1, 2], [2, 5], [3, 6], [8, 7], [8, 8], [7, 3]]) + pd_df = pd.DataFrame(sample_data) + pd_df.columns = [str(c) for c in pd_df.columns] + sp_df = self._session.create_dataframe(pd_df) + opt = OPTICS(input_cols=sp_df.columns, min_samples=2) + sk_opt = SKOPTICS(min_samples=2) + + return_label = opt.fit_predict(sp_df) + sk_label = sk_opt.fit_predict(sample_data) + + np.testing.assert_allclose(return_label, sk_label, rtol=1.0e-1, atol=1.0e-2) + + +if __name__ == "__main__": + main() diff --git a/tests/integ/snowflake/ml/extra_tests/grid_search_on_pipeline_test.py b/tests/integ/snowflake/ml/extra_tests/grid_search_on_pipeline_test.py index 607b29dd..a84d0f6f 100644 --- a/tests/integ/snowflake/ml/extra_tests/grid_search_on_pipeline_test.py +++ b/tests/integ/snowflake/ml/extra_tests/grid_search_on_pipeline_test.py @@ -1,4 +1,15 @@ +import numpy as np +import pandas as pd from absl.testing.absltest import TestCase, main +from importlib_resources import files +from sklearn.compose import ColumnTransformer as SkColumnTransformer +from sklearn.linear_model import LogisticRegression as SkLogisticRegression +from sklearn.model_selection import GridSearchCV as SkGridSearchCV +from sklearn.pipeline import Pipeline as SkPipeline +from sklearn.preprocessing import ( + MinMaxScaler as SkMinMaxScaler, + OneHotEncoder as SkOneHotEncoder, +) from snowflake.ml.modeling.linear_model.logistic_regression import LogisticRegression from snowflake.ml.modeling.compose import ColumnTransformer @@ -6,7 +17,7 @@ from snowflake.ml.modeling.pipeline import Pipeline from snowflake.ml.modeling.preprocessing import MinMaxScaler, OneHotEncoder from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import Column, Session +from snowflake.snowpark import Session categorical_columns = [ "AGE", @@ -35,7 +46,7 @@ feature_cols = categorical_columns + numerical_columns -class GridSearchCVTest(TestCase): +class GridSearchPipelineTest(TestCase): def setUp(self): """Creates Snowpark and Snowflake environments for testing.""" self._session = Session.builder.configs(SnowflakeLoginOptions()).create() @@ -44,11 +55,10 @@ def tearDown(self): self._session.close() def test_fit_and_compare_results(self) -> None: - raw_data = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ).drop(Column("Y")) + data_file = files("tests.integ.snowflake.ml.test_data").joinpath("UCI_BANK_MARKETING_20COLUMNS.csv") + pd_data = pd.read_csv(data_file, index_col=0) + pd_data["INDEX"] = pd_data.reset_index().index + raw_data = self._session.create_dataframe(pd_data) pipeline = Pipeline( steps=[ @@ -73,7 +83,33 @@ def test_fit_and_compare_results(self) -> None: drop_input_cols=True, ) gs.fit(raw_data) - gs.predict(raw_data).to_pandas() + predicted = gs.predict(raw_data.to_pandas().sort_values(by="INDEX")).to_numpy() + + raw_data_pd = raw_data.to_pandas() + sk_pipeline = SkPipeline( + steps=[ + ( + "preprocessing", + SkColumnTransformer( + transformers=[ + ("OHE", SkOneHotEncoder(handle_unknown="ignore", sparse=False), categorical_columns), + ("MMS", SkMinMaxScaler(clip=True), numerical_columns), + ] + ), + ), + ("CLF", SkLogisticRegression(solver="saga")), + ] + ) + sk_gs = SkGridSearchCV( + estimator=sk_pipeline, + param_grid={"CLF__penalty": ["l1", "l2"]}, + ) + sk_gs.fit(raw_data_pd[feature_cols], raw_data_pd[label_column]) + sk_predicted = sk_gs.predict(raw_data_pd[feature_cols]) + + assert gs._sklearn_object.best_params_ == sk_gs.best_params_ + np.testing.assert_allclose(gs._sklearn_object.best_score_, sk_gs.best_score_) + np.testing.assert_allclose(predicted.flatten(), sk_predicted.flatten(), rtol=1.0e-1, atol=1.0e-2) if __name__ == "__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 601b3c7a..15ce9183 100644 --- a/tests/integ/snowflake/ml/extra_tests/grid_search_test.py +++ b/tests/integ/snowflake/ml/extra_tests/grid_search_test.py @@ -69,11 +69,10 @@ def test_invalid_alias_pattern(self) -> None: input_df_pandas["INDEX"] = input_df_pandas.reset_index().index input_df = self._session.create_dataframe(input_df_pandas) - param_grid = { - "penalty": ["l1", "l2"], - "C": [0.1, 1, 10], - "solver": ["liblinear", "lbfgs"], - } + param_grid = [ + {"penalty": ["l2"], "C": [1, 10], "solver": ["lbfgs", "liblinear"]}, + {"penalty": ["l1"], "C": [1, 10], "solver": ["liblinear"]}, + ] reg = GridSearchCV(estimator=LogisticRegression(), param_grid=param_grid) reg.set_input_cols(input_cols) output_cols = ["OUTPUT_" + c for c in label_col] diff --git a/tests/integ/snowflake/ml/extra_tests/pipeline_with_ohe_and_xgbr_test.py b/tests/integ/snowflake/ml/extra_tests/pipeline_with_ohe_and_xgbr_test.py index 637af33b..b5a4f538 100644 --- a/tests/integ/snowflake/ml/extra_tests/pipeline_with_ohe_and_xgbr_test.py +++ b/tests/integ/snowflake/ml/extra_tests/pipeline_with_ohe_and_xgbr_test.py @@ -1,5 +1,7 @@ import numpy as np +import pandas as pd from absl.testing import absltest +from importlib_resources import files from snowflake.ml.modeling.pipeline import Pipeline from snowflake.ml.modeling.preprocessing import ( @@ -9,7 +11,7 @@ ) from snowflake.ml.modeling.xgboost import XGBRegressor from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import Column, Session, functions as F +from snowflake.snowpark import Session categorical_columns = [ "AGE", @@ -43,16 +45,15 @@ class GridSearchCVTest(absltest.TestCase): def setUp(self): """Creates Snowpark and Snowflake environments for testing.""" self._session = Session.builder.configs(SnowflakeLoginOptions()).create() + data_file = files("tests.integ.snowflake.ml.test_data").joinpath("UCI_BANK_MARKETING_20COLUMNS.csv") + self._test_data = pd.read_csv(data_file, index_col=0) def tearDown(self): self._session.close() def test_fit_and_compare_results(self) -> None: - raw_data = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ).drop(Column("Y")) + pd_data = self._test_data + raw_data = self._session.create_dataframe(pd_data) pipeline = Pipeline( steps=[ @@ -78,12 +79,7 @@ def test_fit_and_compare_results(self) -> None: pipeline.predict(raw_data).to_pandas() def test_fit_and_compare_results_pandas_dataframe(self) -> None: - raw_data = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ).drop(Column("Y")) - raw_data_pandas = raw_data.to_pandas() + raw_data_pandas = self._test_data pipeline = Pipeline( steps=[ @@ -109,11 +105,8 @@ def test_fit_and_compare_results_pandas_dataframe(self) -> None: pipeline.predict(raw_data_pandas) def test_fit_and_compare_results_pandas(self) -> None: - raw_data = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ).drop(Column("Y")) + pd_data = self._test_data + raw_data = self._session.create_dataframe(pd_data) pipeline = Pipeline( steps=[ @@ -139,16 +132,10 @@ def test_fit_and_compare_results_pandas(self) -> None: pipeline.predict(raw_data.to_pandas()) def test_pipeline_export(self) -> None: - snow_df = ( - self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ) - .drop("Y") - .withColumn("ROW_INDEX", F.monotonically_increasing_id()) - ) - pd_df = snow_df.to_pandas().sort_values(by=["ROW_INDEX"]).drop("LABEL", axis=1) + pd_data = self._test_data + pd_data["ROW_INDEX"] = pd_data.reset_index().index + snow_df = self._session.create_dataframe(pd_data) + pd_df = pd_data.drop("LABEL", axis=1) pipeline = Pipeline( steps=[ @@ -182,16 +169,10 @@ def test_pipeline_export(self) -> None: np.testing.assert_allclose(snow_results.flatten(), sk_results.flatten(), rtol=1.0e-1, atol=1.0e-2) def test_pipeline_with_limited_number_of_columns_in_estimator_export(self) -> None: - snow_df = ( - self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ) - .drop("Y", "DEFAULT") - .withColumn("ROW_INDEX", F.monotonically_increasing_id()) - ) - pd_df = snow_df.to_pandas().sort_values(by=["ROW_INDEX"]).drop("LABEL", axis=1) + pd_data = self._test_data + pd_data["ROW_INDEX"] = pd_data.reset_index().index + snow_df = self._session.create_dataframe(pd_data.drop("DEFAULT", axis=1)) + pd_df = pd_data.drop("LABEL", axis=1) pipeline = Pipeline( steps=[ diff --git a/tests/integ/snowflake/ml/image_builds/image_registry_client_integ_test.py b/tests/integ/snowflake/ml/image_builds/image_registry_client_integ_test.py index bb1e9a0d..af70f7a7 100644 --- a/tests/integ/snowflake/ml/image_builds/image_registry_client_integ_test.py +++ b/tests/integ/snowflake/ml/image_builds/image_registry_client_integ_test.py @@ -16,8 +16,10 @@ def setUp(self) -> None: client.create_image_repo( identifier.get_schema_level_object_identifier(self._test_db, self._test_schema, self._TEST_REPO) ) + self._session.sql("ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'json'").collect() def tearDown(self) -> None: + self._session.sql("ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'arrow'").collect() super().tearDown() def _get_repo_url(self) -> str: @@ -40,10 +42,10 @@ def _get_repo_url(self) -> str: ) return result[0]["repository_url"] - def test_copy_from_docker_hub_to_spcs_registry(self) -> None: + def test_copy_from_docker_hub_to_spcs_registry_and_add_tag(self) -> None: repo_url = self._get_repo_url() dest_image = "/".join([repo_url, "kaniko-project/executor:v1.16.0-debug"]) - client = image_registry_client.ImageRegistryClient(self._session) + client = image_registry_client.ImageRegistryClient(self._session, full_dest_image_name=dest_image) self.assertFalse(client.image_exists(dest_image)) client.copy_image( "gcr.io/kaniko-project/executor@sha256:b8c0977f88f24dbd7cbc2ffe5c5f824c410ccd0952a72cc066efc4b6dfbb52b6", @@ -51,6 +53,14 @@ def test_copy_from_docker_hub_to_spcs_registry(self) -> None: ) self.assertTrue(client.image_exists(dest_image)) + parts = dest_image.split(":") + assert len(parts) == 2 + new_tag = "snowml-test-tag" + full_image_with_new_tag = ":".join([parts[0], new_tag]) + self.assertFalse(client.image_exists(full_image_with_new_tag)) + client.add_tag_to_remote_image(dest_image, new_tag=new_tag) + self.assertTrue(client.image_exists(full_image_with_new_tag)) + if __name__ == "__main__": absltest.main() 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 2c57960b..98459bfc 100644 --- a/tests/integ/snowflake/ml/model/model_badcase_integ_test.py +++ b/tests/integ/snowflake/ml/model/model_badcase_integ_test.py @@ -92,7 +92,7 @@ def test_custom_demo_model(self) -> None: arr = np.random.randint(100, size=(10000, 3)) pd_df = pd.DataFrame(arr, columns=["c1", "c2", "c3"]) - module_model = model_api.save_model( + model_composer = model_api.save_model( name="custom_demo_model", session=self._session, stage_path=posixpath.join(tmp_stage, "custom_demo_model"), @@ -104,7 +104,7 @@ def test_custom_demo_model(self) -> None: metadata={"author": "halu", "version": "1"}, ) - self.assertIsNotNone(module_model.packager.meta.env._snowpark_ml_version.local) + self.assertIsNotNone(model_composer.packager.meta.env._snowpark_ml_version.local) function_name = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(self.run_id, "custom_demo_model") with self.assertRaises(snowml_exceptions.SnowflakeMLException) as e: diff --git a/tests/integ/snowflake/ml/model/warehouse_huggingface_pipeline_model_integ_test.py b/tests/integ/snowflake/ml/model/warehouse_huggingface_pipeline_model_integ_test.py index f5c639ec..479a7ff9 100644 --- a/tests/integ/snowflake/ml/model/warehouse_huggingface_pipeline_model_integ_test.py +++ b/tests/integ/snowflake/ml/model/warehouse_huggingface_pipeline_model_integ_test.py @@ -6,7 +6,6 @@ import numpy as np import pandas as pd -import pytest from absl.testing import absltest, parameterized from packaging import requirements @@ -18,7 +17,6 @@ from tests.integ.snowflake.ml.test_utils import db_manager -@pytest.mark.pip_incompatible class TestWarehouseHuggingFacehModelInteg(parameterized.TestCase): @classmethod def setUpClass(self) -> None: diff --git a/tests/integ/snowflake/ml/model/warehouse_model_compat_v1_test.py b/tests/integ/snowflake/ml/model/warehouse_model_compat_v1_test.py index 85e2a3d6..9c84afea 100644 --- a/tests/integ/snowflake/ml/model/warehouse_model_compat_v1_test.py +++ b/tests/integ/snowflake/ml/model/warehouse_model_compat_v1_test.py @@ -310,8 +310,8 @@ def log_model(session: session.Session, run_id: str, model_stage_file_path: str) from snowflake.ml.model import ( # type: ignore[attr-defined] _model as model_api, ) - from snowflake.ml.modeling.linear_model import ( - LogisticRegression, # type: ignore[attr-defined] + from snowflake.ml.modeling.linear_model import ( # type: ignore[attr-defined] + LogisticRegression, ) iris_X = datasets.load_iris(as_frame=True).frame @@ -363,8 +363,8 @@ def log_model(session: session.Session, run_id: str, model_stage_file_path: str) from snowflake.ml.model import ( # type: ignore[attr-defined] _model as model_api, ) - from snowflake.ml.modeling.xgboost import ( - XGBRegressor, # type: ignore[attr-defined] + from snowflake.ml.modeling.xgboost import ( # type: ignore[attr-defined] + XGBRegressor, ) iris_X = datasets.load_iris(as_frame=True).frame diff --git a/tests/integ/snowflake/ml/modeling/model_selection/BUILD.bazel b/tests/integ/snowflake/ml/modeling/model_selection/BUILD.bazel index 5918f58c..2d1bcbee 100644 --- a/tests/integ/snowflake/ml/modeling/model_selection/BUILD.bazel +++ b/tests/integ/snowflake/ml/modeling/model_selection/BUILD.bazel @@ -1,10 +1,47 @@ -load("//codegen:codegen_rules.bzl", "autogen_tests_for_estimators") -load("//snowflake/ml/modeling/model_selection:estimators_info.bzl", "estimator_info_list") +load("//bazel:py_rules.bzl", "py_test") package(default_visibility = ["//visibility:public"]) -autogen_tests_for_estimators( - estimator_info_list = estimator_info_list, - module = "sklearn.model_selection", - module_root_dir = "snowflake/ml/modeling/model_selection", +py_test( + name = "grid_search_integ_test", + timeout = "long", + srcs = ["grid_search_integ_test.py"], + shard_count = 5, + deps = [ + "//snowflake/ml/modeling/decomposition:pca", + "//snowflake/ml/modeling/ensemble:random_forest_classifier", + "//snowflake/ml/modeling/model_selection:grid_search_cv", + "//snowflake/ml/modeling/svm:svc", + "//snowflake/ml/modeling/svm:svr", + "//snowflake/ml/modeling/xgboost:xgb_classifier", + "//snowflake/ml/utils:connection_params", + ], +) + +py_test( + name = "randomized_search_integ_test", + timeout = "long", + srcs = ["randomized_search_integ_test.py"], + shard_count = 3, + deps = [ + "//snowflake/ml/modeling/decomposition:pca", + "//snowflake/ml/modeling/ensemble:random_forest_classifier", + "//snowflake/ml/modeling/model_selection:randomized_search_cv", + "//snowflake/ml/modeling/svm:svc", + "//snowflake/ml/modeling/xgboost:xgb_classifier", + "//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:grid_search_cv", + "//snowflake/ml/modeling/model_selection:randomized_search_cv", + "//snowflake/ml/modeling/xgboost:xgb_classifier", + "//snowflake/ml/utils:connection_params", + ], ) diff --git a/tests/integ/snowflake/ml/modeling/model_selection/grid_search_integ_test.py b/tests/integ/snowflake/ml/modeling/model_selection/grid_search_integ_test.py new file mode 100644 index 00000000..3c008849 --- /dev/null +++ b/tests/integ/snowflake/ml/modeling/model_selection/grid_search_integ_test.py @@ -0,0 +1,257 @@ +from typing import List, Tuple +from unittest import mock + +import inflection +import numpy as np +import pandas as pd +from absl.testing import absltest, parameterized +from sklearn.datasets import load_iris +from sklearn.decomposition import PCA as SkPCA +from sklearn.ensemble import RandomForestClassifier as SkRandomForestClassifier +from sklearn.model_selection import GridSearchCV as SkGridSearchCV +from sklearn.svm import SVC as SkSVC, SVR as SkSVR +from xgboost import XGBClassifier as SkXGBClassifier + +from snowflake.ml.modeling.decomposition import PCA +from snowflake.ml.modeling.ensemble import RandomForestClassifier +from snowflake.ml.modeling.model_selection import GridSearchCV +from snowflake.ml.modeling.svm import SVC, SVR +from snowflake.ml.modeling.xgboost import XGBClassifier +from snowflake.ml.utils.connection_params import SnowflakeLoginOptions +from snowflake.snowpark import Session + + +def _load_iris_data() -> Tuple[pd.DataFrame, List[str], List[str]]: + 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_df_pandas["INDEX"] = input_df_pandas.reset_index().index + + 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")] + + return input_df_pandas, input_cols, label_col + + +class GridSearchCVTest(parameterized.TestCase): + def setUp(self): + """Creates Snowpark and Snowflake environments for testing.""" + self._session = Session.builder.configs(SnowflakeLoginOptions()).create() + + pd_data, input_col, label_col = _load_iris_data() + self._input_df_pandas = pd_data + self._input_cols = input_col + self._label_col = label_col + self._input_df = self._session.create_dataframe(self._input_df_pandas) + + def tearDown(self): + self._session.close() + + def _compare_cv_results(self, cv_result_1, cv_result_2) -> None: + # compare the keys + self.assertEqual(cv_result_1.keys(), cv_result_2.keys()) + # compare the values + for k, v in cv_result_1.items(): + if isinstance(v, np.ndarray): + if k.startswith("param_"): # compare the masked array + np.ma.allequal(v, cv_result_2[k]) + elif k == "params": # compare the parameter combination + self.assertEqual(v.tolist(), cv_result_2[k]) + elif k.endswith("test_score"): # compare the test score + np.testing.assert_allclose(v, cv_result_2[k], rtol=1.0e-1, atol=1.0e-2) + # Do not compare the fit time + + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_fit_and_compare_results(self, mock_is_single_node) -> None: + mock_is_single_node.return_value = True # falls back to HPO implementation + + 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(self._input_cols) + output_cols = ["OUTPUT_" + c for c in self._label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(self._label_col) + + reg.fit(self._input_df) + sklearn_reg.fit(X=self._input_df_pandas[self._input_cols], y=self._input_df_pandas[self._label_col].squeeze()) + + actual_arr = reg.predict(self._input_df).to_pandas().sort_values(by="INDEX")[output_cols].to_numpy() + sklearn_numpy_arr = sklearn_reg.predict(self._input_df_pandas[self._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_, rtol=1.0e-1, atol=1.0e-2) + 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) + + # Test on fitting on snowpark Dataframe, and predict on pandas dataframe + actual_arr_pd = reg.predict(self._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) + + @parameterized.parameters( + { + "is_single_node": False, + "skmodel": SkRandomForestClassifier, + "model": RandomForestClassifier, + "params": {"n_estimators": [50, 200], "min_samples_split": [1.0, 2, 3], "max_depth": [3, 8]}, + "kwargs": dict(), + "estimator_kwargs": dict(random_state=0), + }, + { + "is_single_node": False, + "skmodel": SkSVC, + "model": SVC, + "params": {"kernel": ("linear", "rbf"), "C": [1, 10, 80]}, + "kwargs": dict(), + "estimator_kwargs": dict(random_state=0), + }, + { + "is_single_node": False, + "skmodel": SkXGBClassifier, + "model": XGBClassifier, + "params": {"max_depth": [2, 6], "learning_rate": [0.1, 0.01]}, + "kwargs": dict(scoring=["accuracy", "f1_macro"], refit="f1_macro"), + "estimator_kwargs": dict(seed=42), + }, + ) + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_fit_and_compare_results_distributed( + self, mock_is_single_node, is_single_node, skmodel, model, params, kwargs, estimator_kwargs + ) -> None: + mock_is_single_node.return_value = is_single_node + + sklearn_reg = SkGridSearchCV(estimator=skmodel(**estimator_kwargs), param_grid=params, cv=3, **kwargs) + reg = GridSearchCV(estimator=model(**estimator_kwargs), param_grid=params, cv=3, **kwargs) + reg.set_input_cols(self._input_cols) + output_cols = ["OUTPUT_" + c for c in self._label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(self._label_col) + + reg.fit(self._input_df) + sklearn_reg.fit(X=self._input_df_pandas[self._input_cols], y=self._input_df_pandas[self._label_col].squeeze()) + 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_) + self.assertEqual(sk_obj.multimetric_, sklearn_reg.multimetric_) + + # self.assertEqual(sklearn_reg.multimetric_, False) + self.assertEqual(sk_obj.best_index_, sklearn_reg.best_index_) + self._compare_cv_results(sk_obj.cv_results_, sklearn_reg.cv_results_) + + if not sk_obj.multimetric_: + self.assertEqual(sk_obj.best_params_, sklearn_reg.best_params_) + + actual_arr = reg.predict(self._input_df).to_pandas().sort_values(by="INDEX")[output_cols].to_numpy() + sklearn_numpy_arr = sklearn_reg.predict(self._input_df_pandas[self._input_cols]) + 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(self._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) + + # Test score + actual_score = reg.score(self._input_df) + sklearn_score = sklearn_reg.score( + self._input_df_pandas[self._input_cols], self._input_df_pandas[self._label_col] + ) + np.testing.assert_allclose(actual_score, sklearn_score, rtol=1.0e-1, atol=1.0e-2) + + # n_features_in_ is available because `refit` is set to `True`. + self.assertEqual(sk_obj.n_features_in_, sklearn_reg.n_features_in_) + + # classes are available because these are classifier models + for idx, class_ in enumerate(sk_obj.classes_): + self.assertEqual(class_, sklearn_reg.classes_[idx]) + + # Test predict_proba + if hasattr(reg, "predict_proba"): + actual_inference_result = ( + reg.predict_proba(self._input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") + ) + 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() + sklearn_predict_prob_array = sklearn_reg.predict_proba(self._input_df_pandas[self._input_cols]) + np.testing.assert_allclose(actual_inference_result.flatten(), sklearn_predict_prob_array.flatten()) + + actual_pandas_result = reg.predict_proba( + self._input_df_pandas[self._input_cols], output_cols_prefix="OUTPUT_" + ) + actual_pandas_result = actual_pandas_result[actual_output_cols].to_numpy() + np.testing.assert_allclose( + actual_pandas_result.flatten(), sklearn_predict_prob_array.flatten(), rtol=1.0e-1, atol=1.0e-2 + ) + + # Test predict_log_proba + if hasattr(reg, "predict_log_proba"): + actual_log_proba_result = reg.predict_log_proba(self._input_df).to_pandas().sort_values(by="INDEX") + actual_output_cols = [c for c in actual_log_proba_result.columns if c.find("predict_log_proba_") >= 0] + actual_log_proba_result = actual_log_proba_result[actual_output_cols].to_numpy() + sklearn_log_prob_array = sklearn_reg.predict_log_proba(self._input_df_pandas[self._input_cols]) + np.testing.assert_allclose(actual_log_proba_result.flatten(), sklearn_log_prob_array.flatten()) + + actual_pandas_result = reg.predict_log_proba(self._input_df_pandas[self._input_cols]) + actual_pandas_result = actual_pandas_result[actual_output_cols].to_numpy() + np.testing.assert_allclose(actual_pandas_result.flatten(), sklearn_log_prob_array.flatten()) + + # Test decision function + if hasattr(reg, "decision_function"): + actual_decision_function = ( + reg.decision_function(self._input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") + ) + actual_output_cols = [c for c in actual_decision_function.columns if c.find("OUTPUT_") >= 0] + actual_decision_function_result = actual_decision_function[actual_output_cols].to_numpy() + sklearn_decision_function = sklearn_reg.decision_function(self._input_df_pandas[self._input_cols]) + np.testing.assert_allclose( + actual_decision_function_result, sklearn_decision_function, rtol=1.0e-1, atol=1.0e-2 + ) + + actual_pandas_result = reg.decision_function( + self._input_df_pandas[self._input_cols], output_cols_prefix="OUTPUT_" + ) + actual_pandas_result = actual_pandas_result[actual_output_cols].to_numpy() + np.testing.assert_allclose( + actual_pandas_result.flatten(), sklearn_decision_function.flatten(), rtol=1.0e-1, atol=1.0e-2 + ) + + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_transform(self, mock_is_single_node) -> None: + mock_is_single_node.return_value = False + + params = {"n_components": range(1, 3)} + sk_pca = SkPCA() + sklearn_reg = SkGridSearchCV(sk_pca, params, cv=3) + + pca = PCA() + reg = GridSearchCV(estimator=pca, param_grid=params, cv=3) + reg.set_input_cols(self._input_cols) + output_cols = ["OUTPUT_" + c for c in self._label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(self._label_col) + + reg.fit(self._input_df) + sklearn_reg.fit(X=self._input_df_pandas[self._input_cols], y=self._input_df_pandas[self._label_col].squeeze()) + + transformed = reg.transform(self._input_df).to_pandas().sort_values(by="INDEX") + sk_transformed = sklearn_reg.transform(self._input_df_pandas[self._input_cols]) + + actual_output_cols = [c for c in transformed.columns if c.find("OUTPUT_") >= 0] + transformed = transformed[actual_output_cols].astype("float64").to_numpy() + + np.testing.assert_allclose(transformed, sk_transformed, rtol=1.0e-1, atol=1.0e-2) + + def test_not_fitted_exception(self) -> None: + param_grid = {"max_depth": [2, 6], "learning_rate": [0.1, 0.01]} + reg = GridSearchCV(estimator=XGBClassifier(), param_grid=param_grid) + + with self.assertRaises(RuntimeError, msg="Estimator not fitted before accessing property model_signatures!"): + reg.predict(self._input_df) + + with self.assertRaises( + RuntimeError, msg="Estimator GridSearchCV not fitted before calling predict_proba method." + ): + reg.predict_proba(self._input_df) + + +if __name__ == "__main__": + absltest.main() diff --git a/tests/integ/snowflake/ml/modeling/model_selection/randomized_search_integ_test.py b/tests/integ/snowflake/ml/modeling/model_selection/randomized_search_integ_test.py new file mode 100644 index 00000000..666b9ebb --- /dev/null +++ b/tests/integ/snowflake/ml/modeling/model_selection/randomized_search_integ_test.py @@ -0,0 +1,234 @@ +from typing import List, Tuple +from unittest import mock + +import inflection +import numpy as np +import pandas as pd +from absl.testing import absltest, parameterized +from sklearn.datasets import load_iris +from sklearn.decomposition import PCA as SkPCA +from sklearn.ensemble import RandomForestClassifier as SkRandomForestClassifier +from sklearn.model_selection import RandomizedSearchCV as SkRandomizedSearchCV +from sklearn.svm import SVC as SkSVC +from xgboost import XGBClassifier as SkXGBClassifier + +from snowflake.ml.modeling.decomposition import PCA +from snowflake.ml.modeling.ensemble import RandomForestClassifier +from snowflake.ml.modeling.model_selection import RandomizedSearchCV +from snowflake.ml.modeling.svm import SVC +from snowflake.ml.modeling.xgboost import XGBClassifier +from snowflake.ml.utils.connection_params import SnowflakeLoginOptions +from snowflake.snowpark import Session + + +def _load_iris_data() -> Tuple[pd.DataFrame, List[str], List[str]]: + 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_df_pandas["INDEX"] = input_df_pandas.reset_index().index + + 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")] + + return input_df_pandas, input_cols, label_col + + +class RandomizedSearchCVTest(parameterized.TestCase): + def setUp(self): + """Creates Snowpark and Snowflake environments for testing.""" + self._session = Session.builder.configs(SnowflakeLoginOptions()).create() + + pd_data, input_col, label_col = _load_iris_data() + self._input_df_pandas = pd_data + self._input_cols = input_col + self._label_col = label_col + self._input_df = self._session.create_dataframe(self._input_df_pandas) + + def tearDown(self): + self._session.close() + + def _compare_cv_results(self, cv_result_1, cv_result_2) -> None: + # compare the keys + self.assertEqual(cv_result_1.keys(), cv_result_2.keys()) + # compare the values + for k, v in cv_result_1.items(): + if isinstance(v, np.ndarray): + if k.startswith("param_"): # compare the masked array + self.assertTrue(np.ma.allequal(v, cv_result_2[k])) + elif k == "params": # compare the parameter combination + self.assertItemsEqual(v.tolist(), cv_result_2[k]) + elif ("test_") in k: # compare the test score + np.testing.assert_allclose(v, cv_result_2[k], rtol=1.0e-1, atol=1.0e-2) + # Do not compare the fit time + + @parameterized.parameters( + { + "is_single_node": True, + "skmodel": SkRandomForestClassifier, + "model": RandomForestClassifier, + "params": {"n_estimators": [50, 200], "max_depth": [3, 8]}, + "kwargs": dict(), + "estimator_kwargs": dict(random_state=0), + }, + { + "is_single_node": False, + "skmodel": SkSVC, + "model": SVC, + "params": {"kernel": ("linear", "rbf"), "C": [1, 10, 80]}, + "kwargs": dict(), + "estimator_kwargs": dict(random_state=0), + }, + { + "is_single_node": False, + "skmodel": SkXGBClassifier, + "model": XGBClassifier, + "params": {"max_depth": [2, 6], "learning_rate": [0.1, 0.01]}, + "kwargs": dict(scoring=["accuracy", "f1_macro"], refit="f1_macro"), + "estimator_kwargs": dict(seed=42), + }, + ) + @mock.patch("snowflake.ml.modeling.model_selection.randomized_search_cv.is_single_node") + def test_fit_and_compare_results( + self, mock_is_single_node, is_single_node, skmodel, model, params, kwargs, estimator_kwargs + ) -> None: + mock_is_single_node.return_value = is_single_node + + sklearn_reg = SkRandomizedSearchCV( + estimator=skmodel(**estimator_kwargs), param_distributions=params, random_state=0, cv=3, **kwargs + ) + + reg = RandomizedSearchCV( + estimator=model(**estimator_kwargs), param_distributions=params, random_state=0, cv=3, **kwargs + ) + reg.set_input_cols(self._input_cols) + output_cols = ["OUTPUT_" + c for c in self._label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(self._label_col) + + reg.fit(self._input_df) + sklearn_reg.fit(X=self._input_df_pandas[self._input_cols], y=self._input_df_pandas[self._label_col].squeeze()) + 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_) + self.assertEqual(sk_obj.multimetric_, sklearn_reg.multimetric_) + + # self.assertEqual(sklearn_reg.multimetric_, False) + self.assertEqual(sk_obj.best_index_, sklearn_reg.best_index_) + self._compare_cv_results(sk_obj.cv_results_, sklearn_reg.cv_results_) + + if not sk_obj.multimetric_: + self.assertEqual(sk_obj.best_params_, sklearn_reg.best_params_) + + actual_arr = reg.predict(self._input_df).to_pandas().sort_values(by="INDEX")[output_cols].to_numpy() + sklearn_numpy_arr = sklearn_reg.predict(self._input_df_pandas[self._input_cols]) + 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(self._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) + + # Test score + actual_score = reg.score(self._input_df) + sklearn_score = sklearn_reg.score( + self._input_df_pandas[self._input_cols], self._input_df_pandas[self._label_col] + ) + np.testing.assert_allclose(actual_score, sklearn_score, rtol=1.0e-1, atol=1.0e-2) + + # n_features_in_ is available because `refit` is set to `True`. + self.assertEqual(sk_obj.n_features_in_, sklearn_reg.n_features_in_) + + # classes are available because these are classifier models + for idx, class_ in enumerate(sk_obj.classes_): + self.assertEqual(class_, sklearn_reg.classes_[idx]) + + # Test predict_proba + if hasattr(reg, "predict_proba"): + actual_inference_result = ( + reg.predict_proba(self._input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") + ) + 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() + sklearn_predict_prob_array = sklearn_reg.predict_proba(self._input_df_pandas[self._input_cols]) + np.testing.assert_allclose(actual_inference_result.flatten(), sklearn_predict_prob_array.flatten()) + + actual_pandas_result = reg.predict_proba( + self._input_df_pandas[self._input_cols], output_cols_prefix="OUTPUT_" + ) + actual_pandas_result = actual_pandas_result[actual_output_cols].to_numpy() + np.testing.assert_allclose( + actual_pandas_result.flatten(), sklearn_predict_prob_array.flatten(), rtol=1.0e-1, atol=1.0e-2 + ) + + # Test predict_log_proba + if hasattr(reg, "predict_log_proba"): + actual_log_proba_result = reg.predict_log_proba(self._input_df).to_pandas().sort_values(by="INDEX") + actual_output_cols = [c for c in actual_log_proba_result.columns if c.find("predict_log_proba_") >= 0] + actual_log_proba_result = actual_log_proba_result[actual_output_cols].to_numpy() + sklearn_log_prob_array = sklearn_reg.predict_log_proba(self._input_df_pandas[self._input_cols]) + np.testing.assert_allclose(actual_log_proba_result.flatten(), sklearn_log_prob_array.flatten()) + + actual_pandas_result = reg.predict_log_proba(self._input_df_pandas[self._input_cols]) + actual_pandas_result = actual_pandas_result[actual_output_cols].to_numpy() + np.testing.assert_allclose(actual_pandas_result.flatten(), sklearn_log_prob_array.flatten()) + + # Test decision function + if hasattr(reg, "decision_function"): + actual_decision_function = ( + reg.decision_function(self._input_df, output_cols_prefix="OUTPUT_").to_pandas().sort_values(by="INDEX") + ) + actual_output_cols = [c for c in actual_decision_function.columns if c.find("OUTPUT_") >= 0] + actual_decision_function_result = actual_decision_function[actual_output_cols].to_numpy() + sklearn_decision_function = sklearn_reg.decision_function(self._input_df_pandas[self._input_cols]) + np.testing.assert_allclose( + actual_decision_function_result, sklearn_decision_function, rtol=1.0e-1, atol=1.0e-2 + ) + + actual_pandas_result = reg.decision_function( + self._input_df_pandas[self._input_cols], output_cols_prefix="OUTPUT_" + ) + actual_pandas_result = actual_pandas_result[actual_output_cols].to_numpy() + np.testing.assert_allclose( + actual_pandas_result.flatten(), sklearn_decision_function.flatten(), rtol=1.0e-1, atol=1.0e-2 + ) + + @mock.patch("snowflake.ml.modeling.model_selection.randomized_search_cv.is_single_node") + def test_transform(self, mock_is_single_node) -> None: + mock_is_single_node.return_value = False + + params = {"n_components": range(1, 3)} + sk_pca = SkPCA() + sklearn_reg = SkRandomizedSearchCV(sk_pca, params, cv=3) + + pca = PCA() + reg = RandomizedSearchCV(estimator=pca, param_distributions=params, cv=3) + reg.set_input_cols(self._input_cols) + output_cols = ["OUTPUT_" + c for c in self._label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(self._label_col) + + reg.fit(self._input_df) + sklearn_reg.fit(X=self._input_df_pandas[self._input_cols], y=self._input_df_pandas[self._label_col].squeeze()) + + transformed = reg.transform(self._input_df).to_pandas().sort_values(by="INDEX") + sk_transformed = sklearn_reg.transform(self._input_df_pandas[self._input_cols]) + + actual_output_cols = [c for c in transformed.columns if c.find("OUTPUT_") >= 0] + transformed = transformed[actual_output_cols].astype("float64").to_numpy() + + np.testing.assert_allclose(transformed, sk_transformed, rtol=1.0e-1, atol=1.0e-2) + + def test_not_fitted_exception(self) -> None: + param_distributions = {"max_depth": [2, 6], "learning_rate": [0.1, 0.01]} + reg = RandomizedSearchCV(estimator=XGBClassifier(), param_distributions=param_distributions) + + with self.assertRaises(RuntimeError, msg="Estimator not fitted before accessing property model_signatures!"): + reg.predict(self._input_df) + + with self.assertRaises( + RuntimeError, msg="Estimator RandomizedSearchCV not fitted before calling predict_proba method." + ): + reg.predict_proba(self._input_df) + + +if __name__ == "__main__": + absltest.main() diff --git a/tests/integ/snowflake/ml/_internal/search_single_node_test.py b/tests/integ/snowflake/ml/modeling/model_selection/search_single_node_test.py similarity index 82% rename from tests/integ/snowflake/ml/_internal/search_single_node_test.py rename to tests/integ/snowflake/ml/modeling/model_selection/search_single_node_test.py index 827b7ccd..f6fd1e4f 100644 --- a/tests/integ/snowflake/ml/_internal/search_single_node_test.py +++ b/tests/integ/snowflake/ml/modeling/model_selection/search_single_node_test.py @@ -4,10 +4,7 @@ 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.model_selection import GridSearchCV, RandomizedSearchCV from snowflake.ml.modeling.xgboost import XGBClassifier from snowflake.ml.utils.connection_params import SnowflakeLoginOptions from snowflake.snowpark import Session @@ -20,9 +17,9 @@ def setUp(self) -> None: 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 + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_single_node_grid(self, mock_is_single_node) -> None: + mock_is_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")] @@ -46,9 +43,9 @@ def test_single_node_grid(self, mock_if_single_node) -> None: 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 + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_single_node_random(self, mock_is_single_node) -> None: + mock_is_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")] @@ -72,9 +69,9 @@ def test_single_node_random(self, mock_if_single_node) -> None: 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 + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_not_single_node_grid(self, mock_is_single_node) -> None: + mock_is_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")] @@ -96,9 +93,9 @@ def test_not_single_node_grid(self, mock_if_single_node) -> None: 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 + @mock.patch("snowflake.ml.modeling.model_selection.grid_search_cv.is_single_node") + def test_not_single_node_random(self, mock_is_single_node) -> None: + mock_is_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")] diff --git a/tests/integ/snowflake/ml/modeling/pipeline/BUILD.bazel b/tests/integ/snowflake/ml/modeling/pipeline/BUILD.bazel index 2c6d50ff..c3b2af0f 100644 --- a/tests/integ/snowflake/ml/modeling/pipeline/BUILD.bazel +++ b/tests/integ/snowflake/ml/modeling/pipeline/BUILD.bazel @@ -15,6 +15,7 @@ py_test( "//snowflake/ml/modeling/linear_model:linear_regression", "//snowflake/ml/modeling/linear_model:logistic_regression", "//snowflake/ml/modeling/pipeline", + "//snowflake/ml/modeling/preprocessing:label_encoder", "//snowflake/ml/modeling/preprocessing:min_max_scaler", "//snowflake/ml/modeling/preprocessing:standard_scaler", "//snowflake/ml/utils:connection_params", diff --git a/tests/integ/snowflake/ml/modeling/pipeline/pipeline_test.py b/tests/integ/snowflake/ml/modeling/pipeline/pipeline_test.py index 44b0c4ea..471504c5 100644 --- a/tests/integ/snowflake/ml/modeling/pipeline/pipeline_test.py +++ b/tests/integ/snowflake/ml/modeling/pipeline/pipeline_test.py @@ -30,6 +30,7 @@ LogisticRegression as SnowmlLogisticRegression, ) from snowflake.ml.modeling.preprocessing import ( # type: ignore[attr-defined] + LabelEncoder, MinMaxScaler, StandardScaler, ) @@ -352,6 +353,74 @@ def test_pipeline_with_regression_estimators_pandas_dataframe(self) -> None: np.testing.assert_allclose(actual_results, sk_predict_results) + def test_pipeline_signature_quoted_columns_pandas(self) -> None: + input_df_pandas = load_diabetes(as_frame=True).frame + # Normalize column names + input_df_pandas.columns = [f'"{inflection.parameterize(c, "_")}"' for c in input_df_pandas.columns] + + input_cols = [c for c in input_df_pandas.columns if not c.startswith('"target"')] + label_cols = ['"target"'] + output_cols = '"output"' + + mms = MinMaxScaler() + mms.set_input_cols(['"age"']) + mms.set_output_cols(['"age"']) + ss = StandardScaler() + ss.set_input_cols(['"age"']) + ss.set_output_cols(['"age"']) + + estimator = SnowmlLinearRegression(input_cols=input_cols, output_cols=output_cols, label_cols=label_cols) + + pipeline = snowml_pipeline.Pipeline(steps=[("mms", mms), ("ss", ss), ("estimator", estimator)]) + pipeline.fit(input_df_pandas) + + model_signatures = pipeline.model_signatures + + expected_model_signatures = { + "predict": ModelSignature( + inputs=[FeatureSpec(name=c, dtype=DataType.DOUBLE) for c in input_cols], + outputs=[FeatureSpec(name=c, dtype=DataType.DOUBLE) for c in input_cols] + + [FeatureSpec(name=output_cols, dtype=DataType.DOUBLE)], + ) + } + self.assertEqual(model_signatures["predict"].to_dict(), expected_model_signatures["predict"].to_dict()) + + def test_pipeline_signature_snowpark(self) -> None: + input_df_pandas = load_diabetes(as_frame=True).frame + # If the pandas dataframe columns are not quoted, they will be quoted after create_dataframe. + input_df_pandas.columns = [inflection.parameterize(c, "_") for c in input_df_pandas.columns] + + input_df = self._session.create_dataframe(input_df_pandas) + + input_cols = [c for c in input_df.columns if not c.startswith('"target"')] + label_cols = ['"target"'] + output_cols = "OUTPUT" + + mms = MinMaxScaler() + mms.set_input_cols(['"age"']) + mms.set_output_cols(['"age"']) + ss = StandardScaler() + ss.set_input_cols(['"age"']) + ss.set_output_cols(['"age"']) + + estimator = SnowmlLinearRegression(input_cols=input_cols, output_cols=output_cols, label_cols=label_cols) + + pipeline = snowml_pipeline.Pipeline(steps=[("mms", mms), ("ss", ss), ("estimator", estimator)]) + + pipeline.fit(input_df) + + model_signatures = pipeline.model_signatures + + expected_model_signatures = { + "predict": ModelSignature( + inputs=[FeatureSpec(name=c, dtype=DataType.DOUBLE) for c in input_cols], + outputs=[FeatureSpec(name=c, dtype=DataType.DOUBLE) for c in input_cols] + + [FeatureSpec(name=output_cols, dtype=DataType.DOUBLE)], + ) + } + + self.assertEqual(model_signatures["predict"].to_dict(), expected_model_signatures["predict"].to_dict()) + def test_pipeline_signature(self) -> None: input_df_pandas = load_diabetes(as_frame=True).frame # Normalize column names @@ -382,7 +451,25 @@ def test_pipeline_signature(self) -> None: + [FeatureSpec(name="OUTPUT", dtype=DataType.DOUBLE)], ) } - self.assertItemsEqual(model_signatures["predict"].to_dict(), expected_model_signatures["predict"].to_dict()) + self.assertEqual(model_signatures["predict"].to_dict(), expected_model_signatures["predict"].to_dict()) + + def test_pipeline_with_label_encoder_output_col(self) -> None: + 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_df = self._session.create_dataframe(input_df_pandas) + + input_cols = ["SEX"] + output_cols = ["TARGET_out"] + le = LabelEncoder(input_cols=input_cols, output_cols=output_cols) + + pipeline = snowml_pipeline.Pipeline(steps=[("le", le)]) + pipeline.fit(input_df) + + snow_df_output = pipeline.transform(input_df).to_pandas() + + assert "TARGET_OUT" in snow_df_output.columns if __name__ == "__main__": diff --git a/tests/integ/snowflake/ml/modeling/preprocessing/BUILD_NATIVE.bzl b/tests/integ/snowflake/ml/modeling/preprocessing/BUILD_NATIVE.bzl index 224b35c6..e5aa462d 100644 --- a/tests/integ/snowflake/ml/modeling/preprocessing/BUILD_NATIVE.bzl +++ b/tests/integ/snowflake/ml/modeling/preprocessing/BUILD_NATIVE.bzl @@ -83,6 +83,7 @@ def get_build_rules_for_native_impl(): "//snowflake/ml/utils:sparse", "//tests/integ/snowflake/ml/modeling/framework:utils", ], + data = ["//tests/integ/snowflake/ml/test_data:UCI_BANK_MARKETING_20COLUMNS.csv"], ) py_test( 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 efb2871a..715c353c 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 @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional, Tuple import cloudpickle +import importlib_resources import joblib import numpy as np import pandas as pd @@ -57,6 +58,10 @@ ["9", '"c"', "g1ehQlL80t", 0.0, 0.0], ] +TEST_DATA_PATH = importlib_resources.files("tests.integ.snowflake.ml.test_data").joinpath( + "UCI_BANK_MARKETING_20COLUMNS.csv" +) + class OneHotEncoderTest(parameterized.TestCase): """Test OneHotEncoder.""" @@ -1653,12 +1658,8 @@ def test_fit_empty(self) -> None: encoder.fit(df) self.assertIn("Empty data while a minimum of 1 sample is required.", str(ex.exception)) - def test_fit_snowpark_transform_numeric_data(self) -> None: - snow_df = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 2000""" - ).drop("Y") + pd_data = pd.read_csv(TEST_DATA_PATH, index_col=0) + snow_df = self._session.create_dataframe(pd_data) input_cols = [c for c in snow_df.columns if c != "LABEL"] # contains dtype as int, object, float. output_cols = [f"OHE_{c}" for c in input_cols] @@ -1698,12 +1699,8 @@ def test_fit_snowpark_transform_everydtypes(self) -> None: def test_identical_snowpark_vs_pandas_output_column_names(self) -> None: # UCI_BANK_MARKETING_20COLUMNS - snow_df = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 1000""" - ).drop("Y") - pd_df = snow_df.to_pandas() + pd_df = pd.read_csv(TEST_DATA_PATH, index_col=0) + snow_df = self._session.create_dataframe(pd_df) cols = [ "AGE", "CAMPAIGN", @@ -1721,11 +1718,9 @@ def test_identical_snowpark_vs_pandas_output_column_names(self) -> None: self.assertCountEqual(snow_cols, pd_cols) def test_select_partial_cols(self) -> None: - snow_df = self._session.sql( - """SELECT AGE as AGE_1, * - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 1000""" - ).drop("Y") + pd_df = pd.read_csv(TEST_DATA_PATH, index_col=0) + pd_df["AGE_1"] = pd_df["AGE"] + snow_df = self._session.create_dataframe(pd_df) cols = [ "AGE", "CAMPAIGN", @@ -1758,12 +1753,8 @@ def test_get_output_cols_sparse(self) -> None: self.assertCountEqual(ohe.get_output_cols(), out_cols) def test_column_insensitivity(self) -> None: - # UCI_BANK_MARKETING_20COLUMNS - snow_df = self._session.sql( - """SELECT *, IFF(Y = 'yes', 1.0, 0.0) as LABEL - FROM ML_DATASETS.PUBLIC.UCI_BANK_MARKETING_20COLUMNS - LIMIT 1000""" - ).drop("Y") + pd_data = pd.read_csv(TEST_DATA_PATH, index_col=0) + snow_df = self._session.create_dataframe(pd_data) cols = [ "AGE", "CAMPAIGN", diff --git a/tests/integ/snowflake/ml/modeling/preprocessing/ordinal_encoder_test.py b/tests/integ/snowflake/ml/modeling/preprocessing/ordinal_encoder_test.py index 015414f7..e976fd6e 100644 --- a/tests/integ/snowflake/ml/modeling/preprocessing/ordinal_encoder_test.py +++ b/tests/integ/snowflake/ml/modeling/preprocessing/ordinal_encoder_test.py @@ -912,6 +912,30 @@ def test_large_num_cols(self) -> None: res = encoder.transform(df) res.collect() + def test_lowercase_output_cols_set_input_output(self) -> None: + input_cols = "COL" + output_cols = "OUT_foo" + data = {"COL": np.random.randint(0, 2, size=10)} + df = self._session.create_dataframe(pd.DataFrame(data)) + + encoder = OrdinalEncoder().set_input_cols(input_cols).set_output_cols(output_cols) + encoder.fit(df) + res = encoder.transform(df) + res.collect() + assert "OUT_FOO" in res.columns + + def test_quoted_output_cols_set_input_output(self) -> None: + input_cols = "COL" + output_cols = '"OUT_foo"' + data = {"COL": np.random.randint(0, 2, size=10)} + df = self._session.create_dataframe(pd.DataFrame(data)) + + encoder = OrdinalEncoder().set_input_cols(input_cols).set_output_cols(output_cols) + encoder.fit(df) + res = encoder.transform(df) + res.collect() + assert output_cols in res.columns + def test_large_num_cols_unknown(self) -> None: num_cols = 300 input_cols = [f"COL{i}" for i in range(1, num_cols + 1)] diff --git a/tests/integ/snowflake/ml/registry/model_registry_compat_test.py b/tests/integ/snowflake/ml/registry/model_registry_compat_test.py index 3adec8ba..781ae871 100644 --- a/tests/integ/snowflake/ml/registry/model_registry_compat_test.py +++ b/tests/integ/snowflake/ml/registry/model_registry_compat_test.py @@ -1,7 +1,7 @@ import uuid from typing import Callable, Tuple -from absl.testing import absltest +from absl.testing import absltest, parameterized from sklearn import datasets from snowflake.ml.registry import model_registry @@ -17,12 +17,17 @@ def setUp(self) -> None: self._db_manager = db_manager.DBManager(self.session) self.current_db = self.session.get_current_database() self.current_schema = self.session.get_current_schema() + self.registry_name = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(self.run_id, "registry_db") + + def tearDown(self) -> None: + 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) + super().tearDown() 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 @@ -37,26 +42,11 @@ def prepare_registry(session: session.Session, registry_name: str) -> None: 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] + prepare_fn_factory=_prepare_registry_fn_factory, version_range=">=1.0.1" # 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) + def test_open_registry_compat(self) -> None: + model_registry.ModelRegistry(session=self.session, database_name=self.registry_name, create_if_not_exists=True) def _prepare_registry_and_log_model_fn_factory( self, @@ -93,31 +83,25 @@ def prepare_registry_and_log_model(session: session.Session, registry_name: str, return prepare_registry_and_log_model, (self.registry_name, self.run_id) @common_test_base.CommonTestBase.compatibility_test( - prepare_fn_factory=_prepare_registry_and_log_model_fn_factory, # type: ignore[misc, arg-type] - version_range=">=1.0.6,<=1.0.11", + prepare_fn_factory=_prepare_registry_and_log_model_fn_factory, # type: ignore[arg-type] + version_range=">=1.0.6", ) - def test_log_model_compat_v1(self) -> None: - try: - registry = model_registry.ModelRegistry( - session=self.session, database_name=self.registry_name, create_if_not_exists=True - ) - model_ref = model_registry.ModelReference( - registry=registry, - model_name="model", - model_version=self.run_id, - ) - deployment_name = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(self.run_id, "predict") - model_ref.deploy( # type: ignore[attr-defined] - deployment_name=deployment_name, - target_method="predict", - ) - iris_X, iris_y = datasets.load_iris(return_X_y=True, as_frame=True) - model_ref.predict(deployment_name, iris_X) - - 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) + @parameterized.parameters({"permanent": True}) + def test_log_model_compat(self, permanent: bool) -> None: + registry = model_registry.ModelRegistry( + session=self.session, database_name=self.registry_name, create_if_not_exists=True + ) + model_ref = model_registry.ModelReference( + registry=registry, + model_name="model", + model_version=self.run_id, + ) + deployment_name = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(self.run_id, "predict") + model_ref.deploy( # type: ignore[attr-defined] + deployment_name=deployment_name, target_method="predict", permanent=permanent + ) + iris_X, iris_y = datasets.load_iris(return_X_y=True, as_frame=True) + model_ref.predict(deployment_name, iris_X) if __name__ == "__main__": 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 700e502c..e1812eb8 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 @@ -83,8 +83,7 @@ def _test_snowservice_deployment( deploy_info = model_ref.deploy(**deployment_options) # type: ignore[attr-defined] deploy_details = deploy_info["details"] self.assertNotEmpty(deploy_details) - self.assertTrue(deploy_details["image_name"]) - self.assertTrue(is_valid_yaml(deploy_details["service_spec"])) + self.assertTrue(deploy_details["service_info"]) self.assertTrue(deploy_details["service_function_sql"]) remote_prediction = model_ref.predict(deployment_name, test_features) diff --git a/tests/integ/snowflake/ml/test_data/BUILD.bazel b/tests/integ/snowflake/ml/test_data/BUILD.bazel new file mode 100644 index 00000000..e2ba9907 --- /dev/null +++ b/tests/integ/snowflake/ml/test_data/BUILD.bazel @@ -0,0 +1,3 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["UCI_BANK_MARKETING_20COLUMNS.csv"]) diff --git a/tests/integ/snowflake/ml/test_data/UCI_BANK_MARKETING_20COLUMNS.csv b/tests/integ/snowflake/ml/test_data/UCI_BANK_MARKETING_20COLUMNS.csv new file mode 100644 index 00000000..f57419d2 --- /dev/null +++ b/tests/integ/snowflake/ml/test_data/UCI_BANK_MARKETING_20COLUMNS.csv @@ -0,0 +1,2001 @@ +,AGE,JOB,MARITAL,EDUCATION,DEFAULT,HOUSING,LOAN,CONTACT,MONTH,DAY_OF_WEEK,DURATION,CAMPAIGN,PDAYS,PREVIOUS,POUTCOME,EMP_VAR_RATE,CONS_PRICE_IDX,CONS_CONF_IDX,EURIBOR3M,NR_EMPLOYED,LABEL +0,56,housemaid,married,basic.4y,no,no,no,telephone,may,mon,261,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1,57,services,married,high.school,unknown,no,no,telephone,may,mon,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +2,37,services,married,high.school,no,yes,no,telephone,may,mon,226,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +3,40,admin.,married,basic.6y,no,no,no,telephone,may,mon,151,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +4,56,services,married,high.school,no,no,yes,telephone,may,mon,307,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +5,45,services,married,basic.9y,unknown,no,no,telephone,may,mon,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +6,59,admin.,married,professional.course,no,no,no,telephone,may,mon,139,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +7,41,blue-collar,married,unknown,unknown,no,no,telephone,may,mon,217,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +8,24,technician,single,professional.course,no,yes,no,telephone,may,mon,380,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +9,25,services,single,high.school,no,yes,no,telephone,may,mon,50,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +10,41,blue-collar,married,unknown,unknown,no,no,telephone,may,mon,55,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +11,25,services,single,high.school,no,yes,no,telephone,may,mon,222,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +12,29,blue-collar,single,high.school,no,no,yes,telephone,may,mon,137,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +13,57,housemaid,divorced,basic.4y,no,yes,no,telephone,may,mon,293,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +14,35,blue-collar,married,basic.6y,no,yes,no,telephone,may,mon,146,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +15,54,retired,married,basic.9y,unknown,yes,yes,telephone,may,mon,174,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +16,35,blue-collar,married,basic.6y,no,yes,no,telephone,may,mon,312,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +17,46,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,mon,440,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +18,50,blue-collar,married,basic.9y,no,yes,yes,telephone,may,mon,353,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +19,39,management,single,basic.9y,unknown,no,no,telephone,may,mon,195,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +20,30,unemployed,married,high.school,no,no,no,telephone,may,mon,38,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +21,55,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,mon,262,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +22,55,retired,single,high.school,no,yes,no,telephone,may,mon,342,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +23,41,technician,single,high.school,no,yes,no,telephone,may,mon,181,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +24,37,admin.,married,high.school,no,yes,no,telephone,may,mon,172,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +25,35,technician,married,university.degree,no,no,yes,telephone,may,mon,99,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +26,59,technician,married,unknown,no,yes,no,telephone,may,mon,93,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +27,39,self-employed,married,basic.9y,unknown,no,no,telephone,may,mon,233,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +28,54,technician,single,university.degree,unknown,no,no,telephone,may,mon,255,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +29,55,unknown,married,university.degree,unknown,unknown,unknown,telephone,may,mon,362,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +30,46,admin.,married,unknown,no,no,no,telephone,may,mon,348,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +31,59,technician,married,unknown,no,yes,no,telephone,may,mon,386,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +32,49,blue-collar,married,unknown,no,no,no,telephone,may,mon,73,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +33,54,management,married,basic.4y,unknown,yes,no,telephone,may,mon,230,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +34,54,blue-collar,divorced,basic.4y,no,no,no,telephone,may,mon,208,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +35,55,unknown,married,basic.4y,unknown,yes,no,telephone,may,mon,336,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +36,34,services,married,high.school,no,no,no,telephone,may,mon,365,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +37,52,technician,married,basic.9y,no,yes,no,telephone,may,mon,1666,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +38,41,admin.,married,university.degree,no,yes,no,telephone,may,mon,577,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +39,56,technician,married,basic.4y,no,yes,no,telephone,may,mon,137,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +40,58,management,unknown,university.degree,no,yes,no,telephone,may,mon,366,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +41,32,entrepreneur,married,high.school,no,yes,no,telephone,may,mon,314,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +42,38,admin.,single,professional.course,no,no,no,telephone,may,mon,160,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +43,57,admin.,married,university.degree,no,no,yes,telephone,may,mon,212,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +44,44,admin.,married,university.degree,unknown,yes,no,telephone,may,mon,188,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +45,42,technician,single,professional.course,unknown,no,no,telephone,may,mon,22,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +46,57,admin.,married,university.degree,no,yes,yes,telephone,may,mon,616,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +47,40,blue-collar,married,basic.9y,no,no,yes,telephone,may,mon,178,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +48,35,admin.,married,university.degree,no,yes,no,telephone,may,mon,355,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +49,45,blue-collar,married,basic.9y,no,yes,no,telephone,may,mon,225,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +50,54,admin.,married,high.school,no,no,no,telephone,may,mon,160,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +51,39,housemaid,married,basic.4y,no,no,yes,telephone,may,mon,266,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +52,60,admin.,married,high.school,no,no,no,telephone,may,mon,253,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +53,53,admin.,single,professional.course,no,no,no,telephone,may,mon,179,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +54,55,blue-collar,married,basic.4y,unknown,no,no,telephone,may,mon,269,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +55,55,technician,married,professional.course,unknown,yes,no,telephone,may,mon,135,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +56,50,management,married,university.degree,unknown,no,yes,telephone,may,mon,161,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +57,45,services,married,high.school,unknown,yes,no,telephone,may,mon,787,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +58,55,unemployed,married,professional.course,unknown,yes,yes,telephone,may,mon,145,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +59,25,technician,single,university.degree,no,yes,no,telephone,may,mon,174,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +60,47,entrepreneur,married,university.degree,unknown,no,no,telephone,may,mon,449,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +61,51,blue-collar,married,basic.9y,no,yes,no,telephone,may,mon,812,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +62,42,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,mon,164,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +63,42,blue-collar,married,basic.6y,unknown,no,no,telephone,may,mon,366,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +64,48,admin.,married,high.school,no,no,no,telephone,may,mon,357,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +65,37,admin.,married,university.degree,no,no,no,telephone,may,mon,232,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +66,44,blue-collar,single,basic.9y,no,yes,no,telephone,may,mon,91,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +67,33,admin.,married,unknown,no,yes,no,telephone,may,mon,273,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +68,56,admin.,married,basic.9y,no,yes,no,telephone,may,mon,158,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +69,44,blue-collar,single,basic.4y,unknown,yes,yes,telephone,may,mon,177,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +70,41,management,married,basic.6y,no,no,no,telephone,may,mon,200,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +71,44,management,divorced,university.degree,no,yes,no,telephone,may,mon,172,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +72,47,admin.,married,university.degree,unknown,yes,no,telephone,may,mon,176,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +73,57,unknown,married,unknown,unknown,no,no,telephone,may,mon,211,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +74,37,admin.,married,university.degree,unknown,yes,no,telephone,may,mon,214,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +75,41,blue-collar,divorced,basic.4y,unknown,yes,no,telephone,may,mon,1575,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +76,55,technician,married,university.degree,no,no,no,telephone,may,mon,349,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +77,33,services,married,high.school,unknown,yes,no,telephone,may,mon,337,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +78,55,management,married,unknown,unknown,yes,no,telephone,may,mon,272,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +79,42,blue-collar,married,basic.9y,unknown,no,no,telephone,may,mon,208,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +80,50,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,mon,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +81,51,blue-collar,married,basic.4y,unknown,unknown,unknown,telephone,may,mon,212,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +82,38,admin.,married,high.school,unknown,no,no,telephone,may,mon,165,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +83,49,entrepreneur,married,university.degree,unknown,yes,no,telephone,may,mon,1042,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +84,38,technician,single,university.degree,no,no,yes,telephone,may,mon,20,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +85,31,admin.,divorced,high.school,no,no,no,telephone,may,mon,246,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +86,41,management,married,basic.6y,no,no,no,telephone,may,mon,529,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +87,39,admin.,married,university.degree,no,yes,yes,telephone,may,mon,192,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +88,49,technician,married,basic.9y,no,no,no,telephone,may,mon,1467,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +89,34,admin.,married,high.school,no,yes,no,telephone,may,mon,188,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +90,35,admin.,married,university.degree,no,yes,no,telephone,may,mon,180,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +91,57,unknown,married,unknown,unknown,yes,no,telephone,may,mon,48,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +92,60,admin.,married,unknown,unknown,no,yes,telephone,may,mon,213,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +93,33,unemployed,married,basic.9y,no,no,no,telephone,may,mon,545,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +94,42,blue-collar,married,basic.6y,no,no,yes,telephone,may,mon,583,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +95,45,services,married,professional.course,no,yes,no,telephone,may,mon,221,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +96,42,management,married,university.degree,no,no,no,telephone,may,mon,426,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +97,53,admin.,divorced,university.degree,unknown,no,no,telephone,may,mon,287,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +98,37,technician,single,professional.course,no,no,no,telephone,may,mon,197,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +99,44,blue-collar,married,basic.6y,no,no,no,telephone,may,mon,257,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +100,54,services,married,unknown,no,yes,no,telephone,may,mon,229,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +101,49,blue-collar,married,basic.4y,no,no,no,telephone,may,mon,55,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +102,54,services,married,unknown,no,no,no,telephone,may,mon,400,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +103,52,admin.,divorced,university.degree,no,no,no,telephone,may,mon,197,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +104,52,admin.,divorced,university.degree,no,no,no,telephone,may,mon,190,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +105,43,services,single,high.school,unknown,no,no,telephone,may,mon,21,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +106,34,housemaid,married,basic.6y,no,yes,no,telephone,may,mon,300,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +107,35,admin.,married,high.school,no,yes,no,telephone,may,mon,123,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +108,42,entrepreneur,married,unknown,unknown,yes,no,telephone,may,mon,293,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +109,43,technician,married,unknown,unknown,yes,yes,telephone,may,mon,325,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +110,60,retired,divorced,university.degree,unknown,no,no,telephone,may,mon,514,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +111,58,unemployed,married,basic.4y,unknown,no,no,telephone,may,mon,849,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +112,35,services,divorced,high.school,no,yes,no,telephone,may,mon,194,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +113,55,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,mon,212,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +114,37,management,married,university.degree,no,no,no,telephone,may,mon,337,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +115,36,blue-collar,married,high.school,no,no,no,telephone,may,mon,286,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +116,52,admin.,divorced,university.degree,no,no,no,telephone,may,mon,247,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +117,57,blue-collar,divorced,unknown,unknown,yes,no,telephone,may,mon,518,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +118,56,admin.,married,unknown,no,yes,no,telephone,may,mon,364,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +119,48,blue-collar,divorced,basic.4y,unknown,yes,no,telephone,may,mon,178,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +120,40,blue-collar,married,high.school,unknown,yes,no,telephone,may,mon,98,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +121,49,blue-collar,divorced,high.school,no,no,no,telephone,may,mon,439,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +122,41,services,married,high.school,no,no,no,telephone,may,mon,139,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +123,45,technician,married,high.school,no,yes,no,telephone,may,mon,79,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +124,32,admin.,married,university.degree,no,yes,yes,telephone,may,mon,175,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +125,42,admin.,married,university.degree,no,no,yes,telephone,may,mon,262,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +126,50,admin.,married,basic.6y,no,no,no,telephone,may,mon,61,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +127,31,technician,divorced,professional.course,no,yes,yes,telephone,may,mon,78,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +128,56,retired,married,basic.4y,no,yes,no,telephone,may,mon,102,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +129,41,technician,married,professional.course,unknown,yes,no,telephone,may,mon,579,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +130,37,blue-collar,married,basic.6y,no,yes,yes,telephone,may,mon,143,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +131,41,technician,married,professional.course,unknown,no,no,telephone,may,mon,677,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +132,35,blue-collar,married,unknown,no,yes,no,telephone,may,mon,267,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +133,37,blue-collar,married,basic.6y,no,yes,no,telephone,may,mon,345,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +134,52,management,married,university.degree,no,no,no,telephone,may,mon,185,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +135,39,management,divorced,university.degree,no,no,no,telephone,may,mon,207,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +136,39,housemaid,married,basic.4y,unknown,no,no,telephone,may,mon,69,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +137,56,blue-collar,married,basic.4y,unknown,no,no,telephone,may,mon,100,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +138,34,services,married,high.school,no,yes,no,telephone,may,mon,125,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +139,45,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,mon,461,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +140,43,unemployed,single,university.degree,no,yes,no,telephone,may,mon,240,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +141,56,management,married,unknown,no,yes,no,telephone,may,mon,70,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +142,39,management,married,university.degree,no,yes,no,telephone,may,mon,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +143,53,blue-collar,married,basic.4y,no,yes,no,telephone,may,mon,136,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +144,38,unknown,divorced,high.school,unknown,yes,no,telephone,may,mon,73,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +145,42,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,mon,528,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +146,42,blue-collar,married,basic.4y,unknown,no,no,telephone,may,mon,541,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +147,35,admin.,single,high.school,no,yes,no,telephone,may,mon,338,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +148,40,admin.,married,university.degree,unknown,yes,no,telephone,may,mon,163,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +149,51,blue-collar,married,basic.4y,no,yes,no,telephone,may,mon,87,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +150,60,blue-collar,married,basic.9y,unknown,no,no,telephone,may,mon,301,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +151,56,entrepreneur,married,unknown,unknown,yes,no,telephone,may,mon,46,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +152,39,services,divorced,high.school,unknown,yes,no,telephone,may,mon,52,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +153,56,entrepreneur,married,unknown,unknown,no,no,telephone,may,mon,204,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +154,40,blue-collar,married,unknown,no,yes,yes,telephone,may,mon,155,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +155,36,blue-collar,married,basic.9y,no,yes,yes,telephone,may,mon,98,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +156,51,blue-collar,married,unknown,unknown,yes,no,telephone,may,mon,71,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +157,51,blue-collar,married,unknown,unknown,no,no,telephone,may,mon,243,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +158,43,technician,single,professional.course,no,yes,no,telephone,may,mon,186,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +159,59,services,married,high.school,no,no,no,telephone,may,mon,579,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +160,24,management,single,university.degree,no,yes,no,telephone,may,mon,165,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +161,37,blue-collar,single,basic.9y,unknown,yes,yes,telephone,may,mon,163,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +162,40,blue-collar,married,basic.9y,unknown,no,no,telephone,may,mon,46,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +163,42,blue-collar,married,basic.9y,unknown,no,no,telephone,may,mon,559,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +164,39,services,divorced,high.school,unknown,no,no,telephone,may,mon,2033,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +165,37,entrepreneur,married,basic.6y,no,no,no,telephone,may,mon,85,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +166,38,services,married,high.school,no,yes,yes,telephone,may,mon,506,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +167,56,admin.,divorced,unknown,unknown,no,no,telephone,may,mon,114,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +168,40,blue-collar,married,unknown,no,no,no,telephone,may,mon,114,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +169,54,management,divorced,university.degree,no,yes,no,telephone,may,mon,843,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +170,43,blue-collar,married,high.school,unknown,no,yes,telephone,may,mon,181,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +171,36,services,married,high.school,unknown,no,no,telephone,may,mon,427,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +172,45,technician,married,university.degree,no,yes,yes,telephone,may,mon,292,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +173,42,admin.,single,university.degree,unknown,no,no,telephone,may,mon,192,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +174,43,services,married,high.school,no,no,no,telephone,may,mon,93,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +175,46,management,married,basic.9y,no,no,no,telephone,may,mon,128,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +176,44,services,married,high.school,no,yes,yes,telephone,may,mon,107,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +177,51,admin.,single,basic.6y,no,no,no,telephone,may,mon,303,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +178,28,services,married,high.school,no,yes,no,telephone,may,mon,81,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +179,37,blue-collar,married,basic.9y,no,no,no,telephone,may,mon,270,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +180,34,technician,married,high.school,no,no,no,telephone,may,mon,228,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +181,46,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,mon,240,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +182,42,blue-collar,married,basic.9y,no,yes,yes,telephone,may,mon,673,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +183,36,admin.,married,university.degree,no,yes,no,telephone,may,mon,233,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +184,54,blue-collar,married,basic.4y,unknown,no,no,telephone,may,mon,102,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +185,57,management,married,university.degree,no,no,no,telephone,may,mon,461,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +186,51,technician,married,basic.6y,unknown,no,no,telephone,may,mon,250,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +187,54,retired,married,high.school,unknown,no,no,telephone,may,mon,130,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +188,49,blue-collar,married,basic.4y,unknown,no,no,telephone,may,mon,252,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +189,34,admin.,married,university.degree,no,yes,no,telephone,may,mon,138,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +190,41,technician,married,professional.course,no,no,no,telephone,may,mon,412,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +191,38,blue-collar,single,basic.9y,no,yes,no,telephone,may,mon,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +192,45,management,divorced,university.degree,no,no,no,telephone,may,mon,19,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +193,42,technician,married,university.degree,no,no,no,telephone,may,mon,228,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +194,34,blue-collar,single,basic.9y,unknown,yes,no,telephone,may,mon,55,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +195,48,blue-collar,married,basic.4y,no,yes,no,telephone,may,mon,717,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +196,37,admin.,married,high.school,unknown,yes,no,telephone,may,mon,313,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +197,34,blue-collar,single,basic.9y,unknown,no,no,telephone,may,mon,289,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +198,40,admin.,married,basic.9y,unknown,yes,no,telephone,may,mon,683,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +199,43,blue-collar,married,basic.6y,no,yes,no,telephone,may,mon,1077,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +200,33,services,married,high.school,no,no,no,telephone,may,mon,146,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +201,32,services,single,basic.6y,no,no,no,telephone,may,mon,167,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +202,39,blue-collar,married,high.school,no,yes,no,telephone,may,mon,356,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +203,43,admin.,married,unknown,no,no,no,telephone,may,mon,277,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +204,44,blue-collar,married,basic.9y,no,yes,no,telephone,may,mon,172,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +205,35,student,single,university.degree,unknown,no,no,telephone,may,mon,218,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +206,35,student,single,university.degree,unknown,yes,yes,telephone,may,mon,217,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +207,35,unemployed,married,professional.course,no,no,no,telephone,may,mon,67,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +208,36,services,single,basic.4y,no,no,no,telephone,may,mon,291,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +209,35,student,single,university.degree,unknown,yes,no,telephone,may,mon,248,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +210,36,student,single,basic.9y,no,yes,no,telephone,may,mon,256,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +211,55,housemaid,divorced,university.degree,no,no,no,telephone,may,mon,286,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +212,59,admin.,married,university.degree,no,no,no,telephone,may,mon,477,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +213,57,retired,married,unknown,unknown,no,no,telephone,may,mon,611,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +214,39,blue-collar,married,professional.course,unknown,yes,no,telephone,may,mon,471,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +215,39,housemaid,married,basic.4y,unknown,yes,no,telephone,may,mon,381,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +216,30,blue-collar,single,unknown,no,yes,no,telephone,may,mon,251,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +217,30,blue-collar,single,unknown,no,yes,yes,telephone,may,mon,408,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +218,50,housemaid,married,basic.4y,unknown,no,no,telephone,may,mon,287,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +219,40,services,married,high.school,no,no,no,telephone,may,mon,322,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +220,35,admin.,married,high.school,no,yes,no,telephone,may,mon,216,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +221,43,technician,married,unknown,unknown,no,no,telephone,may,mon,366,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +222,36,services,single,basic.6y,unknown,yes,no,telephone,may,mon,210,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +223,46,technician,married,basic.4y,unknown,yes,no,telephone,may,mon,288,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +224,40,technician,married,basic.9y,no,no,no,telephone,may,mon,168,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +225,58,retired,married,university.degree,no,no,no,telephone,may,mon,132,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +226,42,technician,married,university.degree,no,no,no,telephone,may,mon,64,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +227,35,admin.,single,university.degree,no,no,no,telephone,may,mon,209,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +228,43,retired,married,basic.4y,unknown,no,no,telephone,may,mon,410,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +229,45,blue-collar,married,basic.4y,no,yes,yes,telephone,may,mon,177,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +230,39,admin.,married,professional.course,no,yes,no,telephone,may,mon,580,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +231,50,services,divorced,professional.course,no,yes,no,telephone,may,mon,165,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +232,32,management,divorced,basic.4y,unknown,yes,no,telephone,may,mon,127,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +233,39,admin.,married,high.school,unknown,no,no,telephone,may,mon,357,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +234,52,self-employed,married,university.degree,unknown,no,no,telephone,may,mon,175,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +235,44,admin.,married,professional.course,no,yes,no,telephone,may,mon,300,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +236,56,blue-collar,married,basic.4y,no,no,no,telephone,may,mon,136,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +237,48,admin.,married,high.school,no,no,no,telephone,may,mon,125,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +238,33,services,married,high.school,unknown,no,yes,telephone,may,mon,189,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +239,43,management,married,university.degree,unknown,no,yes,telephone,may,mon,213,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +240,57,retired,married,high.school,no,no,no,telephone,may,mon,238,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +241,36,technician,single,professional.course,no,no,no,telephone,may,mon,124,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +242,34,management,married,university.degree,no,yes,no,telephone,may,mon,18,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +243,34,services,single,basic.9y,no,no,no,telephone,may,mon,730,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +244,30,technician,married,university.degree,unknown,no,no,telephone,may,mon,40,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +245,33,services,divorced,unknown,no,no,no,telephone,may,mon,181,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +246,32,technician,single,professional.course,no,no,no,telephone,may,mon,79,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +247,36,technician,single,high.school,no,yes,no,telephone,may,mon,142,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +248,36,blue-collar,single,high.school,no,no,yes,telephone,may,mon,389,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +249,33,services,single,high.school,no,no,no,telephone,may,mon,702,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +250,40,technician,divorced,professional.course,no,no,no,telephone,may,mon,151,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +251,41,blue-collar,married,basic.4y,no,yes,no,telephone,may,mon,211,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +252,51,services,married,basic.6y,no,yes,no,telephone,may,mon,117,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +253,36,technician,married,professional.course,no,yes,no,telephone,may,mon,232,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +254,28,services,married,unknown,no,no,no,telephone,may,mon,408,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +255,53,services,married,high.school,no,yes,yes,telephone,may,mon,370,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +256,58,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,mon,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +257,45,management,married,basic.9y,no,no,yes,telephone,may,mon,46,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +258,43,technician,married,unknown,no,yes,no,telephone,may,mon,200,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +259,33,admin.,married,high.school,no,no,yes,telephone,may,mon,50,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +260,33,admin.,married,high.school,no,no,yes,telephone,may,mon,181,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +261,27,blue-collar,single,basic.6y,no,unknown,unknown,telephone,may,mon,119,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +262,42,technician,married,high.school,no,no,no,telephone,may,mon,361,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +263,43,admin.,divorced,basic.9y,no,no,no,telephone,may,mon,73,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +264,42,admin.,married,university.degree,no,no,no,telephone,may,mon,67,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +265,27,blue-collar,single,basic.6y,no,no,no,telephone,may,mon,350,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +266,34,self-employed,single,university.degree,no,yes,no,telephone,may,mon,150,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +267,31,student,single,university.degree,unknown,no,no,telephone,may,mon,332,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +268,54,management,divorced,university.degree,no,no,no,telephone,may,mon,611,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +269,40,self-employed,married,basic.4y,unknown,no,no,telephone,may,mon,58,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +270,52,unemployed,married,high.school,unknown,yes,no,telephone,may,mon,151,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +271,47,management,divorced,university.degree,no,no,no,telephone,may,mon,89,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +272,49,housemaid,married,basic.9y,unknown,no,no,telephone,may,mon,152,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +273,32,admin.,single,high.school,no,no,no,telephone,may,mon,611,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +274,49,housemaid,single,high.school,unknown,yes,yes,telephone,may,mon,110,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +275,50,blue-collar,married,basic.9y,unknown,no,no,telephone,may,mon,463,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +276,34,services,married,high.school,no,no,no,telephone,may,mon,962,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +277,55,blue-collar,married,basic.4y,unknown,no,no,telephone,may,mon,102,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +278,47,technician,single,basic.9y,no,no,no,telephone,may,mon,10,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +279,51,retired,married,professional.course,no,no,no,telephone,may,mon,118,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +280,47,blue-collar,married,basic.6y,no,no,no,telephone,may,mon,92,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +281,56,technician,married,basic.4y,no,yes,no,telephone,may,mon,143,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +282,53,admin.,married,basic.9y,no,no,no,telephone,may,mon,189,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +283,36,blue-collar,married,basic.9y,no,yes,no,telephone,may,mon,75,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +284,42,blue-collar,divorced,basic.9y,no,yes,no,telephone,may,mon,189,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +285,44,blue-collar,divorced,basic.6y,no,no,no,telephone,may,mon,55,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +286,39,housemaid,married,basic.9y,no,yes,no,telephone,may,mon,935,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +287,37,admin.,single,high.school,no,yes,yes,telephone,may,mon,56,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +288,60,blue-collar,married,unknown,unknown,yes,no,telephone,may,mon,5,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +289,36,services,married,high.school,no,no,no,telephone,may,mon,225,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +290,42,management,divorced,university.degree,no,yes,no,telephone,may,mon,125,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +291,31,admin.,married,high.school,no,yes,yes,telephone,may,mon,286,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +292,53,technician,divorced,professional.course,unknown,no,yes,telephone,may,mon,206,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +293,45,blue-collar,married,basic.4y,no,yes,no,telephone,may,mon,164,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +294,37,technician,single,professional.course,no,yes,no,telephone,may,mon,98,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +295,47,self-employed,married,professional.course,no,no,no,telephone,may,mon,446,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +296,49,blue-collar,divorced,high.school,no,no,yes,telephone,may,mon,742,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +297,36,admin.,married,university.degree,no,yes,no,telephone,may,mon,120,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +298,35,services,divorced,high.school,no,no,no,telephone,may,mon,122,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +299,38,unknown,married,unknown,unknown,no,no,telephone,may,mon,362,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +300,38,services,single,high.school,unknown,no,no,telephone,may,mon,357,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +301,50,blue-collar,divorced,high.school,unknown,no,yes,telephone,may,mon,200,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +302,59,retired,married,professional.course,unknown,no,no,telephone,may,mon,107,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +303,43,unknown,married,unknown,no,yes,no,telephone,may,mon,267,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +304,42,admin.,married,high.school,no,yes,no,telephone,may,mon,248,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +305,40,admin.,married,basic.6y,unknown,yes,no,telephone,may,mon,215,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +306,46,blue-collar,married,unknown,unknown,no,no,telephone,may,mon,209,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +307,57,housemaid,divorced,basic.4y,no,yes,yes,telephone,may,mon,205,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +308,42,admin.,married,university.degree,no,yes,no,telephone,may,mon,261,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +309,41,blue-collar,married,basic.4y,no,yes,no,telephone,may,mon,83,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +310,54,management,divorced,university.degree,no,yes,no,telephone,may,mon,106,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +311,27,admin.,single,university.degree,no,no,no,telephone,may,mon,106,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +312,36,admin.,married,high.school,no,no,no,telephone,may,mon,108,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +313,58,blue-collar,married,professional.course,unknown,yes,no,telephone,may,mon,214,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +314,40,management,married,university.degree,unknown,yes,no,telephone,may,mon,358,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +315,34,admin.,single,basic.9y,unknown,no,no,telephone,may,mon,453,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +316,33,admin.,divorced,high.school,no,no,no,telephone,may,mon,364,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +317,56,retired,married,basic.6y,no,no,no,telephone,may,mon,136,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +318,42,blue-collar,married,basic.9y,no,no,no,telephone,may,mon,173,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +319,43,services,married,high.school,no,yes,no,telephone,may,mon,241,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +320,54,management,single,basic.9y,no,yes,no,telephone,may,mon,224,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +321,53,admin.,single,basic.6y,no,yes,yes,telephone,may,mon,148,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +322,42,technician,single,unknown,no,no,no,telephone,may,mon,230,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +323,40,services,married,basic.6y,unknown,no,no,telephone,may,mon,199,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +324,54,technician,married,professional.course,no,yes,yes,telephone,may,mon,196,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +325,40,housemaid,single,professional.course,no,no,no,telephone,may,mon,111,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +326,26,admin.,single,university.degree,no,yes,no,telephone,may,mon,231,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +327,33,admin.,married,high.school,no,no,no,telephone,may,mon,316,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +328,35,blue-collar,married,basic.9y,unknown,no,no,telephone,may,mon,240,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +329,36,admin.,married,high.school,no,no,no,telephone,may,mon,669,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +330,29,services,single,high.school,no,yes,no,telephone,may,mon,425,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +331,36,blue-collar,married,basic.9y,no,yes,no,telephone,may,mon,121,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +332,35,management,married,high.school,no,yes,no,telephone,may,mon,174,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +333,50,self-employed,single,university.degree,no,no,yes,telephone,may,mon,88,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +334,46,services,married,high.school,unknown,yes,no,telephone,may,mon,313,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +335,49,entrepreneur,married,high.school,no,yes,no,telephone,may,mon,135,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +336,42,technician,single,professional.course,unknown,yes,no,telephone,may,mon,152,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +337,36,blue-collar,single,high.school,no,yes,yes,telephone,may,mon,402,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +338,55,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,mon,221,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +339,41,management,married,university.degree,unknown,no,no,telephone,may,mon,213,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +340,57,management,married,professional.course,unknown,yes,no,telephone,may,mon,144,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +341,32,services,single,high.school,no,yes,no,telephone,may,mon,158,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +342,49,admin.,married,basic.9y,no,no,no,telephone,may,mon,220,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +343,57,unknown,married,unknown,unknown,yes,no,telephone,may,mon,325,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +344,58,blue-collar,divorced,basic.4y,no,yes,no,telephone,may,mon,254,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +345,52,technician,married,high.school,no,yes,no,telephone,may,mon,503,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +346,50,services,divorced,professional.course,no,no,no,telephone,may,mon,680,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +347,41,blue-collar,single,basic.9y,unknown,yes,no,telephone,may,mon,421,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +348,33,entrepreneur,married,university.degree,no,yes,no,telephone,may,mon,130,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +349,57,blue-collar,married,basic.4y,no,no,no,telephone,may,mon,164,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +350,48,blue-collar,married,basic.6y,unknown,no,no,telephone,may,mon,174,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +351,55,technician,married,professional.course,no,no,no,telephone,may,mon,113,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +352,38,management,married,university.degree,no,no,no,telephone,may,mon,195,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +353,38,services,married,high.school,no,yes,no,telephone,may,mon,347,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +354,53,admin.,married,university.degree,no,yes,no,telephone,may,mon,208,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +355,43,unemployed,single,university.degree,no,no,no,telephone,may,mon,404,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +356,36,blue-collar,single,high.school,no,no,no,telephone,may,mon,396,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +357,32,blue-collar,married,basic.9y,no,no,no,telephone,may,mon,98,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +358,33,services,married,basic.4y,no,no,no,telephone,may,mon,229,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +359,58,entrepreneur,married,basic.4y,no,yes,no,telephone,may,mon,350,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +360,40,unemployed,divorced,high.school,unknown,no,no,telephone,may,tue,88,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +361,57,management,married,university.degree,no,yes,no,telephone,may,tue,379,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +362,45,technician,married,high.school,no,yes,no,telephone,may,tue,168,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +363,42,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,tue,190,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +364,46,admin.,married,university.degree,no,yes,no,telephone,may,tue,158,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +365,36,technician,married,university.degree,no,no,no,telephone,may,tue,210,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +366,41,self-employed,married,high.school,no,yes,no,telephone,may,tue,102,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +367,57,admin.,divorced,basic.9y,no,no,no,telephone,may,tue,306,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +368,49,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,tue,64,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +369,56,management,divorced,high.school,no,no,no,telephone,may,tue,218,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +370,37,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,77,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +371,37,blue-collar,married,basic.9y,no,no,yes,telephone,may,tue,54,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +372,46,admin.,single,high.school,no,yes,no,telephone,may,tue,344,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +373,22,blue-collar,single,basic.9y,no,yes,no,telephone,may,tue,195,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +374,43,services,married,high.school,no,no,no,telephone,may,tue,202,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +375,49,blue-collar,married,unknown,no,no,no,telephone,may,tue,286,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +376,41,technician,married,basic.6y,no,yes,no,telephone,may,tue,278,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +377,38,admin.,single,university.degree,no,no,no,telephone,may,tue,189,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +378,33,admin.,married,basic.9y,no,no,no,telephone,may,tue,83,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +379,49,admin.,married,university.degree,no,yes,yes,telephone,may,tue,18,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +380,30,blue-collar,single,unknown,no,no,no,telephone,may,tue,184,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +381,33,admin.,married,basic.9y,no,yes,no,telephone,may,tue,235,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +382,34,management,single,university.degree,no,no,no,telephone,may,tue,290,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +383,30,student,single,unknown,unknown,no,no,telephone,may,tue,133,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +384,59,management,married,basic.4y,unknown,yes,yes,telephone,may,tue,318,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +385,59,management,married,basic.4y,unknown,unknown,unknown,telephone,may,tue,437,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +386,41,self-employed,married,university.degree,no,no,no,telephone,may,tue,402,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +387,48,housemaid,married,basic.6y,unknown,yes,yes,telephone,may,tue,501,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +388,28,unknown,single,unknown,unknown,yes,yes,telephone,may,tue,1201,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +389,44,services,married,high.school,no,yes,no,telephone,may,tue,1030,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +390,59,retired,unknown,university.degree,unknown,no,no,telephone,may,tue,253,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +391,50,management,married,high.school,unknown,no,no,telephone,may,tue,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +392,35,admin.,married,university.degree,no,no,no,telephone,may,tue,144,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +393,42,admin.,married,high.school,no,no,no,telephone,may,tue,69,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +394,45,admin.,married,basic.9y,no,no,no,telephone,may,tue,243,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +395,36,technician,married,professional.course,no,no,no,telephone,may,tue,769,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +396,56,services,married,high.school,unknown,no,no,telephone,may,tue,135,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +397,29,blue-collar,divorced,basic.4y,no,no,no,telephone,may,tue,231,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +398,41,self-employed,married,university.degree,no,yes,yes,telephone,may,tue,442,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +399,42,blue-collar,married,basic.9y,unknown,no,no,telephone,may,tue,199,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +400,52,services,single,high.school,no,no,no,telephone,may,tue,455,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +401,48,services,married,high.school,unknown,unknown,unknown,telephone,may,tue,152,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +402,31,blue-collar,married,high.school,unknown,yes,no,telephone,may,tue,124,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +403,43,unemployed,married,university.degree,unknown,yes,no,telephone,may,tue,424,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +404,36,blue-collar,married,basic.4y,unknown,no,no,telephone,may,tue,43,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +405,42,entrepreneur,married,university.degree,no,yes,no,telephone,may,tue,154,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +406,52,services,divorced,basic.6y,no,no,no,telephone,may,tue,393,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +407,39,admin.,married,high.school,no,yes,no,telephone,may,tue,203,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +408,36,services,married,high.school,no,no,yes,telephone,may,tue,140,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +409,42,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,tue,326,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +410,41,admin.,married,high.school,no,no,no,telephone,may,tue,483,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +411,55,technician,married,basic.4y,no,no,yes,telephone,may,tue,259,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +412,30,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,227,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +413,57,retired,unknown,basic.4y,no,no,no,telephone,may,tue,673,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +414,45,entrepreneur,married,basic.4y,unknown,yes,no,telephone,may,tue,576,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +415,51,blue-collar,married,basic.4y,unknown,yes,yes,telephone,may,tue,180,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +416,39,self-employed,single,basic.9y,unknown,yes,yes,telephone,may,tue,90,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +417,47,admin.,married,high.school,unknown,no,no,telephone,may,tue,505,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +418,27,services,married,high.school,no,yes,no,telephone,may,tue,245,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +419,34,technician,single,university.degree,no,yes,no,telephone,may,tue,186,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +420,57,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,tue,208,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +421,43,entrepreneur,married,basic.4y,no,no,no,telephone,may,tue,623,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +422,49,blue-collar,married,basic.4y,unknown,no,no,telephone,may,tue,180,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +423,39,management,married,university.degree,unknown,no,no,telephone,may,tue,496,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +424,38,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,tue,118,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +425,49,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,102,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +426,58,management,married,basic.6y,no,no,no,telephone,may,tue,342,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +427,43,technician,married,professional.course,no,yes,no,telephone,may,tue,225,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +428,50,unknown,married,unknown,unknown,yes,no,telephone,may,tue,185,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +429,31,services,married,high.school,unknown,no,no,telephone,may,tue,276,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +430,43,self-employed,divorced,university.degree,no,no,no,telephone,may,tue,87,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +431,43,self-employed,married,basic.9y,unknown,no,no,telephone,may,tue,744,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +432,30,self-employed,single,university.degree,no,yes,no,telephone,may,tue,262,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +433,27,services,single,professional.course,no,yes,yes,telephone,may,tue,271,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +434,51,services,married,unknown,unknown,no,no,telephone,may,tue,141,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +435,37,technician,married,professional.course,no,yes,no,telephone,may,tue,198,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +436,46,housemaid,married,basic.9y,no,yes,no,telephone,may,tue,150,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +437,35,blue-collar,married,professional.course,no,no,no,telephone,may,tue,241,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +438,47,admin.,divorced,university.degree,no,no,no,telephone,may,tue,196,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +439,46,management,married,unknown,no,yes,no,telephone,may,tue,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +440,40,technician,married,professional.course,unknown,no,yes,telephone,may,tue,264,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +441,26,housemaid,married,university.degree,no,no,no,telephone,may,tue,246,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +442,32,blue-collar,divorced,basic.9y,unknown,no,no,telephone,may,tue,309,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +443,45,management,married,university.degree,no,yes,no,telephone,may,tue,140,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +444,34,admin.,married,basic.9y,no,no,no,telephone,may,tue,175,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +445,47,blue-collar,married,unknown,unknown,no,no,telephone,may,tue,136,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +446,42,technician,married,professional.course,no,no,no,telephone,may,tue,1623,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +447,57,technician,married,basic.4y,unknown,no,yes,telephone,may,tue,50,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +448,57,technician,married,basic.4y,unknown,no,no,telephone,may,tue,101,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +449,38,blue-collar,married,basic.4y,no,no,yes,telephone,may,tue,144,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +450,57,technician,married,basic.4y,unknown,no,no,telephone,may,tue,238,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +451,28,student,single,basic.9y,unknown,yes,no,telephone,may,tue,354,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +452,60,management,married,university.degree,no,no,no,telephone,may,tue,451,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +453,48,admin.,married,university.degree,no,yes,no,telephone,may,tue,159,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +454,47,technician,married,professional.course,no,yes,no,telephone,may,tue,170,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +455,40,admin.,married,high.school,no,no,no,telephone,may,tue,243,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +456,51,management,married,professional.course,unknown,yes,no,telephone,may,tue,141,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +457,48,unemployed,single,basic.4y,no,yes,no,telephone,may,tue,112,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +458,54,housemaid,married,basic.4y,no,no,no,telephone,may,tue,262,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +459,40,blue-collar,married,basic.6y,no,no,yes,telephone,may,tue,53,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +460,44,admin.,single,basic.9y,unknown,no,no,telephone,may,tue,134,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +461,42,technician,divorced,high.school,no,no,no,telephone,may,tue,204,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +462,52,self-employed,married,university.degree,no,yes,no,telephone,may,tue,678,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +463,31,blue-collar,single,basic.9y,no,yes,no,telephone,may,tue,182,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +464,32,services,married,high.school,no,no,no,telephone,may,tue,162,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +465,44,admin.,married,high.school,no,yes,yes,telephone,may,tue,177,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +466,39,management,married,basic.6y,unknown,no,no,telephone,may,tue,27,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +467,57,management,married,professional.course,unknown,yes,no,telephone,may,tue,699,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +468,52,admin.,married,basic.4y,no,yes,no,telephone,may,tue,358,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +469,42,management,married,university.degree,no,no,no,telephone,may,tue,1677,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +470,42,technician,single,professional.course,unknown,unknown,unknown,telephone,may,tue,529,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +471,43,management,married,professional.course,no,yes,no,telephone,may,tue,310,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +472,31,admin.,divorced,high.school,no,no,no,telephone,may,tue,47,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +473,29,admin.,married,university.degree,unknown,yes,no,telephone,may,tue,379,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +474,57,blue-collar,divorced,basic.4y,no,no,no,telephone,may,tue,30,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +475,44,blue-collar,married,basic.9y,unknown,no,no,telephone,may,tue,472,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +476,59,unknown,married,unknown,unknown,no,no,telephone,may,tue,113,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +477,39,blue-collar,married,basic.4y,unknown,no,no,telephone,may,tue,114,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +478,57,retired,married,university.degree,no,no,no,telephone,may,tue,116,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +479,41,management,married,unknown,unknown,no,no,telephone,may,tue,448,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +480,37,technician,single,high.school,unknown,no,yes,telephone,may,tue,264,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +481,38,services,married,high.school,no,yes,no,telephone,may,tue,169,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +482,42,retired,married,basic.4y,no,no,no,telephone,may,tue,145,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +483,49,unknown,married,unknown,unknown,yes,no,telephone,may,tue,288,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +484,54,management,married,basic.6y,unknown,yes,yes,telephone,may,tue,381,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +485,36,admin.,married,university.degree,no,unknown,unknown,telephone,may,tue,176,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +486,32,admin.,single,university.degree,unknown,no,no,telephone,may,tue,215,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +487,35,technician,married,professional.course,no,yes,no,telephone,may,tue,278,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +488,45,housemaid,married,professional.course,unknown,no,no,telephone,may,tue,188,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +489,56,technician,married,unknown,no,yes,no,telephone,may,tue,174,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +490,34,technician,married,university.degree,no,yes,no,telephone,may,tue,226,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +491,41,unemployed,married,basic.9y,unknown,no,no,telephone,may,tue,111,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +492,41,services,married,basic.9y,no,no,no,telephone,may,tue,157,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +493,36,blue-collar,married,basic.9y,unknown,no,no,telephone,may,tue,46,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +494,32,admin.,single,university.degree,unknown,unknown,unknown,telephone,may,tue,49,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +495,31,services,married,high.school,unknown,unknown,unknown,telephone,may,tue,374,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +496,38,technician,married,professional.course,no,yes,no,telephone,may,tue,349,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +497,35,admin.,divorced,university.degree,unknown,yes,no,telephone,may,tue,325,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +498,53,admin.,married,professional.course,no,yes,no,telephone,may,tue,233,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +499,51,admin.,married,basic.6y,unknown,yes,no,telephone,may,tue,531,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +500,56,entrepreneur,married,university.degree,unknown,no,no,telephone,may,tue,153,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +501,33,admin.,married,basic.9y,no,unknown,unknown,telephone,may,tue,80,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +502,33,admin.,married,basic.9y,no,no,no,telephone,may,tue,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +503,51,services,divorced,high.school,unknown,yes,no,telephone,may,tue,568,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +504,39,services,married,high.school,unknown,yes,no,telephone,may,tue,918,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +505,45,entrepreneur,married,university.degree,no,no,no,telephone,may,tue,82,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +506,40,technician,single,university.degree,no,no,no,telephone,may,tue,198,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +507,60,unknown,single,unknown,unknown,yes,no,telephone,may,tue,211,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +508,33,technician,married,university.degree,no,yes,no,telephone,may,tue,120,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +509,47,blue-collar,married,high.school,unknown,yes,no,telephone,may,tue,269,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +510,48,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,128,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +511,43,entrepreneur,married,high.school,no,no,no,telephone,may,tue,166,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +512,37,management,married,university.degree,no,yes,no,telephone,may,tue,211,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +513,50,services,married,basic.9y,no,yes,yes,telephone,may,tue,369,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +514,59,entrepreneur,married,high.school,no,yes,no,telephone,may,tue,91,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +515,32,technician,married,unknown,unknown,yes,no,telephone,may,tue,267,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +516,42,housemaid,single,basic.4y,unknown,yes,no,telephone,may,tue,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +517,52,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,371,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +518,42,blue-collar,married,unknown,unknown,no,no,telephone,may,tue,288,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +519,42,admin.,married,unknown,no,no,no,telephone,may,tue,221,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +520,40,management,married,basic.9y,unknown,unknown,unknown,telephone,may,tue,427,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +521,38,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,310,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +522,39,blue-collar,married,basic.6y,no,yes,no,telephone,may,tue,158,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +523,39,blue-collar,married,basic.6y,no,no,no,telephone,may,tue,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +524,38,admin.,married,high.school,no,no,no,telephone,may,tue,145,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +525,47,admin.,single,unknown,unknown,yes,no,telephone,may,tue,247,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +526,30,services,single,high.school,no,no,no,telephone,may,tue,102,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +527,38,admin.,divorced,high.school,no,yes,yes,telephone,may,tue,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +528,39,technician,divorced,university.degree,no,no,no,telephone,may,tue,73,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +529,44,admin.,married,high.school,no,no,no,telephone,may,tue,263,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +530,36,admin.,married,high.school,no,no,no,telephone,may,tue,342,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +531,41,services,married,high.school,unknown,yes,yes,telephone,may,tue,41,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +532,41,blue-collar,married,basic.6y,no,no,no,telephone,may,tue,13,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +533,35,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,79,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +534,41,technician,married,high.school,no,no,no,telephone,may,tue,358,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +535,36,services,married,high.school,unknown,no,no,telephone,may,tue,162,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +536,35,admin.,single,high.school,no,yes,yes,telephone,may,tue,150,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +537,33,blue-collar,single,basic.4y,unknown,no,no,telephone,may,tue,26,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +538,38,admin.,single,basic.6y,unknown,no,no,telephone,may,tue,250,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +539,33,self-employed,married,university.degree,no,no,no,telephone,may,tue,792,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +540,55,self-employed,married,unknown,unknown,no,no,telephone,may,tue,146,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +541,36,blue-collar,single,basic.9y,unknown,no,no,telephone,may,tue,440,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +542,57,technician,married,professional.course,unknown,no,yes,telephone,may,tue,289,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +543,32,entrepreneur,single,university.degree,no,yes,no,telephone,may,tue,242,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +544,30,self-employed,single,high.school,no,yes,no,telephone,may,tue,123,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +545,43,admin.,married,professional.course,no,yes,yes,telephone,may,tue,161,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +546,46,admin.,married,university.degree,no,no,no,telephone,may,tue,268,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +547,35,admin.,divorced,high.school,no,no,yes,telephone,may,tue,259,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +548,34,admin.,single,university.degree,no,yes,yes,telephone,may,tue,26,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +549,50,blue-collar,married,basic.4y,unknown,unknown,unknown,telephone,may,tue,153,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +550,39,admin.,single,university.degree,no,no,no,telephone,may,tue,424,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +551,39,blue-collar,married,basic.9y,unknown,no,no,telephone,may,tue,375,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +552,42,entrepreneur,married,basic.6y,no,no,no,telephone,may,tue,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +553,57,retired,married,university.degree,no,yes,no,telephone,may,tue,383,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +554,45,blue-collar,married,basic.4y,unknown,no,no,telephone,may,tue,440,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +555,40,technician,married,basic.4y,no,no,yes,telephone,may,tue,195,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +556,42,blue-collar,married,high.school,no,no,yes,telephone,may,tue,1297,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +557,40,technician,married,basic.4y,no,no,no,telephone,may,tue,217,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +558,38,blue-collar,single,basic.4y,unknown,no,no,telephone,may,tue,87,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +559,39,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,tue,427,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +560,41,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,189,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +561,39,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,tue,502,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +562,41,blue-collar,married,basic.4y,no,yes,yes,telephone,may,tue,260,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +563,45,admin.,married,university.degree,unknown,no,no,telephone,may,tue,209,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +564,35,admin.,divorced,university.degree,no,unknown,unknown,telephone,may,tue,179,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +565,35,admin.,married,basic.6y,no,yes,no,telephone,may,tue,179,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +566,49,blue-collar,married,high.school,unknown,no,no,telephone,may,tue,69,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +567,36,technician,married,unknown,no,no,no,telephone,may,tue,105,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +568,23,admin.,single,university.degree,no,no,no,telephone,may,tue,266,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +569,43,unemployed,married,university.degree,unknown,unknown,unknown,telephone,may,tue,87,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +570,49,entrepreneur,married,professional.course,no,unknown,unknown,telephone,may,tue,524,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +571,36,technician,married,professional.course,no,unknown,unknown,telephone,may,tue,155,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +572,51,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,tue,162,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +573,56,retired,married,basic.4y,unknown,yes,yes,telephone,may,tue,316,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +574,45,housemaid,married,high.school,no,no,no,telephone,may,tue,352,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +575,42,admin.,married,high.school,no,no,no,telephone,may,tue,695,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +576,41,admin.,divorced,high.school,no,no,no,telephone,may,tue,76,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +577,32,services,married,high.school,unknown,no,yes,telephone,may,tue,535,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +578,46,management,married,university.degree,no,no,no,telephone,may,tue,310,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +579,37,blue-collar,married,unknown,no,no,no,telephone,may,tue,390,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +580,53,management,married,university.degree,unknown,no,no,telephone,may,tue,369,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +581,30,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,112,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +582,45,unknown,married,unknown,unknown,no,no,telephone,may,tue,79,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +583,24,technician,single,professional.course,no,no,yes,telephone,may,tue,140,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +584,45,unknown,married,unknown,unknown,no,no,telephone,may,tue,315,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +585,34,admin.,single,university.degree,no,no,yes,telephone,may,tue,262,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +586,41,management,married,university.degree,unknown,no,no,telephone,may,tue,174,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +587,59,blue-collar,married,basic.4y,no,yes,no,telephone,may,tue,424,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +588,45,blue-collar,married,basic.4y,unknown,no,no,telephone,may,tue,135,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +589,54,blue-collar,married,basic.6y,unknown,no,no,telephone,may,tue,36,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +590,32,technician,married,professional.course,no,no,no,telephone,may,tue,1906,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +591,33,technician,married,professional.course,no,yes,no,telephone,may,tue,219,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +592,32,admin.,married,university.degree,no,no,no,telephone,may,tue,135,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +593,34,services,single,high.school,no,unknown,unknown,telephone,may,tue,147,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +594,46,management,married,university.degree,no,no,no,telephone,may,tue,407,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +595,57,technician,married,professional.course,no,yes,no,telephone,may,tue,402,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +596,42,technician,married,basic.9y,no,no,no,telephone,may,tue,209,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +597,48,admin.,married,professional.course,no,yes,no,telephone,may,tue,92,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +598,58,admin.,divorced,university.degree,unknown,yes,no,telephone,may,tue,208,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +599,34,services,married,high.school,no,no,no,telephone,may,tue,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +600,32,admin.,single,university.degree,no,yes,yes,telephone,may,tue,65,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +601,48,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,284,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +602,47,blue-collar,married,basic.9y,no,no,no,telephone,may,tue,285,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +603,31,services,married,high.school,unknown,no,no,telephone,may,tue,231,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +604,32,admin.,single,university.degree,no,yes,no,telephone,may,tue,278,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +605,32,admin.,single,university.degree,no,no,no,telephone,may,tue,389,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +606,36,services,married,high.school,no,no,no,telephone,may,tue,158,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +607,53,blue-collar,married,basic.4y,unknown,no,yes,telephone,may,tue,78,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +608,37,blue-collar,married,basic.9y,unknown,no,no,telephone,may,tue,258,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +609,50,services,married,high.school,no,no,no,telephone,may,tue,87,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +610,46,blue-collar,married,basic.6y,no,yes,no,telephone,may,tue,147,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +611,41,admin.,married,high.school,no,no,no,telephone,may,tue,635,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +612,43,blue-collar,single,basic.4y,no,no,no,telephone,may,tue,289,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +613,51,management,married,university.degree,no,yes,no,telephone,may,tue,170,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +614,38,admin.,married,university.degree,no,yes,no,telephone,may,tue,802,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +615,41,technician,married,basic.6y,no,yes,no,telephone,may,tue,381,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +616,39,services,married,high.school,unknown,no,no,telephone,may,tue,218,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +617,52,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,57,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +618,37,technician,married,professional.course,no,yes,no,telephone,may,tue,304,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +619,41,admin.,married,basic.9y,no,yes,no,telephone,may,tue,241,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +620,46,admin.,divorced,university.degree,no,yes,yes,telephone,may,tue,230,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +621,47,technician,married,professional.course,no,no,no,telephone,may,tue,79,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +622,35,technician,married,professional.course,no,no,no,telephone,may,tue,262,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +623,38,services,married,high.school,unknown,no,no,telephone,may,tue,392,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +624,30,blue-collar,single,unknown,no,yes,yes,telephone,may,tue,201,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +625,46,blue-collar,married,basic.9y,no,yes,no,telephone,may,tue,145,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +626,30,technician,married,professional.course,no,no,no,telephone,may,tue,252,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +627,35,services,single,basic.4y,no,no,no,telephone,may,tue,329,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +628,58,admin.,married,unknown,unknown,yes,no,telephone,may,tue,328,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +629,40,technician,married,professional.course,no,no,no,telephone,may,tue,191,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +630,34,technician,married,university.degree,no,no,no,telephone,may,tue,116,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +631,30,admin.,single,university.degree,no,no,no,telephone,may,tue,246,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +632,38,services,married,basic.6y,no,no,no,telephone,may,tue,532,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +633,55,management,single,basic.4y,no,no,no,telephone,may,tue,293,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +634,55,management,married,university.degree,unknown,yes,no,telephone,may,tue,416,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +635,45,technician,single,professional.course,no,yes,no,telephone,may,tue,37,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +636,36,admin.,married,university.degree,no,yes,yes,telephone,may,tue,132,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +637,32,admin.,single,university.degree,no,no,no,telephone,may,tue,530,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +638,35,technician,married,unknown,unknown,yes,no,telephone,may,tue,175,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +639,55,management,divorced,university.degree,no,yes,no,telephone,may,tue,90,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +640,49,housemaid,married,basic.4y,unknown,unknown,unknown,telephone,may,tue,524,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +641,29,admin.,married,high.school,no,yes,no,telephone,may,tue,29,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +642,34,blue-collar,married,basic.4y,no,yes,no,telephone,may,tue,311,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +643,54,retired,married,high.school,unknown,yes,no,telephone,may,tue,412,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +644,36,technician,single,professional.course,no,no,no,telephone,may,tue,211,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +645,30,management,single,university.degree,no,yes,no,telephone,may,tue,312,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +646,33,housemaid,divorced,university.degree,no,no,no,telephone,may,tue,392,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +647,37,blue-collar,married,high.school,no,no,no,telephone,may,tue,191,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +648,36,technician,married,basic.9y,unknown,yes,no,telephone,may,tue,284,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +649,33,blue-collar,married,high.school,no,no,no,telephone,may,tue,328,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +650,32,housemaid,married,high.school,no,yes,no,telephone,may,tue,100,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +651,32,housemaid,married,high.school,no,no,no,telephone,may,tue,226,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +652,50,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,tue,507,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +653,39,blue-collar,married,basic.9y,no,no,yes,telephone,may,tue,333,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +654,51,blue-collar,divorced,unknown,unknown,yes,yes,telephone,may,tue,128,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +655,33,services,married,high.school,no,no,no,telephone,may,tue,322,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +656,33,services,married,basic.4y,no,no,no,telephone,may,tue,202,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +657,56,management,married,university.degree,unknown,no,yes,telephone,may,tue,92,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +658,39,management,divorced,university.degree,unknown,yes,no,telephone,may,tue,205,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +659,33,self-employed,single,university.degree,no,no,no,telephone,may,tue,739,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +660,59,retired,divorced,basic.4y,unknown,no,no,telephone,may,tue,273,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +661,42,admin.,single,unknown,no,yes,no,telephone,may,tue,339,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +662,43,services,married,high.school,no,no,yes,telephone,may,tue,262,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +663,35,admin.,married,university.degree,no,unknown,unknown,telephone,may,tue,308,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +664,46,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,tue,467,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +665,27,technician,married,basic.9y,no,no,yes,telephone,may,tue,245,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +666,47,admin.,divorced,university.degree,no,yes,no,telephone,may,tue,160,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +667,31,admin.,single,university.degree,no,no,no,telephone,may,tue,189,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +668,32,blue-collar,married,basic.9y,no,yes,no,telephone,may,tue,477,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +669,54,admin.,married,high.school,no,no,no,telephone,may,tue,65,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +670,56,management,married,university.degree,unknown,no,no,telephone,may,tue,191,1,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +671,48,services,single,high.school,unknown,yes,no,telephone,may,tue,196,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +672,44,admin.,married,high.school,no,yes,no,telephone,may,tue,221,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +673,46,admin.,married,high.school,no,no,yes,telephone,may,tue,197,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +674,45,services,married,high.school,no,yes,no,telephone,may,tue,178,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +675,43,admin.,married,professional.course,no,no,yes,telephone,may,tue,221,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +676,40,admin.,single,high.school,unknown,no,no,telephone,may,tue,64,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +677,56,management,married,university.degree,no,yes,no,telephone,may,tue,75,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +678,47,entrepreneur,married,professional.course,no,no,no,telephone,may,tue,400,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +679,39,management,divorced,university.degree,unknown,yes,no,telephone,may,tue,378,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +680,48,technician,married,basic.4y,unknown,no,no,telephone,may,tue,118,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +681,45,housemaid,married,professional.course,unknown,yes,no,telephone,may,tue,1597,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,1 +682,36,self-employed,married,university.degree,no,no,no,telephone,may,tue,346,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +683,49,management,married,professional.course,unknown,yes,no,telephone,may,tue,107,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +684,46,services,married,high.school,no,no,no,telephone,may,tue,60,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +685,38,blue-collar,married,basic.9y,no,yes,no,telephone,may,tue,276,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +686,50,housemaid,married,basic.4y,unknown,yes,no,telephone,may,tue,176,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +687,27,admin.,single,high.school,no,yes,no,telephone,may,tue,390,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +688,40,technician,married,professional.course,unknown,yes,no,telephone,may,tue,251,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +689,46,management,married,university.degree,no,yes,no,telephone,may,tue,716,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +690,40,admin.,divorced,university.degree,no,yes,no,telephone,may,tue,189,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +691,33,management,single,university.degree,no,no,no,telephone,may,tue,125,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +692,54,retired,married,high.school,no,yes,no,telephone,may,tue,234,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +693,52,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,tue,79,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +694,53,admin.,married,university.degree,unknown,yes,no,telephone,may,tue,13,6,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +695,49,services,married,high.school,unknown,no,no,telephone,may,tue,296,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +696,42,blue-collar,married,basic.4y,unknown,no,no,telephone,may,tue,114,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +697,49,housemaid,married,basic.4y,no,no,no,telephone,may,tue,283,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +698,40,entrepreneur,married,basic.9y,no,no,no,telephone,may,tue,109,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +699,59,admin.,married,university.degree,unknown,no,no,telephone,may,tue,132,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +700,51,self-employed,married,basic.9y,no,yes,no,telephone,may,tue,144,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +701,57,housemaid,married,basic.6y,unknown,yes,no,telephone,may,tue,121,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +702,38,self-employed,divorced,basic.9y,no,yes,no,telephone,may,tue,95,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +703,52,self-employed,married,university.degree,no,no,no,telephone,may,tue,31,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +704,26,unemployed,single,basic.9y,no,yes,no,telephone,may,tue,112,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +705,35,admin.,married,university.degree,no,yes,yes,telephone,may,tue,161,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +706,51,admin.,married,university.degree,no,no,no,telephone,may,tue,87,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +707,43,blue-collar,married,basic.4y,unknown,yes,yes,telephone,may,tue,593,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +708,42,admin.,married,high.school,unknown,yes,no,telephone,may,tue,99,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +709,60,retired,divorced,university.degree,unknown,yes,no,telephone,may,tue,198,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +710,36,blue-collar,married,basic.9y,no,yes,no,telephone,may,tue,285,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +711,57,management,married,university.degree,unknown,no,no,telephone,may,tue,190,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +712,35,student,single,university.degree,unknown,no,no,telephone,may,tue,172,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +713,30,admin.,married,university.degree,no,no,no,telephone,may,tue,178,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +714,43,services,married,high.school,no,no,no,telephone,may,tue,174,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +715,40,technician,married,basic.9y,no,no,no,telephone,may,tue,631,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +716,45,technician,married,university.degree,no,no,no,telephone,may,tue,152,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +717,56,retired,married,high.school,no,no,no,telephone,may,tue,176,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +718,46,services,married,basic.6y,no,no,no,telephone,may,tue,32,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +719,41,blue-collar,married,basic.4y,no,yes,no,telephone,may,tue,1529,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +720,42,admin.,married,university.degree,no,yes,no,telephone,may,tue,254,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +721,56,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,214,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +722,43,entrepreneur,married,basic.4y,no,yes,no,telephone,may,tue,147,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +723,34,blue-collar,married,basic.4y,no,no,no,telephone,may,tue,800,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +724,38,services,married,high.school,no,yes,no,telephone,may,tue,106,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +725,40,technician,married,basic.9y,no,no,no,telephone,may,tue,135,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +726,38,admin.,married,high.school,no,no,no,telephone,may,tue,112,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +727,48,entrepreneur,married,university.degree,no,yes,no,telephone,may,tue,222,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +728,27,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,tue,314,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +729,24,technician,single,professional.course,no,no,no,telephone,may,tue,421,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +730,47,admin.,single,unknown,unknown,yes,yes,telephone,may,tue,410,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +731,48,blue-collar,married,professional.course,unknown,no,no,telephone,may,tue,207,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +732,31,admin.,single,high.school,no,yes,yes,telephone,may,tue,239,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +733,39,self-employed,single,basic.4y,unknown,yes,no,telephone,may,tue,83,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +734,41,technician,single,university.degree,unknown,no,no,telephone,may,tue,160,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +735,49,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,tue,42,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +736,56,retired,married,basic.4y,no,no,no,telephone,may,tue,55,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +737,49,admin.,married,high.school,no,no,no,telephone,may,tue,157,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +738,47,unemployed,divorced,university.degree,no,no,no,telephone,may,tue,303,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +739,44,blue-collar,married,unknown,unknown,yes,no,telephone,may,tue,336,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +740,48,admin.,divorced,high.school,no,no,no,telephone,may,tue,233,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +741,55,housemaid,married,basic.4y,no,yes,yes,telephone,may,tue,211,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +742,59,management,married,basic.4y,unknown,yes,no,telephone,may,tue,88,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +743,44,technician,single,professional.course,no,yes,yes,telephone,may,tue,139,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +744,46,blue-collar,married,basic.9y,no,no,yes,telephone,may,tue,329,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +745,39,blue-collar,divorced,basic.6y,unknown,yes,no,telephone,may,tue,305,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +746,38,unemployed,married,basic.4y,unknown,yes,yes,telephone,may,tue,206,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +747,36,services,married,high.school,no,yes,no,telephone,may,tue,128,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +748,29,admin.,single,university.degree,no,no,no,telephone,may,tue,122,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +749,31,technician,single,professional.course,no,yes,no,telephone,may,tue,343,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +750,30,services,divorced,basic.9y,no,no,yes,telephone,may,tue,126,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +751,36,blue-collar,married,basic.4y,no,yes,no,telephone,may,tue,249,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +752,60,blue-collar,married,professional.course,no,no,no,telephone,may,tue,59,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +753,36,admin.,married,university.degree,no,yes,no,telephone,may,tue,166,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +754,32,services,married,high.school,no,no,no,telephone,may,tue,190,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +755,39,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,tue,216,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +756,30,services,married,university.degree,no,yes,no,telephone,may,wed,51,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +757,39,blue-collar,married,basic.4y,unknown,no,no,telephone,may,wed,169,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +758,37,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,148,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +759,39,blue-collar,married,professional.course,unknown,yes,no,telephone,may,wed,132,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +760,46,blue-collar,married,basic.4y,no,no,no,telephone,may,wed,117,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +761,43,unemployed,married,basic.4y,no,no,no,telephone,may,wed,275,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +762,37,services,married,high.school,no,yes,no,telephone,may,wed,124,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +763,30,technician,married,university.degree,unknown,no,no,telephone,may,wed,118,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +764,38,technician,married,professional.course,no,no,no,telephone,may,wed,479,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +765,31,technician,married,university.degree,no,yes,no,telephone,may,wed,285,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +766,44,admin.,married,university.degree,no,no,no,telephone,may,wed,322,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +767,44,self-employed,married,university.degree,unknown,no,no,telephone,may,wed,202,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +768,38,technician,married,unknown,unknown,unknown,unknown,telephone,may,wed,162,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +769,30,student,single,high.school,unknown,no,no,telephone,may,wed,216,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +770,37,blue-collar,married,basic.4y,unknown,unknown,unknown,telephone,may,wed,195,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +771,54,housemaid,divorced,unknown,no,yes,no,telephone,may,wed,96,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +772,41,management,married,unknown,unknown,yes,no,telephone,may,wed,149,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +773,43,technician,married,professional.course,no,no,no,telephone,may,wed,720,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +774,38,housemaid,married,basic.9y,no,no,no,telephone,may,wed,92,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +775,41,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,188,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +776,38,blue-collar,single,unknown,unknown,no,yes,telephone,may,wed,70,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +777,33,blue-collar,married,basic.9y,no,unknown,unknown,telephone,may,wed,141,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +778,32,admin.,married,high.school,no,yes,no,telephone,may,wed,395,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +779,30,services,married,high.school,unknown,no,no,telephone,may,wed,629,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +780,43,technician,married,basic.6y,no,yes,no,telephone,may,wed,261,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +781,30,services,married,high.school,unknown,yes,no,telephone,may,wed,502,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +782,36,technician,divorced,professional.course,no,no,no,telephone,may,wed,446,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +783,38,blue-collar,married,basic.6y,no,unknown,unknown,telephone,may,wed,131,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +784,35,blue-collar,single,basic.6y,no,yes,no,telephone,may,wed,198,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +785,50,self-employed,married,university.degree,no,no,no,telephone,may,wed,312,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +786,38,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,275,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +787,58,admin.,married,basic.4y,unknown,no,yes,telephone,may,wed,120,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +788,50,technician,married,professional.course,no,yes,no,telephone,may,wed,333,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +789,33,blue-collar,single,basic.9y,unknown,no,no,telephone,may,wed,113,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +790,33,blue-collar,single,basic.9y,unknown,unknown,unknown,telephone,may,wed,150,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +791,44,management,single,university.degree,unknown,yes,yes,telephone,may,wed,91,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +792,31,management,married,high.school,no,no,no,telephone,may,wed,296,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +793,40,entrepreneur,married,basic.9y,no,no,no,telephone,may,wed,128,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +794,28,unknown,single,basic.9y,unknown,no,no,telephone,may,wed,298,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +795,36,services,single,unknown,no,yes,no,telephone,may,wed,326,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +796,44,blue-collar,single,high.school,no,no,no,telephone,may,wed,292,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +797,38,blue-collar,divorced,unknown,no,yes,no,telephone,may,wed,215,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +798,33,blue-collar,married,basic.9y,no,unknown,unknown,telephone,may,wed,97,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +799,39,blue-collar,divorced,basic.6y,unknown,yes,yes,telephone,may,wed,32,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +800,47,blue-collar,single,basic.4y,unknown,yes,no,telephone,may,wed,162,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +801,47,services,divorced,high.school,no,no,no,telephone,may,wed,421,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +802,35,entrepreneur,single,professional.course,no,no,no,telephone,may,wed,268,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +803,42,admin.,divorced,university.degree,no,no,no,telephone,may,wed,232,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +804,36,admin.,married,high.school,no,no,no,telephone,may,wed,152,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +805,35,technician,married,basic.6y,no,no,no,telephone,may,wed,104,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +806,36,services,single,unknown,no,yes,no,telephone,may,wed,852,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +807,56,entrepreneur,married,university.degree,unknown,yes,no,telephone,may,wed,159,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +808,26,admin.,single,high.school,no,no,no,telephone,may,wed,416,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +809,31,admin.,single,university.degree,no,yes,yes,telephone,may,wed,174,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +810,57,self-employed,single,high.school,unknown,no,no,telephone,may,wed,139,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +811,39,services,married,high.school,no,no,no,telephone,may,wed,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +812,43,blue-collar,married,high.school,no,yes,no,telephone,may,wed,294,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +813,50,admin.,married,university.degree,no,no,no,telephone,may,wed,102,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +814,52,blue-collar,single,high.school,unknown,unknown,unknown,telephone,may,wed,124,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +815,39,blue-collar,divorced,basic.9y,no,no,no,telephone,may,wed,143,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +816,44,technician,single,unknown,unknown,yes,no,telephone,may,wed,231,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +817,36,housemaid,married,basic.4y,no,yes,no,telephone,may,wed,128,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +818,54,management,married,high.school,unknown,yes,no,telephone,may,wed,74,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +819,27,admin.,single,basic.9y,no,yes,no,telephone,may,wed,105,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +820,31,admin.,single,university.degree,no,no,no,telephone,may,wed,992,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +821,31,admin.,married,university.degree,unknown,no,no,telephone,may,wed,168,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +822,28,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,250,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +823,31,management,single,university.degree,no,yes,no,telephone,may,wed,254,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +824,30,management,divorced,high.school,no,no,yes,telephone,may,wed,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +825,52,technician,married,professional.course,no,yes,no,telephone,may,wed,133,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +826,37,technician,divorced,professional.course,no,no,no,telephone,may,wed,374,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +827,30,services,married,university.degree,no,yes,no,telephone,may,wed,425,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +828,45,blue-collar,married,basic.9y,unknown,yes,yes,telephone,may,wed,207,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +829,54,technician,married,university.degree,no,yes,no,telephone,may,wed,464,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +830,49,entrepreneur,married,basic.9y,no,yes,no,telephone,may,wed,439,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +831,44,admin.,married,high.school,no,no,no,telephone,may,wed,83,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +832,28,student,single,basic.9y,unknown,yes,yes,telephone,may,wed,732,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +833,53,technician,married,high.school,no,yes,no,telephone,may,wed,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +834,45,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,142,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +835,45,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,121,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +836,51,admin.,married,high.school,unknown,yes,no,telephone,may,wed,359,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +837,41,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,112,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +838,43,technician,married,professional.course,no,yes,no,telephone,may,wed,274,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +839,31,admin.,single,university.degree,no,yes,no,telephone,may,wed,325,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +840,39,admin.,single,unknown,no,yes,no,telephone,may,wed,1521,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +841,38,technician,married,professional.course,no,no,no,telephone,may,wed,216,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +842,50,entrepreneur,married,basic.9y,no,yes,yes,telephone,may,wed,161,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +843,53,admin.,single,university.degree,unknown,yes,no,telephone,may,wed,122,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +844,44,admin.,divorced,high.school,unknown,yes,no,telephone,may,wed,800,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +845,53,blue-collar,married,basic.9y,unknown,yes,yes,telephone,may,wed,615,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +846,24,services,single,high.school,no,no,no,telephone,may,wed,111,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +847,34,admin.,single,high.school,no,yes,no,telephone,may,wed,359,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +848,40,technician,married,professional.course,no,yes,no,telephone,may,wed,327,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +849,57,management,divorced,university.degree,no,yes,no,telephone,may,wed,236,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +850,43,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,wed,227,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +851,31,blue-collar,married,basic.6y,unknown,no,no,telephone,may,wed,109,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +852,31,services,married,basic.6y,no,yes,no,telephone,may,wed,492,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +853,31,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,wed,298,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +854,45,housemaid,married,basic.4y,unknown,yes,no,telephone,may,wed,83,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +855,38,admin.,married,university.degree,no,no,no,telephone,may,wed,241,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +856,41,entrepreneur,married,university.degree,no,yes,no,telephone,may,wed,1138,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +857,47,blue-collar,married,professional.course,unknown,yes,no,telephone,may,wed,131,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +858,42,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,123,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +859,35,blue-collar,single,unknown,no,yes,no,telephone,may,wed,125,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +860,40,management,married,university.degree,no,yes,no,telephone,may,wed,295,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +861,42,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,287,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +862,34,technician,married,professional.course,unknown,yes,no,telephone,may,wed,109,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +863,48,admin.,single,university.degree,no,yes,no,telephone,may,wed,140,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +864,35,blue-collar,married,unknown,no,yes,yes,telephone,may,wed,52,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +865,33,blue-collar,married,high.school,no,yes,no,telephone,may,wed,233,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +866,37,technician,married,professional.course,no,yes,no,telephone,may,wed,254,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +867,43,technician,married,professional.course,no,yes,no,telephone,may,wed,255,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +868,42,admin.,single,unknown,unknown,no,no,telephone,may,wed,126,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +869,46,admin.,divorced,university.degree,no,yes,no,telephone,may,wed,184,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +870,37,blue-collar,single,basic.9y,unknown,yes,no,telephone,may,wed,591,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +871,40,blue-collar,married,high.school,no,yes,no,telephone,may,wed,294,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +872,40,blue-collar,married,high.school,no,yes,no,telephone,may,wed,285,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +873,59,entrepreneur,divorced,high.school,unknown,yes,no,telephone,may,wed,173,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +874,43,technician,single,university.degree,no,no,no,telephone,may,wed,336,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +875,44,blue-collar,married,basic.6y,no,yes,yes,telephone,may,wed,344,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +876,44,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,786,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +877,41,admin.,married,university.degree,no,yes,no,telephone,may,wed,153,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +878,28,unknown,single,basic.9y,unknown,yes,no,telephone,may,wed,99,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +879,48,services,married,high.school,unknown,yes,no,telephone,may,wed,243,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +880,31,admin.,single,high.school,no,yes,no,telephone,may,wed,260,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +881,27,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,164,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +882,43,technician,single,university.degree,no,yes,no,telephone,may,wed,255,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +883,47,retired,married,basic.4y,unknown,yes,no,telephone,may,wed,47,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +884,40,blue-collar,divorced,unknown,no,yes,no,telephone,may,wed,110,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +885,43,unknown,married,high.school,unknown,no,no,telephone,may,wed,463,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +886,29,technician,married,professional.course,no,yes,no,telephone,may,wed,192,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +887,54,admin.,married,university.degree,no,no,no,telephone,may,wed,388,7,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +888,36,technician,married,professional.course,no,no,no,telephone,may,wed,221,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +889,30,admin.,single,university.degree,no,no,no,telephone,may,wed,25,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +890,38,services,single,unknown,unknown,yes,no,telephone,may,wed,256,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +891,39,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,104,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +892,33,blue-collar,married,basic.9y,no,no,no,telephone,may,wed,283,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +893,39,retired,single,high.school,no,yes,no,telephone,may,wed,448,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +894,38,technician,single,professional.course,no,no,no,telephone,may,wed,127,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +895,44,admin.,married,high.school,no,yes,no,telephone,may,wed,378,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +896,33,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,67,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +897,37,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,221,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +898,32,management,married,basic.6y,no,no,no,telephone,may,wed,150,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +899,46,technician,married,professional.course,no,yes,no,telephone,may,wed,144,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +900,34,technician,divorced,professional.course,no,yes,no,telephone,may,wed,296,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +901,52,technician,married,basic.4y,unknown,no,no,telephone,may,wed,161,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +902,46,blue-collar,married,basic.6y,unknown,no,no,telephone,may,wed,401,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +903,57,management,divorced,university.degree,no,unknown,unknown,telephone,may,wed,435,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +904,52,technician,married,basic.4y,unknown,no,yes,telephone,may,wed,388,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +905,32,technician,single,professional.course,no,no,no,telephone,may,wed,245,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +906,31,blue-collar,single,basic.9y,no,no,no,telephone,may,wed,143,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +907,46,admin.,divorced,basic.9y,unknown,no,no,telephone,may,wed,423,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +908,59,services,divorced,high.school,no,no,no,telephone,may,wed,231,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +909,38,blue-collar,married,unknown,unknown,no,yes,telephone,may,wed,181,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +910,42,blue-collar,divorced,basic.6y,unknown,yes,no,telephone,may,wed,107,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +911,40,blue-collar,married,basic.4y,no,no,no,telephone,may,wed,227,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +912,46,admin.,married,professional.course,no,no,no,telephone,may,wed,69,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +913,35,admin.,single,high.school,no,no,no,telephone,may,wed,799,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +914,28,blue-collar,married,basic.9y,no,no,yes,telephone,may,wed,109,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +915,47,services,married,high.school,unknown,yes,no,telephone,may,wed,127,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +916,35,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,45,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +917,48,retired,married,basic.9y,no,yes,no,telephone,may,wed,120,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +918,40,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,wed,68,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +919,32,services,single,high.school,no,yes,no,telephone,may,wed,180,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +920,59,retired,divorced,basic.4y,no,yes,no,telephone,may,wed,112,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +921,48,entrepreneur,married,basic.9y,unknown,no,no,telephone,may,wed,444,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +922,45,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,246,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +923,49,blue-collar,single,basic.6y,no,yes,no,telephone,may,wed,148,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +924,51,admin.,married,university.degree,no,yes,no,telephone,may,wed,223,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +925,38,technician,single,university.degree,no,yes,no,telephone,may,wed,566,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +926,53,technician,married,high.school,no,yes,no,telephone,may,wed,274,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +927,49,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,49,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +928,31,blue-collar,married,basic.9y,no,yes,yes,telephone,may,wed,97,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +929,36,services,married,high.school,unknown,yes,no,telephone,may,wed,376,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +930,54,retired,married,basic.4y,unknown,yes,no,telephone,may,wed,421,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +931,29,admin.,single,high.school,no,yes,no,telephone,may,wed,511,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +932,31,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,wed,121,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +933,35,admin.,married,high.school,no,yes,no,telephone,may,wed,157,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +934,38,blue-collar,married,basic.9y,no,no,no,telephone,may,wed,101,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +935,54,management,married,high.school,unknown,yes,yes,telephone,may,wed,328,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +936,48,admin.,married,basic.9y,unknown,no,no,telephone,may,wed,19,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +937,48,management,divorced,university.degree,no,yes,no,telephone,may,wed,866,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +938,32,admin.,single,university.degree,unknown,yes,no,telephone,may,wed,229,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +939,34,technician,married,professional.course,unknown,yes,no,telephone,may,wed,154,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +940,49,technician,single,university.degree,unknown,yes,no,telephone,may,wed,56,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +941,44,blue-collar,married,basic.9y,unknown,unknown,unknown,telephone,may,wed,199,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +942,42,services,divorced,high.school,unknown,no,no,telephone,may,wed,117,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +943,55,blue-collar,married,basic.4y,unknown,no,no,telephone,may,wed,1581,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +944,42,services,divorced,high.school,unknown,no,no,telephone,may,wed,185,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +945,44,admin.,married,university.degree,unknown,no,no,telephone,may,wed,202,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +946,51,blue-collar,married,basic.9y,no,no,no,telephone,may,wed,279,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +947,43,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,wed,180,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +948,52,technician,married,high.school,no,yes,no,telephone,may,wed,530,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +949,45,blue-collar,single,basic.6y,no,yes,no,telephone,may,wed,129,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +950,41,services,divorced,high.school,no,yes,yes,telephone,may,wed,60,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +951,35,technician,divorced,unknown,no,yes,yes,telephone,may,wed,432,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +952,41,services,divorced,high.school,no,no,yes,telephone,may,wed,516,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +953,46,management,married,basic.9y,no,no,no,telephone,may,wed,617,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +954,36,housemaid,divorced,basic.9y,unknown,no,no,telephone,may,wed,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +955,29,technician,married,professional.course,no,no,no,telephone,may,wed,125,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +956,47,services,married,unknown,no,no,no,telephone,may,wed,262,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +957,43,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,wed,396,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +958,38,blue-collar,single,basic.9y,unknown,no,no,telephone,may,wed,294,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +959,44,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,171,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +960,29,services,married,high.school,no,no,no,telephone,may,wed,614,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +961,58,unknown,married,unknown,unknown,no,no,telephone,may,wed,118,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +962,48,blue-collar,married,professional.course,no,no,yes,telephone,may,wed,485,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +963,46,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,406,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +964,31,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,287,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +965,47,services,married,unknown,no,no,no,telephone,may,wed,216,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +966,44,admin.,married,university.degree,no,yes,no,telephone,may,wed,37,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +967,31,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,650,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +968,38,unemployed,divorced,professional.course,no,no,no,telephone,may,wed,590,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +969,33,admin.,married,university.degree,no,no,no,telephone,may,wed,55,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +970,53,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,166,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +971,33,management,single,unknown,no,yes,no,telephone,may,wed,371,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +972,33,self-employed,married,basic.9y,no,yes,no,telephone,may,wed,48,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +973,37,admin.,divorced,university.degree,no,yes,no,telephone,may,wed,72,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +974,55,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,55,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +975,41,admin.,single,basic.6y,no,unknown,unknown,telephone,may,wed,92,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +976,46,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,wed,196,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +977,32,entrepreneur,divorced,university.degree,no,no,no,telephone,may,wed,96,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +978,48,blue-collar,married,high.school,no,no,no,telephone,may,wed,144,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +979,34,entrepreneur,married,basic.4y,no,yes,no,telephone,may,wed,474,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +980,35,admin.,single,university.degree,no,no,no,telephone,may,wed,559,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +981,55,blue-collar,married,basic.4y,no,yes,yes,telephone,may,wed,1101,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +982,45,entrepreneur,single,university.degree,no,unknown,unknown,telephone,may,wed,229,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +983,48,management,divorced,university.degree,no,no,no,telephone,may,wed,236,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +984,34,blue-collar,married,basic.6y,no,yes,no,telephone,may,wed,164,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +985,46,admin.,divorced,university.degree,unknown,no,no,telephone,may,wed,93,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +986,34,blue-collar,married,basic.6y,no,yes,no,telephone,may,wed,123,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +987,51,unemployed,married,professional.course,unknown,no,no,telephone,may,wed,912,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +988,37,admin.,married,university.degree,no,no,no,telephone,may,wed,209,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +989,54,housemaid,divorced,basic.4y,no,no,no,telephone,may,wed,485,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +990,45,services,married,high.school,unknown,yes,no,telephone,may,wed,206,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +991,39,services,single,high.school,no,unknown,unknown,telephone,may,wed,239,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +992,39,blue-collar,married,basic.9y,no,no,no,telephone,may,wed,311,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +993,39,technician,single,basic.6y,unknown,no,no,telephone,may,wed,362,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +994,39,services,married,basic.9y,no,no,no,telephone,may,wed,274,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +995,32,entrepreneur,married,basic.6y,no,yes,no,telephone,may,wed,163,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +996,41,services,single,high.school,no,yes,yes,telephone,may,wed,345,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +997,59,housemaid,married,basic.6y,no,yes,no,telephone,may,wed,329,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +998,57,technician,married,basic.9y,no,yes,no,telephone,may,wed,68,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +999,30,services,married,unknown,no,no,no,telephone,may,wed,143,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1000,34,entrepreneur,married,basic.4y,no,no,no,telephone,may,wed,214,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1001,34,technician,married,high.school,no,no,no,telephone,may,wed,1062,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1002,38,blue-collar,divorced,unknown,no,no,no,telephone,may,wed,258,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1003,31,blue-collar,married,basic.6y,no,yes,no,telephone,may,wed,253,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1004,47,admin.,married,university.degree,no,unknown,unknown,telephone,may,wed,106,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1005,57,self-employed,single,high.school,unknown,no,no,telephone,may,wed,688,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1006,44,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,103,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1007,45,services,married,high.school,unknown,yes,no,telephone,may,wed,349,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1008,36,unemployed,married,basic.4y,no,yes,no,telephone,may,wed,170,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1009,44,services,married,high.school,no,yes,no,telephone,may,wed,78,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1010,51,management,married,university.degree,no,no,no,telephone,may,wed,194,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1011,28,admin.,single,university.degree,no,no,no,telephone,may,wed,126,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1012,50,technician,married,high.school,no,no,no,telephone,may,wed,224,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1013,51,entrepreneur,married,university.degree,no,no,no,telephone,may,wed,98,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1014,38,blue-collar,single,basic.9y,no,no,no,telephone,may,wed,252,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1015,44,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,607,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1016,35,admin.,single,basic.4y,unknown,no,no,telephone,may,wed,331,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1017,35,admin.,single,basic.4y,unknown,yes,no,telephone,may,wed,398,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1018,32,admin.,single,high.school,no,no,no,telephone,may,wed,103,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1019,48,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,241,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1020,35,admin.,single,basic.4y,unknown,no,no,telephone,may,wed,803,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1021,44,technician,married,professional.course,unknown,no,no,telephone,may,wed,203,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1022,43,technician,married,professional.course,no,no,no,telephone,may,wed,96,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1023,40,management,married,high.school,no,no,no,telephone,may,wed,238,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1024,50,self-employed,married,basic.4y,unknown,unknown,unknown,telephone,may,wed,153,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1025,44,technician,married,professional.course,unknown,yes,no,telephone,may,wed,481,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1026,37,blue-collar,single,basic.9y,unknown,no,no,telephone,may,wed,119,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1027,32,services,divorced,high.school,no,yes,no,telephone,may,wed,245,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1028,58,admin.,married,high.school,no,no,yes,telephone,may,wed,152,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1029,49,technician,married,high.school,no,yes,no,telephone,may,wed,418,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1030,32,blue-collar,married,basic.4y,unknown,no,no,telephone,may,wed,421,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1031,30,services,married,high.school,no,no,yes,telephone,may,wed,198,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1032,28,admin.,married,high.school,no,yes,no,telephone,may,wed,175,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1033,40,blue-collar,married,basic.4y,unknown,unknown,unknown,telephone,may,wed,374,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1034,44,management,married,university.degree,no,no,no,telephone,may,wed,51,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1035,30,services,married,professional.course,unknown,yes,no,telephone,may,wed,263,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1036,30,services,married,professional.course,unknown,no,no,telephone,may,wed,257,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1037,28,technician,single,professional.course,no,yes,no,telephone,may,wed,229,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1038,58,housemaid,married,basic.4y,no,yes,no,telephone,may,wed,154,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1039,45,blue-collar,single,high.school,unknown,no,yes,telephone,may,wed,278,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1040,35,blue-collar,married,professional.course,no,no,no,telephone,may,wed,306,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1041,57,retired,divorced,high.school,no,no,no,telephone,may,wed,114,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1042,35,admin.,divorced,high.school,no,yes,no,telephone,may,wed,24,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1043,41,technician,single,university.degree,unknown,no,no,telephone,may,wed,79,8,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1044,42,services,married,high.school,unknown,no,no,telephone,may,wed,169,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1045,42,blue-collar,married,basic.4y,unknown,no,no,telephone,may,wed,332,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1046,51,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,wed,263,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1047,48,management,married,university.degree,no,yes,no,telephone,may,wed,353,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1048,39,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,108,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1049,47,retired,married,basic.4y,unknown,unknown,unknown,telephone,may,wed,441,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1050,34,technician,single,professional.course,no,unknown,unknown,telephone,may,wed,46,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1051,44,services,married,basic.6y,unknown,no,no,telephone,may,wed,266,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1052,52,housemaid,divorced,professional.course,no,yes,no,telephone,may,wed,222,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1053,51,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,1009,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1054,49,services,married,high.school,no,no,no,telephone,may,wed,105,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1055,35,admin.,single,basic.4y,unknown,yes,no,telephone,may,wed,381,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1056,44,admin.,married,high.school,no,no,no,telephone,may,wed,228,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1057,37,admin.,married,high.school,no,yes,no,telephone,may,wed,128,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1058,47,management,married,university.degree,no,no,no,telephone,may,wed,550,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1059,43,management,married,professional.course,no,no,no,telephone,may,wed,764,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1060,40,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,113,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1061,32,management,divorced,basic.4y,unknown,no,no,telephone,may,wed,396,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1062,42,blue-collar,married,basic.4y,unknown,unknown,unknown,telephone,may,wed,42,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1063,42,self-employed,married,university.degree,no,yes,no,telephone,may,wed,234,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1064,39,technician,married,professional.course,no,unknown,unknown,telephone,may,wed,50,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1065,32,housemaid,married,basic.4y,no,no,no,telephone,may,wed,110,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1066,30,services,married,unknown,no,yes,no,telephone,may,wed,274,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1067,42,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,wed,191,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1068,35,blue-collar,married,basic.6y,no,yes,yes,telephone,may,wed,305,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1069,56,retired,married,basic.4y,no,no,no,telephone,may,wed,134,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1070,40,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,112,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1071,26,admin.,single,university.degree,no,yes,no,telephone,may,wed,217,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1072,57,management,divorced,university.degree,no,no,no,telephone,may,wed,283,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1073,51,blue-collar,married,basic.4y,no,no,no,telephone,may,wed,353,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1074,31,technician,married,professional.course,no,no,no,telephone,may,wed,212,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1075,31,admin.,married,university.degree,no,no,no,telephone,may,wed,225,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1076,60,services,married,unknown,no,no,no,telephone,may,wed,283,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1077,29,admin.,single,university.degree,no,yes,no,telephone,may,wed,1273,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1078,44,entrepreneur,married,basic.4y,unknown,no,no,telephone,may,wed,1574,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +1079,26,technician,single,basic.9y,no,yes,no,telephone,may,wed,139,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1080,54,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,62,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1081,34,blue-collar,single,high.school,unknown,no,yes,telephone,may,wed,256,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1082,29,admin.,single,high.school,no,yes,no,telephone,may,wed,245,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1083,33,admin.,divorced,high.school,no,yes,no,telephone,may,wed,161,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1084,35,blue-collar,single,high.school,no,yes,no,telephone,may,wed,245,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1085,42,management,married,basic.6y,no,yes,yes,telephone,may,wed,89,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1086,53,retired,married,basic.4y,unknown,yes,no,telephone,may,wed,591,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1087,51,technician,married,professional.course,no,yes,no,telephone,may,wed,113,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1088,59,retired,divorced,basic.4y,no,yes,no,telephone,may,wed,517,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1089,51,admin.,married,basic.6y,unknown,no,no,telephone,may,wed,193,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1090,36,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,231,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1091,47,technician,divorced,professional.course,unknown,no,no,telephone,may,wed,348,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1092,51,admin.,married,university.degree,no,no,no,telephone,may,wed,299,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1093,42,admin.,married,university.degree,unknown,yes,no,telephone,may,wed,103,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1094,40,technician,married,professional.course,no,yes,no,telephone,may,wed,253,8,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1095,26,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,73,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1096,45,technician,married,professional.course,no,yes,no,telephone,may,wed,164,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1097,51,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,wed,244,7,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1098,42,services,married,high.school,no,no,no,telephone,may,wed,191,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1099,31,technician,married,professional.course,no,yes,no,telephone,may,wed,157,6,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1100,38,blue-collar,married,basic.4y,no,no,no,telephone,may,wed,548,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1101,51,services,divorced,basic.6y,unknown,no,yes,telephone,may,wed,114,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1102,39,entrepreneur,married,high.school,no,yes,no,telephone,may,wed,126,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1103,36,blue-collar,married,unknown,unknown,yes,yes,telephone,may,wed,161,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1104,60,services,married,unknown,no,yes,no,telephone,may,wed,333,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1105,34,management,married,university.degree,no,yes,no,telephone,may,wed,155,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1106,41,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,152,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1107,34,management,married,university.degree,no,yes,yes,telephone,may,wed,145,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1108,36,blue-collar,married,basic.9y,no,no,no,telephone,may,wed,66,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1109,33,technician,married,professional.course,no,no,no,telephone,may,wed,123,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1110,46,admin.,single,university.degree,no,unknown,unknown,telephone,may,wed,74,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1111,44,blue-collar,married,basic.4y,no,yes,no,telephone,may,wed,248,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1112,30,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,275,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1113,34,management,married,university.degree,no,no,no,telephone,may,wed,984,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1114,54,admin.,divorced,university.degree,no,no,no,telephone,may,wed,1689,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,1 +1115,58,self-employed,married,professional.course,no,yes,no,telephone,may,wed,84,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1116,29,services,married,high.school,no,yes,yes,telephone,may,wed,27,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1117,41,entrepreneur,married,university.degree,no,no,no,telephone,may,wed,130,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1118,53,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,489,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1119,35,admin.,married,university.degree,no,no,no,telephone,may,wed,41,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1120,34,blue-collar,single,basic.9y,no,no,no,telephone,may,wed,159,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1121,36,blue-collar,single,unknown,no,yes,no,telephone,may,wed,276,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1122,31,blue-collar,married,basic.9y,no,no,no,telephone,may,wed,196,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1123,59,retired,married,basic.4y,unknown,no,no,telephone,may,wed,81,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1124,49,technician,married,professional.course,unknown,unknown,unknown,telephone,may,wed,865,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1125,37,management,married,high.school,unknown,no,no,telephone,may,wed,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1126,50,management,married,university.degree,no,yes,no,telephone,may,wed,281,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1127,34,services,married,high.school,no,no,no,telephone,may,wed,122,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1128,32,admin.,married,university.degree,unknown,no,no,telephone,may,wed,361,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1129,45,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,wed,944,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1130,39,management,married,university.degree,no,yes,no,telephone,may,wed,319,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1131,43,unknown,married,high.school,unknown,no,no,telephone,may,wed,35,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1132,43,blue-collar,married,basic.4y,unknown,no,no,telephone,may,wed,143,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1133,52,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,22,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1134,54,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,90,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1135,54,blue-collar,married,basic.9y,no,yes,no,telephone,may,wed,505,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1136,31,services,single,high.school,no,yes,no,telephone,may,wed,17,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1137,35,blue-collar,married,basic.6y,no,no,no,telephone,may,wed,404,5,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1138,28,admin.,married,high.school,no,yes,no,telephone,may,wed,238,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1139,56,retired,married,basic.9y,no,yes,yes,telephone,may,wed,71,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1140,35,admin.,single,basic.4y,unknown,no,no,telephone,may,wed,309,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1141,45,blue-collar,married,basic.9y,unknown,no,no,telephone,may,wed,408,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1142,38,entrepreneur,married,basic.9y,no,no,no,telephone,may,wed,157,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1143,42,technician,married,professional.course,no,yes,no,telephone,may,wed,280,3,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1144,44,blue-collar,married,basic.4y,no,no,no,telephone,may,wed,374,2,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1145,58,admin.,divorced,university.degree,no,yes,no,telephone,may,wed,365,4,999,0,nonexistent,1.1,93.994,-36.4,4.856,5191.0,0 +1146,43,admin.,divorced,high.school,no,yes,no,telephone,may,thu,177,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1147,37,technician,married,professional.course,no,yes,no,telephone,may,thu,238,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1148,45,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,31,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1149,59,retired,married,university.degree,no,no,no,telephone,may,thu,425,6,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1150,50,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,77,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1151,41,admin.,married,high.school,no,yes,yes,telephone,may,thu,223,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1152,33,admin.,married,university.degree,no,yes,no,telephone,may,thu,239,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1153,46,blue-collar,married,professional.course,no,no,no,telephone,may,thu,116,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1154,30,admin.,single,high.school,no,no,no,telephone,may,thu,308,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1155,28,services,single,high.school,no,no,no,telephone,may,thu,137,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1156,58,admin.,married,university.degree,no,no,yes,telephone,may,thu,162,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1157,43,blue-collar,married,basic.4y,no,no,no,telephone,may,thu,134,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1158,39,self-employed,married,high.school,no,yes,no,telephone,may,thu,175,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1159,25,services,married,professional.course,unknown,no,no,telephone,may,thu,125,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1160,39,admin.,single,unknown,no,no,no,telephone,may,thu,211,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1161,42,retired,married,basic.9y,unknown,yes,no,telephone,may,thu,180,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1162,43,admin.,married,high.school,no,yes,no,telephone,may,thu,67,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1163,43,admin.,married,high.school,no,no,no,telephone,may,thu,196,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1164,33,admin.,single,university.degree,no,no,no,telephone,may,thu,342,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1165,52,unknown,married,basic.4y,no,no,no,telephone,may,thu,156,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1166,42,services,married,professional.course,no,yes,no,telephone,may,thu,813,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1167,53,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,178,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1168,41,housemaid,married,basic.6y,no,no,no,telephone,may,thu,142,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1169,48,blue-collar,married,professional.course,no,yes,no,telephone,may,thu,194,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1170,40,admin.,single,high.school,no,yes,no,telephone,may,thu,132,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1171,40,admin.,single,high.school,no,no,yes,telephone,may,thu,110,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1172,53,blue-collar,married,high.school,unknown,no,yes,telephone,may,thu,109,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1173,55,admin.,married,high.school,no,no,no,telephone,may,thu,94,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1174,39,technician,married,professional.course,no,no,no,telephone,may,thu,31,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1175,35,blue-collar,married,basic.9y,no,no,yes,telephone,may,thu,220,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1176,50,housemaid,divorced,basic.4y,unknown,no,no,telephone,may,thu,489,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1177,38,services,divorced,basic.6y,no,no,no,telephone,may,thu,180,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1178,41,unemployed,married,basic.9y,unknown,no,no,telephone,may,thu,314,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1179,44,technician,married,professional.course,unknown,no,no,telephone,may,thu,203,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1180,39,admin.,married,basic.6y,unknown,no,no,telephone,may,thu,328,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1181,44,unknown,married,basic.6y,no,no,no,telephone,may,thu,207,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1182,51,technician,married,professional.course,no,no,yes,telephone,may,thu,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1183,41,technician,married,high.school,no,yes,yes,telephone,may,thu,177,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1184,31,technician,married,professional.course,no,no,no,telephone,may,thu,528,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1185,40,blue-collar,married,high.school,no,yes,no,telephone,may,thu,183,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1186,38,self-employed,single,university.degree,no,no,no,telephone,may,thu,238,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1187,48,blue-collar,married,basic.6y,no,no,yes,telephone,may,thu,61,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1188,32,admin.,married,high.school,no,no,no,telephone,may,thu,70,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1189,46,blue-collar,single,basic.9y,no,yes,no,telephone,may,thu,541,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1190,49,housemaid,married,basic.4y,unknown,no,no,telephone,may,thu,41,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1191,47,blue-collar,married,basic.6y,no,yes,yes,telephone,may,thu,93,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1192,28,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1193,38,admin.,single,university.degree,unknown,yes,no,telephone,may,thu,35,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1194,28,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,262,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1195,51,services,married,unknown,no,yes,no,telephone,may,thu,151,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1196,50,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,135,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1197,38,admin.,single,university.degree,unknown,yes,yes,telephone,may,thu,221,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1198,48,blue-collar,married,basic.6y,no,yes,no,telephone,may,thu,604,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1199,44,technician,divorced,unknown,no,yes,no,telephone,may,thu,86,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1200,30,technician,married,professional.course,no,no,no,telephone,may,thu,65,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1201,34,blue-collar,single,basic.4y,no,no,no,telephone,may,thu,380,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1202,36,blue-collar,married,basic.6y,no,yes,no,telephone,may,thu,11,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1203,41,management,married,university.degree,unknown,no,no,telephone,may,thu,184,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1204,40,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,405,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1205,35,unknown,married,basic.9y,no,yes,yes,telephone,may,thu,20,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1206,46,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,202,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1207,35,unknown,married,basic.9y,no,yes,no,telephone,may,thu,235,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1208,32,management,single,university.degree,no,no,no,telephone,may,thu,75,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1209,34,admin.,married,university.degree,unknown,no,no,telephone,may,thu,134,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1210,35,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,255,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1211,28,admin.,single,professional.course,no,no,no,telephone,may,thu,462,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1212,30,services,married,high.school,unknown,no,no,telephone,may,thu,80,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1213,48,unemployed,married,basic.4y,no,yes,yes,telephone,may,thu,56,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1214,53,blue-collar,married,professional.course,no,yes,yes,telephone,may,thu,418,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1215,31,management,married,university.degree,no,yes,yes,telephone,may,thu,139,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1216,59,retired,married,unknown,no,yes,no,telephone,may,thu,96,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1217,51,unemployed,married,high.school,no,yes,no,telephone,may,thu,39,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1218,47,services,married,high.school,unknown,yes,no,telephone,may,thu,231,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1219,40,technician,married,university.degree,unknown,no,no,telephone,may,thu,66,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1220,42,entrepreneur,single,university.degree,no,yes,no,telephone,may,thu,204,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1221,30,housemaid,married,high.school,unknown,no,no,telephone,may,thu,159,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1222,40,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,129,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1223,30,housemaid,married,high.school,unknown,no,no,telephone,may,thu,200,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1224,50,entrepreneur,married,basic.9y,no,yes,no,telephone,may,thu,187,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1225,36,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,166,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1226,29,unemployed,single,university.degree,no,no,no,telephone,may,thu,144,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1227,43,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,323,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1228,35,services,married,high.school,no,no,no,telephone,may,thu,194,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1229,35,unknown,single,basic.4y,unknown,no,no,telephone,may,thu,82,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1230,48,blue-collar,divorced,basic.4y,no,no,no,telephone,may,thu,521,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1231,49,technician,married,professional.course,no,no,no,telephone,may,thu,269,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1232,44,blue-collar,married,basic.6y,unknown,no,yes,telephone,may,thu,285,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1233,32,blue-collar,married,professional.course,no,no,no,telephone,may,thu,1119,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1234,55,admin.,married,high.school,no,no,no,telephone,may,thu,294,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1235,41,admin.,married,basic.4y,unknown,no,yes,telephone,may,thu,106,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1236,27,student,single,high.school,unknown,no,no,telephone,may,thu,158,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1237,41,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,152,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1238,36,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,thu,12,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1239,45,services,married,basic.9y,no,no,no,telephone,may,thu,187,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1240,36,blue-collar,married,high.school,no,no,no,telephone,may,thu,268,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1241,54,technician,married,basic.9y,no,no,no,telephone,may,thu,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1242,48,admin.,married,high.school,unknown,no,yes,telephone,may,thu,95,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1243,32,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,60,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1244,33,management,married,university.degree,no,no,yes,telephone,may,thu,206,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1245,43,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,155,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1246,44,technician,single,high.school,no,no,no,telephone,may,thu,216,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1247,50,technician,married,professional.course,no,no,no,telephone,may,thu,103,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1248,32,student,single,university.degree,no,no,no,telephone,may,thu,107,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1249,43,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,thu,219,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1250,32,student,single,university.degree,no,yes,no,telephone,may,thu,147,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1251,43,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,190,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1252,40,admin.,divorced,professional.course,no,yes,no,telephone,may,thu,339,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1253,24,services,single,high.school,no,yes,yes,telephone,may,thu,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1254,32,technician,married,university.degree,no,no,yes,telephone,may,thu,141,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1255,43,blue-collar,married,basic.4y,unknown,yes,yes,telephone,may,thu,255,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1256,43,technician,single,university.degree,no,yes,no,telephone,may,thu,1120,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1257,35,admin.,married,university.degree,no,yes,yes,telephone,may,thu,369,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1258,33,admin.,single,high.school,unknown,yes,no,telephone,may,thu,226,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1259,43,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,thu,215,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1260,48,technician,married,professional.course,unknown,yes,no,telephone,may,thu,306,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1261,52,housemaid,married,basic.4y,no,no,no,telephone,may,thu,249,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1262,49,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,thu,143,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1263,30,admin.,married,high.school,no,yes,yes,telephone,may,thu,162,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1264,36,services,married,high.school,no,yes,no,telephone,may,thu,81,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1265,39,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,124,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1266,39,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,124,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1267,36,admin.,divorced,university.degree,no,no,no,telephone,may,thu,33,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1268,39,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,393,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1269,31,admin.,married,high.school,no,no,no,telephone,may,thu,784,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1270,41,technician,married,professional.course,no,yes,yes,telephone,may,thu,87,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1271,35,unknown,married,basic.9y,no,no,no,telephone,may,thu,108,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1272,55,retired,married,university.degree,unknown,no,no,telephone,may,thu,207,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1273,57,retired,married,high.school,unknown,yes,no,telephone,may,thu,278,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1274,38,entrepreneur,married,basic.6y,no,yes,no,telephone,may,thu,196,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1275,52,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1276,60,services,married,high.school,no,yes,no,telephone,may,thu,154,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1277,24,student,single,high.school,no,yes,no,telephone,may,thu,287,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1278,38,entrepreneur,married,basic.6y,no,no,no,telephone,may,thu,229,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1279,44,technician,divorced,high.school,no,no,no,telephone,may,thu,21,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1280,42,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,thu,191,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1281,25,services,divorced,high.school,no,yes,no,telephone,may,thu,147,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1282,32,admin.,married,basic.6y,no,no,no,telephone,may,thu,93,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1283,51,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,665,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1284,28,admin.,single,university.degree,no,no,no,telephone,may,thu,131,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1285,27,housemaid,married,basic.9y,no,yes,no,telephone,may,thu,160,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1286,35,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,74,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1287,45,services,married,basic.9y,no,yes,yes,telephone,may,thu,60,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1288,30,services,single,high.school,no,yes,no,telephone,may,thu,97,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1289,60,entrepreneur,married,basic.4y,no,yes,no,telephone,may,thu,82,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1290,28,services,single,unknown,no,yes,no,telephone,may,thu,475,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1291,45,blue-collar,married,basic.4y,unknown,no,yes,telephone,may,thu,111,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1292,37,unemployed,married,professional.course,no,no,no,telephone,may,thu,140,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1293,37,unemployed,married,professional.course,no,no,yes,telephone,may,thu,110,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1294,36,services,married,basic.6y,no,yes,no,telephone,may,thu,64,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1295,49,blue-collar,married,basic.4y,no,yes,no,telephone,may,thu,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1296,35,services,married,professional.course,unknown,yes,no,telephone,may,thu,156,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1297,34,admin.,single,high.school,no,no,no,telephone,may,thu,63,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1298,31,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,362,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1299,42,admin.,married,university.degree,unknown,yes,no,telephone,may,thu,712,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1300,39,blue-collar,married,basic.6y,no,yes,no,telephone,may,thu,338,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1301,33,services,married,university.degree,no,yes,no,telephone,may,thu,102,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1302,38,unemployed,married,high.school,no,no,yes,telephone,may,thu,446,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1303,30,admin.,divorced,university.degree,unknown,no,no,telephone,may,thu,249,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1304,31,blue-collar,divorced,professional.course,no,yes,no,telephone,may,thu,176,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1305,39,management,married,university.degree,no,no,no,telephone,may,thu,1007,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1306,37,blue-collar,married,unknown,unknown,yes,no,telephone,may,thu,266,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1307,48,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,172,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1308,32,services,married,high.school,no,no,no,telephone,may,thu,175,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1309,48,blue-collar,married,basic.6y,no,yes,no,telephone,may,thu,211,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1310,37,technician,single,university.degree,no,yes,no,telephone,may,thu,237,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1311,37,technician,single,university.degree,no,no,no,telephone,may,thu,500,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1312,26,services,divorced,basic.6y,no,no,no,telephone,may,thu,186,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1313,33,management,married,high.school,unknown,no,no,telephone,may,thu,96,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1314,54,admin.,married,university.degree,no,yes,no,telephone,may,thu,98,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1315,37,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,thu,364,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1316,35,entrepreneur,married,university.degree,no,yes,yes,telephone,may,thu,477,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1317,45,admin.,married,university.degree,no,no,no,telephone,may,thu,319,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1318,33,services,single,high.school,no,yes,no,telephone,may,thu,789,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1319,54,admin.,married,university.degree,no,no,no,telephone,may,thu,513,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1320,37,blue-collar,married,basic.4y,no,no,yes,telephone,may,thu,280,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1321,35,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,170,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1322,33,blue-collar,divorced,basic.6y,unknown,yes,no,telephone,may,thu,365,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1323,49,technician,married,professional.course,no,no,no,telephone,may,thu,63,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1324,43,technician,married,university.degree,no,yes,no,telephone,may,thu,159,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1325,43,entrepreneur,married,university.degree,no,no,no,telephone,may,thu,177,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1326,39,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,108,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1327,47,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,194,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1328,30,services,divorced,high.school,no,no,no,telephone,may,thu,366,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1329,51,admin.,divorced,university.degree,no,yes,no,telephone,may,thu,213,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1330,59,blue-collar,married,basic.6y,no,yes,no,telephone,may,thu,166,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1331,31,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,141,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1332,39,unemployed,married,university.degree,no,yes,no,telephone,may,thu,168,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1333,43,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,468,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1334,30,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,thu,180,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1335,37,blue-collar,married,professional.course,no,no,no,telephone,may,thu,195,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1336,42,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,352,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1337,37,blue-collar,married,professional.course,no,yes,yes,telephone,may,thu,91,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1338,37,admin.,divorced,university.degree,no,yes,no,telephone,may,thu,93,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1339,37,blue-collar,single,basic.6y,unknown,yes,no,telephone,may,thu,288,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1340,36,management,single,basic.6y,no,no,no,telephone,may,thu,218,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1341,48,blue-collar,married,basic.9y,no,yes,yes,telephone,may,thu,289,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1342,43,services,married,high.school,no,no,no,telephone,may,thu,130,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1343,28,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,208,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1344,30,admin.,divorced,high.school,no,yes,no,telephone,may,thu,177,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1345,60,entrepreneur,married,basic.4y,no,yes,no,telephone,may,thu,442,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1346,31,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,101,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1347,38,entrepreneur,married,university.degree,no,no,no,telephone,may,thu,166,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1348,50,technician,married,high.school,no,yes,no,telephone,may,thu,756,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1349,36,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,342,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1350,50,unemployed,married,professional.course,no,no,no,telephone,may,thu,189,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1351,54,entrepreneur,married,basic.4y,unknown,no,yes,telephone,may,thu,108,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1352,33,management,married,university.degree,no,no,no,telephone,may,thu,178,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1353,20,entrepreneur,single,high.school,no,no,no,telephone,may,thu,238,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1354,24,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,thu,226,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1355,28,management,single,unknown,no,yes,no,telephone,may,thu,136,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1356,55,housemaid,married,basic.4y,no,yes,no,telephone,may,thu,14,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1357,25,blue-collar,married,high.school,no,no,no,telephone,may,thu,250,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1358,41,admin.,married,basic.9y,unknown,yes,no,telephone,may,thu,161,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1359,39,blue-collar,married,basic.6y,no,yes,no,telephone,may,thu,269,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1360,27,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,491,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1361,27,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,44,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1362,34,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,thu,26,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1363,25,technician,single,professional.course,no,no,no,telephone,may,thu,22,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1364,48,management,married,university.degree,unknown,yes,no,telephone,may,thu,293,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1365,36,blue-collar,single,basic.6y,no,no,no,telephone,may,thu,989,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1366,36,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,147,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1367,27,blue-collar,married,basic.9y,no,no,yes,telephone,may,thu,1170,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1368,45,management,married,university.degree,no,yes,yes,telephone,may,thu,807,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1369,28,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,thu,347,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1370,30,technician,single,basic.6y,unknown,yes,no,telephone,may,thu,58,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1371,43,blue-collar,married,basic.4y,no,no,no,telephone,may,thu,534,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1372,32,admin.,single,high.school,no,unknown,unknown,telephone,may,thu,155,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1373,29,services,single,basic.9y,no,no,no,telephone,may,thu,159,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1374,35,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,343,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1375,43,admin.,married,high.school,no,yes,no,telephone,may,thu,152,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1376,35,unknown,married,basic.9y,no,no,no,telephone,may,thu,461,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1377,36,admin.,married,high.school,no,yes,no,telephone,may,thu,389,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1378,36,admin.,married,high.school,no,no,no,telephone,may,thu,90,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1379,48,admin.,married,high.school,unknown,no,no,telephone,may,thu,314,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1380,38,blue-collar,single,basic.4y,unknown,no,no,telephone,may,thu,28,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1381,38,blue-collar,single,basic.9y,unknown,no,no,telephone,may,thu,30,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1382,45,admin.,married,high.school,no,yes,no,telephone,may,thu,103,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1383,35,technician,married,professional.course,no,no,yes,telephone,may,thu,32,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1384,42,services,married,high.school,unknown,no,yes,telephone,may,thu,252,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1385,56,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,20,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1386,30,technician,married,professional.course,no,no,no,telephone,may,thu,369,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1387,45,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,thu,116,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1388,39,services,single,high.school,unknown,no,no,telephone,may,thu,302,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1389,36,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,31,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1390,32,admin.,married,university.degree,unknown,no,yes,telephone,may,thu,190,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1391,29,admin.,single,high.school,no,no,no,telephone,may,thu,149,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1392,34,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,210,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1393,36,self-employed,single,basic.6y,no,no,no,telephone,may,thu,180,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1394,33,management,married,high.school,unknown,no,no,telephone,may,thu,234,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1395,43,unemployed,married,basic.4y,no,no,no,telephone,may,thu,92,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1396,31,services,married,basic.6y,no,no,no,telephone,may,thu,2087,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1397,35,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,102,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1398,42,admin.,married,university.degree,no,no,no,telephone,may,thu,193,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1399,35,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,245,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1400,44,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,190,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1401,36,technician,divorced,professional.course,no,yes,no,telephone,may,thu,223,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1402,47,admin.,married,university.degree,no,no,no,telephone,may,thu,42,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1403,59,technician,married,professional.course,unknown,no,no,telephone,may,thu,206,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1404,29,blue-collar,single,high.school,unknown,unknown,unknown,telephone,may,thu,242,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1405,30,services,single,high.school,no,no,no,telephone,may,thu,66,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1406,38,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,173,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1407,45,management,single,university.degree,no,no,no,telephone,may,thu,477,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1408,28,self-employed,single,university.degree,no,no,no,telephone,may,thu,77,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1409,37,blue-collar,married,basic.4y,no,no,no,telephone,may,thu,219,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1410,38,entrepreneur,married,basic.6y,no,yes,yes,telephone,may,thu,205,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1411,28,admin.,single,basic.9y,no,yes,yes,telephone,may,thu,376,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1412,31,technician,divorced,professional.course,no,yes,yes,telephone,may,thu,453,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1413,37,blue-collar,married,basic.4y,no,yes,no,telephone,may,thu,151,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1414,36,housemaid,divorced,university.degree,no,no,no,telephone,may,thu,767,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1415,54,housemaid,married,basic.9y,no,yes,no,telephone,may,thu,200,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1416,53,retired,married,basic.6y,unknown,no,yes,telephone,may,thu,298,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1417,43,unemployed,married,basic.9y,no,yes,no,telephone,may,thu,305,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1418,49,admin.,married,high.school,no,no,yes,telephone,may,thu,627,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1419,32,self-employed,divorced,professional.course,no,no,no,telephone,may,thu,242,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1420,36,services,married,high.school,no,yes,no,telephone,may,thu,287,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1421,50,blue-collar,single,basic.9y,unknown,yes,yes,telephone,may,thu,184,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1422,51,blue-collar,divorced,basic.9y,no,no,no,telephone,may,thu,119,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1423,38,blue-collar,married,basic.4y,no,yes,no,telephone,may,thu,403,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1424,44,self-employed,married,professional.course,no,no,no,telephone,may,thu,626,6,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1425,29,blue-collar,married,high.school,no,no,no,telephone,may,thu,12,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1426,46,blue-collar,divorced,basic.6y,no,no,no,telephone,may,thu,266,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1427,32,unemployed,divorced,basic.4y,no,unknown,unknown,telephone,may,thu,23,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1428,43,blue-collar,divorced,basic.4y,unknown,no,no,telephone,may,thu,154,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1429,53,admin.,married,university.degree,no,no,no,telephone,may,thu,10,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1430,43,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,301,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1431,35,services,single,basic.4y,no,no,yes,telephone,may,thu,240,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1432,49,admin.,divorced,basic.9y,no,yes,no,telephone,may,thu,312,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1433,35,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,thu,224,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1434,40,services,single,professional.course,no,no,no,telephone,may,thu,202,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1435,29,services,divorced,high.school,no,no,no,telephone,may,thu,144,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1436,43,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,263,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1437,47,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,543,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1438,38,technician,married,university.degree,unknown,no,no,telephone,may,thu,257,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1439,48,technician,married,professional.course,no,yes,no,telephone,may,thu,237,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1440,53,retired,married,high.school,unknown,yes,no,telephone,may,thu,23,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1441,53,management,married,university.degree,no,yes,no,telephone,may,thu,209,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1442,51,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,thu,1178,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1443,35,services,married,basic.9y,no,yes,yes,telephone,may,thu,442,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1444,31,entrepreneur,divorced,high.school,no,yes,no,telephone,may,thu,1120,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1445,46,entrepreneur,married,basic.4y,no,no,no,telephone,may,thu,186,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1446,28,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,thu,318,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1447,38,admin.,married,basic.9y,unknown,yes,no,telephone,may,thu,617,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1448,28,self-employed,single,university.degree,no,yes,no,telephone,may,thu,226,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1449,50,technician,married,professional.course,no,yes,no,telephone,may,thu,275,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1450,32,management,single,university.degree,no,no,no,telephone,may,thu,81,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1451,31,blue-collar,divorced,basic.4y,no,yes,no,telephone,may,thu,74,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1452,39,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,285,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1453,41,blue-collar,married,basic.9y,no,no,yes,telephone,may,thu,261,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1454,31,admin.,married,high.school,no,yes,yes,telephone,may,thu,151,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1455,54,blue-collar,married,basic.9y,unknown,no,yes,telephone,may,thu,422,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1456,34,blue-collar,married,basic.6y,unknown,no,yes,telephone,may,thu,159,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1457,51,services,married,high.school,unknown,yes,no,telephone,may,thu,102,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1458,45,blue-collar,single,basic.9y,no,no,no,telephone,may,thu,78,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1459,32,admin.,divorced,high.school,no,no,yes,telephone,may,thu,15,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1460,28,student,married,university.degree,no,no,yes,telephone,may,thu,352,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1461,40,management,married,university.degree,no,yes,no,telephone,may,thu,345,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1462,55,admin.,divorced,university.degree,no,yes,no,telephone,may,thu,230,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1463,41,self-employed,single,university.degree,no,no,no,telephone,may,thu,296,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1464,57,services,divorced,high.school,unknown,no,yes,telephone,may,thu,185,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1465,44,entrepreneur,married,high.school,no,yes,no,telephone,may,thu,181,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1466,29,blue-collar,single,high.school,unknown,yes,no,telephone,may,thu,133,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1467,38,admin.,single,university.degree,no,no,no,telephone,may,thu,335,9,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1468,34,blue-collar,married,basic.4y,no,yes,no,telephone,may,thu,139,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1469,36,technician,single,university.degree,no,yes,no,telephone,may,thu,163,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1470,39,services,divorced,high.school,no,yes,no,telephone,may,thu,956,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1471,30,blue-collar,married,basic.9y,no,yes,yes,telephone,may,thu,166,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1472,35,services,married,basic.9y,no,no,no,telephone,may,thu,95,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1473,60,entrepreneur,married,basic.4y,no,no,no,telephone,may,thu,71,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1474,35,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,191,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1475,38,self-employed,single,university.degree,no,no,no,telephone,may,thu,459,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1476,57,blue-collar,divorced,professional.course,no,no,no,telephone,may,thu,100,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1477,44,technician,divorced,unknown,no,no,yes,telephone,may,thu,233,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1478,40,entrepreneur,married,basic.9y,no,yes,no,telephone,may,thu,255,6,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1479,46,technician,divorced,basic.9y,no,yes,no,telephone,may,thu,128,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1480,50,technician,married,professional.course,unknown,yes,no,telephone,may,thu,56,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1481,45,management,married,university.degree,no,yes,no,telephone,may,thu,4,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1482,57,services,married,basic.6y,no,no,no,telephone,may,thu,43,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1483,35,admin.,single,basic.6y,no,yes,no,telephone,may,thu,210,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1484,38,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,21,9,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1485,30,services,divorced,high.school,no,yes,no,telephone,may,thu,67,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1486,25,services,divorced,high.school,no,no,no,telephone,may,thu,219,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1487,52,self-employed,single,university.degree,unknown,yes,no,telephone,may,thu,169,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1488,37,technician,single,university.degree,no,yes,no,telephone,may,thu,248,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1489,60,retired,divorced,high.school,no,yes,no,telephone,may,thu,223,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1490,42,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,92,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1491,37,blue-collar,married,professional.course,no,yes,no,telephone,may,thu,112,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1492,43,services,married,high.school,no,no,no,telephone,may,thu,205,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1493,33,services,unknown,high.school,no,yes,yes,telephone,may,thu,155,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1494,32,technician,married,professional.course,no,yes,yes,telephone,may,thu,105,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1495,41,technician,married,professional.course,no,no,no,telephone,may,thu,112,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1496,37,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,383,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1497,39,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,193,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1498,36,services,married,high.school,no,no,yes,telephone,may,thu,207,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1499,38,blue-collar,married,basic.6y,unknown,no,no,telephone,may,thu,132,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1500,33,admin.,married,high.school,no,yes,yes,telephone,may,thu,10,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1501,41,services,divorced,high.school,no,yes,no,telephone,may,thu,985,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1502,58,services,married,basic.4y,no,no,no,telephone,may,thu,249,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1503,44,blue-collar,single,basic.9y,unknown,no,no,telephone,may,thu,122,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1504,36,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,300,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1505,33,admin.,married,high.school,no,yes,no,telephone,may,thu,672,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1506,30,technician,married,professional.course,no,no,no,telephone,may,thu,390,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1507,44,blue-collar,married,basic.9y,unknown,no,no,telephone,may,thu,116,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1508,32,admin.,married,high.school,no,yes,yes,telephone,may,thu,21,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1509,38,admin.,married,basic.9y,unknown,no,no,telephone,may,thu,192,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1510,41,blue-collar,married,professional.course,unknown,yes,no,telephone,may,thu,8,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1511,40,services,married,basic.9y,unknown,no,no,telephone,may,thu,13,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1512,33,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,thu,369,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1513,28,admin.,single,university.degree,no,yes,no,telephone,may,thu,393,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1514,46,blue-collar,married,basic.4y,no,no,no,telephone,may,thu,246,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1515,35,technician,married,university.degree,no,yes,no,telephone,may,thu,330,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1516,40,admin.,married,university.degree,unknown,yes,yes,telephone,may,thu,91,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1517,36,services,married,high.school,no,yes,no,telephone,may,thu,84,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1518,38,management,married,university.degree,unknown,no,no,telephone,may,thu,277,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1519,36,services,married,high.school,no,yes,no,telephone,may,thu,399,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1520,40,admin.,married,high.school,unknown,yes,no,telephone,may,thu,89,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1521,31,services,married,basic.9y,no,yes,no,telephone,may,thu,297,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1522,47,technician,married,professional.course,no,yes,no,telephone,may,thu,111,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1523,35,technician,single,professional.course,no,yes,no,telephone,may,thu,170,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1524,29,admin.,single,university.degree,no,no,no,telephone,may,thu,141,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1525,29,blue-collar,single,high.school,no,yes,yes,telephone,may,thu,886,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1526,35,self-employed,married,university.degree,no,no,yes,telephone,may,thu,49,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1527,33,services,single,basic.6y,unknown,no,no,telephone,may,thu,89,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1528,41,management,divorced,university.degree,unknown,yes,yes,telephone,may,thu,341,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1529,54,admin.,married,university.degree,no,yes,no,telephone,may,thu,461,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1530,31,admin.,single,university.degree,no,no,yes,telephone,may,thu,515,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1531,55,technician,married,university.degree,no,yes,no,telephone,may,thu,123,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1532,38,admin.,married,high.school,no,no,no,telephone,may,thu,179,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1533,41,services,married,high.school,no,no,yes,telephone,may,thu,102,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1534,49,unemployed,married,university.degree,no,yes,yes,telephone,may,thu,272,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1535,39,services,married,high.school,no,yes,no,telephone,may,thu,17,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1536,34,blue-collar,divorced,professional.course,no,no,yes,telephone,may,thu,291,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1537,39,services,divorced,high.school,no,yes,no,telephone,may,thu,209,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1538,37,services,married,high.school,no,no,no,telephone,may,thu,1187,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1539,45,blue-collar,married,high.school,no,yes,no,telephone,may,thu,89,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1540,49,admin.,married,university.degree,no,yes,yes,telephone,may,thu,123,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1541,43,blue-collar,married,basic.4y,unknown,no,no,telephone,may,thu,200,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1542,40,management,married,high.school,no,no,no,telephone,may,thu,104,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1543,42,admin.,married,university.degree,no,yes,no,telephone,may,thu,117,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1544,47,services,single,high.school,no,no,no,telephone,may,thu,37,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1545,56,entrepreneur,married,university.degree,no,yes,no,telephone,may,thu,51,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1546,43,management,married,university.degree,no,no,no,telephone,may,thu,627,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1547,34,blue-collar,married,basic.9y,no,no,no,telephone,may,thu,466,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1548,29,admin.,divorced,university.degree,no,no,no,telephone,may,thu,101,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1549,53,entrepreneur,married,university.degree,no,yes,no,telephone,may,thu,303,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1550,50,entrepreneur,married,basic.9y,no,yes,no,telephone,may,thu,283,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1551,51,self-employed,married,university.degree,unknown,no,no,telephone,may,thu,826,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1552,20,entrepreneur,single,high.school,no,no,no,telephone,may,thu,598,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1553,41,blue-collar,married,professional.course,unknown,yes,no,telephone,may,thu,120,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1554,47,blue-collar,married,basic.9y,no,yes,no,telephone,may,thu,185,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1555,48,technician,married,professional.course,no,yes,no,telephone,may,thu,220,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1556,29,entrepreneur,married,university.degree,no,yes,no,telephone,may,thu,423,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1557,28,technician,single,professional.course,no,yes,yes,telephone,may,thu,337,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1558,31,blue-collar,single,professional.course,no,no,no,telephone,may,thu,99,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1559,29,blue-collar,single,basic.6y,no,no,no,telephone,may,thu,27,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1560,42,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,thu,160,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1561,40,blue-collar,single,basic.6y,unknown,no,no,telephone,may,thu,201,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1562,33,management,married,university.degree,no,no,no,telephone,may,thu,166,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1563,33,services,single,professional.course,no,no,no,telephone,may,thu,182,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1564,34,management,divorced,basic.6y,unknown,yes,no,telephone,may,thu,271,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1565,36,admin.,married,high.school,no,yes,no,telephone,may,thu,103,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1566,35,admin.,married,university.degree,no,yes,no,telephone,may,thu,379,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1567,38,technician,divorced,professional.course,no,yes,no,telephone,may,thu,287,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1568,31,blue-collar,single,basic.9y,no,no,no,telephone,may,thu,732,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1569,30,blue-collar,married,basic.6y,no,no,no,telephone,may,thu,126,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1570,42,admin.,single,university.degree,no,no,no,telephone,may,thu,172,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1571,27,technician,single,high.school,no,yes,no,telephone,may,thu,43,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1572,39,retired,single,high.school,no,no,no,telephone,may,thu,109,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1573,38,admin.,married,high.school,no,yes,no,telephone,may,thu,191,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1574,30,management,married,university.degree,no,yes,no,telephone,may,thu,117,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1575,52,housemaid,married,university.degree,no,yes,no,telephone,may,thu,64,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1576,38,admin.,married,high.school,no,yes,no,telephone,may,thu,260,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1577,54,technician,married,basic.9y,no,no,no,telephone,may,thu,207,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1578,29,admin.,single,university.degree,no,yes,no,telephone,may,thu,128,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1579,32,blue-collar,married,basic.9y,unknown,no,no,telephone,may,fri,180,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1580,28,admin.,married,high.school,no,no,no,telephone,may,fri,144,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1581,30,admin.,married,high.school,no,yes,no,telephone,may,fri,110,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1582,44,technician,married,professional.course,unknown,no,yes,telephone,may,fri,203,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1583,37,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,85,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1584,48,admin.,married,professional.course,unknown,yes,no,telephone,may,fri,211,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1585,25,blue-collar,married,professional.course,unknown,no,no,telephone,may,fri,94,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1586,49,blue-collar,married,basic.4y,unknown,no,no,telephone,may,fri,122,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1587,60,technician,married,university.degree,unknown,yes,no,telephone,may,fri,175,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1588,29,admin.,single,high.school,no,no,no,telephone,may,fri,132,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1589,55,technician,married,basic.9y,unknown,unknown,unknown,telephone,may,fri,130,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1590,41,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,208,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1591,39,services,single,high.school,no,no,no,telephone,may,fri,346,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1592,39,management,married,high.school,no,no,no,telephone,may,fri,205,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1593,31,admin.,married,university.degree,no,yes,no,telephone,may,fri,218,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1594,38,services,married,high.school,no,no,no,telephone,may,fri,62,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1595,32,technician,married,university.degree,no,yes,no,telephone,may,fri,93,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1596,53,housemaid,divorced,basic.6y,unknown,unknown,unknown,telephone,may,fri,85,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1597,37,admin.,married,high.school,no,unknown,unknown,telephone,may,fri,97,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1598,34,self-employed,married,professional.course,no,unknown,unknown,telephone,may,fri,252,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1599,31,admin.,married,high.school,no,yes,yes,telephone,may,fri,286,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1600,39,unemployed,married,high.school,no,no,no,telephone,may,fri,241,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1601,39,unemployed,married,high.school,no,yes,no,telephone,may,fri,283,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1602,39,unemployed,married,high.school,no,yes,no,telephone,may,fri,380,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1603,37,admin.,married,high.school,no,yes,no,telephone,may,fri,584,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1604,30,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,371,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1605,32,blue-collar,married,professional.course,no,no,no,telephone,may,fri,274,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1606,37,technician,married,basic.9y,no,no,no,telephone,may,fri,71,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1607,27,blue-collar,married,university.degree,no,no,yes,telephone,may,fri,357,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1608,32,unknown,unknown,university.degree,no,no,no,telephone,may,fri,617,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1609,33,blue-collar,single,basic.9y,no,yes,no,telephone,may,fri,215,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1610,47,blue-collar,single,basic.4y,no,no,no,telephone,may,fri,131,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1611,40,blue-collar,married,basic.9y,unknown,no,no,telephone,may,fri,188,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1612,38,blue-collar,married,basic.4y,unknown,yes,yes,telephone,may,fri,383,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1613,33,services,married,high.school,unknown,no,no,telephone,may,fri,483,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1614,54,admin.,married,university.degree,no,no,no,telephone,may,fri,847,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1615,51,services,married,high.school,no,yes,no,telephone,may,fri,57,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1616,41,blue-collar,single,basic.9y,no,yes,no,telephone,may,fri,306,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1617,51,blue-collar,married,high.school,no,no,no,telephone,may,fri,147,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1618,55,services,married,high.school,no,yes,no,telephone,may,fri,244,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1619,39,admin.,single,high.school,no,yes,no,telephone,may,fri,59,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1620,37,self-employed,married,basic.9y,no,yes,yes,telephone,may,fri,24,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1621,39,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,85,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1622,33,management,married,university.degree,no,no,yes,telephone,may,fri,70,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1623,33,management,married,university.degree,no,no,no,telephone,may,fri,21,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1624,59,management,married,university.degree,no,no,no,telephone,may,fri,659,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1625,55,technician,married,basic.9y,unknown,yes,no,telephone,may,fri,327,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1626,53,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,267,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1627,36,services,married,basic.6y,no,no,no,telephone,may,fri,296,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1628,40,services,married,high.school,no,no,no,telephone,may,fri,307,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1629,37,technician,single,professional.course,no,no,no,telephone,may,fri,120,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1630,56,housemaid,divorced,high.school,no,no,no,telephone,may,fri,390,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1631,32,admin.,married,high.school,no,yes,no,telephone,may,fri,83,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1632,38,management,married,basic.9y,unknown,no,no,telephone,may,fri,772,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1633,28,services,married,high.school,unknown,no,no,telephone,may,fri,143,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1634,30,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1635,45,management,married,basic.4y,unknown,yes,no,telephone,may,fri,100,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1636,60,housemaid,married,high.school,unknown,no,no,telephone,may,fri,929,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1637,32,student,single,university.degree,no,no,no,telephone,may,fri,21,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1638,39,unemployed,single,basic.9y,unknown,yes,no,telephone,may,fri,254,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1639,56,services,married,basic.4y,unknown,yes,yes,telephone,may,fri,166,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1640,31,blue-collar,married,basic.9y,no,no,no,telephone,may,fri,93,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1641,20,entrepreneur,single,high.school,no,no,no,telephone,may,fri,217,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1642,42,blue-collar,married,basic.6y,no,no,no,telephone,may,fri,134,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1643,46,admin.,married,university.degree,no,yes,yes,telephone,may,fri,375,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1644,28,technician,single,university.degree,unknown,no,no,telephone,may,fri,352,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1645,41,admin.,single,university.degree,no,no,no,telephone,may,fri,45,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1646,33,admin.,divorced,basic.9y,no,no,no,telephone,may,fri,93,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1647,43,admin.,married,university.degree,unknown,yes,no,telephone,may,fri,165,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1648,43,admin.,married,university.degree,unknown,no,no,telephone,may,fri,266,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1649,47,unemployed,married,high.school,unknown,yes,no,telephone,may,fri,237,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1650,24,technician,single,professional.course,no,no,no,telephone,may,fri,410,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1651,31,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,157,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1652,25,services,divorced,high.school,no,no,no,telephone,may,fri,72,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1653,35,blue-collar,married,basic.6y,no,unknown,unknown,telephone,may,fri,171,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1654,58,blue-collar,married,basic.4y,unknown,no,no,telephone,may,fri,70,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1655,35,blue-collar,married,professional.course,no,yes,yes,telephone,may,fri,710,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1656,35,blue-collar,married,basic.9y,no,no,no,telephone,may,fri,131,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1657,25,admin.,married,high.school,no,no,no,telephone,may,fri,498,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1658,31,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,514,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1659,39,admin.,married,basic.9y,no,no,no,telephone,may,fri,127,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1660,39,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,fri,162,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1661,37,blue-collar,married,basic.9y,no,yes,yes,telephone,may,fri,198,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1662,36,unemployed,divorced,professional.course,unknown,yes,no,telephone,may,fri,705,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1663,45,unemployed,married,high.school,unknown,yes,no,telephone,may,fri,239,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1664,30,blue-collar,married,basic.9y,no,yes,yes,telephone,may,fri,117,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1665,60,retired,married,high.school,no,no,no,telephone,may,fri,155,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1666,37,admin.,divorced,university.degree,no,no,no,telephone,may,fri,18,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1667,28,services,single,high.school,no,yes,yes,telephone,may,fri,386,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1668,45,admin.,married,basic.9y,unknown,yes,no,telephone,may,fri,138,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1669,31,entrepreneur,single,basic.9y,no,no,no,telephone,may,fri,341,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1670,49,unemployed,single,professional.course,unknown,no,no,telephone,may,fri,339,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1671,46,unknown,married,basic.4y,unknown,no,no,telephone,may,fri,232,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1672,36,services,divorced,university.degree,no,no,no,telephone,may,fri,208,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1673,33,technician,divorced,professional.course,no,yes,no,telephone,may,fri,44,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1674,27,services,single,high.school,unknown,no,no,telephone,may,fri,75,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1675,60,retired,married,university.degree,unknown,no,no,telephone,may,fri,485,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1676,60,retired,married,university.degree,unknown,yes,no,telephone,may,fri,576,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1677,27,services,single,high.school,unknown,no,no,telephone,may,fri,280,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1678,39,blue-collar,married,high.school,no,yes,no,telephone,may,fri,480,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1679,39,services,married,high.school,no,yes,no,telephone,may,fri,86,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1680,35,unknown,single,basic.4y,unknown,no,no,telephone,may,fri,121,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1681,56,admin.,single,high.school,no,no,no,telephone,may,fri,93,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1682,39,admin.,married,university.degree,no,yes,no,telephone,may,fri,238,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1683,35,entrepreneur,married,basic.9y,no,no,no,telephone,may,fri,399,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1684,35,technician,divorced,high.school,no,no,no,telephone,may,fri,93,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1685,58,management,married,unknown,unknown,yes,no,telephone,may,fri,106,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1686,29,management,single,university.degree,no,yes,no,telephone,may,fri,123,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1687,32,blue-collar,married,high.school,no,no,yes,telephone,may,fri,92,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1688,35,management,married,university.degree,no,yes,yes,telephone,may,fri,219,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1689,26,admin.,married,high.school,no,yes,yes,telephone,may,fri,2462,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1690,31,technician,married,professional.course,no,no,no,telephone,may,fri,114,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1691,33,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,fri,1132,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1692,39,blue-collar,divorced,basic.9y,no,yes,no,telephone,may,fri,249,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1693,49,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,fri,210,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1694,31,admin.,married,high.school,no,no,no,telephone,may,fri,187,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1695,31,admin.,married,high.school,unknown,no,no,telephone,may,fri,172,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1696,49,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,122,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1697,49,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,207,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1698,31,entrepreneur,married,basic.9y,unknown,no,no,telephone,may,fri,136,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1699,36,management,married,basic.9y,no,no,no,telephone,may,fri,117,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1700,52,management,married,professional.course,no,no,no,telephone,may,fri,393,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1701,37,blue-collar,single,unknown,unknown,no,no,telephone,may,fri,107,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1702,40,blue-collar,divorced,basic.9y,no,no,no,telephone,may,fri,25,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1703,36,blue-collar,single,basic.4y,no,no,no,telephone,may,fri,136,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1704,34,technician,single,professional.course,unknown,no,no,telephone,may,fri,384,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1705,39,management,single,university.degree,no,no,no,telephone,may,fri,38,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1706,39,management,single,university.degree,no,no,no,telephone,may,fri,164,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1707,58,retired,divorced,university.degree,no,yes,no,telephone,may,fri,825,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1708,56,services,married,high.school,unknown,no,no,telephone,may,fri,331,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1709,29,blue-collar,single,basic.9y,no,no,no,telephone,may,fri,479,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1710,39,admin.,married,high.school,no,no,no,telephone,may,fri,229,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1711,31,technician,married,professional.course,no,no,no,telephone,may,fri,258,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1712,60,management,married,university.degree,unknown,no,no,telephone,may,fri,490,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1713,39,blue-collar,married,basic.6y,no,yes,no,telephone,may,fri,308,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1714,49,admin.,divorced,high.school,no,yes,no,telephone,may,fri,137,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1715,45,unemployed,married,high.school,unknown,no,no,telephone,may,fri,545,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1716,32,blue-collar,married,high.school,unknown,yes,no,telephone,may,fri,109,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1717,41,technician,married,high.school,no,no,no,telephone,may,fri,257,6,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1718,32,services,married,high.school,no,no,no,telephone,may,fri,213,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1719,46,services,married,professional.course,no,no,no,telephone,may,fri,115,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1720,28,services,single,basic.9y,unknown,no,no,telephone,may,fri,646,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1721,51,blue-collar,married,basic.9y,no,no,no,telephone,may,fri,202,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1722,50,services,single,high.school,no,no,no,telephone,may,fri,106,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1723,35,admin.,married,professional.course,no,yes,yes,telephone,may,fri,95,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1724,24,services,single,high.school,no,yes,no,telephone,may,fri,64,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1725,51,blue-collar,married,basic.9y,no,no,yes,telephone,may,fri,653,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1726,59,admin.,married,university.degree,no,no,yes,telephone,may,fri,186,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1727,33,technician,married,professional.course,no,no,no,telephone,may,fri,205,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1728,36,unemployed,single,basic.4y,unknown,no,no,telephone,may,fri,122,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1729,24,services,single,high.school,no,no,no,telephone,may,fri,377,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1730,30,blue-collar,single,basic.9y,no,yes,no,telephone,may,fri,322,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1731,49,admin.,married,university.degree,unknown,no,no,telephone,may,fri,208,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1732,38,technician,married,professional.course,no,yes,no,telephone,may,fri,46,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1733,34,admin.,married,university.degree,no,yes,no,telephone,may,fri,211,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1734,25,technician,single,professional.course,no,yes,yes,telephone,may,fri,156,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1735,35,unemployed,married,basic.9y,unknown,no,no,telephone,may,fri,471,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1736,43,entrepreneur,married,high.school,unknown,yes,no,telephone,may,fri,206,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1737,36,unemployed,single,basic.4y,unknown,no,no,telephone,may,fri,544,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1738,36,unemployed,single,basic.4y,unknown,no,no,telephone,may,fri,143,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1739,37,technician,married,professional.course,unknown,yes,no,telephone,may,fri,200,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1740,36,self-employed,married,university.degree,no,no,no,telephone,may,fri,71,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1741,32,admin.,divorced,high.school,no,yes,no,telephone,may,fri,324,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1742,33,blue-collar,single,high.school,no,no,no,telephone,may,fri,164,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1743,42,services,married,high.school,no,no,no,telephone,may,fri,137,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1744,37,admin.,married,high.school,no,yes,yes,telephone,may,fri,87,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1745,35,admin.,married,high.school,no,no,no,telephone,may,fri,91,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1746,50,admin.,married,high.school,unknown,yes,yes,telephone,may,fri,280,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1747,48,admin.,married,high.school,no,yes,yes,telephone,may,fri,230,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1748,50,management,single,university.degree,no,yes,yes,telephone,may,fri,63,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1749,39,technician,divorced,professional.course,no,yes,no,telephone,may,fri,135,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1750,33,management,single,university.degree,no,no,no,telephone,may,fri,261,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1751,29,admin.,married,high.school,no,no,no,telephone,may,fri,215,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1752,33,admin.,married,high.school,no,yes,no,telephone,may,fri,125,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1753,41,admin.,single,university.degree,no,yes,no,telephone,may,fri,391,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1754,46,blue-collar,married,basic.4y,no,yes,no,telephone,may,fri,107,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1755,27,services,single,high.school,unknown,no,no,telephone,may,fri,108,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1756,42,admin.,single,basic.9y,no,no,no,telephone,may,fri,145,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1757,44,admin.,married,high.school,no,yes,no,telephone,may,fri,142,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1758,37,housemaid,married,high.school,unknown,no,yes,telephone,may,fri,106,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1759,24,services,single,high.school,no,no,yes,telephone,may,fri,134,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1760,30,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,fri,190,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1761,53,management,married,basic.4y,no,yes,no,telephone,may,fri,241,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1762,36,entrepreneur,married,university.degree,no,no,yes,telephone,may,fri,180,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1763,24,services,single,high.school,no,yes,no,telephone,may,fri,654,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1764,49,entrepreneur,married,high.school,no,no,no,telephone,may,fri,189,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1765,41,blue-collar,married,high.school,no,yes,no,telephone,may,fri,70,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1766,40,admin.,married,high.school,no,yes,yes,telephone,may,fri,1087,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1767,40,housemaid,married,basic.4y,no,yes,yes,telephone,may,fri,62,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1768,49,entrepreneur,married,high.school,no,no,no,telephone,may,fri,323,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1769,25,admin.,single,high.school,no,yes,no,telephone,may,fri,111,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1770,34,management,married,university.degree,no,yes,no,telephone,may,fri,197,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1771,34,technician,married,professional.course,no,yes,yes,telephone,may,fri,224,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1772,28,blue-collar,single,basic.6y,no,no,no,telephone,may,fri,557,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1773,39,admin.,married,basic.9y,no,yes,no,telephone,may,fri,150,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1774,34,technician,married,professional.course,no,no,yes,telephone,may,fri,388,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1775,36,technician,married,professional.course,no,no,no,telephone,may,fri,194,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1776,60,technician,divorced,professional.course,unknown,yes,no,telephone,may,fri,57,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1777,32,retired,married,high.school,no,no,no,telephone,may,fri,209,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1778,40,housemaid,divorced,high.school,no,no,no,telephone,may,fri,188,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1779,56,blue-collar,married,basic.6y,unknown,no,no,telephone,may,fri,342,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1780,36,technician,married,professional.course,no,yes,no,telephone,may,fri,84,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1781,27,entrepreneur,married,university.degree,no,no,no,telephone,may,fri,530,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1782,33,blue-collar,single,basic.9y,unknown,yes,yes,telephone,may,fri,97,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1783,36,technician,married,professional.course,no,no,yes,telephone,may,fri,365,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1784,47,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,285,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1785,47,blue-collar,married,basic.4y,no,yes,no,telephone,may,fri,352,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1786,40,unemployed,married,basic.9y,no,yes,yes,telephone,may,fri,316,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1787,30,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,79,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1788,37,housemaid,married,basic.4y,unknown,yes,yes,telephone,may,fri,331,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1789,39,technician,married,professional.course,no,no,no,telephone,may,fri,126,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1790,58,blue-collar,married,basic.9y,no,no,no,telephone,may,fri,76,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1791,32,admin.,married,university.degree,no,no,yes,telephone,may,fri,1692,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1792,34,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,24,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1793,43,blue-collar,married,basic.4y,unknown,no,no,telephone,may,fri,73,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1794,30,technician,married,professional.course,no,yes,no,telephone,may,fri,253,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1795,52,admin.,married,university.degree,no,no,no,telephone,may,fri,622,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1796,46,blue-collar,divorced,basic.9y,no,yes,no,telephone,may,fri,133,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1797,32,admin.,single,university.degree,no,yes,yes,telephone,may,fri,178,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1798,53,self-employed,married,university.degree,no,no,no,telephone,may,fri,404,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1799,45,blue-collar,married,basic.9y,unknown,no,no,telephone,may,fri,275,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1800,31,blue-collar,married,basic.9y,no,no,no,telephone,may,fri,109,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1801,51,technician,married,professional.course,no,no,no,telephone,may,fri,134,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1802,45,admin.,married,high.school,no,no,yes,telephone,may,fri,225,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1803,32,admin.,married,high.school,no,no,no,telephone,may,fri,129,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1804,43,admin.,married,high.school,no,yes,no,telephone,may,fri,93,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1805,34,admin.,married,high.school,no,yes,yes,telephone,may,fri,20,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1806,25,blue-collar,single,basic.4y,no,yes,no,telephone,may,fri,247,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1807,41,management,married,unknown,no,yes,no,telephone,may,fri,129,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1808,25,blue-collar,single,basic.4y,no,no,no,telephone,may,fri,324,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1809,43,admin.,married,university.degree,unknown,yes,no,telephone,may,fri,2016,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1810,34,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,1054,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1811,44,admin.,married,basic.9y,no,no,no,telephone,may,fri,163,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1812,59,admin.,married,university.degree,no,no,no,telephone,may,fri,279,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1813,39,technician,divorced,professional.course,no,no,no,telephone,may,fri,251,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1814,52,management,married,high.school,unknown,yes,no,telephone,may,fri,113,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1815,50,blue-collar,married,basic.4y,unknown,unknown,unknown,telephone,may,fri,193,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1816,42,admin.,divorced,university.degree,no,yes,no,telephone,may,fri,125,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1817,30,technician,married,university.degree,no,no,no,telephone,may,fri,282,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1818,49,services,married,high.school,no,yes,no,telephone,may,fri,344,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1819,44,blue-collar,single,basic.6y,unknown,yes,no,telephone,may,fri,665,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,1 +1820,41,technician,married,university.degree,no,yes,no,telephone,may,fri,67,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1821,27,services,single,high.school,no,yes,no,telephone,may,fri,167,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1822,32,admin.,single,university.degree,no,yes,no,telephone,may,fri,395,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1823,27,admin.,single,high.school,no,yes,no,telephone,may,fri,137,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1824,29,management,single,university.degree,no,no,no,telephone,may,fri,118,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1825,53,admin.,married,university.degree,unknown,yes,no,telephone,may,fri,231,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1826,30,blue-collar,single,basic.9y,no,yes,no,telephone,may,fri,128,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1827,29,management,single,university.degree,no,yes,no,telephone,may,fri,174,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1828,32,technician,single,university.degree,no,no,no,telephone,may,fri,195,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1829,55,admin.,divorced,university.degree,no,yes,no,telephone,may,fri,412,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1830,39,admin.,married,university.degree,no,yes,no,telephone,may,fri,127,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1831,37,blue-collar,single,high.school,no,no,no,telephone,may,fri,79,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1832,26,admin.,married,university.degree,no,yes,yes,telephone,may,fri,13,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1833,52,self-employed,married,university.degree,no,no,no,telephone,may,fri,61,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1834,28,admin.,single,university.degree,no,yes,no,telephone,may,fri,286,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1835,33,management,single,university.degree,no,yes,yes,telephone,may,fri,274,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1836,60,management,married,university.degree,unknown,no,no,telephone,may,fri,409,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1837,45,blue-collar,married,basic.4y,unknown,yes,yes,telephone,may,fri,325,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1838,32,admin.,single,university.degree,no,yes,yes,telephone,may,fri,144,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1839,50,unemployed,married,basic.9y,no,yes,no,telephone,may,fri,1713,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1840,47,management,divorced,basic.4y,no,no,no,telephone,may,fri,241,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1841,40,technician,married,university.degree,no,no,no,telephone,may,fri,338,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1842,45,services,married,high.school,no,no,no,telephone,may,fri,182,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1843,52,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,346,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1844,43,management,married,unknown,no,no,no,telephone,may,fri,204,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1845,40,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,fri,296,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1846,43,blue-collar,single,basic.9y,no,yes,no,telephone,may,fri,551,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1847,28,blue-collar,single,basic.9y,no,yes,no,telephone,may,fri,663,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1848,36,technician,married,professional.course,no,yes,no,telephone,may,fri,338,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1849,37,services,married,high.school,no,yes,no,telephone,may,fri,153,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1850,32,admin.,single,university.degree,no,no,no,telephone,may,fri,188,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1851,26,technician,married,high.school,no,yes,no,telephone,may,fri,305,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1852,35,admin.,married,high.school,no,yes,no,telephone,may,fri,1080,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1853,56,admin.,married,university.degree,no,no,no,telephone,may,fri,1461,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1854,56,blue-collar,married,basic.4y,unknown,no,no,telephone,may,fri,116,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1855,37,entrepreneur,single,university.degree,no,no,no,telephone,may,fri,129,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1856,43,technician,single,professional.course,no,no,no,telephone,may,fri,98,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1857,41,housemaid,married,high.school,no,yes,no,telephone,may,fri,262,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1858,55,unemployed,single,basic.4y,unknown,unknown,unknown,telephone,may,fri,147,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1859,43,technician,single,professional.course,no,no,no,telephone,may,fri,150,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1860,29,unknown,married,university.degree,no,unknown,unknown,telephone,may,fri,282,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1861,31,services,married,high.school,no,yes,no,telephone,may,fri,332,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1862,39,admin.,single,university.degree,no,no,no,telephone,may,fri,94,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1863,38,blue-collar,single,basic.9y,unknown,yes,no,telephone,may,fri,455,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1864,56,admin.,married,high.school,no,yes,no,telephone,may,fri,49,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1865,36,entrepreneur,married,unknown,unknown,yes,no,telephone,may,fri,181,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1866,54,management,married,basic.4y,unknown,no,no,telephone,may,fri,345,9,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1867,34,admin.,married,university.degree,no,yes,no,telephone,may,fri,154,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1868,28,services,single,basic.9y,unknown,yes,no,telephone,may,fri,294,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1869,34,blue-collar,married,basic.4y,no,yes,no,telephone,may,fri,750,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1870,45,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,202,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1871,59,admin.,married,university.degree,no,yes,no,telephone,may,fri,191,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1872,46,blue-collar,married,professional.course,no,yes,no,telephone,may,fri,106,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1873,25,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,214,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1874,33,admin.,divorced,university.degree,no,no,yes,telephone,may,fri,128,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1875,27,admin.,married,university.degree,no,yes,no,telephone,may,fri,70,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1876,33,admin.,divorced,university.degree,no,no,no,telephone,may,fri,279,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1877,56,blue-collar,married,basic.4y,unknown,yes,no,telephone,may,fri,400,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1878,25,admin.,married,high.school,no,yes,no,telephone,may,fri,231,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1879,30,housemaid,married,high.school,unknown,yes,no,telephone,may,fri,175,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1880,38,blue-collar,divorced,basic.9y,no,no,yes,telephone,may,fri,70,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1881,37,self-employed,single,university.degree,unknown,yes,no,telephone,may,fri,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1882,58,entrepreneur,married,university.degree,unknown,no,no,telephone,may,fri,107,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1883,28,blue-collar,married,basic.6y,unknown,no,no,telephone,may,fri,142,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1884,42,blue-collar,married,basic.9y,unknown,no,no,telephone,may,fri,119,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1885,36,technician,single,professional.course,unknown,no,no,telephone,may,fri,180,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1886,52,management,married,professional.course,no,unknown,unknown,telephone,may,fri,135,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1887,33,technician,single,high.school,no,no,yes,telephone,may,fri,213,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1888,33,management,single,university.degree,no,no,no,telephone,may,fri,136,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1889,29,blue-collar,single,high.school,unknown,no,no,telephone,may,fri,44,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1890,34,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,229,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1891,50,management,single,university.degree,no,yes,no,telephone,may,fri,184,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1892,38,management,married,university.degree,no,no,no,telephone,may,fri,32,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1893,33,services,married,high.school,no,no,no,telephone,may,fri,73,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1894,42,admin.,married,university.degree,no,no,no,telephone,may,fri,126,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1895,52,self-employed,married,university.degree,no,yes,no,telephone,may,fri,83,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1896,28,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,379,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1897,56,services,married,high.school,unknown,yes,no,telephone,may,fri,26,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1898,35,technician,married,professional.course,no,no,no,telephone,may,fri,169,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1899,33,admin.,married,high.school,unknown,yes,no,telephone,may,fri,179,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1900,32,blue-collar,married,basic.9y,no,yes,no,telephone,may,fri,280,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1901,50,retired,married,basic.4y,unknown,yes,no,telephone,may,fri,89,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1902,29,admin.,single,basic.9y,unknown,no,no,telephone,may,fri,210,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1903,30,technician,married,professional.course,no,no,yes,telephone,may,fri,393,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1904,46,blue-collar,married,basic.9y,no,yes,yes,telephone,may,fri,128,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1905,26,admin.,single,high.school,unknown,yes,no,telephone,may,fri,161,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1906,34,admin.,single,basic.9y,no,no,no,telephone,may,fri,1178,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1907,46,self-employed,married,basic.9y,unknown,yes,no,telephone,may,fri,182,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1908,33,management,married,high.school,no,yes,no,telephone,may,fri,178,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1909,50,housemaid,divorced,high.school,no,no,no,telephone,may,fri,177,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1910,31,technician,single,basic.9y,unknown,no,no,telephone,may,fri,191,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1911,48,blue-collar,married,professional.course,no,no,no,telephone,may,fri,245,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1912,57,entrepreneur,married,high.school,unknown,yes,no,telephone,may,fri,255,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1913,45,admin.,divorced,basic.9y,unknown,no,no,telephone,may,fri,488,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1914,50,services,married,professional.course,unknown,yes,no,telephone,may,fri,211,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1915,44,unknown,single,basic.9y,unknown,unknown,unknown,telephone,may,fri,226,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1916,44,services,married,high.school,no,no,no,telephone,may,fri,460,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1917,32,services,married,high.school,no,yes,no,telephone,may,fri,432,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1918,37,housemaid,single,basic.9y,unknown,yes,yes,telephone,may,fri,136,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1919,34,admin.,single,high.school,no,no,no,telephone,may,fri,176,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1920,27,services,married,basic.9y,no,yes,no,telephone,may,fri,162,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1921,40,housemaid,single,university.degree,no,yes,no,telephone,may,fri,237,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1922,39,services,married,high.school,unknown,no,yes,telephone,may,fri,134,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1923,29,admin.,married,high.school,no,yes,no,telephone,may,fri,44,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1924,34,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,fri,47,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1925,25,blue-collar,married,basic.9y,no,no,no,telephone,may,fri,483,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1926,29,services,married,basic.9y,no,no,no,telephone,may,fri,116,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1927,44,technician,single,professional.course,no,no,no,telephone,may,fri,182,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1928,33,admin.,single,university.degree,no,yes,no,telephone,may,fri,122,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1929,33,blue-collar,married,basic.4y,no,yes,no,telephone,may,fri,232,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1930,40,blue-collar,married,high.school,no,no,no,telephone,may,fri,51,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1931,59,retired,married,basic.4y,unknown,no,no,telephone,may,fri,260,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1932,38,technician,single,professional.course,no,no,no,telephone,may,fri,214,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1933,41,blue-collar,married,basic.6y,unknown,no,no,telephone,may,fri,407,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1934,33,services,married,high.school,no,yes,no,telephone,may,fri,389,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1935,36,admin.,married,high.school,no,yes,no,telephone,may,fri,31,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1936,48,admin.,married,basic.9y,no,no,yes,telephone,may,fri,145,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1937,44,services,divorced,high.school,unknown,yes,no,telephone,may,fri,115,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1938,50,blue-collar,divorced,basic.9y,no,no,no,telephone,may,fri,878,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1939,35,self-employed,married,professional.course,no,no,no,telephone,may,fri,268,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1940,51,entrepreneur,married,basic.4y,unknown,no,no,telephone,may,fri,277,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1941,24,blue-collar,married,basic.9y,no,yes,yes,telephone,may,fri,101,6,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1942,32,management,married,high.school,no,yes,no,telephone,may,fri,119,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1943,56,services,divorced,high.school,unknown,no,no,telephone,may,fri,185,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1944,50,technician,married,university.degree,no,yes,no,telephone,may,fri,162,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1945,38,blue-collar,married,high.school,no,no,no,telephone,may,fri,18,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1946,25,self-employed,single,university.degree,no,unknown,unknown,telephone,may,fri,317,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1947,28,services,single,high.school,no,unknown,unknown,telephone,may,fri,71,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1948,41,blue-collar,married,basic.4y,no,no,no,telephone,may,fri,43,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1949,34,blue-collar,married,unknown,unknown,unknown,unknown,telephone,may,fri,298,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1950,27,services,single,high.school,no,no,no,telephone,may,fri,86,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1951,31,admin.,married,university.degree,unknown,no,no,telephone,may,fri,255,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1952,39,housemaid,married,basic.4y,no,yes,yes,telephone,may,fri,83,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1953,26,entrepreneur,married,unknown,no,no,no,telephone,may,fri,194,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1954,37,blue-collar,married,basic.6y,unknown,yes,yes,telephone,may,fri,268,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1955,25,student,single,high.school,no,no,no,telephone,may,fri,97,1,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1956,33,services,married,high.school,no,yes,no,telephone,may,fri,263,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1957,40,blue-collar,married,unknown,no,no,no,telephone,may,fri,338,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1958,37,entrepreneur,single,university.degree,no,no,no,telephone,may,fri,180,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1959,46,housemaid,married,basic.9y,no,no,no,telephone,may,fri,322,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1960,53,admin.,divorced,high.school,no,yes,no,telephone,may,fri,64,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1961,32,unemployed,married,basic.9y,no,no,no,telephone,may,fri,71,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1962,43,services,divorced,basic.9y,no,yes,no,telephone,may,fri,284,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1963,38,technician,single,professional.course,no,yes,no,telephone,may,fri,166,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1964,26,technician,married,professional.course,no,yes,no,telephone,may,fri,210,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1965,52,blue-collar,single,basic.9y,unknown,yes,no,telephone,may,fri,160,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1966,54,admin.,married,basic.4y,unknown,no,no,telephone,may,fri,77,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1967,51,services,married,professional.course,unknown,no,no,telephone,may,fri,328,7,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1968,42,admin.,married,basic.9y,no,yes,no,telephone,may,fri,164,8,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1969,41,management,single,unknown,no,no,no,telephone,may,fri,154,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1970,26,services,single,high.school,no,no,no,telephone,may,fri,155,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1971,38,housemaid,married,basic.6y,unknown,yes,no,telephone,may,fri,153,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1972,54,technician,married,unknown,unknown,no,no,telephone,may,fri,111,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1973,41,blue-collar,divorced,basic.4y,no,no,no,telephone,may,fri,91,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1974,26,blue-collar,single,high.school,no,no,no,telephone,may,fri,213,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1975,43,management,married,basic.4y,no,yes,no,telephone,may,fri,257,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1976,28,technician,single,professional.course,no,yes,no,telephone,may,fri,315,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1977,35,technician,divorced,professional.course,no,no,yes,telephone,may,fri,102,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1978,45,admin.,married,high.school,no,no,yes,telephone,may,fri,35,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1979,40,admin.,married,high.school,no,no,no,telephone,may,fri,83,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1980,37,technician,married,professional.course,no,yes,no,telephone,may,fri,834,9,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1981,52,technician,married,university.degree,no,no,no,telephone,may,fri,244,5,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1982,36,admin.,married,high.school,no,yes,no,telephone,may,fri,143,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1983,40,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,fri,277,3,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1984,43,blue-collar,married,basic.4y,no,yes,no,telephone,may,fri,1534,2,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1985,36,blue-collar,married,basic.6y,unknown,no,no,telephone,may,fri,291,4,999,0,nonexistent,1.1,93.994,-36.4,4.855,5191.0,0 +1986,36,blue-collar,single,high.school,no,no,yes,telephone,may,mon,163,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1987,43,blue-collar,married,basic.4y,unknown,yes,yes,telephone,may,mon,149,6,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1988,44,blue-collar,married,basic.9y,unknown,yes,no,telephone,may,mon,33,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1989,49,blue-collar,married,professional.course,unknown,no,no,telephone,may,mon,144,6,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1990,56,retired,married,basic.4y,unknown,yes,yes,telephone,may,mon,146,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1991,39,blue-collar,divorced,basic.9y,no,no,no,telephone,may,mon,40,3,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1992,29,blue-collar,married,professional.course,unknown,yes,no,telephone,may,mon,79,6,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1993,41,admin.,single,university.degree,unknown,no,no,telephone,may,mon,112,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1994,47,admin.,married,university.degree,no,yes,no,telephone,may,mon,147,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1995,38,blue-collar,married,basic.6y,unknown,yes,no,telephone,may,mon,836,4,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1996,36,technician,married,high.school,no,yes,no,telephone,may,mon,290,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1997,46,technician,divorced,professional.course,no,yes,no,telephone,may,mon,148,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1998,47,services,divorced,high.school,no,yes,no,telephone,may,mon,289,5,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 +1999,47,blue-collar,single,basic.4y,no,yes,no,telephone,may,mon,345,2,999,0,nonexistent,1.1,93.994,-36.4,4.857,5191.0,0 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 1a37e7a0..d92460fa 100644 --- a/tests/integ/snowflake/ml/test_utils/common_test_base.py +++ b/tests/integ/snowflake/ml/test_utils/common_test_base.py @@ -1,9 +1,8 @@ -import functools import inspect import itertools import os import tempfile -from typing import Any, Callable, List, Literal, Optional, Tuple, Type, TypeVar +from typing import Any, Callable, List, Literal, Optional, Tuple, Type, TypeVar, Union import cloudpickle from absl.testing import absltest, parameterized @@ -53,45 +52,56 @@ def tearDown(self) -> None: @classmethod def sproc_test( kclass: Type[_V], local: bool = True, test_callers_rights: bool = 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: _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) + ) -> Callable[ + [Callable[Concatenate[_V, _T_args], None]], + Union[parameterized._ParameterizedTestIter, Callable[Concatenate[_V, _T_args], None]], + ]: + def decorator( + fn: Union[parameterized._ParameterizedTestIter, Callable[Concatenate[_V, _T_args], None]] + ) -> Union[parameterized._ParameterizedTestIter, Callable[Concatenate[_V, _T_args], None]]: + if snowpark_utils.is_in_stored_procedure(): # type: ignore[no-untyped-call] + return fn + + if isinstance(fn, parameterized._ParameterizedTestIter): + actual_method = fn._test_method + original_name = fn._original_name + naming_type = fn._naming_type + test_cases = list(fn.testcases) + else: + actual_method = fn + original_name = fn.__name__ + naming_type = parameterized._ARGUMENT_REPR + test_cases = [{}] + + test_module = inspect.getmodule(actual_method) + assert 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, ".") + method_list = [func for func in dir(kclass) if func.startswith(original_name)] + + def test_wrapper( + self: _V, + /, + *args: _T_args.args, + _sproc_test_mode: Literal["local", "owner", "caller"], + **kwargs: _T_args.kwargs, + ) -> None: + if _sproc_test_mode == "local": + actual_method(self, *args, **kwargs) return - if local: - 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") + file_utils.zip_python_package(snowml_zip_module_filename, "snowflake.ml") 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()) + file_utils.zip_python_package(tests_zip_module_filename, "tests") imports = [snowml_zip_module_filename, tests_zip_module_filename] packages = [ @@ -101,6 +111,8 @@ def _in_sproc_test(execute_as: Literal["owner", "caller"] = "owner") -> None: if "snowflake-connector-python" not in req and "_" not in req ] + cloudpickle.register_pickle_by_value(test_module) + @F.sproc( # type: ignore[misc] is_permanent=False, packages=packages, # type: ignore[arg-type] @@ -125,18 +137,27 @@ def test_in_sproc(sess: session.Session, test_name: str) -> None: if result.testsRun == 0: raise RuntimeError("Unit test does not run any test.") - test_in_sproc(self.session, test_name) + for method in method_list: + test_in_sproc(self.session, f"{test_module_name}.{self.__class__.__qualname__}.{method}") cloudpickle.unregister_pickle_by_value(test_module) - with self.subTest("In-sproc Test (Owner's rights)"): - _in_sproc_test(execute_as="owner") + _in_sproc_test(execute_as=_sproc_test_mode) + + additional_cases = [ + {"_sproc_test_mode": "owner"}, + ] + if local: + additional_cases.append({"_sproc_test_mode": "local"}) + + if test_callers_rights: + additional_cases.append({"_sproc_test_mode": "caller"}) - if test_callers_rights: - with self.subTest("In-sproc Test (Caller's rights)"): - _in_sproc_test(execute_as="caller") + modified_test_cases = [{**t1, **t2} for t1 in test_cases for t2 in additional_cases] - return test_wrapper + return parameterized._ParameterizedTestIter( + test_wrapper, modified_test_cases, naming_type, original_name=original_name + ) return decorator @@ -146,10 +167,25 @@ def compatibility_test( 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: + ) -> Callable[ + [Union[parameterized._ParameterizedTestIter, Callable[Concatenate[_V, _T_args], None]]], + parameterized._ParameterizedTestIter, + ]: + def decorator( + fn: Union[parameterized._ParameterizedTestIter, Callable[Concatenate[_V, _T_args], None]] + ) -> parameterized._ParameterizedTestIter: + if isinstance(fn, parameterized._ParameterizedTestIter): + actual_method = fn._test_method + original_name = fn._original_name + naming_type = fn._naming_type + test_cases = list(fn.testcases) + else: + actual_method = fn + original_name = fn.__name__ + naming_type = parameterized._ARGUMENT_REPR + test_cases = [{}] + + def test_wrapper(self: _V, /, *args: _T_args.args, _snowml_pkg_ver: str, **kwargs: _T_args.kwargs) -> None: prepare_fn, prepare_fn_args = prepare_fn_factory(self) if additional_packages: packages = additional_packages @@ -182,35 +218,38 @@ def {func_name}({first_arg_name}: snowflake.snowpark.Session, {", ".join(arg_lis {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, - ) - - prepare_fn_sproc(*prepare_fn_args, session=self.session) - - fn(self, *args, **kwargs) - - return test_wrapper + final_packages = packages[:] + [f"snowflake-ml-python=={_snowml_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, + ) + + prepare_fn_sproc(*prepare_fn_args, session=self.session) + + actual_method(self, *args, **kwargs) + + additional_cases = [ + {"_snowml_pkg_ver": pkg_ver} + for pkg_ver in test_env_utils.get_package_versions_in_conda(f"snowflake-ml-python{version_range}") + ] + + modified_test_cases = [{**t1, **t2} for t1 in test_cases for t2 in additional_cases] + + return parameterized._ParameterizedTestIter( + test_wrapper, modified_test_cases, naming_type, original_name=original_name + ) return decorator