1470 lines
52 KiB
Python
1470 lines
52 KiB
Python
import importlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import re
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
import torch
|
|
import yaml
|
|
from packaging.version import Version
|
|
from sklearn import datasets
|
|
from torch import nn
|
|
from torch.utils.data import DataLoader
|
|
|
|
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
|
|
import mlflow.pytorch
|
|
from mlflow import pyfunc
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.models import Model, ModelSignature
|
|
from mlflow.models.utils import _read_example, load_serving_example
|
|
from mlflow.pytorch import pickle_module as mlflow_pytorch_pickle_module
|
|
from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository
|
|
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
|
|
from mlflow.types.schema import DataType, Schema, TensorSpec
|
|
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,
|
|
_is_importable,
|
|
_mlflow_major_version_string,
|
|
assert_array_almost_equal,
|
|
assert_register_model_called_with_local_model_path,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# This test suite is included as a code dependency when testing PyTorch model scoring in new
|
|
# processes and docker containers. In these environments, the `tests` module is not available.
|
|
# Therefore, we attempt to import from `tests` and gracefully emit a warning if it's unavailable.
|
|
try:
|
|
from tests.helper_functions import pyfunc_serve_and_score_model
|
|
except ImportError:
|
|
_logger.warning(
|
|
"Failed to import test helper functions. Tests depending on these functions may fail!"
|
|
)
|
|
|
|
EXTRA_PYFUNC_SERVING_TEST_ARGS = (
|
|
[] if _is_available_on_pypi("torch") else ["--env-manager", "local"]
|
|
)
|
|
|
|
# in pytorch >= 2.6.0, the `weights_only` kwarg default has been changed from
|
|
# `False` to `True`. this can cause pickle deserialization errors when loading
|
|
# models, unless the model classes have been explicitly marked as safe using
|
|
# `torch.serialization.add_safe_globals()`
|
|
ENABLE_LEGACY_DESERIALIZATION = Version(torch.__version__) >= Version("2.6.0")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def data():
|
|
iris = datasets.load_iris()
|
|
data = pd.DataFrame(
|
|
data=np.c_[iris["data"], iris["target"]], columns=iris["feature_names"] + ["target"]
|
|
)
|
|
y = data["target"]
|
|
x = data.drop("target", axis=1)
|
|
return x, y
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def iris_tensor_spec():
|
|
return ModelSignature(
|
|
inputs=Schema([TensorSpec(np.dtype("float32"), (-1, 4))]),
|
|
outputs=Schema([TensorSpec(np.dtype("float32"), (-1, 1))]),
|
|
)
|
|
|
|
|
|
def get_dataset(data):
|
|
x, y = data
|
|
return [(xi.astype(np.float32), yi.astype(np.float32)) for xi, yi in zip(x.values, y.values)]
|
|
|
|
|
|
def train_model(model, data):
|
|
dataset = get_dataset(data)
|
|
criterion = nn.MSELoss()
|
|
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
|
|
batch_size = 16
|
|
num_workers = 4
|
|
dataloader = DataLoader(
|
|
dataset, batch_size=batch_size, num_workers=num_workers, shuffle=True, drop_last=False
|
|
)
|
|
|
|
model.train()
|
|
for _ in range(5):
|
|
for batch in dataloader:
|
|
optimizer.zero_grad()
|
|
batch_size = batch[0].shape[0]
|
|
y_pred = model(batch[0]).squeeze(dim=1)
|
|
loss = criterion(y_pred, batch[1])
|
|
loss.backward()
|
|
optimizer.step()
|
|
|
|
|
|
def get_sequential_model():
|
|
return nn.Sequential(nn.Linear(4, 3), nn.ReLU(), nn.Linear(3, 1))
|
|
|
|
|
|
@pytest.fixture
|
|
def sequential_model(data, scripted_model):
|
|
model = get_sequential_model()
|
|
if scripted_model:
|
|
model = torch.jit.script(model)
|
|
|
|
train_model(model=model, data=data)
|
|
return model
|
|
|
|
|
|
def get_subclassed_model_definition():
|
|
"""
|
|
Defines a PyTorch model class that inherits from ``torch.nn.Module``. This method can be invoked
|
|
within a pytest fixture to define the model class in the ``__main__`` scope. Alternatively, it
|
|
can be invoked within a module to define the class in the module's scope.
|
|
"""
|
|
|
|
class SubclassedModel(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.linear = torch.nn.Linear(4, 1)
|
|
|
|
def forward(self, x):
|
|
return self.linear(x)
|
|
|
|
return SubclassedModel
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def main_scoped_subclassed_model(data):
|
|
"""
|
|
A custom PyTorch model inheriting from ``torch.nn.Module`` whose class is defined in the
|
|
"__main__" scope.
|
|
"""
|
|
model_class = get_subclassed_model_definition()
|
|
model = model_class()
|
|
train_model(model=model, data=data)
|
|
return model
|
|
|
|
|
|
class ModuleScopedSubclassedModel(get_subclassed_model_definition()):
|
|
"""
|
|
A custom PyTorch model class defined in the test module scope. This is a subclass of
|
|
``torch.nn.Module``.
|
|
"""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def module_scoped_subclassed_model(data):
|
|
"""
|
|
A custom PyTorch model inheriting from ``torch.nn.Module`` whose class is defined in the test
|
|
module scope.
|
|
"""
|
|
model = ModuleScopedSubclassedModel()
|
|
train_model(model=model, data=data)
|
|
return model
|
|
|
|
|
|
@pytest.fixture
|
|
def model_path(tmp_path):
|
|
return os.path.join(tmp_path, "model")
|
|
|
|
|
|
@pytest.fixture
|
|
def pytorch_custom_env(tmp_path):
|
|
conda_env = os.path.join(tmp_path, "conda_env.yml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["pytorch", "torchvision", "pytest"])
|
|
return conda_env
|
|
|
|
|
|
def _predict(model, data):
|
|
from torch.fx import GraphModule
|
|
|
|
dataset = get_dataset(data)
|
|
batch_size = 16
|
|
num_workers = 4
|
|
dataloader = DataLoader(
|
|
dataset, batch_size=batch_size, num_workers=num_workers, shuffle=False, drop_last=False
|
|
)
|
|
predictions = np.zeros((len(dataloader.sampler),))
|
|
|
|
if not isinstance(model, GraphModule):
|
|
model.eval()
|
|
with torch.no_grad():
|
|
for i, batch in enumerate(dataloader):
|
|
y_preds = model(batch[0]).squeeze(dim=1).numpy()
|
|
predictions[i * batch_size : (i + 1) * batch_size] = y_preds
|
|
return predictions
|
|
|
|
|
|
@pytest.fixture
|
|
def sequential_predicted(sequential_model, data):
|
|
return _predict(sequential_model, data)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_signature_and_examples_are_saved_correctly(sequential_model, data, iris_tensor_spec):
|
|
model = sequential_model
|
|
example_ = data[0].head(3).values.astype(np.float32)
|
|
for signature in (None, iris_tensor_spec):
|
|
for example in (None, example_):
|
|
with TempDir() as tmp:
|
|
path = tmp.path("model")
|
|
mlflow.pytorch.save_model(
|
|
model,
|
|
path=path,
|
|
signature=signature,
|
|
input_example=example,
|
|
serialization_format="pickle",
|
|
)
|
|
mlflow_model = Model.load(path)
|
|
if signature is None and example is None:
|
|
assert mlflow_model.signature is None
|
|
else:
|
|
assert mlflow_model.signature == iris_tensor_spec
|
|
if example is None:
|
|
assert mlflow_model.saved_input_example_info is None
|
|
else:
|
|
np.testing.assert_allclose(_read_example(mlflow_model, path), example)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_log_model(sequential_model, data, sequential_predicted):
|
|
try:
|
|
artifact_path = "pytorch"
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model, name=artifact_path, serialization_format="pickle"
|
|
)
|
|
|
|
sequential_model_loaded = mlflow.pytorch.load_model(model_uri=model_info.model_uri)
|
|
test_predictions = _predict(sequential_model_loaded, data)
|
|
np.testing.assert_array_equal(test_predictions, sequential_predicted)
|
|
finally:
|
|
mlflow.end_run()
|
|
|
|
|
|
def test_log_model_calls_register_model(module_scoped_subclassed_model):
|
|
custom_pickle_module = pickle
|
|
artifact_path = "model"
|
|
register_model_patch = mock.patch("mlflow.tracking._model_registry.fluent._register_model")
|
|
with mlflow.start_run(), register_model_patch:
|
|
model_info = mlflow.pytorch.log_model(
|
|
module_scoped_subclassed_model,
|
|
name=artifact_path,
|
|
pickle_module=custom_pickle_module,
|
|
registered_model_name="AdsModel1",
|
|
serialization_format="pickle",
|
|
)
|
|
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="AdsModel1",
|
|
)
|
|
|
|
|
|
def test_log_model_no_registered_model_name(module_scoped_subclassed_model):
|
|
custom_pickle_module = pickle
|
|
artifact_path = "model"
|
|
register_model_patch = mock.patch("mlflow.tracking._model_registry.fluent._register_model")
|
|
with mlflow.start_run(), register_model_patch:
|
|
mlflow.pytorch.log_model(
|
|
module_scoped_subclassed_model,
|
|
name=artifact_path,
|
|
pickle_module=custom_pickle_module,
|
|
serialization_format="pickle",
|
|
)
|
|
mlflow.tracking._model_registry.fluent._register_model.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_raise_exception(sequential_model):
|
|
with TempDir(chdr=True, remove_on_exit=True) as tmp:
|
|
path = tmp.path("model")
|
|
with pytest.raises(MlflowException, match="No such artifact"):
|
|
mlflow.pytorch.load_model(path)
|
|
|
|
with pytest.raises(TypeError, match="Argument 'pytorch_model' should be a torch.nn.Module"):
|
|
mlflow.pytorch.save_model([1, 2, 3], path)
|
|
|
|
mlflow.pytorch.save_model(sequential_model, path, serialization_format="pickle")
|
|
with pytest.raises(MlflowException, match=f"Path '{os.path.abspath(path)}' already exists"):
|
|
mlflow.pytorch.save_model(sequential_model, path, serialization_format="pickle")
|
|
|
|
import sklearn.neighbors as knn
|
|
|
|
from mlflow import sklearn
|
|
|
|
path = tmp.path("knn.pkl")
|
|
knn = knn.KNeighborsClassifier()
|
|
with open(path, "wb") as f:
|
|
pickle.dump(knn, f)
|
|
path = tmp.path("knn")
|
|
sklearn.save_model(knn, path=path)
|
|
with pytest.raises(MlflowException, match='Model does not have the "pytorch" flavor'):
|
|
mlflow.pytorch.load_model(path)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_save_and_load_model(sequential_model, model_path, data, sequential_predicted):
|
|
mlflow.pytorch.save_model(sequential_model, model_path, serialization_format="pickle")
|
|
|
|
# Loading pytorch model
|
|
sequential_model_loaded = mlflow.pytorch.load_model(model_path)
|
|
np.testing.assert_array_equal(_predict(sequential_model_loaded, data), sequential_predicted)
|
|
|
|
# Loading pyfunc model
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
np.testing.assert_array_almost_equal(
|
|
pyfunc_loaded.predict(data[0]).values[:, 0], sequential_predicted, decimal=4
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_pyfunc_model_works_with_np_input_type(
|
|
sequential_model, model_path, data, sequential_predicted
|
|
):
|
|
mlflow.pytorch.save_model(sequential_model, model_path, serialization_format="pickle")
|
|
|
|
# Loading pyfunc model
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
# predict works with dataframes
|
|
df_result = pyfunc_loaded.predict(data[0])
|
|
assert type(df_result) == pd.DataFrame
|
|
np.testing.assert_array_almost_equal(df_result.values[:, 0], sequential_predicted, decimal=4)
|
|
|
|
# predict works with numpy ndarray
|
|
np_result = pyfunc_loaded.predict(data[0].values.astype(np.float32))
|
|
assert type(np_result) == np.ndarray
|
|
np.testing.assert_array_almost_equal(np_result[:, 0], sequential_predicted, decimal=4)
|
|
|
|
# predict does not work with lists
|
|
with pytest.raises(
|
|
TypeError, match="The PyTorch flavor does not support List or Dict input types"
|
|
):
|
|
pyfunc_loaded.predict([1, 2, 3, 4])
|
|
|
|
# predict does not work with scalars
|
|
with pytest.raises(TypeError, match="Input data should be pandas.DataFrame or numpy.ndarray"):
|
|
pyfunc_loaded.predict(4)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_load_model_from_remote_uri_succeeds(
|
|
sequential_model, model_path, mock_s3_bucket, data, sequential_predicted
|
|
):
|
|
mlflow.pytorch.save_model(sequential_model, model_path, serialization_format="pickle")
|
|
|
|
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
|
|
sequential_model_loaded = mlflow.pytorch.load_model(model_uri=model_uri)
|
|
np.testing.assert_array_equal(_predict(sequential_model_loaded, data), sequential_predicted)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_save_persists_specified_conda_env_in_mlflow_model_directory(
|
|
sequential_model, model_path, pytorch_custom_env
|
|
):
|
|
mlflow.pytorch.save_model(
|
|
pytorch_model=sequential_model,
|
|
path=model_path,
|
|
conda_env=pytorch_custom_env,
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
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 != pytorch_custom_env
|
|
|
|
with open(pytorch_custom_env) as f:
|
|
pytorch_custom_env_text = f.read()
|
|
with open(saved_conda_env_path) as f:
|
|
saved_conda_env_text = f.read()
|
|
assert saved_conda_env_text == pytorch_custom_env_text
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_save_persists_requirements_in_mlflow_model_directory(
|
|
sequential_model, model_path, pytorch_custom_env
|
|
):
|
|
mlflow.pytorch.save_model(
|
|
pytorch_model=sequential_model,
|
|
path=model_path,
|
|
conda_env=pytorch_custom_env,
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
|
|
_compare_conda_env_requirements(pytorch_custom_env, saved_pip_req_path)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [False])
|
|
def test_save_model_with_pip_requirements(sequential_model, tmp_path):
|
|
expected_mlflow_version = _mlflow_major_version_string()
|
|
# Path to a requirements file
|
|
tmpdir1 = tmp_path.joinpath("1")
|
|
req_file = tmp_path.joinpath("requirements.txt")
|
|
req_file.write_text("a")
|
|
mlflow.pytorch.save_model(
|
|
sequential_model, tmpdir1, pip_requirements=str(req_file), serialization_format="pickle"
|
|
)
|
|
_assert_pip_requirements(tmpdir1, [expected_mlflow_version, "a"], strict=True)
|
|
|
|
# List of requirements
|
|
tmpdir2 = tmp_path.joinpath("2")
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
tmpdir2,
|
|
pip_requirements=[f"-r {req_file}", "b"],
|
|
serialization_format="pickle",
|
|
)
|
|
_assert_pip_requirements(tmpdir2, [expected_mlflow_version, "a", "b"], strict=True)
|
|
|
|
# Constraints file
|
|
tmpdir3 = tmp_path.joinpath("3")
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
tmpdir3,
|
|
pip_requirements=[f"-c {req_file}", "b"],
|
|
serialization_format="pickle",
|
|
)
|
|
_assert_pip_requirements(
|
|
tmpdir3, [expected_mlflow_version, "b", "-c constraints.txt"], ["a"], strict=True
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [False])
|
|
def test_save_model_with_extra_pip_requirements(sequential_model, tmp_path):
|
|
expected_mlflow_version = _mlflow_major_version_string()
|
|
default_reqs = mlflow.pytorch.get_default_pip_requirements()
|
|
|
|
# Path to a requirements file
|
|
tmpdir1 = tmp_path.joinpath("1")
|
|
req_file = tmp_path.joinpath("requirements.txt")
|
|
req_file.write_text("a")
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
tmpdir1,
|
|
extra_pip_requirements=str(req_file),
|
|
serialization_format="pickle",
|
|
)
|
|
_assert_pip_requirements(tmpdir1, [expected_mlflow_version, *default_reqs, "a"])
|
|
|
|
# List of requirements
|
|
tmpdir2 = tmp_path.joinpath("2")
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
tmpdir2,
|
|
extra_pip_requirements=[f"-r {req_file}", "b"],
|
|
serialization_format="pickle",
|
|
)
|
|
_assert_pip_requirements(tmpdir2, [expected_mlflow_version, *default_reqs, "a", "b"])
|
|
|
|
# Constraints file
|
|
tmpdir3 = tmp_path.joinpath("3")
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
tmpdir3,
|
|
extra_pip_requirements=[f"-c {req_file}", "b"],
|
|
serialization_format="pickle",
|
|
)
|
|
_assert_pip_requirements(
|
|
tmpdir3, [expected_mlflow_version, *default_reqs, "b", "-c constraints.txt"], ["a"]
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_save_accepts_conda_env_as_dict(sequential_model, model_path):
|
|
conda_env = dict(mlflow.pytorch.get_default_conda_env())
|
|
conda_env["dependencies"].append("pytest")
|
|
mlflow.pytorch.save_model(
|
|
pytorch_model=sequential_model,
|
|
path=model_path,
|
|
conda_env=conda_env,
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_log_persists_specified_conda_env_in_mlflow_model_directory(
|
|
sequential_model, pytorch_custom_env
|
|
):
|
|
artifact_path = "model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name=artifact_path,
|
|
conda_env=pytorch_custom_env,
|
|
serialization_format="pickle",
|
|
)
|
|
model_path = _download_artifact_from_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 != pytorch_custom_env
|
|
|
|
with open(pytorch_custom_env) as f:
|
|
pytorch_custom_env_text = f.read()
|
|
with open(saved_conda_env_path) as f:
|
|
saved_conda_env_text = f.read()
|
|
assert saved_conda_env_text == pytorch_custom_env_text
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_log_persists_requirements_in_mlflow_model_directory(
|
|
sequential_model, pytorch_custom_env
|
|
):
|
|
artifact_path = "model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name=artifact_path,
|
|
conda_env=pytorch_custom_env,
|
|
serialization_format="pickle",
|
|
)
|
|
model_path = _download_artifact_from_uri(model_info.model_uri)
|
|
|
|
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
|
|
_compare_conda_env_requirements(pytorch_custom_env, saved_pip_req_path)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_save_without_specified_conda_env_uses_default_env_with_expected_dependencies(
|
|
sequential_model, model_path
|
|
):
|
|
mlflow.pytorch.save_model(
|
|
pytorch_model=sequential_model, path=model_path, serialization_format="pickle"
|
|
)
|
|
_assert_pip_requirements(model_path, mlflow.pytorch.get_default_pip_requirements())
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_log_without_specified_conda_env_uses_default_env_with_expected_dependencies(
|
|
sequential_model,
|
|
):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model, name="model", serialization_format="pickle"
|
|
)
|
|
|
|
_assert_pip_requirements(model_info.model_uri, mlflow.pytorch.get_default_pip_requirements())
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_load_model_with_differing_pytorch_version_logs_warning(sequential_model, model_path):
|
|
mlflow.pytorch.save_model(
|
|
pytorch_model=sequential_model, path=model_path, serialization_format="pickle"
|
|
)
|
|
saver_pytorch_version = "1.0"
|
|
model_config_path = os.path.join(model_path, "MLmodel")
|
|
model_config = Model.load(model_config_path)
|
|
model_config.flavors[mlflow.pytorch.FLAVOR_NAME]["pytorch_version"] = saver_pytorch_version
|
|
model_config.save(model_config_path)
|
|
|
|
log_messages = []
|
|
|
|
def custom_warn(message_text, *args, **kwargs):
|
|
log_messages.append(message_text % args % kwargs)
|
|
|
|
loader_pytorch_version = "0.8.2"
|
|
with (
|
|
mock.patch("mlflow.pytorch._logger.warning") as warn_mock,
|
|
mock.patch("torch.__version__", loader_pytorch_version),
|
|
):
|
|
warn_mock.side_effect = custom_warn
|
|
mlflow.pytorch.load_model(model_uri=model_path)
|
|
|
|
assert any(
|
|
"does not match installed PyTorch version" in log_message
|
|
and saver_pytorch_version in log_message
|
|
and loader_pytorch_version in log_message
|
|
for log_message in log_messages
|
|
)
|
|
|
|
|
|
def test_pyfunc_model_serving_with_module_scoped_subclassed_model_and_default_conda_env(
|
|
module_scoped_subclassed_model, data
|
|
):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
module_scoped_subclassed_model,
|
|
name="pytorch_model",
|
|
code_paths=[__file__],
|
|
input_example=data[0],
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
inference_payload = load_serving_example(model_info.model_uri)
|
|
scoring_response = pyfunc_serve_and_score_model(
|
|
model_uri=model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
assert scoring_response.status_code == 200
|
|
|
|
deployed_model_preds = pd.DataFrame(json.loads(scoring_response.content)["predictions"])
|
|
np.testing.assert_array_almost_equal(
|
|
deployed_model_preds.values[:, 0],
|
|
_predict(model=module_scoped_subclassed_model, data=data),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
def test_save_model_with_wrong_codepaths_fails_correctly(
|
|
module_scoped_subclassed_model, model_path, data
|
|
):
|
|
with pytest.raises(TypeError, match="Argument code_paths should be a list, not <class 'str'>"):
|
|
mlflow.pytorch.save_model(
|
|
path=model_path, pytorch_model=module_scoped_subclassed_model, code_paths="some string"
|
|
)
|
|
|
|
|
|
def test_pyfunc_model_serving_with_main_scoped_subclassed_model_and_custom_pickle_module(
|
|
main_scoped_subclassed_model, data
|
|
):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
main_scoped_subclassed_model,
|
|
name="pytorch_model",
|
|
pickle_module=mlflow_pytorch_pickle_module,
|
|
input_example=data[0],
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
inference_payload = load_serving_example(model_info.model_uri)
|
|
scoring_response = pyfunc_serve_and_score_model(
|
|
model_uri=model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
assert scoring_response.status_code == 200
|
|
|
|
deployed_model_preds = pd.DataFrame(json.loads(scoring_response.content)["predictions"])
|
|
np.testing.assert_array_almost_equal(
|
|
deployed_model_preds.values[:, 0],
|
|
_predict(model=main_scoped_subclassed_model, data=data),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
def test_load_model_succeeds_with_dependencies_specified_via_code_paths(
|
|
module_scoped_subclassed_model, model_path, data
|
|
):
|
|
# Save a PyTorch model whose class is defined in the current test suite. Because the
|
|
# `tests` module is not available when the model is deployed for local scoring, we include
|
|
# the test suite file as a code dependency
|
|
mlflow.pytorch.save_model(
|
|
path=model_path,
|
|
pytorch_model=module_scoped_subclassed_model,
|
|
code_paths=[__file__],
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
# Define a custom pyfunc model that loads a PyTorch model artifact using
|
|
# `mlflow.pytorch.load_model`
|
|
class TorchValidatorModel(pyfunc.PythonModel):
|
|
def load_context(self, context):
|
|
self.pytorch_model = mlflow.pytorch.load_model(context.artifacts["pytorch_model"])
|
|
|
|
def predict(self, context, model_input, params=None):
|
|
with torch.no_grad():
|
|
input_tensor = torch.from_numpy(model_input.values.astype(np.float32))
|
|
output_tensor = self.pytorch_model(input_tensor)
|
|
return pd.DataFrame(output_tensor.numpy())
|
|
|
|
pyfunc_artifact_path = "pyfunc_model"
|
|
with mlflow.start_run():
|
|
model_info = pyfunc.log_model(
|
|
pyfunc_artifact_path,
|
|
python_model=TorchValidatorModel(),
|
|
artifacts={"pytorch_model": model_path},
|
|
input_example=data[0],
|
|
# save file into code_paths, otherwise after first model loading (happens when
|
|
# validating input_example) then we can not load the model again
|
|
code_paths=[__file__],
|
|
)
|
|
|
|
# Deploy the custom pyfunc model and ensure that it is able to successfully load its
|
|
# constituent PyTorch model via `mlflow.pytorch.load_model`
|
|
inference_payload = load_serving_example(model_info.model_uri)
|
|
scoring_response = pyfunc_serve_and_score_model(
|
|
model_uri=model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
assert scoring_response.status_code == 200
|
|
|
|
deployed_model_preds = pd.DataFrame(json.loads(scoring_response.content)["predictions"])
|
|
np.testing.assert_array_almost_equal(
|
|
deployed_model_preds.values[:, 0],
|
|
_predict(model=module_scoped_subclassed_model, data=data),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
def test_load_pyfunc_loads_torch_model_using_pickle_module_specified_at_save_time(
|
|
module_scoped_subclassed_model, model_path
|
|
):
|
|
custom_pickle_module = pickle
|
|
|
|
mlflow.pytorch.save_model(
|
|
path=model_path,
|
|
pytorch_model=module_scoped_subclassed_model,
|
|
pickle_module=custom_pickle_module,
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
import_module_fn = importlib.import_module
|
|
imported_modules = []
|
|
|
|
def track_module_imports(module_name):
|
|
imported_modules.append(module_name)
|
|
return import_module_fn(module_name)
|
|
|
|
with (
|
|
mock.patch("importlib.import_module") as import_mock,
|
|
mock.patch("torch.load") as torch_load_mock,
|
|
):
|
|
import_mock.side_effect = track_module_imports
|
|
pyfunc.load_model(model_path)
|
|
|
|
expected_kwargs = {"pickle_module": custom_pickle_module}
|
|
if ENABLE_LEGACY_DESERIALIZATION:
|
|
expected_kwargs["weights_only"] = False
|
|
|
|
torch_load_mock.assert_called_with(mock.ANY, **expected_kwargs)
|
|
assert custom_pickle_module.__name__ in imported_modules
|
|
|
|
|
|
def test_load_model_loads_torch_model_using_pickle_module_specified_at_save_time(
|
|
module_scoped_subclassed_model,
|
|
):
|
|
custom_pickle_module = pickle
|
|
|
|
artifact_path = "pytorch_model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
module_scoped_subclassed_model,
|
|
name=artifact_path,
|
|
pickle_module=custom_pickle_module,
|
|
serialization_format="pickle",
|
|
)
|
|
model_uri = model_info.model_uri
|
|
|
|
import_module_fn = importlib.import_module
|
|
imported_modules = []
|
|
|
|
def track_module_imports(module_name):
|
|
imported_modules.append(module_name)
|
|
return import_module_fn(module_name)
|
|
|
|
with (
|
|
mock.patch("importlib.import_module") as import_mock,
|
|
mock.patch("torch.load") as torch_load_mock,
|
|
):
|
|
import_mock.side_effect = track_module_imports
|
|
pyfunc.load_model(model_uri=model_uri)
|
|
|
|
expected_kwargs = {"pickle_module": custom_pickle_module}
|
|
if ENABLE_LEGACY_DESERIALIZATION:
|
|
expected_kwargs["weights_only"] = False
|
|
|
|
torch_load_mock.assert_called_with(mock.ANY, **expected_kwargs)
|
|
assert custom_pickle_module.__name__ in imported_modules
|
|
|
|
|
|
def test_load_pyfunc_succeeds_when_data_is_model_file_instead_of_directory(
|
|
module_scoped_subclassed_model, model_path, data
|
|
):
|
|
"""
|
|
This test verifies that PyTorch models saved in older versions of MLflow are loaded successfully
|
|
by ``mlflow.pytorch.load_model``. The ``data`` path associated with these older models is
|
|
serialized PyTorch model file, as opposed to the current format: a directory containing a
|
|
serialized model file and pickle module information.
|
|
"""
|
|
mlflow.pytorch.save_model(
|
|
path=model_path, pytorch_model=module_scoped_subclassed_model, serialization_format="pickle"
|
|
)
|
|
|
|
model_conf_path = os.path.join(model_path, "MLmodel")
|
|
model_conf = Model.load(model_conf_path)
|
|
pyfunc_conf = model_conf.flavors.get(pyfunc.FLAVOR_NAME)
|
|
assert pyfunc_conf is not None
|
|
model_data_path = os.path.join(model_path, pyfunc_conf[pyfunc.DATA])
|
|
assert os.path.exists(model_data_path)
|
|
assert mlflow.pytorch._SERIALIZED_TORCH_MODEL_FILE_NAME in os.listdir(model_data_path)
|
|
pyfunc_conf[pyfunc.DATA] = os.path.join(
|
|
model_data_path, mlflow.pytorch._SERIALIZED_TORCH_MODEL_FILE_NAME
|
|
)
|
|
model_conf.save(model_conf_path)
|
|
|
|
loaded_pyfunc = pyfunc.load_model(model_path)
|
|
|
|
np.testing.assert_array_almost_equal(
|
|
loaded_pyfunc.predict(data[0]),
|
|
pd.DataFrame(_predict(model=module_scoped_subclassed_model, data=data)),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
def test_load_model_succeeds_when_data_is_model_file_instead_of_directory(
|
|
module_scoped_subclassed_model, model_path, data
|
|
):
|
|
"""
|
|
This test verifies that PyTorch models saved in older versions of MLflow are loaded successfully
|
|
by ``mlflow.pytorch.load_model``. The ``data`` path associated with these older models is
|
|
serialized PyTorch model file, as opposed to the current format: a directory containing a
|
|
serialized model file and pickle module information.
|
|
"""
|
|
artifact_path = "pytorch_model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
module_scoped_subclassed_model, name=artifact_path, serialization_format="pickle"
|
|
)
|
|
model_path = _download_artifact_from_uri(model_info.model_uri)
|
|
|
|
model_conf_path = os.path.join(model_path, "MLmodel")
|
|
model_conf = Model.load(model_conf_path)
|
|
pyfunc_conf = model_conf.flavors.get(pyfunc.FLAVOR_NAME)
|
|
assert pyfunc_conf is not None
|
|
model_data_path = os.path.join(model_path, pyfunc_conf[pyfunc.DATA])
|
|
assert os.path.exists(model_data_path)
|
|
assert mlflow.pytorch._SERIALIZED_TORCH_MODEL_FILE_NAME in os.listdir(model_data_path)
|
|
pyfunc_conf[pyfunc.DATA] = os.path.join(
|
|
model_data_path, mlflow.pytorch._SERIALIZED_TORCH_MODEL_FILE_NAME
|
|
)
|
|
model_conf.save(model_conf_path)
|
|
|
|
loaded_pyfunc = pyfunc.load_model(model_path)
|
|
|
|
np.testing.assert_array_almost_equal(
|
|
loaded_pyfunc.predict(data[0]),
|
|
pd.DataFrame(_predict(model=module_scoped_subclassed_model, data=data)),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
def test_load_model_allows_user_to_override_pickle_module_via_keyword_argument(
|
|
module_scoped_subclassed_model, model_path
|
|
):
|
|
mlflow.pytorch.save_model(
|
|
path=model_path,
|
|
pytorch_model=module_scoped_subclassed_model,
|
|
pickle_module=pickle,
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
with (
|
|
mock.patch("torch.load") as torch_load_mock,
|
|
mock.patch("mlflow.pytorch._logger.warning") as warn_mock,
|
|
):
|
|
mlflow.pytorch.load_model(model_uri=model_path, pickle_module=mlflow_pytorch_pickle_module)
|
|
torch_load_mock.assert_called_with(mock.ANY, pickle_module=mlflow_pytorch_pickle_module)
|
|
warn_mock.assert_any_call(mock.ANY, mlflow_pytorch_pickle_module.__name__, pickle.__name__)
|
|
|
|
|
|
def test_load_model_raises_exception_when_pickle_module_cannot_be_imported(
|
|
main_scoped_subclassed_model, model_path
|
|
):
|
|
mlflow.pytorch.save_model(
|
|
path=model_path, pytorch_model=main_scoped_subclassed_model, serialization_format="pickle"
|
|
)
|
|
|
|
bad_pickle_module_name = "not.a.real.module"
|
|
|
|
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
|
|
model_data_path = os.path.join(model_path, pyfunc_conf[pyfunc.DATA])
|
|
assert os.path.exists(model_data_path)
|
|
assert mlflow.pytorch._PICKLE_MODULE_INFO_FILE_NAME in os.listdir(model_data_path)
|
|
with open(
|
|
os.path.join(model_data_path, mlflow.pytorch._PICKLE_MODULE_INFO_FILE_NAME), "w"
|
|
) as f:
|
|
f.write(bad_pickle_module_name)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Failed to import the pickle module.+" + re.escape(bad_pickle_module_name),
|
|
):
|
|
mlflow.pytorch.load_model(model_uri=model_path)
|
|
|
|
|
|
def test_pyfunc_serve_and_score(data):
|
|
model = torch.nn.Linear(4, 1)
|
|
train_model(model=model, data=data)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
model, name="model", input_example=data[0], serialization_format="pickle"
|
|
)
|
|
|
|
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,
|
|
)
|
|
from mlflow.deployments import PredictionsResponse
|
|
|
|
scores = PredictionsResponse.from_json(resp.content).get_predictions()
|
|
np.testing.assert_array_almost_equal(scores.values[:, 0], _predict(model=model, data=data))
|
|
|
|
|
|
@pytest.mark.skipif(not _is_importable("transformers"), reason="This test requires transformers")
|
|
def test_pyfunc_serve_and_score_transformers():
|
|
from transformers import BertConfig, BertModel
|
|
|
|
from mlflow.deployments import PredictionsResponse
|
|
|
|
class MyBertModel(BertModel):
|
|
def forward(self, *args, **kwargs):
|
|
return super().forward(*args, **kwargs).last_hidden_state
|
|
|
|
model = MyBertModel(
|
|
BertConfig(
|
|
vocab_size=16,
|
|
hidden_size=2,
|
|
num_hidden_layers=2,
|
|
num_attention_heads=2,
|
|
intermediate_size=2,
|
|
)
|
|
)
|
|
model.eval()
|
|
|
|
input_ids = model.dummy_inputs["input_ids"]
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
model,
|
|
name="model",
|
|
input_example=np.array(input_ids.tolist()),
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
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 = PredictionsResponse.from_json(resp.content.decode("utf-8")).get_predictions(
|
|
predictions_format="ndarray"
|
|
)
|
|
assert_array_almost_equal(scores, model(input_ids).detach().numpy(), rtol=1e-6)
|
|
|
|
|
|
@pytest.fixture
|
|
def create_requirements_file(tmp_path):
|
|
requirement_file_name = "requirements.txt"
|
|
fp = tmp_path.joinpath(requirement_file_name)
|
|
test_string = "mlflow"
|
|
fp.write_text(test_string)
|
|
return str(fp), test_string
|
|
|
|
|
|
@pytest.fixture
|
|
def create_extra_files(tmp_path):
|
|
fp1 = tmp_path.joinpath("extra1.txt")
|
|
fp2 = tmp_path.joinpath("extra2.txt")
|
|
fp1.write_text("1")
|
|
fp2.write_text("2")
|
|
return [str(fp1), str(fp2)], ["1", "2"]
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_extra_files_log_model(create_extra_files, sequential_model):
|
|
extra_files, contents_expected = create_extra_files
|
|
with mlflow.start_run():
|
|
mlflow.pytorch.log_model(
|
|
sequential_model, name="models", extra_files=extra_files, serialization_format="pickle"
|
|
)
|
|
|
|
model_uri = "runs:/{run_id}/{model_path}".format(
|
|
run_id=mlflow.active_run().info.run_id, model_path="models"
|
|
)
|
|
with TempDir(remove_on_exit=True) as tmp:
|
|
model_path = _download_artifact_from_uri(model_uri, tmp.path())
|
|
model_config_path = os.path.join(model_path, "MLmodel")
|
|
model_config = Model.load(model_config_path)
|
|
flavor_config = model_config.flavors["pytorch"]
|
|
|
|
assert "extra_files" in flavor_config
|
|
loaded_extra_files = flavor_config["extra_files"]
|
|
|
|
for loaded_extra_file, content_expected in zip(loaded_extra_files, contents_expected):
|
|
assert "path" in loaded_extra_file
|
|
extra_file_path = os.path.join(model_path, loaded_extra_file["path"])
|
|
with open(extra_file_path) as fp:
|
|
assert fp.read() == content_expected
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_extra_files_save_model(create_extra_files, sequential_model):
|
|
extra_files, contents_expected = create_extra_files
|
|
with TempDir(remove_on_exit=True) as tmp:
|
|
model_path = os.path.join(tmp.path(), "models")
|
|
mlflow.pytorch.save_model(
|
|
pytorch_model=sequential_model,
|
|
path=model_path,
|
|
extra_files=extra_files,
|
|
serialization_format="pickle",
|
|
)
|
|
model_config_path = os.path.join(model_path, "MLmodel")
|
|
model_config = Model.load(model_config_path)
|
|
flavor_config = model_config.flavors["pytorch"]
|
|
|
|
assert "extra_files" in flavor_config
|
|
loaded_extra_files = flavor_config["extra_files"]
|
|
|
|
for loaded_extra_file, content_expected in zip(loaded_extra_files, contents_expected):
|
|
assert "path" in loaded_extra_file
|
|
extra_file_path = os.path.join(model_path, loaded_extra_file["path"])
|
|
with open(extra_file_path) as fp:
|
|
assert fp.read() == content_expected
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_log_model_invalid_extra_file_path(sequential_model):
|
|
with (
|
|
mlflow.start_run(),
|
|
pytest.raises(MlflowException, match="No such artifact: 'non_existing_file.txt'"),
|
|
):
|
|
mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name="models",
|
|
extra_files=["non_existing_file.txt"],
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_log_model_invalid_extra_file_type(sequential_model):
|
|
with (
|
|
mlflow.start_run(),
|
|
pytest.raises(TypeError, match="Extra files argument should be a list"),
|
|
):
|
|
mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name="models",
|
|
extra_files="non_existing_file.txt",
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
|
|
def state_dict_equal(state_dict1, state_dict2):
|
|
for key1 in state_dict1:
|
|
if key1 not in state_dict2:
|
|
return False
|
|
|
|
value1 = state_dict1[key1]
|
|
value2 = state_dict2[key1]
|
|
|
|
if type(value1) != type(value2):
|
|
return False
|
|
elif isinstance(value1, dict):
|
|
if not state_dict_equal(value1, value2):
|
|
return False
|
|
elif isinstance(value1, torch.Tensor):
|
|
if not torch.equal(value1, value2):
|
|
return False
|
|
elif value1 != value2:
|
|
return False
|
|
else:
|
|
continue
|
|
|
|
return True
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_save_state_dict(sequential_model, model_path, data):
|
|
state_dict = sequential_model.state_dict()
|
|
mlflow.pytorch.save_state_dict(state_dict, model_path)
|
|
|
|
loaded_state_dict = mlflow.pytorch.load_state_dict(model_path)
|
|
assert state_dict_equal(loaded_state_dict, state_dict)
|
|
model = get_sequential_model()
|
|
model.load_state_dict(loaded_state_dict)
|
|
np.testing.assert_array_almost_equal(
|
|
_predict(model, data),
|
|
_predict(sequential_model, data),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
def test_save_state_dict_can_save_nested_state_dict(model_path):
|
|
"""
|
|
This test ensures that `save_state_dict` supports a use case described in the page below
|
|
where a user bundles multiple objects (e.g., model, optimizer, learning-rate scheduler)
|
|
into a single nested state_dict and loads it back later for inference or re-training:
|
|
https://pytorch.org/tutorials/recipes/recipes/saving_and_loading_a_general_checkpoint.html
|
|
"""
|
|
model = get_sequential_model()
|
|
optim = torch.optim.Adam(model.parameters())
|
|
state_dict = {"model": model.state_dict(), "optim": optim.state_dict()}
|
|
mlflow.pytorch.save_state_dict(state_dict, model_path)
|
|
|
|
loaded_state_dict = mlflow.pytorch.load_state_dict(model_path)
|
|
assert state_dict_equal(loaded_state_dict, state_dict)
|
|
model.load_state_dict(loaded_state_dict["model"])
|
|
optim.load_state_dict(loaded_state_dict["optim"])
|
|
|
|
|
|
def test_load_state_dict_disallows_pickle_deserialization(model_path, monkeypatch):
|
|
model = get_sequential_model()
|
|
mlflow.pytorch.save_state_dict(model.state_dict(), model_path)
|
|
|
|
monkeypatch.setenv("MLFLOW_ALLOW_PICKLE_DESERIALIZATION", "false")
|
|
with pytest.raises(MlflowException, match="MLFLOW_ALLOW_PICKLE_DESERIALIZATION"):
|
|
mlflow.pytorch.load_state_dict(model_path)
|
|
|
|
|
|
@pytest.mark.parametrize("not_state_dict", [0, "", get_sequential_model()])
|
|
def test_save_state_dict_throws_for_invalid_object_type(not_state_dict, model_path):
|
|
with pytest.raises(TypeError, match="Invalid object type for `state_dict`"):
|
|
mlflow.pytorch.save_state_dict(not_state_dict, model_path)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_log_state_dict(sequential_model, data):
|
|
artifact_path = "model"
|
|
state_dict = sequential_model.state_dict()
|
|
with mlflow.start_run():
|
|
mlflow.pytorch.log_state_dict(state_dict, artifact_path)
|
|
state_dict_uri = mlflow.get_artifact_uri(artifact_path)
|
|
|
|
loaded_state_dict = mlflow.pytorch.load_state_dict(state_dict_uri)
|
|
assert state_dict_equal(loaded_state_dict, state_dict)
|
|
model = get_sequential_model()
|
|
model.load_state_dict(loaded_state_dict)
|
|
np.testing.assert_array_almost_equal(
|
|
_predict(model, data),
|
|
_predict(sequential_model, data),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_log_model_with_code_paths(sequential_model):
|
|
artifact_path = "model"
|
|
with (
|
|
mlflow.start_run(),
|
|
mock.patch("mlflow.pytorch._add_code_from_conf_to_system_path") as add_mock,
|
|
):
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name=artifact_path,
|
|
code_paths=[__file__],
|
|
serialization_format="pickle",
|
|
)
|
|
_compare_logged_code_paths(__file__, model_info.model_uri, mlflow.pytorch.FLAVOR_NAME)
|
|
mlflow.pytorch.load_model(model_info.model_uri)
|
|
add_mock.assert_called()
|
|
|
|
|
|
def test_virtualenv_subfield_points_to_correct_path(model_path):
|
|
model = get_sequential_model()
|
|
mlflow.pytorch.save_model(model, path=model_path, serialization_format="pickle")
|
|
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()
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_save_load_with_metadata(sequential_model, model_path):
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
path=model_path,
|
|
metadata={"metadata_key": "metadata_value"},
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_path)
|
|
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_log_with_metadata(sequential_model):
|
|
artifact_path = "model"
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name=artifact_path,
|
|
metadata={"metadata_key": "metadata_value"},
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_info.model_uri)
|
|
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [True, False])
|
|
def test_model_log_with_signature_inference(sequential_model, data):
|
|
artifact_path = "model"
|
|
example_ = data[0].head(3).values.astype(np.float32)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model,
|
|
name=artifact_path,
|
|
input_example=example_,
|
|
serialization_format="pickle",
|
|
)
|
|
|
|
assert model_info.signature == ModelSignature(
|
|
inputs=Schema([TensorSpec(np.dtype("float32"), (-1, 4))]),
|
|
outputs=Schema([TensorSpec(np.dtype("float32"), (-1, 1))]),
|
|
)
|
|
inference_payload = load_serving_example(model_info.model_uri)
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
inference_payload,
|
|
pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
assert response.status_code == 200
|
|
deployed_model_preds = pd.DataFrame(json.loads(response.content)["predictions"])
|
|
np.testing.assert_array_almost_equal(
|
|
deployed_model_preds.values[:, 0],
|
|
_predict(model=sequential_model, data=(data[0].head(3), data[1].head(3))),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("scripted_model", [False])
|
|
def test_load_model_to_device(sequential_model):
|
|
with mock.patch("mlflow.pytorch._load_model") as load_model_mock:
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
sequential_model, name="pytorch", serialization_format="pickle"
|
|
)
|
|
model_config = {"device": "cuda"}
|
|
if ENABLE_LEGACY_DESERIALIZATION:
|
|
model_config["weights_only"] = False
|
|
|
|
mlflow.pyfunc.load_model(model_uri=model_info.model_uri, model_config=model_config)
|
|
|
|
load_model_mock.assert_called_with(mock.ANY, **model_config)
|
|
mlflow.pytorch.load_model(model_uri=model_info.model_uri, **model_config)
|
|
load_model_mock.assert_called_with(path=mock.ANY, **model_config)
|
|
|
|
|
|
def test_passing_params_to_model(data):
|
|
class CustomModel(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.linear = torch.nn.Linear(4, 1)
|
|
|
|
def forward(self, x, y):
|
|
if not torch.is_tensor(x):
|
|
x = torch.from_numpy(x)
|
|
y = torch.tensor(y)
|
|
combined = x * y
|
|
return self.linear(combined)
|
|
|
|
model = CustomModel()
|
|
x = np.random.randn(8, 4).astype(np.float32)
|
|
|
|
signature = mlflow.models.infer_signature(x, None, {"y": 1})
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pytorch.log_model(
|
|
model, name="model", signature=signature, serialization_format="pickle"
|
|
)
|
|
|
|
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
|
|
with torch.no_grad():
|
|
np.testing.assert_array_almost_equal(pyfunc_model.predict(x), model(x, 1), decimal=4)
|
|
np.testing.assert_array_almost_equal(
|
|
pyfunc_model.predict(x, {"y": 2}), model(x, 2), decimal=4
|
|
)
|
|
|
|
|
|
def test_log_model_with_datetime_input():
|
|
df = pd.DataFrame({
|
|
"datetime": pd.date_range("2022-01-01", periods=5, freq="D"),
|
|
"x": np.random.uniform(20, 30, 5),
|
|
"y": np.random.uniform(2, 4, 5),
|
|
"z": np.random.uniform(0, 10, 5),
|
|
})
|
|
model = get_sequential_model()
|
|
model_info = mlflow.pytorch.log_model(
|
|
model, name="pytorch", input_example=df, serialization_format="pickle"
|
|
)
|
|
assert model_info.signature.inputs.inputs[0].type == DataType.datetime
|
|
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
|
|
with torch.no_grad():
|
|
input_tensor = torch.from_numpy(df.to_numpy(dtype=np.float32))
|
|
expected_result = model(input_tensor)
|
|
with torch.no_grad():
|
|
np.testing.assert_array_almost_equal(pyfunc_model.predict(df), expected_result, decimal=4)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
Version(torch.__version__) < Version("2.4"), reason="This test requires torch>=2.4"
|
|
)
|
|
@pytest.mark.parametrize("scripted_model", [False])
|
|
def test_save_and_load_exported_model(sequential_model, model_path, data, sequential_predicted):
|
|
input_example = data[0].to_numpy(dtype=np.float32)
|
|
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
model_path,
|
|
serialization_format="pt2",
|
|
input_example=input_example,
|
|
)
|
|
|
|
# Loading pytorch model
|
|
sequential_model_loaded = mlflow.pytorch.load_model(model_path)
|
|
np.testing.assert_array_equal(_predict(sequential_model_loaded, data), sequential_predicted)
|
|
|
|
# Loading pyfunc model
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
np.testing.assert_array_almost_equal(
|
|
pyfunc_loaded.predict(input_example)[:, 0], sequential_predicted, decimal=4
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
Version(torch.__version__) < Version("2.4"), reason="This test requires torch>=2.4"
|
|
)
|
|
def test_exported_model_infer_dynamic_dim(tmp_path):
|
|
class MyModule(torch.nn.Module):
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
return torch.sin(x)
|
|
|
|
origin_model = MyModule()
|
|
|
|
input_example = torch.randn(3, 4, 5).numpy()
|
|
|
|
save_path1 = tmp_path / "model1"
|
|
|
|
# test exporting model with auto inferred signature,
|
|
# which sets the first dim (batch dim) of input data as dynamic dim.
|
|
mlflow.pytorch.save_model(
|
|
origin_model,
|
|
save_path1,
|
|
serialization_format="pt2",
|
|
input_example=input_example,
|
|
)
|
|
|
|
# Test the exported model works with test data that changes the first dim (batch dim) size.
|
|
loaded_model1 = mlflow.pytorch.load_model(save_path1)
|
|
|
|
test_data1 = torch.randn(6, 4, 5)
|
|
np.testing.assert_array_almost_equal(
|
|
loaded_model1(test_data1),
|
|
origin_model(test_data1),
|
|
decimal=4,
|
|
)
|
|
|
|
save_path2 = tmp_path / "model2"
|
|
# test exporting model with provided signature,
|
|
# which sets the second dim of input data as dynamic dim.
|
|
mlflow.pytorch.save_model(
|
|
origin_model,
|
|
save_path2,
|
|
serialization_format="pt2",
|
|
input_example=input_example,
|
|
signature=ModelSignature(
|
|
inputs=Schema([TensorSpec(np.dtype("float32"), (3, -1, 5))]),
|
|
),
|
|
)
|
|
|
|
# Test the exported model works with test data that changes the second dim (batch dim) size.
|
|
loaded_model2 = mlflow.pytorch.load_model(save_path2)
|
|
|
|
test_data2 = torch.randn(3, 2, 5)
|
|
np.testing.assert_array_almost_equal(
|
|
loaded_model2(test_data2),
|
|
origin_model(test_data2),
|
|
decimal=4,
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
Version(torch.__version__) < Version("2.4"), reason="This test requires torch>=2.4"
|
|
)
|
|
@pytest.mark.parametrize("scripted_model", [False])
|
|
def test_load_exported_model_check_device_mismatch(sequential_model, model_path):
|
|
mlflow.pytorch.save_model(
|
|
sequential_model,
|
|
model_path,
|
|
serialization_format="pt2",
|
|
input_example=torch.randn(3, 4).numpy(),
|
|
)
|
|
|
|
# test loading model to CPU works
|
|
mlflow.pytorch.load_model(model_path, device="cpu")
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="it can't be loaded on 'cuda' device.",
|
|
):
|
|
mlflow.pytorch.load_model(model_path, device="cuda")
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
Version(torch.__version__) < Version("2.4"), reason="This test requires torch>=2.4"
|
|
)
|
|
def test_save_and_load_exported_model_with_multi_inputs(model_path):
|
|
|
|
class CustomModel(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.linear = torch.nn.Linear(4, 1)
|
|
|
|
def forward(self, x, y):
|
|
with torch.no_grad():
|
|
return self.linear(x + y)
|
|
|
|
model = CustomModel()
|
|
input_example = (torch.randn(10, 4), torch.randn(10, 4))
|
|
|
|
mlflow.pytorch.save_model(
|
|
model,
|
|
model_path,
|
|
serialization_format="pt2",
|
|
input_example=input_example,
|
|
signature=ModelSignature(
|
|
inputs=Schema([
|
|
TensorSpec(np.dtype("float32"), (-1, 4), "v1"),
|
|
TensorSpec(np.dtype("float32"), (-1, 4), "v2"),
|
|
]),
|
|
),
|
|
)
|
|
|
|
model_loaded = mlflow.pytorch.load_model(model_path)
|
|
|
|
np.testing.assert_array_almost_equal(
|
|
model(*input_example),
|
|
model_loaded(*input_example),
|
|
decimal=4,
|
|
)
|