Files
mlflow--mlflow/tests/catboost/test_catboost_model_export.py
2026-07-13 13:22:34 +08:00

539 lines
20 KiB
Python

import json
import os
from pathlib import Path
from typing import Any, NamedTuple
from unittest import mock
import catboost as cb
import numpy as np
import pandas as pd
import pytest
import yaml
from packaging.version import Version
from sklearn import datasets
from sklearn.pipeline import Pipeline
import mlflow.catboost
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
from mlflow import pyfunc
from mlflow.models import Model, ModelSignature
from mlflow.models.utils import _read_example, load_serving_example
from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.types import DataType
from mlflow.types.schema import ColSpec, Schema, TensorSpec
from mlflow.utils.environment import _mlflow_conda_env
from mlflow.utils.model_utils import _get_flavor_configuration
from tests.helper_functions import (
_assert_pip_requirements,
_compare_conda_env_requirements,
_compare_logged_code_paths,
_is_available_on_pypi,
_mlflow_major_version_string,
assert_register_model_called_with_local_model_path,
pyfunc_serve_and_score_model,
)
EXTRA_PYFUNC_SERVING_TEST_ARGS = (
[] if _is_available_on_pypi("catboost") else ["--env-manager", "local"]
)
class ModelWithData(NamedTuple):
model: Any
inference_dataframe: Any
def get_iris():
iris = datasets.load_iris()
X = pd.DataFrame(iris.data[:, :2], columns=iris.feature_names[:2])
y = pd.Series(iris.target)
return X, y
def read_yaml(path):
with open(path) as f:
return yaml.safe_load(f)
MODEL_PARAMS = {"allow_writing_files": False, "iterations": 10}
def iter_models():
X, y = get_iris()
model = cb.CatBoost(MODEL_PARAMS).fit(X, y)
yield ModelWithData(model=model, inference_dataframe=X)
model = cb.CatBoostClassifier(**MODEL_PARAMS).fit(X, y)
yield ModelWithData(model=model, inference_dataframe=X)
model = cb.CatBoostRegressor(**MODEL_PARAMS).fit(X, y)
yield ModelWithData(model=model, inference_dataframe=X)
@pytest.fixture(
scope="module",
params=iter_models(),
ids=["CatBoost", "CatBoostClassifier", "CatBoostRegressor"],
)
def cb_model(request):
return request.param
@pytest.fixture
def reg_model():
model = cb.CatBoostRegressor(**MODEL_PARAMS)
X, y = get_iris()
return ModelWithData(model=model.fit(X, y), inference_dataframe=X)
def get_reg_model_signature():
return ModelSignature(
inputs=Schema([
ColSpec(name="sepal length (cm)", type=DataType.double),
ColSpec(name="sepal width (cm)", type=DataType.double),
]),
outputs=Schema([ColSpec(type=DataType.double)]),
)
@pytest.fixture
def model_path(tmp_path):
return os.path.join(tmp_path, "model")
@pytest.fixture
def custom_env(tmp_path):
conda_env_path = os.path.join(tmp_path, "conda_env.yml")
_mlflow_conda_env(conda_env_path, additional_pip_deps=["catboost", "pytest"])
return conda_env_path
@pytest.mark.parametrize("model_type", ["CatBoost", "CatBoostClassifier", "CatBoostRegressor"])
def test_init_model(model_type):
model = mlflow.catboost._init_model(model_type)
assert model.__class__.__name__ == model_type
@pytest.mark.skipif(
Version(cb.__version__) < Version("0.26.0"),
reason="catboost < 0.26.0 does not support CatBoostRanker",
)
def test_log_catboost_ranker():
"""
This is a separate test for the CatBoostRanker model.
It is separate since the ranking task requires a group_id column which makes the code different.
"""
# the ranking task requires setting a group_id
# we are creating a dummy group_id here that doesn't make any sense for the Iris dataset,
# but is ok for testing if the code is running correctly
X, y = get_iris()
dummy_group_id = np.arange(len(X)) % 3
dummy_group_id.sort()
model = cb.CatBoostRanker(**MODEL_PARAMS, subsample=1.0)
model.fit(X, y, group_id=dummy_group_id)
with mlflow.start_run():
model_info = mlflow.catboost.log_model(model, name="model")
loaded_model = mlflow.catboost.load_model(model_info.model_uri)
assert isinstance(loaded_model, cb.CatBoostRanker)
np.testing.assert_array_almost_equal(model.predict(X), loaded_model.predict(X))
def test_init_model_throws_for_invalid_model_type():
with pytest.raises(TypeError, match="Invalid model type"):
mlflow.catboost._init_model("unsupported")
def test_model_save_load(cb_model, model_path):
model, inference_dataframe = cb_model
mlflow.catboost.save_model(cb_model=model, path=model_path)
loaded_model = mlflow.catboost.load_model(model_uri=model_path)
np.testing.assert_array_almost_equal(
model.predict(inference_dataframe),
loaded_model.predict(inference_dataframe),
)
loaded_pyfunc = pyfunc.load_model(model_uri=model_path)
np.testing.assert_array_almost_equal(
loaded_model.predict(inference_dataframe),
loaded_pyfunc.predict(inference_dataframe),
)
def test_log_model_logs_model_type(cb_model):
with mlflow.start_run():
artifact_path = "model"
model_info = mlflow.catboost.log_model(cb_model.model, name=artifact_path)
flavor_conf = Model.load(model_info.model_uri).flavors["catboost"]
assert "model_type" in flavor_conf
assert flavor_conf["model_type"] == cb_model.model.__class__.__name__
# Supported serialization formats:
# https://catboost.ai/docs/concepts/python-reference_catboost_save_model.html
SUPPORTS_DESERIALIZATION = ["cbm", "coreml", "json", "onnx"]
save_formats = SUPPORTS_DESERIALIZATION + ["python", "cpp", "pmml"]
@pytest.mark.allow_infer_pip_requirements_fallback
@pytest.mark.parametrize("save_format", save_formats)
def test_log_model_logs_save_format(reg_model, save_format):
with mlflow.start_run():
artifact_path = "model"
model_info = mlflow.catboost.log_model(
reg_model.model, name=artifact_path, format=save_format
)
flavor_conf = Model.load(model_info.model_uri).flavors["catboost"]
assert "save_format" in flavor_conf
assert flavor_conf["save_format"] == save_format
if save_format in SUPPORTS_DESERIALIZATION:
mlflow.catboost.load_model(model_info.model_uri)
else:
with pytest.raises(cb.CatBoostError, match="deserialization not supported or missing"):
mlflow.catboost.load_model(model_info.model_uri)
@pytest.mark.parametrize("signature", [None, get_reg_model_signature()])
@pytest.mark.parametrize("input_example", [None, get_iris()[0].head(3)])
def test_signature_and_examples_are_saved_correctly(
reg_model, model_path, signature, input_example
):
mlflow.catboost.save_model(
reg_model.model, model_path, signature=signature, input_example=input_example
)
mlflow_model = Model.load(model_path)
if signature is None and input_example is None:
assert mlflow_model.signature is None
else:
assert mlflow_model.signature == get_reg_model_signature()
if input_example is None:
assert mlflow_model.saved_input_example_info is None
else:
pd.testing.assert_frame_equal(_read_example(mlflow_model, model_path), input_example)
def test_model_load_from_remote_uri_succeeds(reg_model, model_path, mock_s3_bucket):
model, inference_dataframe = reg_model
mlflow.catboost.save_model(cb_model=model, path=model_path)
artifact_root = f"s3://{mock_s3_bucket}"
artifact_repo = S3ArtifactRepository(artifact_root)
artifact_path = "model"
artifact_repo.log_artifacts(model_path, artifact_path=artifact_path)
model_uri = artifact_root + "/" + artifact_path
loaded_model = mlflow.catboost.load_model(model_uri=model_uri)
np.testing.assert_array_almost_equal(
model.predict(inference_dataframe),
loaded_model.predict(inference_dataframe),
)
def test_log_model(cb_model, tmp_path):
model, inference_dataframe = cb_model
with mlflow.start_run():
artifact_path = "model"
conda_env = os.path.join(tmp_path, "conda_env.yaml")
_mlflow_conda_env(conda_env, additional_pip_deps=["catboost"])
model_info = mlflow.catboost.log_model(model, name=artifact_path, conda_env=conda_env)
loaded_model = mlflow.catboost.load_model(model_info.model_uri)
np.testing.assert_array_almost_equal(
model.predict(inference_dataframe),
loaded_model.predict(inference_dataframe),
)
local_path = _download_artifact_from_uri(model_info.model_uri)
model_config = Model.load(os.path.join(local_path, "MLmodel"))
assert pyfunc.FLAVOR_NAME in model_config.flavors
assert pyfunc.ENV in model_config.flavors[pyfunc.FLAVOR_NAME]
env_path = model_config.flavors[pyfunc.FLAVOR_NAME][pyfunc.ENV]["conda"]
assert os.path.exists(os.path.join(local_path, env_path))
def test_log_model_calls_register_model(cb_model, tmp_path):
artifact_path = "model"
registered_model_name = "registered_model"
with (
mlflow.start_run(),
mock.patch("mlflow.tracking._model_registry.fluent._register_model"),
):
conda_env_path = os.path.join(tmp_path, "conda_env.yaml")
_mlflow_conda_env(conda_env_path, additional_pip_deps=["catboost"])
model_info = mlflow.catboost.log_model(
cb_model.model,
name=artifact_path,
conda_env=conda_env_path,
registered_model_name=registered_model_name,
)
assert_register_model_called_with_local_model_path(
register_model_mock=mlflow.tracking._model_registry.fluent._register_model,
model_uri=model_info.model_uri,
registered_model_name=registered_model_name,
)
def test_log_model_no_registered_model_name(cb_model, tmp_path):
with mlflow.start_run(), mock.patch("mlflow.register_model") as register_model_mock:
artifact_path = "model"
conda_env_path = os.path.join(tmp_path, "conda_env.yaml")
_mlflow_conda_env(conda_env_path, additional_pip_deps=["catboost"])
mlflow.catboost.log_model(cb_model.model, name=artifact_path, conda_env=conda_env_path)
register_model_mock.assert_not_called()
def test_model_save_persists_specified_conda_env_in_mlflow_model_directory(
reg_model, model_path, custom_env
):
mlflow.catboost.save_model(cb_model=reg_model.model, path=model_path, conda_env=custom_env)
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV]["conda"])
assert os.path.exists(saved_conda_env_path)
assert saved_conda_env_path != custom_env
assert read_yaml(saved_conda_env_path) == read_yaml(custom_env)
def test_model_save_persists_requirements_in_mlflow_model_directory(
reg_model, model_path, custom_env
):
mlflow.catboost.save_model(cb_model=reg_model.model, path=model_path, conda_env=custom_env)
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
_compare_conda_env_requirements(custom_env, saved_pip_req_path)
def test_model_save_accepts_conda_env_as_dict(reg_model, model_path):
conda_env = mlflow.catboost.get_default_conda_env()
conda_env["dependencies"].append("pytest")
mlflow.catboost.save_model(cb_model=reg_model.model, path=model_path, conda_env=conda_env)
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV]["conda"])
assert os.path.exists(saved_conda_env_path)
assert read_yaml(saved_conda_env_path) == conda_env
def test_model_log_persists_specified_conda_env_in_mlflow_model_directory(reg_model, custom_env):
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name=artifact_path, conda_env=custom_env
)
local_path = _download_artifact_from_uri(artifact_uri=model_info.model_uri)
pyfunc_conf = _get_flavor_configuration(model_path=local_path, flavor_name=pyfunc.FLAVOR_NAME)
saved_conda_env_path = os.path.join(local_path, pyfunc_conf[pyfunc.ENV]["conda"])
assert os.path.exists(saved_conda_env_path)
assert saved_conda_env_path != custom_env
assert read_yaml(saved_conda_env_path) == read_yaml(custom_env)
def test_model_log_persists_requirements_in_mlflow_model_directory(reg_model, custom_env):
with mlflow.start_run():
model_info = mlflow.catboost.log_model(reg_model.model, name="model", conda_env=custom_env)
local_path = _download_artifact_from_uri(artifact_uri=model_info.model_uri)
saved_pip_req_path = os.path.join(local_path, "requirements.txt")
_compare_conda_env_requirements(custom_env, saved_pip_req_path)
def test_log_model_with_pip_requirements(reg_model, tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name="model", pip_requirements=str(req_file)
)
_assert_pip_requirements(model_info.model_uri, [expected_mlflow_version, "a"], strict=True)
# List of requirements
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name="model", pip_requirements=[f"-r {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri, [expected_mlflow_version, "a", "b"], strict=True
)
# Constraints file
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name="model", pip_requirements=[f"-c {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, "b", "-c constraints.txt"],
["a"],
strict=True,
)
def test_log_model_with_extra_pip_requirements(reg_model, tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
default_reqs = mlflow.catboost.get_default_pip_requirements()
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name="model", extra_pip_requirements=str(req_file)
)
_assert_pip_requirements(
model_info.model_uri, [expected_mlflow_version, *default_reqs, "a"]
)
# List of requirements
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name="model", extra_pip_requirements=[f"-r {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri, [expected_mlflow_version, *default_reqs, "a", "b"]
)
# Constraints file
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
reg_model.model, name="model", extra_pip_requirements=[f"-c {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, *default_reqs, "b", "-c constraints.txt"],
["a"],
)
def test_model_save_without_specified_conda_env_uses_default_env_with_expected_dependencies(
reg_model, model_path
):
mlflow.catboost.save_model(reg_model.model, model_path)
_assert_pip_requirements(model_path, mlflow.catboost.get_default_pip_requirements())
def test_model_log_without_specified_conda_env_uses_default_env_with_expected_dependencies(
reg_model,
):
with mlflow.start_run():
model_info = mlflow.catboost.log_model(reg_model.model, name="model")
_assert_pip_requirements(model_info.model_uri, mlflow.catboost.get_default_pip_requirements())
def test_pyfunc_serve_and_score(reg_model):
model, inference_dataframe = reg_model
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
model, name=artifact_path, input_example=inference_dataframe
)
inference_payload = load_serving_example(model_info.model_uri)
resp = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=EXTRA_PYFUNC_SERVING_TEST_ARGS,
)
scores = pd.DataFrame(
data=json.loads(resp.content.decode("utf-8"))["predictions"]
).values.squeeze()
np.testing.assert_array_almost_equal(scores, model.predict(inference_dataframe))
def test_pyfunc_serve_and_score_sklearn(reg_model):
model, inference_dataframe = reg_model
model = Pipeline([("model", reg_model.model)])
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(
model,
name="model",
input_example=inference_dataframe.head(3),
skops_trusted_types=["catboost.core.CatBoostRegressor"],
)
inference_payload = load_serving_example(model_info.model_uri)
resp = pyfunc_serve_and_score_model(
model_info.model_uri,
inference_payload,
pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=EXTRA_PYFUNC_SERVING_TEST_ARGS,
)
scores = pd.DataFrame(
data=json.loads(resp.content.decode("utf-8"))["predictions"]
).values.squeeze()
np.testing.assert_array_almost_equal(scores, model.predict(inference_dataframe.head(3)))
def test_log_model_with_code_paths(cb_model):
artifact_path = "model"
with (
mlflow.start_run(),
mock.patch("mlflow.catboost._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.catboost.log_model(
cb_model.model, name=artifact_path, code_paths=[__file__]
)
_compare_logged_code_paths(__file__, model_info.model_uri, mlflow.catboost.FLAVOR_NAME)
mlflow.catboost.load_model(model_uri=model_info.model_uri)
add_mock.assert_called()
def test_virtualenv_subfield_points_to_correct_path(cb_model, model_path):
mlflow.catboost.save_model(cb_model.model, path=model_path)
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
python_env_path = Path(model_path, pyfunc_conf[pyfunc.ENV]["virtualenv"])
assert python_env_path.exists()
assert python_env_path.is_file()
def test_model_save_load_with_metadata(cb_model, model_path):
mlflow.catboost.save_model(
cb_model.model, path=model_path, metadata={"metadata_key": "metadata_value"}
)
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_path)
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
def test_model_log_with_metadata(cb_model):
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
cb_model.model, name="model", metadata={"metadata_key": "metadata_value"}
)
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_info.model_uri)
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
def test_model_log_with_signature_inference(cb_model):
artifact_path = "model"
example = cb_model.inference_dataframe.head(3)
with mlflow.start_run():
model_info = mlflow.catboost.log_model(
cb_model.model, name=artifact_path, input_example=example
)
loaded_model_info = Model.load(model_info.model_uri)
assert loaded_model_info.signature.inputs == Schema([
ColSpec(name="sepal length (cm)", type=DataType.double),
ColSpec(name="sepal width (cm)", type=DataType.double),
])
assert loaded_model_info.signature.outputs in [
# when the model output is a 1D numpy array, it is cast into a `ColSpec`
Schema([ColSpec(type=DataType.double)]),
# when the model output is a higher dimensional numpy array, it remains a `TensorSpec`
Schema([TensorSpec(np.dtype("int64"), (-1, 1))]),
]