388 lines
15 KiB
Python
388 lines
15 KiB
Python
import os
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import boto3
|
|
import flavor
|
|
import moto
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
from botocore.config import Config
|
|
from sktime.datasets import load_airline, load_longley
|
|
from sktime.datatypes import convert
|
|
from sktime.forecasting.arima import AutoARIMA
|
|
from sktime.forecasting.model_selection import temporal_train_test_split
|
|
from sktime.forecasting.naive import NaiveForecaster
|
|
|
|
import mlflow
|
|
from mlflow import pyfunc
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.models import Model, infer_signature
|
|
from mlflow.models.utils import _read_example
|
|
from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository
|
|
from mlflow.tracking._model_registry import DEFAULT_AWAIT_MAX_SLEEP_SECONDS
|
|
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
|
|
from mlflow.utils.environment import _mlflow_conda_env
|
|
|
|
FH = [1, 2, 3]
|
|
COVERAGE = [0.1, 0.5, 0.9]
|
|
ALPHA = [0.1, 0.5, 0.9]
|
|
COV = False
|
|
|
|
|
|
@pytest.fixture
|
|
def model_path(tmp_path):
|
|
"""Create a temporary path to save/log model."""
|
|
return tmp_path.joinpath("model")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def mock_s3_bucket():
|
|
"""Create a mock S3 bucket using moto.
|
|
|
|
Returns
|
|
-------
|
|
string with name of mock S3 bucket
|
|
"""
|
|
with moto.mock_s3():
|
|
bucket_name = "mock-bucket"
|
|
my_config = Config(region_name="us-east-1")
|
|
s3_client = boto3.client("s3", config=my_config)
|
|
s3_client.create_bucket(Bucket=bucket_name)
|
|
yield bucket_name
|
|
|
|
|
|
@pytest.fixture
|
|
def sktime_custom_env(tmp_path):
|
|
"""Create a conda environment and returns path to conda environment yml file."""
|
|
conda_env = tmp_path.joinpath("conda_env.yml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["sktime"])
|
|
return conda_env
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def data_airline():
|
|
"""Create sample data for univariate model without exogenous regressor."""
|
|
return load_airline()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def data_longley():
|
|
"""Create sample data for univariate model with exogenous regressor."""
|
|
y, X = load_longley()
|
|
y_train, y_test, X_train, X_test = temporal_train_test_split(y, X)
|
|
return y_train, y_test, X_train, X_test
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def auto_arima_model(data_airline):
|
|
"""Create instance of fitted auto arima model."""
|
|
return AutoARIMA(sp=12, d=0, max_p=2, max_q=2, suppress_warnings=True).fit(data_airline)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def naive_forecaster_model_with_regressor(data_longley):
|
|
"""Create instance of fitted naive forecaster model."""
|
|
y_train, _, X_train, _ = data_longley
|
|
model = NaiveForecaster()
|
|
return model.fit(y_train, X_train)
|
|
|
|
|
|
@pytest.mark.parametrize("serialization_format", ["pickle", "cloudpickle"])
|
|
def test_auto_arima_model_save_and_load(auto_arima_model, model_path, serialization_format):
|
|
flavor.save_model(
|
|
sktime_model=auto_arima_model,
|
|
path=model_path,
|
|
serialization_format=serialization_format,
|
|
)
|
|
loaded_model = flavor.load_model(
|
|
model_uri=model_path,
|
|
)
|
|
|
|
np.testing.assert_array_equal(auto_arima_model.predict(fh=FH), loaded_model.predict(fh=FH))
|
|
|
|
|
|
@pytest.mark.parametrize("serialization_format", ["pickle", "cloudpickle"])
|
|
def test_auto_arima_model_pyfunc_output(auto_arima_model, model_path, serialization_format):
|
|
flavor.save_model(
|
|
sktime_model=auto_arima_model,
|
|
path=model_path,
|
|
serialization_format=serialization_format,
|
|
)
|
|
loaded_pyfunc = flavor.pyfunc.load_model(model_uri=model_path)
|
|
|
|
model_predict = auto_arima_model.predict(fh=FH)
|
|
predict_conf = pd.DataFrame([{"fh": FH, "predict_method": "predict"}])
|
|
pyfunc_predict = loaded_pyfunc.predict(predict_conf)
|
|
np.testing.assert_array_equal(model_predict, pyfunc_predict)
|
|
|
|
model_predict_interval = auto_arima_model.predict_interval(fh=FH, coverage=COVERAGE)
|
|
predict_interval_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_interval",
|
|
"coverage": COVERAGE,
|
|
}
|
|
])
|
|
pyfunc_predict_interval = loaded_pyfunc.predict(predict_interval_conf)
|
|
np.testing.assert_array_equal(model_predict_interval.values, pyfunc_predict_interval.values)
|
|
|
|
model_predict_quantiles = auto_arima_model.predict_quantiles(fh=FH, alpha=ALPHA)
|
|
predict_quantiles_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_quantiles",
|
|
"alpha": ALPHA,
|
|
}
|
|
])
|
|
pyfunc_predict_quantiles = loaded_pyfunc.predict(predict_quantiles_conf)
|
|
np.testing.assert_array_equal(model_predict_quantiles.values, pyfunc_predict_quantiles.values)
|
|
|
|
model_predict_var = auto_arima_model.predict_var(fh=FH, cov=COV)
|
|
predict_var_conf = pd.DataFrame([{"fh": FH, "predict_method": "predict_var", "cov": COV}])
|
|
pyfunc_predict_var = loaded_pyfunc.predict(predict_var_conf)
|
|
np.testing.assert_array_equal(model_predict_var.values, pyfunc_predict_var.values)
|
|
|
|
|
|
def test_naive_forecaster_model_with_regressor_pyfunc_output(
|
|
naive_forecaster_model_with_regressor, model_path, data_longley
|
|
):
|
|
_, _, _, X_test = data_longley
|
|
|
|
flavor.save_model(sktime_model=naive_forecaster_model_with_regressor, path=model_path)
|
|
loaded_pyfunc = flavor.pyfunc.load_model(model_uri=model_path)
|
|
|
|
X_test_array = convert(X_test, "pd.DataFrame", "np.ndarray")
|
|
|
|
model_predict = naive_forecaster_model_with_regressor.predict(fh=FH, X=X_test)
|
|
predict_conf = pd.DataFrame([{"fh": FH, "predict_method": "predict", "X": X_test_array}])
|
|
pyfunc_predict = loaded_pyfunc.predict(predict_conf)
|
|
np.testing.assert_array_equal(model_predict, pyfunc_predict)
|
|
|
|
model_predict_interval = naive_forecaster_model_with_regressor.predict_interval(
|
|
fh=FH, coverage=COVERAGE, X=X_test
|
|
)
|
|
predict_interval_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_interval",
|
|
"coverage": COVERAGE,
|
|
"X": X_test_array,
|
|
}
|
|
])
|
|
pyfunc_predict_interval = loaded_pyfunc.predict(predict_interval_conf)
|
|
np.testing.assert_array_equal(model_predict_interval.values, pyfunc_predict_interval.values)
|
|
|
|
model_predict_quantiles = naive_forecaster_model_with_regressor.predict_quantiles(
|
|
fh=FH, alpha=ALPHA, X=X_test
|
|
)
|
|
predict_quantiles_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_quantiles",
|
|
"alpha": ALPHA,
|
|
"X": X_test_array,
|
|
}
|
|
])
|
|
pyfunc_predict_quantiles = loaded_pyfunc.predict(predict_quantiles_conf)
|
|
np.testing.assert_array_equal(model_predict_quantiles.values, pyfunc_predict_quantiles.values)
|
|
|
|
model_predict_var = naive_forecaster_model_with_regressor.predict_var(fh=FH, cov=COV, X=X_test)
|
|
predict_var_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_var",
|
|
"cov": COV,
|
|
"X": X_test_array,
|
|
}
|
|
])
|
|
pyfunc_predict_var = loaded_pyfunc.predict(predict_var_conf)
|
|
np.testing.assert_array_equal(model_predict_var.values, pyfunc_predict_var.values)
|
|
|
|
|
|
@pytest.mark.parametrize("use_signature", [True, False])
|
|
@pytest.mark.parametrize("use_example", [True, False])
|
|
def test_signature_and_examples_saved_correctly(
|
|
auto_arima_model, data_airline, model_path, use_signature, use_example
|
|
):
|
|
# Note: Signature inference fails on native model predict_interval/predict_quantiles
|
|
prediction = auto_arima_model.predict(fh=FH)
|
|
signature = infer_signature(data_airline, prediction) if use_signature else None
|
|
example = pd.DataFrame(data_airline[0:5].copy(deep=False)) if use_example else None
|
|
flavor.save_model(auto_arima_model, path=model_path, signature=signature, input_example=example)
|
|
mlflow_model = Model.load(model_path)
|
|
assert signature == mlflow_model.signature
|
|
if example is None:
|
|
assert mlflow_model.saved_input_example_info is None
|
|
else:
|
|
r_example = _read_example(mlflow_model, model_path).copy(deep=False)
|
|
np.testing.assert_array_equal(r_example, example)
|
|
|
|
|
|
@pytest.mark.parametrize("use_signature", [True, False])
|
|
def test_predict_var_signature_saved_correctly(
|
|
auto_arima_model, data_airline, model_path, use_signature
|
|
):
|
|
prediction = auto_arima_model.predict_var(fh=FH)
|
|
signature = infer_signature(data_airline, prediction) if use_signature else None
|
|
flavor.save_model(auto_arima_model, path=model_path, signature=signature)
|
|
mlflow_model = Model.load(model_path)
|
|
assert signature == mlflow_model.signature
|
|
|
|
|
|
@pytest.mark.parametrize("use_signature", [True, False])
|
|
@pytest.mark.parametrize("use_example", [True, False])
|
|
def test_signature_and_example_for_pyfunc_predict_interval(
|
|
auto_arima_model, model_path, data_airline, use_signature, use_example
|
|
):
|
|
model_path_primary = model_path.joinpath("primary")
|
|
model_path_secondary = model_path.joinpath("secondary")
|
|
flavor.save_model(sktime_model=auto_arima_model, path=model_path_primary)
|
|
loaded_pyfunc = flavor.pyfunc.load_model(model_uri=model_path_primary)
|
|
predict_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_interval",
|
|
"coverage": COVERAGE,
|
|
}
|
|
])
|
|
forecast = loaded_pyfunc.predict(predict_conf)
|
|
signature = infer_signature(data_airline, forecast) if use_signature else None
|
|
example = pd.DataFrame(data_airline[0:5].copy(deep=False)) if use_example else None
|
|
flavor.save_model(
|
|
auto_arima_model,
|
|
path=model_path_secondary,
|
|
signature=signature,
|
|
input_example=example,
|
|
)
|
|
mlflow_model = Model.load(model_path_secondary)
|
|
assert signature == mlflow_model.signature
|
|
if example is None:
|
|
assert mlflow_model.saved_input_example_info is None
|
|
else:
|
|
r_example = _read_example(mlflow_model, model_path_secondary).copy(deep=False)
|
|
np.testing.assert_array_equal(r_example, example)
|
|
|
|
|
|
@pytest.mark.parametrize("use_signature", [True, False])
|
|
def test_signature_for_pyfunc_predict_quantiles(
|
|
auto_arima_model, model_path, data_airline, use_signature
|
|
):
|
|
model_path_primary = model_path.joinpath("primary")
|
|
model_path_secondary = model_path.joinpath("secondary")
|
|
flavor.save_model(sktime_model=auto_arima_model, path=model_path_primary)
|
|
loaded_pyfunc = flavor.pyfunc.load_model(model_uri=model_path_primary)
|
|
predict_conf = pd.DataFrame([
|
|
{
|
|
"fh": FH,
|
|
"predict_method": "predict_quantiles",
|
|
"alpha": ALPHA,
|
|
}
|
|
])
|
|
forecast = loaded_pyfunc.predict(predict_conf)
|
|
signature = infer_signature(data_airline, forecast) if use_signature else None
|
|
flavor.save_model(auto_arima_model, path=model_path_secondary, signature=signature)
|
|
mlflow_model = Model.load(model_path_secondary)
|
|
assert signature == mlflow_model.signature
|
|
|
|
|
|
def test_load_from_remote_uri_succeeds(auto_arima_model, model_path, mock_s3_bucket):
|
|
flavor.save_model(sktime_model=auto_arima_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 = os.path.join(artifact_root, artifact_path)
|
|
reloaded_sktime_model = flavor.load_model(model_uri=model_uri)
|
|
|
|
np.testing.assert_array_equal(
|
|
auto_arima_model.predict(fh=FH),
|
|
reloaded_sktime_model.predict(fh=FH),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("should_start_run", [True, False])
|
|
@pytest.mark.parametrize("serialization_format", ["pickle", "cloudpickle"])
|
|
def test_log_model(auto_arima_model, tmp_path, should_start_run, serialization_format):
|
|
try:
|
|
if should_start_run:
|
|
mlflow.start_run()
|
|
artifact_path = "sktime"
|
|
conda_env = tmp_path.joinpath("conda_env.yaml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["sktime"])
|
|
model_info = flavor.log_model(
|
|
sktime_model=auto_arima_model,
|
|
artifact_path=artifact_path,
|
|
conda_env=str(conda_env),
|
|
serialization_format=serialization_format,
|
|
)
|
|
model_uri = f"runs:/{mlflow.active_run().info.run_id}/{artifact_path}"
|
|
assert model_info.model_uri == model_uri
|
|
reloaded_model = flavor.load_model(
|
|
model_uri=model_uri,
|
|
)
|
|
np.testing.assert_array_equal(auto_arima_model.predict(), reloaded_model.predict())
|
|
model_path = Path(_download_artifact_from_uri(artifact_uri=model_uri))
|
|
model_config = Model.load(str(model_path.joinpath("MLmodel")))
|
|
assert pyfunc.FLAVOR_NAME in model_config.flavors
|
|
finally:
|
|
mlflow.end_run()
|
|
|
|
|
|
def test_log_model_calls_register_model(auto_arima_model, tmp_path):
|
|
artifact_path = "sktime"
|
|
register_model_patch = mock.patch("mlflow.register_model")
|
|
with mlflow.start_run(), register_model_patch:
|
|
conda_env = tmp_path.joinpath("conda_env.yaml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["sktime"])
|
|
flavor.log_model(
|
|
sktime_model=auto_arima_model,
|
|
artifact_path=artifact_path,
|
|
conda_env=str(conda_env),
|
|
registered_model_name="SktimeModel",
|
|
)
|
|
model_uri = f"runs:/{mlflow.active_run().info.run_id}/{artifact_path}"
|
|
mlflow.register_model.assert_called_once_with(
|
|
model_uri,
|
|
"SktimeModel",
|
|
await_registration_for=DEFAULT_AWAIT_MAX_SLEEP_SECONDS,
|
|
)
|
|
|
|
|
|
def test_log_model_no_registered_model_name(auto_arima_model, tmp_path):
|
|
artifact_path = "sktime"
|
|
register_model_patch = mock.patch("mlflow.register_model")
|
|
with mlflow.start_run(), register_model_patch:
|
|
conda_env = tmp_path.joinpath("conda_env.yaml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["sktime"])
|
|
flavor.log_model(
|
|
sktime_model=auto_arima_model,
|
|
artifact_path=artifact_path,
|
|
conda_env=str(conda_env),
|
|
)
|
|
mlflow.register_model.assert_not_called()
|
|
|
|
|
|
def test_sktime_pyfunc_raises_invalid_df_input(auto_arima_model, model_path):
|
|
flavor.save_model(sktime_model=auto_arima_model, path=model_path)
|
|
loaded_pyfunc = flavor.pyfunc.load_model(model_uri=model_path)
|
|
|
|
with pytest.raises(MlflowException, match="The provided prediction pd.DataFrame "):
|
|
loaded_pyfunc.predict(pd.DataFrame([{"predict_method": "predict"}, {"fh": FH}]))
|
|
|
|
with pytest.raises(MlflowException, match="The provided prediction configuration "):
|
|
loaded_pyfunc.predict(pd.DataFrame([{"invalid": True}]))
|
|
|
|
with pytest.raises(MlflowException, match="Invalid `predict_method` value"):
|
|
loaded_pyfunc.predict(pd.DataFrame([{"predict_method": "predict_proba"}]))
|
|
|
|
|
|
def test_sktime_save_model_raises_invalid_serialization_format(auto_arima_model, model_path):
|
|
with pytest.raises(MlflowException, match="Unrecognized serialization format: "):
|
|
flavor.save_model(
|
|
sktime_model=auto_arima_model, path=model_path, serialization_format="json"
|
|
)
|