import json import math from io import StringIO from unittest import mock import numpy as np import pandas as pd import pytest import sklearn.linear_model as logreg_module from scipy.sparse import csc_matrix, csr_matrix from sklearn import datasets from sklearn.base import BaseEstimator, ClassifierMixin import mlflow from mlflow.models import Model from mlflow.models.signature import ModelSignature, infer_signature from mlflow.models.utils import ( _Example, _read_sparse_matrix_from_json, parse_inputs_data, ) from mlflow.types import DataType from mlflow.types.schema import ColSpec, Schema, TensorSpec from mlflow.types.utils import TensorsNotSupportedException from mlflow.utils.file_utils import TempDir from mlflow.utils.proto_json_utils import dataframe_from_raw_json @pytest.fixture def pandas_df_with_all_types(): df = pd.DataFrame({ "boolean": [True, False, True], "integer": np.array([1, 2, 3], np.int32), "long": np.array([1, 2, 3], np.int64), "float": np.array([math.pi, 2 * math.pi, 3 * math.pi], np.float32), "double": [math.pi, 2 * math.pi, 3 * math.pi], "binary": [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])], "string": ["a", "b", "c"], "boolean_ext": [True, False, True], "integer_ext": [1, 2, 3], "string_ext": ["a", "b", "c"], "array": np.array(["a", "b", "c"]), }) df["boolean_ext"] = df["boolean_ext"].astype("boolean") df["integer_ext"] = df["integer_ext"].astype("Int64") df["string_ext"] = df["string_ext"].astype("string") return df @pytest.fixture def df_without_columns(): return pd.DataFrame({0: [1, 2, 3], 1: [4, 5, 6], 2: [7, 8, 9]}) @pytest.fixture def df_with_nan(): return pd.DataFrame({ "boolean": [True, False, True], "integer": np.array([1, 2, 3], np.int32), "long": np.array([1, 2, 3], np.int64), "float": np.array([np.nan, 2 * math.pi, 3 * math.pi], np.float32), "double": [math.pi, np.nan, 3 * math.pi], "binary": [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])], "string": ["a", "b", "c"], }) @pytest.fixture def dict_of_ndarrays(): return { "1D": np.arange(0, 12, 0.5), "2D": np.arange(0, 12, 0.5).reshape(3, 8), "3D": np.arange(0, 12, 0.5).reshape(2, 3, 4), "4D": np.arange(0, 12, 0.5).reshape(3, 2, 2, 2), } @pytest.fixture def dict_of_ndarrays_with_nans(): return { "1D": np.array([0.5, np.nan, 2.0]), "2D": np.array([[0.1, 0.2], [np.nan, 0.5]]), "3D": np.array([[[0.1, np.nan], [0.3, 0.4]], [[np.nan, 0.6], [0.7, np.nan]]]), } @pytest.fixture def dict_of_sparse_matrix(): return { "sparse_matrix_csc": csc_matrix(np.arange(0, 12, 0.5).reshape(3, 8)), "sparse_matrix_csr": csr_matrix(np.arange(0, 12, 0.5).reshape(3, 8)), } def test_input_examples(pandas_df_with_all_types, dict_of_ndarrays): sig = infer_signature(pandas_df_with_all_types) # test setting example with data frame with all supported data types with TempDir() as tmp: example = _Example(pandas_df_with_all_types) example.save(tmp.path()) filename = example.info["artifact_path"] with open(tmp.path(filename)) as f: data = json.load(f) assert set(data.keys()) == {"columns", "data"} parsed_df = dataframe_from_raw_json(tmp.path(filename), schema=sig.inputs) pd.testing.assert_frame_equal(pandas_df_with_all_types, parsed_df, check_dtype=False) # the frame read without schema should match except for the binary values pd.testing.assert_frame_equal( parsed_df.drop(columns=["binary"]), dataframe_from_raw_json(tmp.path(filename)).drop(columns=["binary"]), check_dtype=False, ) # NB: Drop columns that cannot be encoded by proto_json_utils.pyNumpyEncoder new_df = pandas_df_with_all_types.drop(columns=["boolean_ext", "integer_ext", "string_ext"]) # pass the input as dictionary instead with TempDir() as tmp: d = {name: new_df[name].values for name in new_df.columns} example = _Example(d) example.save(tmp.path()) filename = example.info["artifact_path"] parsed_dict = parse_inputs_data(tmp.path(filename)) assert d.keys() == parsed_dict.keys() # Asserting binary will fail since it is converted to base64 encoded strings. # The check above suffices that the binary input is stored. del d["binary"] for key in d: np.testing.assert_array_equal(d[key], parsed_dict[key]) # input passed as numpy array new_df = pandas_df_with_all_types.drop(columns=["binary"]) for col in new_df: input_example = new_df[col].to_numpy() with TempDir() as tmp: example = _Example(input_example) example.save(tmp.path()) filename = example.info["artifact_path"] parsed_ary = parse_inputs_data(tmp.path(filename)) np.testing.assert_array_equal(parsed_ary, input_example) # pass multidimensional array for col in dict_of_ndarrays: input_example = dict_of_ndarrays[col] with TempDir() as tmp: example = _Example(input_example) example.save(tmp.path()) filename = example.info["artifact_path"] parsed_ary = parse_inputs_data(tmp.path(filename)) np.testing.assert_array_equal(parsed_ary, input_example) # pass multidimensional array as a list example = np.array([[1, 2, 3]]) with pytest.raises( TensorsNotSupportedException, match=r"Numpy arrays in list are not supported as input examples.", ): _Example([example, example]) # pass dict with scalars with TempDir() as tmp: example = {"a": 1, "b": "abc"} x = _Example(example) x.save(tmp.path()) filename = x.info["artifact_path"] with open(tmp.path(filename)) as f: parsed_data = json.load(f) assert example == parsed_data def test_pandas_orients_for_input_examples( pandas_df_with_all_types, df_without_columns, dict_of_ndarrays ): # test setting example with data frame with all supported data types with TempDir() as tmp: example = _Example(pandas_df_with_all_types) example.save(tmp.path()) filename = example.info["artifact_path"] assert example.info["type"] == "dataframe" assert example.info["pandas_orient"] == "split" with open(tmp.path(filename)) as f: data = json.load(f) dataframe = pd.read_json( StringIO(json.dumps(data)), orient=example.info["pandas_orient"], precise_float=True ) pd.testing.assert_frame_equal( pandas_df_with_all_types.drop(columns=["binary"]), dataframe.drop(columns=["binary"]), check_dtype=False, ) with TempDir() as tmp: example = _Example(df_without_columns) example.save(tmp.path()) filename = example.info["artifact_path"] assert example.info["type"] == "dataframe" assert example.info["pandas_orient"] == "values" with open(tmp.path(filename)) as f: data = json.load(f) assert set(data.keys()) == {"data"} # NOTE: when no column names are provided (i.e. values orient), # saving an example adds a "data" key rather than directly storing the plain data data = data["data"] dataframe = pd.read_json( StringIO(json.dumps(data)), orient=example.info["pandas_orient"] ) pd.testing.assert_frame_equal(dataframe, df_without_columns, check_dtype=False) # pass dict with scalars with TempDir() as tmp: example = {"a": 1, "b": "abc"} x = _Example(example) x.save(tmp.path()) filename = x.info["artifact_path"] assert x.info["type"] == "json_object" with open(tmp.path(filename)) as f: parsed_json = json.load(f) assert parsed_json == example def test_sparse_matrix_input_examples(dict_of_sparse_matrix): for example_type, input_example in dict_of_sparse_matrix.items(): with TempDir() as tmp: example = _Example(input_example) example.save(tmp.path()) filename = example.info["artifact_path"] assert example.info["type"] == example_type parsed_matrix = _read_sparse_matrix_from_json(tmp.path(filename), example_type) np.testing.assert_array_equal(parsed_matrix.toarray(), input_example.toarray()) def test_input_examples_with_nan(df_with_nan, dict_of_ndarrays_with_nans): # test setting example with data frame with NaN values in it sig = infer_signature(df_with_nan) with TempDir() as tmp: example = _Example(df_with_nan) example.save(tmp.path()) filename = example.info["artifact_path"] assert example.info["type"] == "dataframe" assert example.info["pandas_orient"] == "split" with open(tmp.path(filename)) as f: data = json.load(f) assert set(data.keys()) == {"columns", "data"} pd.read_json(StringIO(json.dumps(data)), orient=example.info["pandas_orient"]) parsed_df = dataframe_from_raw_json(tmp.path(filename), schema=sig.inputs) # by definition of NaN, NaN == NaN is False but NaN != NaN is True pd.testing.assert_frame_equal(df_with_nan, parsed_df, check_dtype=False) # the frame read without schema should match except for the binary values no_schema_df = dataframe_from_raw_json(tmp.path(filename)) a = parsed_df.drop(columns=["binary"]) b = no_schema_df.drop(columns=["binary"]) pd.testing.assert_frame_equal(a, b, check_dtype=False) # pass multidimensional array for col in dict_of_ndarrays_with_nans: input_example = dict_of_ndarrays_with_nans[col] sig = infer_signature(input_example) with TempDir() as tmp: example = _Example(input_example) example.save(tmp.path()) filename = example.info["artifact_path"] assert example.info["type"] == "ndarray" parsed_ary = parse_inputs_data(tmp.path(filename), schema=sig.inputs) assert np.array_equal(parsed_ary, input_example, equal_nan=True) # without a schema/dtype specified, the resulting tensor will keep the None type no_schema_df = parse_inputs_data(tmp.path(filename)) np.testing.assert_array_equal( no_schema_df, np.where(np.isnan(input_example), None, input_example) ) class DummySklearnModel(BaseEstimator, ClassifierMixin): def __init__(self, output_shape=(1,)): self.output_shape = output_shape def fit(self, X, y=None): return self def predict(self, X): n_samples = X.shape[0] full_output_shape = (n_samples,) + self.output_shape return np.zeros(full_output_shape, dtype=np.dtype("int64")) @pytest.mark.parametrize( ("input_is_tabular", "output_shape", "expected_signature"), [ # When the input example is column-based, output 1D numpy arrays are interpreted `ColSpec`s ( True, (), ModelSignature( inputs=Schema([ColSpec(name="feature", type=DataType.string)]), outputs=Schema([ColSpec(type=DataType.long)]), ), ), # But if the output numpy array has higher dimensions, fallback to interpreting the model # output as `TensorSpec`s. ( True, (2,), ModelSignature( inputs=Schema([ColSpec(name="feature", type=DataType.string)]), outputs=Schema([TensorSpec(np.dtype("int64"), (-1, 2))]), ), ), # If the input example is tensor-based, interpret output numpy arrays as `TensorSpec`s ( False, (), ModelSignature( inputs=Schema([TensorSpec(np.dtype("int64"), (-1, 1))]), outputs=Schema([TensorSpec(np.dtype("int64"), (-1,))]), ), ), ], ) def test_infer_signature_with_input_example(input_is_tabular, output_shape, expected_signature): model = DummySklearnModel(output_shape=output_shape) artifact_path = "model" example = pd.DataFrame({"feature": ["value"]}) if input_is_tabular else np.array([[1]]) with mlflow.start_run(): model_info = mlflow.sklearn.log_model( model, name=artifact_path, input_example=example, serialization_format="cloudpickle", ) mlflow_model = Model.load(model_info.model_uri) assert mlflow_model.signature == expected_signature def test_infer_signature_from_example_can_be_disabled(): artifact_path = "model" with mlflow.start_run(): model_info = mlflow.sklearn.log_model( DummySklearnModel(output_shape=()), name=artifact_path, input_example=np.array([[1]]), signature=False, serialization_format="cloudpickle", ) mlflow_model = Model.load(model_info.model_uri) assert mlflow_model.signature is None def test_infer_signature_raises_if_predict_on_input_example_fails(monkeypatch): monkeypatch.setenv("MLFLOW_TESTING", "false") class ErrorModel(BaseEstimator, ClassifierMixin): def fit(self, X, y=None): return self def predict(self, X): raise Exception("oh no!") with mock.patch("mlflow.models.model._logger.warning") as mock_warning: with mlflow.start_run(): mlflow.sklearn.log_model( ErrorModel(), name="model", input_example=np.array([[1]]), serialization_format="cloudpickle", ) assert any( "Failed to validate serving input example" in call[0][0] for call in mock_warning.call_args_list ) @pytest.fixture(scope="module") def iris_model(): X, y = datasets.load_iris(return_X_y=True, as_frame=True) return logreg_module.LogisticRegression().fit(X, y) @pytest.mark.parametrize( "input_example", [ { "sepal length (cm)": 5.1, "sepal width (cm)": 3.5, "petal length (cm)": 1.4, "petal width (cm)": 0.2, }, pd.DataFrame([[5.1, 3.5, 1.4, 0.2]]), pd.DataFrame( { "sepal length (cm)": 5.1, "sepal width (cm)": 3.5, "petal length (cm)": 1.4, "petal width (cm)": 0.2, }, index=[0], ), ], ) def test_infer_signature_on_multi_column_input_examples(input_example, iris_model): artifact_path = "model" with mlflow.start_run(): model_info = mlflow.sklearn.log_model( iris_model, name=artifact_path, input_example=input_example ) mlflow_model = Model.load(model_info.model_uri) input_columns = mlflow_model.signature.inputs.inputs assert len(input_columns) == 4 assert all(col.type == DataType.double for col in input_columns) assert mlflow_model.signature.outputs == Schema([ColSpec(type=DataType.long)]) @pytest.mark.parametrize( "input_example", ["some string", bytes([1, 2, 3])], ) def test_infer_signature_on_scalar_input_examples(input_example): class IdentitySklearnModel(BaseEstimator, ClassifierMixin): def fit(self, X, y=None): return self def predict(self, X): if isinstance(X, pd.DataFrame): return X raise Exception("Unsupported input type") artifact_path = "model" with mlflow.start_run(): model_info = mlflow.sklearn.log_model( IdentitySklearnModel(), name=artifact_path, input_example=input_example, serialization_format="cloudpickle", ) mlflow_model = Model.load(model_info.model_uri) signature = mlflow_model.signature assert isinstance(signature, ModelSignature) assert signature.inputs.inputs[0].name is None t = DataType.string if isinstance(input_example, str) else DataType.binary assert signature == ModelSignature( inputs=Schema([ColSpec(type=t)]), outputs=Schema([ColSpec(name=0, type=t)]), ) # test that a single string still passes pyfunc schema enforcement mlflow.pyfunc.load_model(model_info.model_uri).predict(input_example)