232 lines
7.9 KiB
Python
232 lines
7.9 KiB
Python
import os
|
|
import sys
|
|
from io import BytesIO
|
|
from stat import S_IRGRP, S_IROTH, S_IRUSR, S_IXGRP, S_IXOTH, S_IXUSR
|
|
from typing import NamedTuple
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
import sklearn
|
|
from sklearn.datasets import load_iris
|
|
from sklearn.linear_model import LogisticRegression
|
|
|
|
import mlflow
|
|
from mlflow.environment_variables import MLFLOW_ENV_ROOT
|
|
from mlflow.pyfunc.scoring_server import CONTENT_TYPE_JSON
|
|
from mlflow.utils.environment import _PYTHON_ENV_FILE_NAME, _REQUIREMENTS_FILE_NAME
|
|
from mlflow.utils.virtualenv import _is_pyenv_available
|
|
|
|
from tests.helper_functions import pyfunc_serve_and_score_model
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not _is_pyenv_available(),
|
|
reason="requires pyenv",
|
|
)
|
|
|
|
TEST_DIR = "tests"
|
|
TEST_MLFLOW_1X_MODEL_DIR = os.path.join(TEST_DIR, "resources", "example_mlflow_1x_sklearn_model")
|
|
|
|
|
|
class Model(NamedTuple):
|
|
model: LogisticRegression
|
|
X_pred: pd.DataFrame
|
|
y_pred: np.ndarray
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sklearn_model():
|
|
X, y = load_iris(return_X_y=True, as_frame=True)
|
|
model = LogisticRegression().fit(X, y)
|
|
X_pred = X.sample(frac=0.1, random_state=0)
|
|
y_pred = model.predict(X_pred)
|
|
return Model(model, X_pred, y_pred)
|
|
|
|
|
|
def serve_and_score(model_uri, data, extra_args=None):
|
|
resp = pyfunc_serve_and_score_model(
|
|
model_uri,
|
|
data=data,
|
|
content_type=CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager=virtualenv"] + (extra_args or []),
|
|
)
|
|
return pd.read_json(BytesIO(resp.content), orient="records").values.squeeze()
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_mlflow_env_root(tmp_path, monkeypatch):
|
|
env_root = tmp_path / "envs"
|
|
env_root.mkdir(exist_ok=True)
|
|
monkeypatch.setenv(MLFLOW_ENV_ROOT.name, str(env_root))
|
|
return env_root
|
|
|
|
|
|
use_temp_mlflow_env_root = pytest.mark.usefixtures(temp_mlflow_env_root.__name__)
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_restore_environment_with_virtualenv(sklearn_model):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
|
|
|
|
scores = serve_and_score(model_info.model_uri, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_serve_and_score_read_only_model_directory(sklearn_model, tmp_path):
|
|
model_path = str(tmp_path / "model")
|
|
mlflow.sklearn.save_model(sklearn_model.model, path=model_path)
|
|
os.chmod(
|
|
model_path,
|
|
S_IRUSR | S_IRGRP | S_IROTH | S_IXUSR | S_IXGRP | S_IXOTH,
|
|
)
|
|
|
|
scores = serve_and_score(model_path, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_serve_and_score_1x_models():
|
|
X, _ = load_iris(return_X_y=True, as_frame=True)
|
|
X_pred = X.sample(frac=0.1, random_state=0)
|
|
loaded_model = mlflow.pyfunc.load_model(TEST_MLFLOW_1X_MODEL_DIR)
|
|
y_pred = loaded_model.predict(X_pred)
|
|
|
|
scores = serve_and_score(TEST_MLFLOW_1X_MODEL_DIR, X_pred)
|
|
np.testing.assert_array_almost_equal(scores, y_pred)
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_reuse_environment(temp_mlflow_env_root, sklearn_model):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
|
|
|
|
# Serve the model
|
|
scores = serve_and_score(model_info.model_uri, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
# Serve the model again. The environment created in the previous serving should be reused.
|
|
scores = serve_and_score(model_info.model_uri, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
assert len(list(temp_mlflow_env_root.iterdir())) == 1
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_different_requirements_create_different_environments(temp_mlflow_env_root, sklearn_model):
|
|
sklearn_req = f"scikit-learn=={sklearn.__version__}"
|
|
with mlflow.start_run():
|
|
model_info1 = mlflow.sklearn.log_model(
|
|
sklearn_model.model,
|
|
name="model",
|
|
pip_requirements=[sklearn_req],
|
|
)
|
|
scores = serve_and_score(model_info1.model_uri, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
|
|
# Log the same model with different requirements
|
|
with mlflow.start_run():
|
|
model_info2 = mlflow.sklearn.log_model(
|
|
sklearn_model.model,
|
|
name="model",
|
|
pip_requirements=[sklearn_req, "numpy"],
|
|
)
|
|
scores = serve_and_score(model_info2.model_uri, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
# Two environments should exist now because the first and second models have different
|
|
# requirements
|
|
assert len(list(temp_mlflow_env_root.iterdir())) == 2
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_environment_directory_is_cleaned_up_when_unexpected_error_occurs(
|
|
temp_mlflow_env_root, sklearn_model
|
|
):
|
|
sklearn_req = "scikit-learn==999.999.999"
|
|
with mlflow.start_run():
|
|
model_info1 = mlflow.sklearn.log_model(
|
|
sklearn_model.model,
|
|
name="model",
|
|
pip_requirements=[sklearn_req],
|
|
)
|
|
|
|
try:
|
|
serve_and_score(model_info1.model_uri, sklearn_model.X_pred)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
assert False, "Should have raised an exception"
|
|
assert len(list(temp_mlflow_env_root.iterdir())) == 0
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_python_env_file_does_not_exist(sklearn_model, tmp_path):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
|
|
|
|
mlflow.artifacts.download_artifacts(artifact_uri=model_info.model_uri, dst_path=tmp_path)
|
|
python_env = next(tmp_path.rglob(_PYTHON_ENV_FILE_NAME))
|
|
python_env.unlink()
|
|
|
|
scores = serve_and_score(tmp_path, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_python_env_file_and_requirements_file_do_not_exist(sklearn_model, tmp_path):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
|
|
|
|
mlflow.artifacts.download_artifacts(artifact_uri=model_info.model_uri, dst_path=tmp_path)
|
|
python_env = next(tmp_path.rglob(_PYTHON_ENV_FILE_NAME))
|
|
python_env.unlink()
|
|
requirements = next(tmp_path.rglob(_REQUIREMENTS_FILE_NAME))
|
|
requirements.unlink()
|
|
|
|
scores = serve_and_score(tmp_path, sklearn_model.X_pred)
|
|
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
|
|
|
|
|
|
def test_environment_is_removed_when_package_installation_fails(
|
|
temp_mlflow_env_root, sklearn_model
|
|
):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(
|
|
sklearn_model.model,
|
|
name="model",
|
|
# Enforce pip install to fail using a non-existent package version
|
|
pip_requirements=["mlflow==999.999.999"],
|
|
)
|
|
with pytest.raises(AssertionError, match="scoring process died"):
|
|
serve_and_score(model_info.model_uri, sklearn_model.X_pred)
|
|
assert len(list(temp_mlflow_env_root.iterdir())) == 0
|
|
|
|
|
|
@use_temp_mlflow_env_root
|
|
def test_restore_environment_from_conda_yaml_containing_conda_packages(sklearn_model, tmp_path):
|
|
conda_env = {
|
|
"name": "mlflow-env",
|
|
"channels": ["conda-forge"],
|
|
"dependencies": [
|
|
"python=" + ".".join(map(str, sys.version_info[:3])),
|
|
"conda-package=1.2.3", # conda package
|
|
"pip",
|
|
{
|
|
"pip": [
|
|
"mlflow",
|
|
f"scikit-learn=={sklearn.__version__}",
|
|
]
|
|
},
|
|
],
|
|
}
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(
|
|
sklearn_model.model,
|
|
name="model",
|
|
conda_env=conda_env,
|
|
)
|
|
|
|
mlflow.artifacts.download_artifacts(artifact_uri=model_info.model_uri, dst_path=tmp_path)
|
|
python_env = next(tmp_path.rglob(_PYTHON_ENV_FILE_NAME))
|
|
python_env.unlink()
|
|
serve_and_score(tmp_path, sklearn_model.X_pred)
|