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

474 lines
17 KiB
Python

import json
import os
from pathlib import Path
from unittest import mock
import numpy as np
import pandas as pd
import pytest
import yaml
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
import mlflow.statsmodels
from mlflow import pyfunc
from mlflow.models import Model
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.utils.environment import _mlflow_conda_env
from mlflow.utils.file_utils import TempDir
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,
)
from tests.statsmodels.model_fixtures import (
arma_model,
gee_model,
glm_model,
gls_model,
glsar_model,
ols_model,
ols_model_signature,
recursivels_model,
rolling_ols_model,
rolling_wls_model,
wls_model,
)
EXTRA_PYFUNC_SERVING_TEST_ARGS = (
[] if _is_available_on_pypi("statsmodels") else ["--env-manager", "local"]
)
# The code in this file has been adapted from the test cases of the lightgbm flavor.
def _get_dates_from_df(df):
start_date = df["start"][0]
end_date = df["end"][0]
return start_date, end_date
@pytest.fixture
def model_path(tmp_path, subdir="model"):
return os.path.join(tmp_path, subdir)
@pytest.fixture
def statsmodels_custom_env(tmp_path):
conda_env = os.path.join(tmp_path, "conda_env.yml")
_mlflow_conda_env(conda_env, additional_pip_deps=["pytest", "statsmodels"])
return conda_env
def _test_models_list(tmp_path, func_to_apply):
from statsmodels.tsa.base.tsa_model import TimeSeriesModel
fixtures = [
ols_model,
arma_model,
glsar_model,
gee_model,
glm_model,
gls_model,
recursivels_model,
rolling_ols_model,
rolling_wls_model,
wls_model,
]
for algorithm in fixtures:
name = algorithm.__name__
path = os.path.join(tmp_path, name)
model = algorithm()
if isinstance(model.alg, TimeSeriesModel):
start_date, end_date = _get_dates_from_df(model.inference_dataframe)
func_to_apply(model, path, start_date, end_date)
else:
func_to_apply(model, path, model.inference_dataframe)
def _test_model_save_load(statsmodels_model, model_path, *predict_args):
mlflow.statsmodels.save_model(statsmodels_model=statsmodels_model.model, path=model_path)
reloaded_model = mlflow.statsmodels.load_model(model_uri=model_path)
reloaded_pyfunc = pyfunc.load_model(model_uri=model_path)
if hasattr(statsmodels_model.model, "predict"):
np.testing.assert_array_almost_equal(
statsmodels_model.model.predict(*predict_args),
reloaded_model.predict(*predict_args),
)
np.testing.assert_array_almost_equal(
reloaded_model.predict(*predict_args),
reloaded_pyfunc.predict(statsmodels_model.inference_dataframe),
)
def _test_model_log(statsmodels_model, model_path, *predict_args):
model = statsmodels_model.model
with TempDir(chdr=True, remove_on_exit=True) as tmp:
try:
artifact_path = "model"
conda_env = os.path.join(tmp.path(), "conda_env.yaml")
_mlflow_conda_env(conda_env, additional_pip_deps=["statsmodels"])
model_info = mlflow.statsmodels.log_model(
model, name=artifact_path, conda_env=conda_env
)
reloaded_model = mlflow.statsmodels.load_model(model_uri=model_info.model_uri)
if hasattr(model, "predict"):
np.testing.assert_array_almost_equal(
model.predict(*predict_args), reloaded_model.predict(*predict_args)
)
model_path = _download_artifact_from_uri(artifact_uri=model_info.model_uri)
model_config = Model.load(os.path.join(model_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(model_path, env_path))
finally:
mlflow.end_run()
def test_models_save_load(tmp_path):
_test_models_list(tmp_path, _test_model_save_load)
def test_models_log(tmp_path):
_test_models_list(tmp_path, _test_model_log)
def test_signature_and_examples_are_saved_correctly():
model, _, X = ols_model()
signature_ = ols_model_signature()
example_ = X[0:3, :]
for signature in (None, signature_):
for example in (None, example_):
with TempDir() as tmp:
path = tmp.path("model")
mlflow.statsmodels.save_model(
model, path=path, signature=signature, input_example=example
)
mlflow_model = Model.load(path)
if signature is None and example is None:
assert mlflow_model.signature is None
else:
assert mlflow_model.signature == signature_
if example is None:
assert mlflow_model.saved_input_example_info is None
else:
np.testing.assert_array_equal(_read_example(mlflow_model, path), example)
def test_model_load_from_remote_uri_succeeds(model_path, mock_s3_bucket):
model, _, inference_dataframe = arma_model()
mlflow.statsmodels.save_model(statsmodels_model=model, path=model_path)
artifact_root = f"s3://{mock_s3_bucket}"
artifact_path = "model"
artifact_repo = S3ArtifactRepository(artifact_root)
artifact_repo.log_artifacts(model_path, artifact_path=artifact_path)
model_uri = artifact_root + "/" + artifact_path
reloaded_model = mlflow.statsmodels.load_model(model_uri=model_uri)
start_date, end_date = _get_dates_from_df(inference_dataframe)
np.testing.assert_array_almost_equal(
model.predict(start=start_date, end=end_date),
reloaded_model.predict(start=start_date, end=end_date),
)
def test_log_model_calls_register_model():
# Adapted from lightgbm tests
ols = ols_model()
artifact_path = "model"
register_model_patch = mock.patch("mlflow.tracking._model_registry.fluent._register_model")
with mlflow.start_run(), register_model_patch, TempDir(chdr=True, remove_on_exit=True) as tmp:
conda_env = os.path.join(tmp.path(), "conda_env.yaml")
_mlflow_conda_env(conda_env, additional_pip_deps=["statsmodels"])
model_info = mlflow.statsmodels.log_model(
ols.model,
name=artifact_path,
conda_env=conda_env,
registered_model_name="OLSModel1",
)
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="OLSModel1",
)
def test_log_model_no_registered_model_name():
ols = ols_model()
artifact_path = "model"
register_model_patch = mock.patch("mlflow.tracking._model_registry.fluent._register_model")
with mlflow.start_run(), register_model_patch, TempDir(chdr=True, remove_on_exit=True) as tmp:
conda_env = os.path.join(tmp.path(), "conda_env.yaml")
_mlflow_conda_env(conda_env, additional_pip_deps=["statsmodels"])
mlflow.statsmodels.log_model(ols.model, name=artifact_path, conda_env=conda_env)
mlflow.tracking._model_registry.fluent._register_model.assert_not_called()
def test_model_save_persists_specified_conda_env_in_mlflow_model_directory(
model_path, statsmodels_custom_env
):
ols = ols_model()
mlflow.statsmodels.save_model(
statsmodels_model=ols.model, path=model_path, conda_env=statsmodels_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 != statsmodels_custom_env
with open(statsmodels_custom_env) as f:
statsmodels_custom_env_parsed = yaml.safe_load(f)
with open(saved_conda_env_path) as f:
saved_conda_env_parsed = yaml.safe_load(f)
assert saved_conda_env_parsed == statsmodels_custom_env_parsed
def test_model_save_persists_requirements_in_mlflow_model_directory(
model_path, statsmodels_custom_env
):
ols = ols_model()
mlflow.statsmodels.save_model(
statsmodels_model=ols.model, path=model_path, conda_env=statsmodels_custom_env
)
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
_compare_conda_env_requirements(statsmodels_custom_env, saved_pip_req_path)
def test_log_model_with_pip_requirements(tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
ols = ols_model()
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
model_info = mlflow.statsmodels.log_model(
ols.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.statsmodels.log_model(
ols.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.statsmodels.log_model(
ols.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(tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
ols = ols_model()
default_reqs = mlflow.statsmodels.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.statsmodels.log_model(
ols.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.statsmodels.log_model(
ols.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.statsmodels.log_model(
ols.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_accepts_conda_env_as_dict(model_path):
ols = ols_model()
conda_env = dict(mlflow.statsmodels.get_default_conda_env())
conda_env["dependencies"].append("pytest")
mlflow.statsmodels.save_model(statsmodels_model=ols.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)
with open(saved_conda_env_path) as f:
saved_conda_env_parsed = yaml.safe_load(f)
assert saved_conda_env_parsed == conda_env
def test_model_log_persists_specified_conda_env_in_mlflow_model_directory(statsmodels_custom_env):
ols = ols_model()
with mlflow.start_run():
model_info = mlflow.statsmodels.log_model(
ols.model,
name="model",
conda_env=statsmodels_custom_env,
)
model_path = _download_artifact_from_uri(artifact_uri=model_info.model_uri)
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 != statsmodels_custom_env
with open(statsmodels_custom_env) as f:
statsmodels_custom_env_parsed = yaml.safe_load(f)
with open(saved_conda_env_path) as f:
saved_conda_env_parsed = yaml.safe_load(f)
assert saved_conda_env_parsed == statsmodels_custom_env_parsed
def test_model_log_persists_requirements_in_mlflow_model_directory(statsmodels_custom_env):
ols = ols_model()
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.statsmodels.log_model(
ols.model,
name=artifact_path,
conda_env=statsmodels_custom_env,
)
model_path = _download_artifact_from_uri(artifact_uri=model_info.model_uri)
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
_compare_conda_env_requirements(statsmodels_custom_env, saved_pip_req_path)
def test_model_save_without_specified_conda_env_uses_default_env_with_expected_dependencies(
model_path,
):
ols = ols_model()
mlflow.statsmodels.save_model(statsmodels_model=ols.model, path=model_path)
_assert_pip_requirements(model_path, mlflow.statsmodels.get_default_pip_requirements())
def test_model_log_without_specified_conda_env_uses_default_env_with_expected_dependencies():
ols = ols_model()
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.statsmodels.log_model(ols.model, name=artifact_path)
_assert_pip_requirements(
model_info.model_uri, mlflow.statsmodels.get_default_pip_requirements()
)
def test_pyfunc_serve_and_score():
model, _, inference_dataframe = ols_model()
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.statsmodels.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_log_model_with_code_paths():
artifact_path = "model"
ols = ols_model()
with (
mlflow.start_run(),
mock.patch("mlflow.statsmodels._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.statsmodels.log_model(
ols.model, name=artifact_path, code_paths=[__file__]
)
_compare_logged_code_paths(__file__, model_info.model_uri, mlflow.statsmodels.FLAVOR_NAME)
mlflow.statsmodels.load_model(model_info.model_uri)
add_mock.assert_called()
def test_virtualenv_subfield_points_to_correct_path(model_path):
ols = ols_model()
mlflow.statsmodels.save_model(ols.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(model_path):
ols = ols_model()
mlflow.statsmodels.save_model(
ols.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():
ols = ols_model()
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.statsmodels.log_model(
ols.model, name=artifact_path, 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():
model, _, X = ols_model()
artifact_path = "model"
example = X[0:3, :]
with mlflow.start_run():
model_info = mlflow.statsmodels.log_model(model, name=artifact_path, input_example=example)
loaded_model = Model.load(model_info.model_uri)
assert loaded_model.signature == ols_model_signature()