Files
2026-07-13 13:22:34 +08:00

387 lines
13 KiB
Python

import json
from dataclasses import asdict, dataclass
import numpy as np
import pandas as pd
import pydantic
import pyspark
import pytest
from sklearn.ensemble import RandomForestRegressor
import mlflow
from mlflow.exceptions import MlflowException
from mlflow.models import Model, ModelSignature, infer_signature, rag_signatures, set_signature
from mlflow.models.model import get_model_info
from mlflow.types import DataType
from mlflow.types.schema import (
Array,
ColSpec,
ParamSchema,
ParamSpec,
Schema,
TensorSpec,
convert_dataclass_to_schema,
)
from mlflow.types.utils import InvalidDataForSignatureInferenceError
def test_model_signature_with_colspec():
signature1 = ModelSignature(
inputs=Schema([ColSpec(DataType.boolean), ColSpec(DataType.binary)]),
outputs=Schema([
ColSpec(name=None, type=DataType.double),
ColSpec(name=None, type=DataType.double),
]),
)
signature2 = ModelSignature(
inputs=Schema([ColSpec(DataType.boolean), ColSpec(DataType.binary)]),
outputs=Schema([
ColSpec(name=None, type=DataType.double),
ColSpec(name=None, type=DataType.double),
]),
)
assert signature1 == signature2
signature3 = ModelSignature(
inputs=Schema([ColSpec(DataType.boolean), ColSpec(DataType.binary)]),
outputs=Schema([
ColSpec(name=None, type=DataType.float),
ColSpec(name=None, type=DataType.double),
]),
)
assert signature3 != signature1
as_json = json.dumps(signature1.to_dict())
signature4 = ModelSignature.from_dict(json.loads(as_json))
assert signature1 == signature4
signature5 = ModelSignature(
inputs=Schema([ColSpec(DataType.boolean), ColSpec(DataType.binary)]), outputs=None
)
as_json = json.dumps(signature5.to_dict())
signature6 = ModelSignature.from_dict(json.loads(as_json))
assert signature5 == signature6
def test_model_signature_with_tensorspec():
signature1 = ModelSignature(
inputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]),
outputs=Schema([TensorSpec(np.dtype("float"), (-1, 10))]),
)
signature2 = ModelSignature(
inputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]),
outputs=Schema([TensorSpec(np.dtype("float"), (-1, 10))]),
)
# Single type mismatch
assert signature1 == signature2
signature3 = ModelSignature(
inputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]),
outputs=Schema([TensorSpec(np.dtype("int"), (-1, 10))]),
)
assert signature3 != signature1
# Name mismatch
signature4 = ModelSignature(
inputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]),
outputs=Schema([TensorSpec(np.dtype("float"), (-1, 10), "mismatch")]),
)
assert signature3 != signature4
as_json = json.dumps(signature1.to_dict())
signature5 = ModelSignature.from_dict(json.loads(as_json))
assert signature1 == signature5
# Test with name
signature6 = ModelSignature(
inputs=Schema([
TensorSpec(np.dtype("float"), (-1, 28, 28), name="image"),
TensorSpec(np.dtype("int"), (-1, 10), name="metadata"),
]),
outputs=Schema([TensorSpec(np.dtype("float"), (-1, 10), name="outputs")]),
)
signature7 = ModelSignature(
inputs=Schema([
TensorSpec(np.dtype("float"), (-1, 28, 28), name="image"),
TensorSpec(np.dtype("int"), (-1, 10), name="metadata"),
]),
outputs=Schema([TensorSpec(np.dtype("float"), (-1, 10), name="outputs")]),
)
assert signature6 == signature7
assert signature1 != signature6
# Test w/o output
signature8 = ModelSignature(
inputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]), outputs=None
)
as_json = json.dumps(signature8.to_dict())
signature9 = ModelSignature.from_dict(json.loads(as_json))
assert signature8 == signature9
def test_model_signature_with_colspec_and_tensorspec():
signature1 = ModelSignature(inputs=Schema([ColSpec(DataType.double)]))
signature2 = ModelSignature(inputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]))
assert signature1 != signature2
assert signature2 != signature1
signature3 = ModelSignature(
inputs=Schema([ColSpec(DataType.double)]),
outputs=Schema([TensorSpec(np.dtype("float"), (-1, 28, 28))]),
)
signature4 = ModelSignature(
inputs=Schema([ColSpec(DataType.double)]),
outputs=Schema([ColSpec(DataType.double)]),
)
assert signature3 != signature4
assert signature4 != signature3
def test_signature_inference_infers_input_and_output_as_expected():
sig0 = infer_signature(np.array([1]))
assert sig0.inputs is not None
assert sig0.outputs is None
sig1 = infer_signature(np.array([1]), np.array([1]))
assert sig1.inputs == sig0.inputs
assert sig1.outputs == sig0.inputs
def test_infer_signature_on_nested_array():
signature = infer_signature(
model_input=[{"queries": [["a", "b", "c"], ["d", "e"], []]}],
model_output=[{"answers": [["f", "g"], ["h"]]}],
)
assert signature.inputs == Schema([ColSpec(Array(Array(DataType.string)), name="queries")])
assert signature.outputs == Schema([ColSpec(Array(Array(DataType.string)), name="answers")])
signature = infer_signature(
model_input=[
{
"inputs": [
np.array([["a", "b"], ["c", "d"]]),
np.array([["e", "f"], ["g", "h"]]),
]
}
],
model_output=[{"outputs": [np.int32(5), np.int32(6)]}],
)
assert signature.inputs == Schema([
ColSpec(Array(Array(Array(DataType.string))), name="inputs")
])
assert signature.outputs == Schema([ColSpec(Array(DataType.integer), name="outputs")])
def test_infer_signature_on_list_of_dictionaries():
signature = infer_signature(
model_input=[{"query": "test query"}],
model_output=[
{
"output": "Output from the LLM",
"candidate_ids": ["412", "1233"],
"candidate_sources": ["file1.md", "file201.md"],
}
],
)
assert signature.inputs == Schema([ColSpec(DataType.string, name="query")])
assert signature.outputs == Schema([
ColSpec(DataType.string, name="output"),
ColSpec(Array(DataType.string), name="candidate_ids"),
ColSpec(Array(DataType.string), name="candidate_sources"),
])
def test_signature_inference_infers_datime_types_as_expected():
col_name = "datetime_col"
test_datetime = np.datetime64("2021-01-01")
test_series = pd.Series(pd.to_datetime([test_datetime]))
test_df = test_series.to_frame(col_name)
signature = infer_signature(test_series)
assert signature.inputs == Schema([ColSpec(DataType.datetime)])
signature = infer_signature(test_df)
assert signature.inputs == Schema([ColSpec(DataType.datetime, name=col_name)])
with pyspark.sql.SparkSession.builder.getOrCreate() as spark:
spark_df = spark.range(1).selectExpr(
"current_timestamp() as timestamp", "current_date() as date"
)
signature = infer_signature(spark_df)
assert signature.inputs == Schema([
ColSpec(DataType.datetime, name="timestamp"),
ColSpec(DataType.datetime, name="date"),
])
def test_set_signature_to_logged_model():
artifact_path = "regr-model"
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(RandomForestRegressor(), name=artifact_path)
signature = infer_signature(np.array([1]))
set_signature(model_info.model_uri, signature)
model_info = get_model_info(model_info.model_uri)
assert model_info.signature == signature
def test_set_signature_to_saved_model(tmp_path):
model_path = str(tmp_path)
mlflow.sklearn.save_model(
RandomForestRegressor(),
model_path,
serialization_format=mlflow.sklearn.SERIALIZATION_FORMAT_CLOUDPICKLE,
)
signature = infer_signature(np.array([1]))
set_signature(model_path, signature)
assert Model.load(model_path).signature == signature
def test_set_signature_overwrite():
artifact_path = "regr-model"
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(
RandomForestRegressor(),
name=artifact_path,
signature=infer_signature(np.array([1])),
)
new_signature = infer_signature(np.array([1]), np.array([1]))
set_signature(model_info.model_uri, new_signature)
model_info = get_model_info(model_info.model_uri)
assert model_info.signature == new_signature
def test_cannot_set_signature_on_models_scheme_uris():
signature = infer_signature(np.array([1]))
with pytest.raises(
MlflowException,
match="Model URIs with the `models:/<name>/<version>` scheme are not supported.",
):
set_signature("models:/dummy_model@champion", signature)
def test_signature_construction():
signature = ModelSignature(inputs=Schema([ColSpec(DataType.binary)]))
assert signature.to_dict() == {
"inputs": '[{"type": "binary", "required": true}]',
"outputs": None,
"params": None,
}
signature = ModelSignature(outputs=Schema([ColSpec(DataType.double)]))
assert signature.to_dict() == {
"inputs": None,
"outputs": '[{"type": "double", "required": true}]',
"params": None,
}
signature = ModelSignature(params=ParamSchema([ParamSpec("param1", DataType.string, "test")]))
assert signature.to_dict() == {
"inputs": None,
"outputs": None,
"params": '[{"name": "param1", "default": "test", "shape": null, "type": "string"}]',
}
def test_signature_with_errors():
with pytest.raises(
TypeError,
match=r"inputs must be either None, mlflow.models.signature.Schema, or a dataclass",
):
ModelSignature(inputs=1)
with pytest.raises(
ValueError, match=r"At least one of inputs, outputs or params must be provided"
):
ModelSignature()
def test_signature_for_rag():
signature = ModelSignature(
inputs=rag_signatures.ChatCompletionRequest(),
outputs=rag_signatures.ChatCompletionResponse(),
)
signature_dict = signature.to_dict()
assert signature_dict == {
"inputs": (
'[{"type": "array", "items": {"type": "object", "properties": '
'{"content": {"type": "string", "required": true}, '
'"role": {"type": "string", "required": true}}}, '
'"name": "messages", "required": true}]'
),
"outputs": (
'[{"type": "array", "items": {"type": "object", "properties": '
'{"finish_reason": {"type": "string", "required": true}, '
'"index": {"type": "long", "required": true}, '
'"message": {"type": "object", "properties": '
'{"content": {"type": "string", "required": true}, '
'"role": {"type": "string", "required": true}}, '
'"required": true}}}, "name": "choices", "required": true}, '
'{"type": "string", "name": "object", "required": true}]'
),
"params": None,
}
def test_infer_signature_and_convert_dataclass_to_schema_for_rag():
inferred_signature = infer_signature(
asdict(rag_signatures.ChatCompletionRequest()),
asdict(rag_signatures.ChatCompletionResponse()),
)
input_schema = convert_dataclass_to_schema(rag_signatures.ChatCompletionRequest())
output_schema = convert_dataclass_to_schema(rag_signatures.ChatCompletionResponse())
assert inferred_signature.inputs == input_schema
assert inferred_signature.outputs == output_schema
def test_infer_signature_with_dataclass():
inferred_signature = infer_signature(
rag_signatures.ChatCompletionRequest(),
rag_signatures.ChatCompletionResponse(),
)
input_schema = convert_dataclass_to_schema(rag_signatures.ChatCompletionRequest())
output_schema = convert_dataclass_to_schema(rag_signatures.ChatCompletionResponse())
assert inferred_signature.inputs == input_schema
assert inferred_signature.outputs == output_schema
@dataclass
class CustomInput:
id: int = 0
@dataclass
class CustomOutput:
id: int = 0
@dataclass
class FlexibleChatCompletionRequest(rag_signatures.ChatCompletionRequest):
custom_input: CustomInput | None = None
@dataclass
class FlexibleChatCompletionResponse(rag_signatures.ChatCompletionResponse):
custom_output: CustomOutput | None = None
def test_infer_signature_with_optional_and_child_dataclass():
inferred_signature = infer_signature(
FlexibleChatCompletionRequest(),
FlexibleChatCompletionResponse(),
)
custom_input_schema = next(
schema for schema in inferred_signature.inputs.to_dict() if schema["name"] == "custom_input"
)
assert custom_input_schema["required"] is False
assert "id" in custom_input_schema["properties"]
assert any(
schema for schema in inferred_signature.inputs.to_dict() if schema["name"] == "messages"
)
def test_infer_signature_for_pydantic_objects_error():
class Message(pydantic.BaseModel):
content: str
role: str
m = Message(content="test", role="user")
with pytest.raises(
InvalidDataForSignatureInferenceError,
match=r"MLflow does not support inferring model signature from "
r"input example with Pydantic objects",
):
infer_signature([m])