1121 lines
40 KiB
Python
1121 lines
40 KiB
Python
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import signal
|
|
from io import BytesIO, StringIO
|
|
from typing import Any, NamedTuple
|
|
|
|
import keras
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pydantic
|
|
import pytest
|
|
import sklearn.linear_model as logreg_module
|
|
from packaging.version import Version
|
|
from sklearn import datasets
|
|
|
|
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
|
|
import mlflow.sklearn
|
|
from mlflow.environment_variables import MLFLOW_SCORING_SERVER_REQUEST_TIMEOUT
|
|
from mlflow.models import ModelSignature, infer_signature
|
|
from mlflow.protos.databricks_pb2 import BAD_REQUEST, ErrorCode
|
|
from mlflow.pyfunc import PythonModel
|
|
from mlflow.pyfunc.scoring_server import _get_jsonable_obj, get_cmd
|
|
from mlflow.types import ColSpec, DataType, ParamSchema, ParamSpec, Schema
|
|
from mlflow.types.schema import Array, Object, Property
|
|
from mlflow.utils import env_manager as _EnvManager
|
|
from mlflow.utils.file_utils import TempDir
|
|
from mlflow.utils.proto_json_utils import NumpyEncoder
|
|
from mlflow.version import VERSION
|
|
|
|
from tests.helper_functions import (
|
|
expect_status_code,
|
|
random_int,
|
|
random_str,
|
|
)
|
|
from tests.pyfunc.utils import score_model_in_process
|
|
|
|
if Version(keras.__version__) >= Version("2.6.0"):
|
|
from tensorflow.keras.layers import Concatenate, Dense, Input
|
|
from tensorflow.keras.models import Model
|
|
from tensorflow.keras.optimizers import SGD
|
|
else:
|
|
from keras.layers import Concatenate, Dense, Input
|
|
from keras.models import Model
|
|
from keras.optimizers import SGD
|
|
|
|
|
|
class ModelWithData(NamedTuple):
|
|
model: Any
|
|
inference_data: Any
|
|
|
|
|
|
def build_and_save_sklearn_model(model_path):
|
|
from sklearn.datasets import load_iris
|
|
from sklearn.linear_model import LogisticRegression
|
|
|
|
X, y = load_iris(return_X_y=True)
|
|
model = LogisticRegression().fit(X, y)
|
|
|
|
mlflow.sklearn.save_model(model, path=model_path)
|
|
|
|
|
|
class MyChatLLM(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
# If (and only-if) we define model signature, input is converted
|
|
# to pandas DataFrame in _enforce_schema applied in Pyfunc.predict.
|
|
# TODO: Confirm if this is ok, for me it sounds confusing.
|
|
if isinstance(model_input, pd.DataFrame):
|
|
model_input = model_input.to_dict(orient="records")[0]
|
|
|
|
messages = model_input["messages"]
|
|
ret = " ".join([m["content"] for m in messages])
|
|
|
|
return {
|
|
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
"object": "chat.completion",
|
|
"created": 1698916461,
|
|
"model": "llama-2-70b-chat-hf",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": ret,
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 47, "completion_tokens": 49, "total_tokens": 96},
|
|
# Echo model input and params for testing purposes
|
|
"model_input": model_input,
|
|
"params": params,
|
|
}
|
|
|
|
|
|
class MyCompletionsLLM(PythonModel):
|
|
# Example model that takes "prompt" as model input
|
|
def predict(self, context, model_input, params=None):
|
|
if isinstance(model_input, pd.DataFrame):
|
|
model_input = model_input.to_dict(orient="records")[0]
|
|
|
|
ret = model_input["prompt"]
|
|
|
|
return {
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"text": ret,
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
# Echo model input and params for testing purposes
|
|
"model_input": model_input,
|
|
"params": params,
|
|
}
|
|
|
|
|
|
class MyEmbeddingsLLM(PythonModel):
|
|
# Example model that takes "input" as model input
|
|
def predict(self, context, model_input, params=None):
|
|
if isinstance(model_input, pd.DataFrame):
|
|
model_input = model_input.to_dict(orient="records")[0]
|
|
|
|
return {
|
|
"data": [
|
|
{
|
|
"index": 0,
|
|
"embedding": [0.1, 0.2, 0.3],
|
|
}
|
|
],
|
|
# Echo model input and params for testing purposes
|
|
"model_input": model_input,
|
|
"params": params,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def pandas_df_with_all_types():
|
|
pdf = 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": [bytearray([1, 2, 3]), bytearray([4, 5, 6]), bytearray([7, 8, 9])],
|
|
"datetime": [
|
|
np.datetime64("2021-01-01 00:00:00"),
|
|
np.datetime64("2021-02-02 00:00:00"),
|
|
np.datetime64("2021-03-03 12:00:00"),
|
|
],
|
|
})
|
|
pdf["string"] = pd.Series(["a", "b", "c"], dtype=DataType.string.to_pandas())
|
|
return pdf
|
|
|
|
|
|
@pytest.fixture
|
|
def pandas_df_with_csv_types():
|
|
pdf = 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],
|
|
})
|
|
pdf["string"] = pd.Series(["a", "b", "c"], dtype=DataType.string.to_pandas())
|
|
return pdf
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sklearn_model():
|
|
iris = datasets.load_iris()
|
|
X = iris.data[:, :2] # we only take the first two features.
|
|
y = iris.target
|
|
logreg_model = logreg_module.LogisticRegression()
|
|
logreg_model.fit(X, y)
|
|
return ModelWithData(model=logreg_model, inference_data=X)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def keras_model():
|
|
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).values
|
|
input_a = Input(shape=(2,), name="a")
|
|
input_b = Input(shape=(2,), name="b")
|
|
output = Dense(1)(Dense(3, input_dim=4)(Concatenate()([input_a, input_b])))
|
|
model = Model(inputs=[input_a, input_b], outputs=output)
|
|
model.compile(loss="mean_squared_error", optimizer=SGD())
|
|
model.fit([X[:, :2], X[:, -2:]], y)
|
|
return ModelWithData(model=model, inference_data=X)
|
|
|
|
|
|
@pytest.fixture
|
|
def model_path(tmp_path):
|
|
return os.path.join(tmp_path, "model")
|
|
|
|
|
|
def test_scoring_server_responds_to_malformed_json_input_with_error_code_and_message(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
malformed_json_content = "this is,,,, not valid json"
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=malformed_json_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert response_json.get("error_code") == ErrorCode.Name(BAD_REQUEST)
|
|
message = response_json.get("message")
|
|
expected_message = "Invalid input. Ensure that input is a valid JSON formatted string."
|
|
assert expected_message in message
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_json_format_with_error_code_and_message(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
for not_a_dict_content in [1, "1", [1]]:
|
|
incorrect_json_content = json.dumps(not_a_dict_content)
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=incorrect_json_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert response_json.get("error_code") == ErrorCode.Name(BAD_REQUEST)
|
|
assert "message" in response_json
|
|
message = response_json.get("message")
|
|
assert "The input must be a JSON dictionary with exactly one of the input fields" in message
|
|
|
|
for incorrect_format in [
|
|
{"not": "a serialized dataframe"},
|
|
{"dataframe_records": [], "dataframe_split": {"data": []}},
|
|
]:
|
|
incorrect_json_content = json.dumps(incorrect_format)
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=incorrect_json_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert response_json.get("error_code") == ErrorCode.Name(BAD_REQUEST)
|
|
message = response_json.get("message")
|
|
assert "The input must be a JSON dictionary with exactly one of the input fields" in message
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_pandas_input_format_with_stacktrace_and_error_code(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
pdf = pd.DataFrame(sklearn_model.inference_data)
|
|
wrong_records_content = json.dumps({"dataframe_records": pdf.to_dict(orient="split")})
|
|
wrong_split_content = json.dumps({"dataframe_split": pdf.to_dict(orient="records")})
|
|
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=wrong_split_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert response_json.get("error_code") == ErrorCode.Name(BAD_REQUEST)
|
|
message = response_json.get("message")
|
|
assert "Dataframe split format must be a dictionary. Got list" in message
|
|
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=wrong_records_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert response_json.get("error_code") == ErrorCode.Name(BAD_REQUEST)
|
|
message = response_json.get("message")
|
|
assert "Dataframe records format must be a list of records. Got dictionary." in message
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_dataframe_with_stacktrace_and_error_code(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
invalid_dataframe_content = json.dumps({
|
|
"dataframe_split": {"index": [1, 2], "data": [[1], [2], [3]]}
|
|
})
|
|
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=invalid_dataframe_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert response_json.get("error_code") == ErrorCode.Name(BAD_REQUEST)
|
|
message = response_json.get("message")
|
|
assert "Provided dataframe_split field is not a valid dataframe representation" in message
|
|
|
|
|
|
def test_scoring_server_responds_to_incompatible_inference_dataframe_with_stacktrace_and_error_code(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
incompatible_df = pd.DataFrame(np.array(range(10)))
|
|
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=incompatible_df,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert "error_code" in response_json
|
|
assert response_json["error_code"] == ErrorCode.Name(BAD_REQUEST)
|
|
assert "message" in response_json
|
|
assert "stack_trace" in response_json
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_csv_input_with_stacktrace_and_error_code(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
# Any empty string is not valid pandas CSV
|
|
incorrect_csv_content = ""
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=incorrect_csv_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_CSV,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert "error_code" in response_json
|
|
assert response_json["error_code"] == ErrorCode.Name(BAD_REQUEST)
|
|
assert "message" in response_json
|
|
assert "stack_trace" in response_json
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_parquet_input_with_stacktrace_and_error_code(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
# Any empty string is not valid pandas parquet input
|
|
incorrect_parquet_content = ""
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=incorrect_parquet_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_PARQUET,
|
|
)
|
|
response_json = json.loads(response.content)
|
|
assert "error_code" in response_json
|
|
assert response_json["error_code"] == ErrorCode.Name(BAD_REQUEST)
|
|
assert "message" in response_json
|
|
assert "stack_trace" in response_json
|
|
|
|
|
|
def test_scoring_server_successfully_evaluates_correct_dataframes_with_pandas_records_orientation(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
pandas_record_content = json.dumps({
|
|
"dataframe_records": pd.DataFrame(sklearn_model.inference_data).to_dict(orient="records")
|
|
})
|
|
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=pandas_record_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
# Testing the charset parameter
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=pandas_record_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON + "; charset=UTF-8",
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
|
|
def test_scoring_server_successfully_evaluates_correct_dataframes_with_pandas_split_orientation(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
pandas_split_content = json.dumps({
|
|
"dataframe_split": pd.DataFrame(sklearn_model.inference_data).to_dict(orient="split")
|
|
})
|
|
|
|
# Testing the charset parameter
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=pandas_split_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON + "; charset=UTF-8",
|
|
)
|
|
|
|
expect_status_code(response, 200)
|
|
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=pandas_split_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response, 200)
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_content_type_request_with_unsupported_content_type_code(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
pandas_split_content = pd.DataFrame(sklearn_model.inference_data).to_json(orient="split")
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=pandas_split_content,
|
|
content_type="not_a_supported_content_type",
|
|
)
|
|
expect_status_code(response, 415)
|
|
|
|
|
|
def test_scoring_server_responds_to_invalid_content_type_request_with_unrecognized_content_param(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
pandas_split_content = pd.DataFrame(sklearn_model.inference_data).to_json(orient="split")
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=pandas_split_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON + "; something=something",
|
|
)
|
|
expect_status_code(response, 415)
|
|
|
|
|
|
def test_scoring_server_successfully_evaluates_correct_tf_serving_sklearn(
|
|
sklearn_model, model_path
|
|
):
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path)
|
|
|
|
inp_dict = {"instances": sklearn_model.inference_data.tolist()}
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=json.dumps(inp_dict),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
|
|
def test_scoring_server_successfully_evaluates_correct_tf_serving_keras_instances(
|
|
keras_model, model_path
|
|
):
|
|
mlflow.tensorflow.save_model(keras_model.model, path=model_path)
|
|
|
|
inp_dict = {
|
|
"instances": [
|
|
{"a": a.tolist(), "b": b.tolist()}
|
|
for (a, b) in zip(keras_model.inference_data[:, :2], keras_model.inference_data[:, -2:])
|
|
]
|
|
}
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=json.dumps(inp_dict),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
|
|
def test_scoring_server_successfully_evaluates_correct_tf_serving_keras_inputs(
|
|
keras_model, model_path
|
|
):
|
|
mlflow.tensorflow.save_model(keras_model.model, path=model_path)
|
|
|
|
inp_dict = {
|
|
"inputs": {
|
|
"a": keras_model.inference_data[:, :2].tolist(),
|
|
"b": keras_model.inference_data[:, -2:].tolist(),
|
|
}
|
|
}
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=json.dumps(inp_dict),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
|
|
def test_parse_json_input_records_oriented():
|
|
size = 2
|
|
data = {
|
|
"col_m": [random_int(0, 1000) for _ in range(size)],
|
|
"col_z": [random_str() for _ in range(size)],
|
|
"col_a": [random_int() for _ in range(size)],
|
|
}
|
|
p1 = pd.DataFrame.from_dict(data)
|
|
records_content = json.dumps({"dataframe_records": p1.to_dict(orient="records")})
|
|
records_content, _ = pyfunc_scoring_server._split_data_and_params(records_content)
|
|
p2 = pyfunc_scoring_server.infer_and_parse_data(records_content)
|
|
# "records" orient may shuffle column ordering. Hence comparing each column Series
|
|
for col in data:
|
|
assert all(p1[col] == p2[col])
|
|
|
|
|
|
def test_parse_json_input_split_oriented():
|
|
size = 200
|
|
data = {
|
|
"col_m": [random_int(0, 1000) for _ in range(size)],
|
|
"col_z": [random_str() for _ in range(size)],
|
|
"col_a": [random_int() for _ in range(size)],
|
|
}
|
|
p1 = pd.DataFrame.from_dict(data)
|
|
split_content = json.dumps({"dataframe_split": p1.to_dict(orient="split")})
|
|
split_content, _ = pyfunc_scoring_server._split_data_and_params(split_content)
|
|
p2 = pyfunc_scoring_server.infer_and_parse_data(split_content)
|
|
assert all(p1 == p2)
|
|
|
|
|
|
def test_records_oriented_json_to_df():
|
|
# test that datatype for "zip" column is not converted to "int64"
|
|
jstr = """
|
|
{
|
|
"dataframe_records": [
|
|
{"zip":"95120","cost":10.45,"score":8},
|
|
{"zip":"95128","cost":23.0,"score":0},
|
|
{"zip":"95128","cost":12.1,"score":10}
|
|
]
|
|
}
|
|
"""
|
|
jstr, _ = pyfunc_scoring_server._split_data_and_params(jstr)
|
|
df = pyfunc_scoring_server.infer_and_parse_data(jstr)
|
|
assert set(df.columns) == {"zip", "cost", "score"}
|
|
assert {str(dt) for dt in df.dtypes} == {"object", "float64", "int64"}
|
|
|
|
|
|
def _shuffle_pdf(pdf):
|
|
cols = list(pdf.columns)
|
|
random.shuffle(cols)
|
|
return pdf[cols]
|
|
|
|
|
|
def test_split_oriented_json_to_df():
|
|
# test that datatype for "zip" column is not converted to "int64"
|
|
jstr = """
|
|
{
|
|
"dataframe_split": {
|
|
"columns":["zip","cost","count"],
|
|
"index":[0,1,2],
|
|
"data":[["95120",10.45,-8],["95128",23.0,-1],["95128",12.1,1000]]
|
|
}
|
|
}
|
|
"""
|
|
jstr, _ = pyfunc_scoring_server._split_data_and_params(jstr)
|
|
df = pyfunc_scoring_server.infer_and_parse_data(jstr)
|
|
|
|
assert set(df.columns) == {"zip", "cost", "count"}
|
|
assert {str(dt) for dt in df.dtypes} == {"object", "float64", "int64"}
|
|
|
|
|
|
def test_parse_with_schema_csv(pandas_df_with_csv_types):
|
|
schema = Schema([ColSpec(c, c) for c in pandas_df_with_csv_types.columns])
|
|
df = _shuffle_pdf(pandas_df_with_csv_types)
|
|
csv_str = df.to_csv(index=False)
|
|
df = pyfunc_scoring_server.parse_csv_input(StringIO(csv_str), schema=schema)
|
|
assert schema == infer_signature(df[schema.input_names()]).inputs
|
|
|
|
|
|
def test_parse_parquet_schema(pandas_df_with_all_types):
|
|
schema = Schema([ColSpec(c, c) for c in pandas_df_with_all_types.columns])
|
|
df = _shuffle_pdf(pandas_df_with_all_types)
|
|
parquet_stream = df.to_parquet()
|
|
df = pyfunc_scoring_server.parse_parquet_input(BytesIO(parquet_stream))
|
|
assert schema == infer_signature(df[schema.input_names()]).inputs
|
|
|
|
|
|
def test_parse_with_schema(pandas_df_with_all_types):
|
|
schema = Schema([ColSpec(c, c) for c in pandas_df_with_all_types.columns])
|
|
df = _shuffle_pdf(pandas_df_with_all_types)
|
|
json_str = json.dumps({"dataframe_split": df.to_dict(orient="split")}, cls=NumpyEncoder)
|
|
json_str, _ = pyfunc_scoring_server._split_data_and_params(json_str)
|
|
df = pyfunc_scoring_server.infer_and_parse_data(json_str, schema=schema)
|
|
json_str = json.dumps({"dataframe_records": df.to_dict(orient="records")}, cls=NumpyEncoder)
|
|
json_str, _ = pyfunc_scoring_server._split_data_and_params(json_str)
|
|
df = pyfunc_scoring_server.infer_and_parse_data(json_str, schema=schema)
|
|
assert schema == infer_signature(df[schema.input_names()]).inputs
|
|
|
|
# The current behavior with pandas json parse with type hints is weird. In some cases, the
|
|
# types are forced ignoring overflow and loss of precision:
|
|
|
|
bad_df = """
|
|
{
|
|
"dataframe_split": {
|
|
"columns":["bad_integer", "bad_float", "bad_string", "bad_boolean"],
|
|
"data":[
|
|
[9007199254740991.0, 1.1, 1, 1.5],
|
|
[9007199254740992.0, 9007199254740992.0, 2, 0],
|
|
[9007199254740994.0, 3.3, 3, "some arbitrary string"]
|
|
]
|
|
}
|
|
}
|
|
"""
|
|
schema = Schema([
|
|
ColSpec("integer", "bad_integer"),
|
|
ColSpec("float", "bad_float"),
|
|
ColSpec("string", "bad_string"),
|
|
ColSpec("boolean", "bad_boolean"),
|
|
])
|
|
bad_df, _ = pyfunc_scoring_server._split_data_and_params(bad_df)
|
|
df = pyfunc_scoring_server.infer_and_parse_data(bad_df, schema=schema)
|
|
# Unfortunately, the current behavior of pandas parse is to force numbers to int32 even if
|
|
# they don't fit:
|
|
assert df["bad_integer"].dtype == np.int32
|
|
assert all(df["bad_integer"] == [-2147483648, -2147483648, -2147483648])
|
|
|
|
# The same goes for floats:
|
|
assert df["bad_float"].dtype == np.float32
|
|
assert all(df["bad_float"] == np.array([1.1, 9007199254740992, 3.3], dtype=np.float32))
|
|
# However bad string is recognized as int64:
|
|
assert all(df["bad_string"] == np.array([1, 2, 3], dtype=object))
|
|
|
|
# Boolean is forced - zero and empty string is false, everything else is true:
|
|
assert df["bad_boolean"].dtype == bool
|
|
assert all(df["bad_boolean"] == [True, False, True])
|
|
|
|
|
|
def test_serving_model_with_schema(pandas_df_with_all_types):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [[k, str(v)] for k, v in model_input.dtypes.items()]
|
|
|
|
schema = Schema([ColSpec(c, c) for c in pandas_df_with_all_types.columns])
|
|
df = _shuffle_pdf(pandas_df_with_all_types)
|
|
with TempDir(chdr=True):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=TestModel(), signature=ModelSignature(schema)
|
|
)
|
|
response = score_model_in_process(
|
|
model_uri=model_info.model_uri,
|
|
data=json.dumps({"dataframe_split": df.to_dict(orient="split")}, cls=NumpyEncoder),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)["predictions"]
|
|
|
|
# objects are not converted to pandas Strings at the moment
|
|
expected_types = {**pandas_df_with_all_types.dtypes, "string": np.dtype(object)}
|
|
assert response_json == [[k, str(v)] for k, v in expected_types.items()]
|
|
response = score_model_in_process(
|
|
model_uri=model_info.model_uri,
|
|
data=json.dumps(
|
|
{"dataframe_records": pandas_df_with_all_types.to_dict(orient="records")},
|
|
cls=NumpyEncoder,
|
|
),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)["predictions"]
|
|
assert response_json == [[k, str(v)] for k, v in expected_types.items()]
|
|
|
|
# Test 'inputs' format
|
|
response = score_model_in_process(
|
|
model_uri=model_info.model_uri,
|
|
data=json.dumps({"inputs": df.to_dict(orient="list")}, cls=NumpyEncoder),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
response_json = json.loads(response.content)["predictions"]
|
|
assert response_json == [[k, str(v)] for k, v in expected_types.items()]
|
|
|
|
|
|
def test_serving_model_with_param_schema(sklearn_model, model_path):
|
|
dataframe = {
|
|
"dataframe_split": pd.DataFrame(sklearn_model.inference_data).to_dict(orient="split")
|
|
}
|
|
signature = infer_signature(sklearn_model.inference_data)
|
|
param_schema = ParamSchema([
|
|
ParamSpec("param1", DataType.datetime, np.datetime64("2023-07-01"))
|
|
])
|
|
signature.params = param_schema
|
|
mlflow.sklearn.save_model(sk_model=sklearn_model.model, path=model_path, signature=signature)
|
|
|
|
# Success if passing no parameters
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=json.dumps(dataframe),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON + "; charset=UTF-8",
|
|
)
|
|
expect_status_code(response, 200)
|
|
|
|
# Raise error if invalid value is passed
|
|
payload = dataframe.copy()
|
|
payload.update({"params": {"param1": "invalid_value1"}})
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=json.dumps(payload),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON + "; charset=UTF-8",
|
|
)
|
|
expect_status_code(response, 400)
|
|
assert (
|
|
" Failed to convert value `invalid_value1` from type `<class 'str'>` "
|
|
"to `DataType.datetime`" in json.loads(response.content.decode("utf-8"))["message"]
|
|
)
|
|
|
|
# Ignore parameters specified in payload if it is not defined in ParamSchema
|
|
payload = dataframe.copy()
|
|
payload.update({"params": {"invalid_param": "value"}})
|
|
response = score_model_in_process(
|
|
model_uri=os.path.abspath(model_path),
|
|
data=json.dumps(payload),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON + "; charset=UTF-8",
|
|
)
|
|
expect_status_code(response, 200)
|
|
|
|
|
|
def test_get_jsonnable_obj():
|
|
py_ary = [["a", "b", "c"], ["e", "f", "g"]]
|
|
np_ary = _get_jsonable_obj(np.array(py_ary))
|
|
assert json.dumps(py_ary, cls=NumpyEncoder) == json.dumps(np_ary, cls=NumpyEncoder)
|
|
np_ary = _get_jsonable_obj(np.array(py_ary, dtype=type(str)))
|
|
assert json.dumps(py_ary, cls=NumpyEncoder) == json.dumps(np_ary, cls=NumpyEncoder)
|
|
|
|
|
|
def test_numpy_encoder_for_pydantic():
|
|
class Message(pydantic.BaseModel):
|
|
role: str
|
|
content: str
|
|
|
|
class Messages(pydantic.BaseModel):
|
|
messages: list[Message]
|
|
|
|
messages = Messages(
|
|
messages=[Message(role="user", content="hello!"), Message(role="assistant", content="hi!")]
|
|
)
|
|
msg_dict = messages.model_dump()
|
|
assert json.dumps(_get_jsonable_obj(messages), cls=NumpyEncoder) == json.dumps(
|
|
msg_dict, cls=NumpyEncoder
|
|
)
|
|
|
|
|
|
def test_parse_parquet_input():
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return 1
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel())
|
|
|
|
pandas_df = pd.DataFrame({
|
|
"foo": [3.0, 4.0],
|
|
"bar": [1.0, 2.0],
|
|
})
|
|
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=f"runs:/{run.info.run_id}/model",
|
|
data=pandas_df,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_PARQUET,
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
|
|
def test_parse_json_input_including_path():
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return 1
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel())
|
|
|
|
pandas_split_content = pd.DataFrame({
|
|
"url": ["http://foo.com", "https://bar.com"],
|
|
"bad_protocol": ["aaa://bbb", "address:/path"],
|
|
})
|
|
|
|
response_records_content_type = score_model_in_process(
|
|
model_uri=f"runs:/{run.info.run_id}/model",
|
|
data=pandas_split_content,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response_records_content_type, 200)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("args", "expected", "timeout"),
|
|
[
|
|
(
|
|
{"port": 5000, "host": "0.0.0.0", "nworkers": 4, "timeout": 60},
|
|
"--host 0.0.0.0 --port 5000 --workers 4",
|
|
"60",
|
|
),
|
|
(
|
|
{"host": "0.0.0.0", "nworkers": 4, "timeout": 60},
|
|
"--host 0.0.0.0 --workers 4",
|
|
"60",
|
|
),
|
|
(
|
|
{"port": 5000, "nworkers": 4, "timeout": 60},
|
|
"--port 5000 --workers 4",
|
|
"60",
|
|
),
|
|
({"nworkers": 4, "timeout": 60}, "--workers 4", "60"),
|
|
({"timeout": 30}, "", "30"),
|
|
],
|
|
)
|
|
def test_get_cmd(args: dict[str, Any], expected: str, timeout: str):
|
|
cmd, env = get_cmd(model_uri="foo", **args)
|
|
|
|
assert cmd == (f"uvicorn {expected} mlflow.pyfunc.scoring_server.app:app")
|
|
assert env[MLFLOW_SCORING_SERVER_REQUEST_TIMEOUT.name] == timeout
|
|
|
|
|
|
def test_scoring_server_client(sklearn_model, model_path):
|
|
from mlflow.models.flavor_backend_registry import get_flavor_backend
|
|
from mlflow.pyfunc.scoring_server.client import ScoringServerClient
|
|
from mlflow.utils import find_free_port
|
|
|
|
mlflow.sklearn.save_model(
|
|
sk_model=sklearn_model.model, path=model_path, metadata={"metadata_key": "value"}
|
|
)
|
|
expected_result = sklearn_model.model.predict(sklearn_model.inference_data)
|
|
|
|
port = find_free_port()
|
|
timeout = 60
|
|
server_proc = None
|
|
try:
|
|
server_proc = get_flavor_backend(
|
|
model_path, env_manager=_EnvManager.CONDA, workers=1, install_mlflow=False
|
|
).serve(
|
|
model_uri=model_path,
|
|
port=port,
|
|
host="127.0.0.1",
|
|
timeout=timeout,
|
|
synchronous=False,
|
|
)
|
|
|
|
client = ScoringServerClient(host="127.0.0.1", port=port)
|
|
client.wait_server_ready()
|
|
|
|
data = pd.DataFrame(sklearn_model.inference_data)
|
|
result = client.invoke(data).get_predictions().to_numpy()[:, 0]
|
|
np.testing.assert_allclose(result, expected_result, rtol=1e-5)
|
|
|
|
version = client.get_version()
|
|
assert version == VERSION
|
|
finally:
|
|
if server_proc is not None:
|
|
os.kill(server_proc.pid, signal.SIGTERM)
|
|
|
|
|
|
_LLM_CHAT_INPUT_SCHEMA = Schema([
|
|
ColSpec(
|
|
Array(
|
|
Object([
|
|
Property("role", DataType.string),
|
|
Property("content", DataType.string),
|
|
]),
|
|
),
|
|
name="messages",
|
|
)
|
|
])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("signature", "expected_model_input", "expected_params"),
|
|
[
|
|
# Test case: no signature, everything should go to data
|
|
(
|
|
None,
|
|
{
|
|
"messages": [{"role": "user", "content": "hello!"}],
|
|
"max_tokens": 20,
|
|
"temperature": 0.5,
|
|
},
|
|
{},
|
|
),
|
|
# Test case: signature with params, split params and data
|
|
(
|
|
ModelSignature(
|
|
inputs=_LLM_CHAT_INPUT_SCHEMA,
|
|
params=ParamSchema([
|
|
ParamSpec("temperature", DataType.double, default=0.5),
|
|
ParamSpec("max_tokens", DataType.integer, default=20),
|
|
ParamSpec("top_p", DataType.double, default=0.9),
|
|
]),
|
|
),
|
|
{
|
|
"messages": [{"role": "user", "content": "hello!"}],
|
|
},
|
|
{
|
|
"temperature": 0.5,
|
|
"max_tokens": 20,
|
|
"top_p": 0.9, # filled with the default value
|
|
},
|
|
),
|
|
# Test case: if some params are not defined in either input and params schema,
|
|
# they will be dropped
|
|
(
|
|
ModelSignature(
|
|
inputs=_LLM_CHAT_INPUT_SCHEMA,
|
|
params=ParamSchema([
|
|
ParamSpec("temperature", DataType.double, default=0.5),
|
|
]),
|
|
),
|
|
{
|
|
"messages": [{"role": "user", "content": "hello!"}],
|
|
},
|
|
{
|
|
# only params defined in the schema are passed
|
|
"temperature": 0.5,
|
|
},
|
|
),
|
|
# Test case: params can be defined in the input schema
|
|
(
|
|
ModelSignature(
|
|
inputs=Schema([
|
|
*_LLM_CHAT_INPUT_SCHEMA.inputs,
|
|
ColSpec(DataType.long, "max_tokens", required=False),
|
|
ColSpec(DataType.double, "temperature", required=False),
|
|
]),
|
|
),
|
|
{
|
|
"messages": [{"role": "user", "content": "hello!"}],
|
|
"temperature": 0.5,
|
|
"max_tokens": 20,
|
|
},
|
|
{},
|
|
),
|
|
],
|
|
)
|
|
def test_scoring_server_allows_payloads_with_llm_chat_keys_for_pyfunc(
|
|
model_path, signature, expected_model_input, expected_params
|
|
):
|
|
mlflow.pyfunc.save_model(model_path, python_model=MyChatLLM(), signature=signature)
|
|
|
|
payload = json.dumps({
|
|
"messages": [{"role": "user", "content": "hello!"}],
|
|
"temperature": 0.5,
|
|
"max_tokens": 20,
|
|
})
|
|
response = score_model_in_process(
|
|
model_uri=model_path,
|
|
data=payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response, 200)
|
|
assert json.loads(response.content)["choices"][0]["message"]["content"] == "hello!"
|
|
assert json.loads(response.content)["model_input"] == expected_model_input
|
|
assert json.loads(response.content)["params"] == expected_params
|
|
|
|
|
|
_LLM_COMPLETIONS_INPUT_SCHEMA = Schema([
|
|
ColSpec(
|
|
DataType.string,
|
|
name="prompt",
|
|
)
|
|
])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("signature", "expected_model_input", "expected_params"),
|
|
[
|
|
# Test case: no signature, everything should go to data
|
|
(
|
|
None,
|
|
{
|
|
"prompt": "hello!",
|
|
"max_tokens": 20,
|
|
"temperature": 0.5,
|
|
},
|
|
{},
|
|
),
|
|
# Test case: signature with params, split params and data
|
|
(
|
|
ModelSignature(
|
|
inputs=_LLM_COMPLETIONS_INPUT_SCHEMA,
|
|
params=ParamSchema([
|
|
ParamSpec("temperature", DataType.double, default=0.5),
|
|
ParamSpec("max_tokens", DataType.integer, default=20),
|
|
ParamSpec("top_p", DataType.double, default=0.9),
|
|
]),
|
|
),
|
|
{
|
|
"prompt": "hello!",
|
|
},
|
|
{
|
|
"temperature": 0.5,
|
|
"max_tokens": 20,
|
|
"top_p": 0.9, # filled with the default value
|
|
},
|
|
),
|
|
],
|
|
)
|
|
def test_scoring_server_allows_payloads_with_llm_completions_keys_for_pyfunc(
|
|
model_path, signature, expected_model_input, expected_params
|
|
):
|
|
mlflow.pyfunc.save_model(model_path, python_model=MyCompletionsLLM(), signature=signature)
|
|
|
|
payload = json.dumps({
|
|
"prompt": "hello!",
|
|
"temperature": 0.5,
|
|
"max_tokens": 20,
|
|
})
|
|
response = score_model_in_process(
|
|
model_uri=model_path,
|
|
data=payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response, 200)
|
|
assert json.loads(response.content)["choices"][0]["text"] == "hello!"
|
|
assert json.loads(response.content)["model_input"] == expected_model_input
|
|
assert json.loads(response.content)["params"] == expected_params
|
|
|
|
|
|
_LLM_EMBEDDINGS_INPUT_SCHEMA = Schema([
|
|
ColSpec(
|
|
DataType.string,
|
|
name="input",
|
|
)
|
|
])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("signature", "expected_model_input", "expected_params"),
|
|
[
|
|
# Test case: no signature, everything should go to data
|
|
(
|
|
None,
|
|
{
|
|
"input": "hello!",
|
|
"random": "test",
|
|
},
|
|
{},
|
|
),
|
|
# Test case: signature with no params accepted, ignores params
|
|
(
|
|
ModelSignature(
|
|
inputs=_LLM_EMBEDDINGS_INPUT_SCHEMA,
|
|
),
|
|
{
|
|
"input": "hello!",
|
|
},
|
|
{},
|
|
),
|
|
],
|
|
)
|
|
def test_scoring_server_allows_payloads_with_llm_embeddings_keys_for_pyfunc(
|
|
model_path, signature, expected_model_input, expected_params
|
|
):
|
|
mlflow.pyfunc.save_model(model_path, python_model=MyEmbeddingsLLM(), signature=signature)
|
|
|
|
payload = json.dumps({
|
|
"input": "hello!",
|
|
"random": "test",
|
|
})
|
|
response = score_model_in_process(
|
|
model_uri=model_path,
|
|
data=payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response, 200)
|
|
assert json.loads(response.content)["data"][0]["embedding"] == [0.1, 0.2, 0.3]
|
|
assert json.loads(response.content)["model_input"] == expected_model_input
|
|
assert json.loads(response.content)["params"] == expected_params
|
|
|
|
|
|
def test_scoring_server_allows_payloads_with_messages_for_pyfunc_wrapped(model_path):
|
|
sklearn_path = model_path + "-sklearn"
|
|
build_and_save_sklearn_model(sklearn_path)
|
|
|
|
# wrapped pyfuncs count as pyfuncs (sklearn is not present in model.metadata.flavors)
|
|
class WrappedSklearn(PythonModel):
|
|
def load_context(self, context):
|
|
self.model = mlflow.pyfunc.load_model(context.artifacts["model_path"])
|
|
|
|
# note: model_input is the value of "messages", not a dict
|
|
def predict(self, context, model_input):
|
|
weird_but_valid_parse = [json.loads(model_input["messages"][0]["content"])]
|
|
return self.model.predict(weird_but_valid_parse)
|
|
|
|
mlflow.pyfunc.save_model(
|
|
model_path, python_model=WrappedSklearn(), artifacts={"model_path": sklearn_path}
|
|
)
|
|
|
|
payload = json.dumps({
|
|
"messages": [{"role": "user", "content": "[2,2,2,2]"}],
|
|
"max_tokens": 20,
|
|
})
|
|
response = score_model_in_process(
|
|
model_uri=model_path,
|
|
data=payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
)
|
|
expect_status_code(response, 200)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("dict_input", "param_schema", "expected"),
|
|
[
|
|
(
|
|
# no param signature, everything should go
|
|
# to data no params should get dropped
|
|
{"messages": ["test"], "max_tokens": 20, "random": "test"},
|
|
None,
|
|
({"messages": ["test"], "max_tokens": 20, "random": "test"}, {}),
|
|
),
|
|
(
|
|
# params defined in the param schema should go to params
|
|
# rest should go to data
|
|
{"messages": ["test"], "max_tokens": 20, "random": "test"},
|
|
ParamSchema([
|
|
ParamSpec("max_tokens", DataType.integer, default=20),
|
|
]),
|
|
({"messages": ["test"], "random": "test"}, {"max_tokens": 20}),
|
|
),
|
|
],
|
|
)
|
|
def test_split_data_and_params_for_llm_input(dict_input, param_schema, expected):
|
|
data, params = pyfunc_scoring_server._split_data_and_params_for_llm_input(
|
|
dict_input, param_schema
|
|
)
|
|
expected_data, expected_params = expected
|
|
assert data == expected_data
|
|
assert params == expected_params
|