3817 lines
140 KiB
Python
3817 lines
140 KiB
Python
import base64
|
|
import gc
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shutil
|
|
import textwrap
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import huggingface_hub
|
|
import librosa
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
import torch
|
|
import transformers
|
|
import yaml
|
|
from datasets import load_dataset
|
|
from huggingface_hub import ModelCard
|
|
from packaging.version import Version
|
|
|
|
import mlflow
|
|
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
|
|
from mlflow import pyfunc
|
|
from mlflow.deployments import PredictionsResponse
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.models import Model, ModelSignature, infer_signature
|
|
from mlflow.models.model import METADATA_FILES
|
|
from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository
|
|
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
|
|
from mlflow.transformers import (
|
|
_CARD_DATA_FILE_NAME,
|
|
_CARD_TEXT_FILE_NAME,
|
|
_build_pipeline_from_model_input,
|
|
_fetch_model_card,
|
|
_get_task_for_model,
|
|
_is_model_distributed_in_memory,
|
|
_should_add_pyfunc_to_model,
|
|
_TransformersWrapper,
|
|
_try_import_conversational_pipeline,
|
|
_validate_llm_inference_task_type,
|
|
_write_card_data,
|
|
_write_license_information,
|
|
get_default_conda_env,
|
|
get_default_pip_requirements,
|
|
)
|
|
from mlflow.types.schema import Array, ColSpec, DataType, ParamSchema, ParamSpec, Schema
|
|
from mlflow.utils.environment import _mlflow_conda_env
|
|
|
|
from tests.helper_functions import (
|
|
_assert_pip_requirements,
|
|
_compare_conda_env_requirements,
|
|
_compare_logged_code_paths,
|
|
_get_deps_from_requirement_file,
|
|
_mlflow_major_version_string,
|
|
assert_register_model_called_with_local_model_path,
|
|
flaky,
|
|
pyfunc_scoring_endpoint,
|
|
pyfunc_serve_and_score_model,
|
|
)
|
|
from tests.transformers.helper import (
|
|
CHAT_TEMPLATE,
|
|
IS_NEW_FEATURE_EXTRACTION_API,
|
|
IS_TRANSFORMERS_V5_OR_LATER,
|
|
)
|
|
from tests.transformers.test_transformers_peft_model import SKIP_IF_PEFT_NOT_AVAILABLE
|
|
|
|
# NB: Some pipelines under test in this suite come very close or outright exceed the
|
|
# default runner containers specs of 7GB RAM. Due to this inability to run the suite without
|
|
# generating a SIGTERM Error (143), some tests are marked as local only.
|
|
# See: https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted- \
|
|
# runners#supported-runners-and-hardware-resources for instance specs.
|
|
RUNNING_IN_GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS") == "true"
|
|
GITHUB_ACTIONS_SKIP_REASON = "Test consumes too much memory"
|
|
|
|
skip_transformers_v5_or_later = pytest.mark.skipif(
|
|
IS_TRANSFORMERS_V5_OR_LATER,
|
|
reason="Incompatible API changes in transformers 5.x",
|
|
)
|
|
image_url = "https://raw.githubusercontent.com/mlflow/mlflow/master/tests/datasets/cat.png"
|
|
image_file_path = pathlib.Path(pathlib.Path(__file__).parent.parent, "datasets", "cat.png")
|
|
# Test that can only be run locally:
|
|
# - Summarization pipeline tests
|
|
# - TextClassifier pipeline tests
|
|
# - Text2TextGeneration pipeline tests
|
|
# - Conversational pipeline tests
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def force_gc():
|
|
# This reduces the memory pressure for the usage of the larger pipeline fixtures ~500MB - 1GB
|
|
gc.disable()
|
|
gc.collect()
|
|
gc.set_threshold(0)
|
|
gc.collect()
|
|
gc.enable()
|
|
|
|
|
|
@pytest.fixture
|
|
def model_path(tmp_path):
|
|
model_path = tmp_path.joinpath("model")
|
|
yield model_path
|
|
|
|
# Pytest keeps the temporary directory created by `tmp_path` fixture for 3 recent test sessions
|
|
# by default. This is useful for debugging during local testing, but in CI it just wastes the
|
|
# disk space.
|
|
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
shutil.rmtree(model_path, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def transformers_custom_env(tmp_path):
|
|
conda_env = tmp_path.joinpath("conda_env.yml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["transformers"])
|
|
return conda_env
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_pyfunc_wrapper():
|
|
return mlflow.transformers._TransformersWrapper("mock")
|
|
|
|
|
|
@pytest.fixture
|
|
@flaky()
|
|
def image_for_test():
|
|
dataset = load_dataset("hf-internal-testing/dummy_image_text_data")
|
|
return dataset["train"]["image"][3]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("pipeline", "expected_requirements"),
|
|
[
|
|
("small_qa_pipeline", {"transformers", "torch", "torchvision"}),
|
|
pytest.param(
|
|
"peft_pipeline",
|
|
{"peft", "transformers", "torch", "torchvision"},
|
|
marks=SKIP_IF_PEFT_NOT_AVAILABLE,
|
|
),
|
|
],
|
|
)
|
|
def test_default_requirements(pipeline, expected_requirements, request):
|
|
if "torch" in expected_requirements and importlib.util.find_spec("accelerate"):
|
|
expected_requirements.add("accelerate")
|
|
|
|
model = request.getfixturevalue(pipeline).model
|
|
pip_requirements = get_default_pip_requirements(model)
|
|
conda_requirements = get_default_conda_env(model)["dependencies"][2]["pip"]
|
|
|
|
def _strip_requirements(requirements):
|
|
return {req.split("==")[0] for req in requirements}
|
|
|
|
assert _strip_requirements(pip_requirements) == expected_requirements
|
|
assert _strip_requirements(conda_requirements) == (expected_requirements | {"mlflow"})
|
|
|
|
|
|
def test_inference_task_validation(small_qa_pipeline):
|
|
with pytest.raises(
|
|
MlflowException, match="The task provided is invalid. 'llm/v1/invalid' is not"
|
|
):
|
|
_validate_llm_inference_task_type("llm/v1/invalid", "text-generation")
|
|
with pytest.raises(
|
|
MlflowException, match="The task provided is invalid. 'llm/v1/completions' is not"
|
|
):
|
|
_validate_llm_inference_task_type("llm/v1/completions", small_qa_pipeline)
|
|
_validate_llm_inference_task_type("llm/v1/completions", "text-generation")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "result"),
|
|
[
|
|
("small_qa_pipeline", True),
|
|
("small_multi_modal_pipeline", False),
|
|
("small_vision_model", True),
|
|
],
|
|
)
|
|
def test_pipeline_eligibility_for_pyfunc_registration(model, result, request):
|
|
pipeline = request.getfixturevalue(model)
|
|
assert _should_add_pyfunc_to_model(pipeline) == result
|
|
|
|
|
|
def test_component_multi_modal_model_ineligible_for_pyfunc(component_multi_modal):
|
|
task = transformers.pipelines.get_task(component_multi_modal["model"].name_or_path)
|
|
pipeline = _build_pipeline_from_model_input(component_multi_modal, task)
|
|
assert not _should_add_pyfunc_to_model(pipeline)
|
|
|
|
|
|
def test_pipeline_construction_from_base_nlp_model(small_qa_pipeline):
|
|
generated = _build_pipeline_from_model_input(
|
|
{"model": small_qa_pipeline.model, "tokenizer": small_qa_pipeline.tokenizer},
|
|
"question-answering",
|
|
)
|
|
assert isinstance(generated, type(small_qa_pipeline))
|
|
assert isinstance(generated.tokenizer, type(small_qa_pipeline.tokenizer))
|
|
|
|
|
|
def test_pipeline_construction_from_base_vision_model(small_vision_model):
|
|
model = {"model": small_vision_model.model, "tokenizer": small_vision_model.tokenizer}
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
model.update({"image_processor": small_vision_model.image_processor})
|
|
else:
|
|
model.update({"feature_extractor": small_vision_model.feature_extractor})
|
|
generated = _build_pipeline_from_model_input(model, task="image-classification")
|
|
assert isinstance(generated, type(small_vision_model))
|
|
assert isinstance(generated.tokenizer, type(small_vision_model.tokenizer))
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
assert isinstance(generated.image_processor, type(small_vision_model.image_processor))
|
|
else:
|
|
assert isinstance(generated.feature_extractor, transformers.MobileNetV2ImageProcessor)
|
|
|
|
|
|
def test_saving_with_invalid_dict_as_model(model_path):
|
|
with pytest.raises(
|
|
MlflowException, match="Invalid dictionary submitted for 'transformers_model'. The "
|
|
):
|
|
mlflow.transformers.save_model(transformers_model={"invalid": "key"}, path=model_path)
|
|
|
|
with pytest.raises(
|
|
MlflowException, match="The 'transformers_model' dictionary must have an entry"
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model={"tokenizer": "some_tokenizer"}, path=model_path
|
|
)
|
|
|
|
|
|
def test_model_card_acquisition_vision_model(small_vision_model):
|
|
model_provided_card = _fetch_model_card(small_vision_model.model.name_or_path)
|
|
assert model_provided_card.data.to_dict()["tags"] == ["vision", "image-classification"]
|
|
assert len(model_provided_card.text) > 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("repo_id", "license_file"),
|
|
[
|
|
("google/mobilenet_v2_1.0_224", "LICENSE.txt"), # no license declared
|
|
("csarron/mobilebert-uncased-squad-v2", "LICENSE.txt"), # mit license
|
|
("codellama/CodeLlama-34b-hf", "LICENSE"), # custom license
|
|
("openai/whisper-tiny", "LICENSE.txt"), # apache license
|
|
("stabilityai/stable-code-3b", "LICENSE"), # custom
|
|
("mistralai/Mixtral-8x7B-Instruct-v0.1", "LICENSE.txt"), # apache
|
|
],
|
|
)
|
|
def test_license_acquisition(repo_id, license_file, tmp_path):
|
|
card_data = _fetch_model_card(repo_id)
|
|
_write_license_information(repo_id, card_data, tmp_path)
|
|
license_file = list(tmp_path.glob("*LICENSE*"))
|
|
assert len(license_file) == 1
|
|
assert tmp_path.joinpath(license_file[0]).stat().st_size > 0
|
|
|
|
|
|
def test_license_fallback(tmp_path):
|
|
_write_license_information("not a real repo", None, tmp_path)
|
|
assert tmp_path.joinpath("LICENSE.txt").stat().st_size > 0
|
|
|
|
|
|
def test_vision_model_save_pipeline_with_defaults(small_vision_model, model_path):
|
|
mlflow.transformers.save_model(transformers_model=small_vision_model, path=model_path)
|
|
# validate inferred pip requirements
|
|
requirements = model_path.joinpath("requirements.txt").read_text()
|
|
reqs = {req.split("==")[0] for req in requirements.split("\n")}
|
|
expected_requirements = {"torch", "torchvision", "transformers"}
|
|
assert reqs.intersection(expected_requirements) == expected_requirements
|
|
# validate inferred model card data
|
|
card_data = yaml.safe_load(model_path.joinpath("model_card_data.yaml").read_bytes())
|
|
assert card_data["tags"] == ["vision", "image-classification"]
|
|
# verify the license file has been written
|
|
license_file = model_path.joinpath("LICENSE.txt").read_text()
|
|
assert len(license_file) > 0
|
|
# Validate inferred model card text
|
|
with model_path.joinpath("model_card.md").open() as file:
|
|
card_text = file.read()
|
|
assert len(card_text) > 0
|
|
# Validate conda.yaml
|
|
conda_env = yaml.safe_load(model_path.joinpath("conda.yaml").read_bytes())
|
|
assert {req.split("==")[0] for req in conda_env["dependencies"][2]["pip"]}.intersection(
|
|
expected_requirements
|
|
) == expected_requirements
|
|
# Validate the MLModel file
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["instance_type"] == "ImageClassificationPipeline"
|
|
assert flavor_config["pipeline_model_type"] == "MobileNetV2ForImageClassification"
|
|
assert flavor_config["task"] == "image-classification"
|
|
assert flavor_config["source_model_name"] == "google/mobilenet_v2_1.0_224"
|
|
|
|
|
|
def test_vision_model_save_model_for_task_and_card_inference(small_vision_model, model_path):
|
|
mlflow.transformers.save_model(transformers_model=small_vision_model, path=model_path)
|
|
# validate inferred pip requirements
|
|
requirements = model_path.joinpath("requirements.txt").read_text()
|
|
reqs = {req.split("==")[0] for req in requirements.split("\n")}
|
|
expected_requirements = {"torch", "torchvision", "transformers"}
|
|
assert reqs.intersection(expected_requirements) == expected_requirements
|
|
# validate inferred model card data
|
|
card_data = yaml.safe_load(model_path.joinpath("model_card_data.yaml").read_bytes())
|
|
assert card_data["tags"] == ["vision", "image-classification"]
|
|
# Validate inferred model card text
|
|
card_text = model_path.joinpath("model_card.md").read_text(encoding="utf-8")
|
|
assert len(card_text) > 0
|
|
# verify the license file has been written
|
|
license_file = model_path.joinpath("LICENSE.txt").read_text()
|
|
assert len(license_file) > 0
|
|
# Validate the MLModel file
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["instance_type"] == "ImageClassificationPipeline"
|
|
assert flavor_config["pipeline_model_type"] == "MobileNetV2ForImageClassification"
|
|
assert flavor_config["task"] == "image-classification"
|
|
assert flavor_config["source_model_name"] == "google/mobilenet_v2_1.0_224"
|
|
|
|
|
|
def test_qa_model_save_model_for_task_and_card_inference(small_qa_pipeline, model_path):
|
|
mlflow.transformers.save_model(
|
|
transformers_model={
|
|
"model": small_qa_pipeline.model,
|
|
"tokenizer": small_qa_pipeline.tokenizer,
|
|
},
|
|
path=model_path,
|
|
)
|
|
# validate inferred pip requirements
|
|
with model_path.joinpath("requirements.txt").open() as file:
|
|
requirements = file.read()
|
|
reqs = {req.split("==")[0] for req in requirements.split("\n")}
|
|
expected_requirements = {"torch", "transformers"}
|
|
assert reqs.intersection(expected_requirements) == expected_requirements
|
|
# validate that the card was acquired by model reference
|
|
card_data = yaml.safe_load(model_path.joinpath("model_card_data.yaml").read_bytes())
|
|
assert card_data["datasets"] == ["squad_v2"]
|
|
assert "tags" in card_data
|
|
# verify the license file has been written
|
|
license_file = model_path.joinpath("LICENSE.txt").read_text()
|
|
assert len(license_file) > 0
|
|
# Validate inferred model card text
|
|
with model_path.joinpath("model_card.md").open() as file:
|
|
card_text = file.read()
|
|
assert len(card_text) > 0
|
|
# validate MLmodel files
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["instance_type"] == "QuestionAnsweringPipeline"
|
|
assert flavor_config["pipeline_model_type"] == "MobileBertForQuestionAnswering"
|
|
assert flavor_config["task"] == "question-answering"
|
|
assert flavor_config["source_model_name"] == "csarron/mobilebert-uncased-squad-v2"
|
|
|
|
|
|
def test_qa_model_save_and_override_card(small_qa_pipeline, model_path):
|
|
supplied_card = """
|
|
---
|
|
language: en
|
|
license: bsd
|
|
---
|
|
|
|
# I made a new model!
|
|
"""
|
|
card_info = textwrap.dedent(supplied_card)
|
|
card = ModelCard(card_info)
|
|
# save the model instance
|
|
mlflow.transformers.save_model(
|
|
transformers_model=small_qa_pipeline,
|
|
path=model_path,
|
|
model_card=card,
|
|
)
|
|
# validate that the card was acquired by model reference
|
|
card_data = yaml.safe_load(model_path.joinpath("model_card_data.yaml").read_bytes())
|
|
assert card_data["language"] == "en"
|
|
assert card_data["license"] == "bsd"
|
|
# Validate inferred model card text
|
|
with model_path.joinpath("model_card.md").open() as file:
|
|
card_text = file.read()
|
|
# verify the license file has been written
|
|
license_file = model_path.joinpath("LICENSE.txt").read_text()
|
|
assert len(license_file) > 0
|
|
assert card_text.startswith("\n# I made a new model!")
|
|
# validate MLmodel files
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["instance_type"] == "QuestionAnsweringPipeline"
|
|
assert flavor_config["pipeline_model_type"] == "MobileBertForQuestionAnswering"
|
|
assert flavor_config["task"] == "question-answering"
|
|
assert flavor_config["source_model_name"] == "csarron/mobilebert-uncased-squad-v2"
|
|
|
|
|
|
def test_basic_save_model_and_load_text_pipeline(text_classification_pipeline, model_path):
|
|
mlflow.transformers.save_model(
|
|
transformers_model={
|
|
"model": text_classification_pipeline.model,
|
|
"tokenizer": text_classification_pipeline.tokenizer,
|
|
},
|
|
path=model_path,
|
|
)
|
|
loaded = mlflow.transformers.load_model(model_path)
|
|
result = loaded("MLflow is a really neat tool!")
|
|
assert result[0]["label"] == "POSITIVE"
|
|
assert result[0]["score"] > 0.5
|
|
|
|
|
|
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float64])
|
|
def test_basic_save_model_with_torch_dtype(text2text_generation_pipeline, model_path, dtype):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text2text_generation_pipeline,
|
|
path=model_path,
|
|
torch_dtype=dtype,
|
|
)
|
|
|
|
loaded = mlflow.transformers.load_model(model_path)
|
|
assert loaded.model.dtype == dtype
|
|
|
|
loaded = mlflow.transformers.load_model(model_path, torch_dtype=torch.float32)
|
|
assert loaded.model.dtype == torch.float32
|
|
|
|
|
|
def test_basic_save_model_and_load_vision_pipeline(small_vision_model, model_path, image_for_test):
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
model = {
|
|
"model": small_vision_model.model,
|
|
"image_processor": small_vision_model.image_processor,
|
|
"tokenizer": small_vision_model.tokenizer,
|
|
}
|
|
else:
|
|
model = {
|
|
"model": small_vision_model.model,
|
|
"feature_extractor": small_vision_model.feature_extractor,
|
|
"tokenizer": small_vision_model.tokenizer,
|
|
}
|
|
mlflow.transformers.save_model(
|
|
transformers_model=model,
|
|
path=model_path,
|
|
)
|
|
loaded = mlflow.transformers.load_model(model_path)
|
|
prediction = loaded(image_for_test)
|
|
assert prediction[0]["label"] == "wall clock"
|
|
assert prediction[0]["score"] > 0.5
|
|
|
|
|
|
@flaky()
|
|
def test_multi_modal_pipeline_save_and_load(small_multi_modal_pipeline, model_path, image_for_test):
|
|
mlflow.transformers.save_model(transformers_model=small_multi_modal_pipeline, path=model_path)
|
|
question = "How many wall clocks are in the picture?"
|
|
# Load components
|
|
components = mlflow.transformers.load_model(model_path, return_type="components")
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
expected_components = {"model", "task", "tokenizer", "image_processor"}
|
|
else:
|
|
expected_components = {"model", "task", "tokenizer", "feature_extractor"}
|
|
assert set(components.keys()).intersection(expected_components) == expected_components
|
|
constructed_pipeline = transformers.pipeline(**components)
|
|
answer = constructed_pipeline(image=image_for_test, question=question)
|
|
assert answer[0]["answer"] == "1"
|
|
# Load pipeline
|
|
pipeline = mlflow.transformers.load_model(model_path)
|
|
pipeline_answer = pipeline(image=image_for_test, question=question)
|
|
assert pipeline_answer[0]["answer"] == "1"
|
|
# Test invalid loading mode
|
|
with pytest.raises(MlflowException, match="The specified return_type mode 'magic' is"):
|
|
mlflow.transformers.load_model(model_path, return_type="magic")
|
|
|
|
|
|
def test_multi_modal_component_save_and_load(component_multi_modal, model_path, image_for_test):
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
processor = component_multi_modal["image_processor"]
|
|
else:
|
|
processor = component_multi_modal["feature_extractor"]
|
|
mlflow.transformers.save_model(
|
|
transformers_model=component_multi_modal,
|
|
path=model_path,
|
|
processor=processor,
|
|
)
|
|
# Ensure that the appropriate Processor object was detected and loaded with the pipeline.
|
|
loaded_components = mlflow.transformers.load_model(
|
|
model_uri=model_path, return_type="components"
|
|
)
|
|
assert isinstance(loaded_components["model"], transformers.ViltForQuestionAnswering)
|
|
assert isinstance(loaded_components["tokenizer"], transformers.BertTokenizerFast)
|
|
# This is to simulate a post-processing processor that would be used externally to a Pipeline
|
|
# This isn't being tested on an actual use case of such a model type due to the size of
|
|
# these types of models that have this interface being ill-suited for CI testing.
|
|
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
processor_key = "image_processor"
|
|
assert isinstance(loaded_components[processor_key], transformers.ViltImageProcessor)
|
|
else:
|
|
processor_key = "feature_extractor"
|
|
assert isinstance(loaded_components[processor_key], transformers.ViltProcessor)
|
|
assert isinstance(loaded_components["processor"], transformers.ViltProcessor)
|
|
if not IS_NEW_FEATURE_EXTRACTION_API:
|
|
# NB: This simulated behavior is no longer valid in versions 4.27.4 and above.
|
|
# With the port of functionality away from feature extractor types, the new architecture
|
|
# for multi-modal models is entirely pipeline based.
|
|
# Make sure that the component usage works correctly when extracted from inference loading
|
|
model = loaded_components["model"]
|
|
processor = loaded_components["processor"]
|
|
question = "What are the cats doing?"
|
|
inputs = processor(image_for_test, question, return_tensors="pt")
|
|
outputs = model(**inputs)
|
|
logits = outputs.logits
|
|
idx = logits.argmax(-1).item()
|
|
answer = model.config.id2label[idx]
|
|
assert answer == "sleeping"
|
|
|
|
|
|
@flaky()
|
|
def test_pipeline_saved_model_with_processor_cannot_be_loaded_as_pipeline(
|
|
component_multi_modal, model_path
|
|
):
|
|
invalid_pipeline = transformers.pipeline(
|
|
task="visual-question-answering", **component_multi_modal
|
|
)
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
processor = component_multi_modal["image_processor"]
|
|
else:
|
|
processor = component_multi_modal["feature_extractor"]
|
|
mlflow.transformers.save_model(
|
|
transformers_model=invalid_pipeline,
|
|
path=model_path,
|
|
processor=processor, # If this is specified, we cannot guarantee correct inference
|
|
)
|
|
with pytest.raises(
|
|
MlflowException, match="This model has been saved with a processor. Processor objects"
|
|
):
|
|
mlflow.transformers.load_model(model_uri=model_path, return_type="pipeline")
|
|
|
|
|
|
def test_component_saved_model_with_processor_cannot_be_loaded_as_pipeline(
|
|
component_multi_modal, model_path
|
|
):
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
processor = component_multi_modal["image_processor"]
|
|
else:
|
|
processor = component_multi_modal["feature_extractor"]
|
|
mlflow.transformers.save_model(
|
|
transformers_model=component_multi_modal,
|
|
path=model_path,
|
|
processor=processor,
|
|
)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="This model has been saved with a processor. Processor objects are not compatible "
|
|
"with Pipelines. Please load",
|
|
):
|
|
mlflow.transformers.load_model(model_uri=model_path, return_type="pipeline")
|
|
|
|
|
|
@pytest.mark.parametrize("should_start_run", [True, False])
|
|
def test_log_and_load_transformers_pipeline(small_qa_pipeline, tmp_path, should_start_run):
|
|
try:
|
|
if should_start_run:
|
|
mlflow.start_run()
|
|
artifact_path = "transformers"
|
|
conda_env = tmp_path.joinpath("conda_env.yaml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["transformers"])
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline,
|
|
name=artifact_path,
|
|
conda_env=str(conda_env),
|
|
)
|
|
reloaded_model = mlflow.transformers.load_model(
|
|
model_uri=model_info.model_uri, return_type="pipeline"
|
|
)
|
|
assert (
|
|
reloaded_model(
|
|
question="Who's house?", context="The house is owned by a man named Run."
|
|
)["answer"]
|
|
== "Run"
|
|
)
|
|
model_path = pathlib.Path(_download_artifact_from_uri(artifact_uri=model_info.model_uri))
|
|
model_config = Model.load(str(model_path.joinpath("MLmodel")))
|
|
assert pyfunc.FLAVOR_NAME in model_config.flavors
|
|
assert pyfunc.ENV in model_config.flavors[pyfunc.FLAVOR_NAME]
|
|
env_path = model_config.flavors[pyfunc.FLAVOR_NAME][pyfunc.ENV]["conda"]
|
|
assert model_path.joinpath(env_path).exists()
|
|
finally:
|
|
mlflow.end_run()
|
|
|
|
|
|
def test_load_pipeline_from_remote_uri_succeeds(
|
|
text_classification_pipeline, model_path, mock_s3_bucket
|
|
):
|
|
mlflow.transformers.save_model(transformers_model=text_classification_pipeline, path=model_path)
|
|
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 = os.path.join(artifact_root, artifact_path)
|
|
loaded = mlflow.transformers.load_model(model_uri=str(model_uri), return_type="pipeline")
|
|
assert loaded("I like it when CI checks pass and are never flaky!")[0]["label"] == "POSITIVE"
|
|
|
|
|
|
def test_transformers_log_model_calls_register_model(small_qa_pipeline, tmp_path):
|
|
artifact_path = "transformers"
|
|
register_model_patch = mock.patch("mlflow.tracking._model_registry.fluent._register_model")
|
|
with mlflow.start_run(), register_model_patch:
|
|
conda_env = tmp_path.joinpath("conda_env.yaml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["transformers", "torch", "torchvision"])
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline,
|
|
name=artifact_path,
|
|
conda_env=str(conda_env),
|
|
registered_model_name="Question-Answering Model 1",
|
|
)
|
|
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="Question-Answering Model 1",
|
|
)
|
|
|
|
|
|
def test_transformers_log_model_with_no_registered_model_name(small_vision_model, tmp_path):
|
|
if IS_NEW_FEATURE_EXTRACTION_API:
|
|
model = {
|
|
"model": small_vision_model.model,
|
|
"image_processor": small_vision_model.image_processor,
|
|
"tokenizer": small_vision_model.tokenizer,
|
|
}
|
|
else:
|
|
model = {
|
|
"model": small_vision_model.model,
|
|
"feature_extractor": small_vision_model.feature_extractor,
|
|
"tokenizer": small_vision_model.tokenizer,
|
|
}
|
|
|
|
artifact_path = "transformers"
|
|
registered_model_patch = mock.patch("mlflow.tracking._model_registry.fluent._register_model")
|
|
with mlflow.start_run(), registered_model_patch:
|
|
conda_env = tmp_path.joinpath("conda_env.yaml")
|
|
_mlflow_conda_env(conda_env, additional_pip_deps=["tensorflow", "transformers"])
|
|
mlflow.transformers.log_model(
|
|
model,
|
|
name=artifact_path,
|
|
conda_env=str(conda_env),
|
|
)
|
|
mlflow.tracking._model_registry.fluent._register_model.assert_not_called()
|
|
|
|
|
|
def test_transformers_log_model_with_prompt_template_sets_return_full_text_false(
|
|
text_generation_pipeline,
|
|
):
|
|
artifact_path = "text_generation_with_prompt_template"
|
|
prompt_template = "User: {prompt}"
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
text_generation_pipeline,
|
|
name=artifact_path,
|
|
prompt_template=prompt_template,
|
|
)
|
|
|
|
model_path = pathlib.Path(_download_artifact_from_uri(model_info.model_uri))
|
|
mlmodel = Model.load(str(model_path.joinpath("MLmodel")))
|
|
|
|
pyfunc_flavor = mlmodel.flavors["python_function"]
|
|
config = pyfunc_flavor.get("config")
|
|
|
|
assert config.get("return_full_text") is False
|
|
|
|
|
|
def test_transformers_save_persists_requirements_in_mlflow_directory(
|
|
small_qa_pipeline, model_path, transformers_custom_env
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=small_qa_pipeline,
|
|
path=model_path,
|
|
conda_env=str(transformers_custom_env),
|
|
)
|
|
saved_pip_req_path = model_path.joinpath("requirements.txt")
|
|
_compare_conda_env_requirements(transformers_custom_env, saved_pip_req_path)
|
|
|
|
|
|
def test_transformers_log_with_pip_requirements(small_multi_modal_pipeline, tmp_path):
|
|
expected_mlflow_version = _mlflow_major_version_string()
|
|
|
|
requirements_file = tmp_path.joinpath("requirements.txt")
|
|
requirements_file.write_text("coolpackage")
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline, name="model", pip_requirements=str(requirements_file)
|
|
)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri, [expected_mlflow_version, "coolpackage"], strict=True
|
|
)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline,
|
|
name="model",
|
|
pip_requirements=[f"-r {requirements_file}", "alsocool"],
|
|
)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri,
|
|
[expected_mlflow_version, "coolpackage", "alsocool"],
|
|
strict=True,
|
|
)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline,
|
|
name="model",
|
|
pip_requirements=[f"-c {requirements_file}", "constrainedcool"],
|
|
)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri,
|
|
[expected_mlflow_version, "constrainedcool", "-c constraints.txt"],
|
|
["coolpackage"],
|
|
strict=True,
|
|
)
|
|
|
|
|
|
def test_transformers_log_with_extra_pip_requirements(small_multi_modal_pipeline, tmp_path):
|
|
expected_mlflow_version = _mlflow_major_version_string()
|
|
default_requirements = mlflow.transformers.get_default_pip_requirements(
|
|
small_multi_modal_pipeline.model
|
|
)
|
|
requirements_file = tmp_path.joinpath("requirements.txt")
|
|
requirements_file.write_text("coolpackage")
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline, name="model", extra_pip_requirements=str(requirements_file)
|
|
)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri,
|
|
[expected_mlflow_version, *default_requirements, "coolpackage"],
|
|
strict=True,
|
|
)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline,
|
|
name="model",
|
|
extra_pip_requirements=[f"-r {requirements_file}", "alsocool"],
|
|
)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri,
|
|
[expected_mlflow_version, *default_requirements, "coolpackage", "alsocool"],
|
|
strict=True,
|
|
)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline,
|
|
name="model",
|
|
extra_pip_requirements=[f"-c {requirements_file}", "constrainedcool"],
|
|
)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri,
|
|
[
|
|
expected_mlflow_version,
|
|
*default_requirements,
|
|
"constrainedcool",
|
|
"-c constraints.txt",
|
|
],
|
|
["coolpackage"],
|
|
strict=True,
|
|
)
|
|
|
|
|
|
def test_transformers_log_with_duplicate_extra_pip_requirements(small_multi_modal_pipeline):
|
|
with pytest.raises(
|
|
MlflowException, match="The specified requirements versions are incompatible"
|
|
):
|
|
with mlflow.start_run():
|
|
mlflow.transformers.log_model(
|
|
small_multi_modal_pipeline,
|
|
name="model",
|
|
extra_pip_requirements=["transformers==1.1.0"],
|
|
)
|
|
|
|
|
|
def test_transformers_pt_model_save_without_conda_env_uses_default_env_with_expected_dependencies(
|
|
small_qa_pipeline, model_path
|
|
):
|
|
mlflow.transformers.save_model(small_qa_pipeline, model_path)
|
|
_assert_pip_requirements(
|
|
model_path, mlflow.transformers.get_default_pip_requirements(small_qa_pipeline.model)
|
|
)
|
|
pip_requirements = _get_deps_from_requirement_file(model_path)
|
|
assert "tensorflow" not in pip_requirements
|
|
assert "accelerate" in pip_requirements
|
|
assert "torch" in pip_requirements
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
importlib.util.find_spec("accelerate") is not None, reason="fails when accelerate is installed"
|
|
)
|
|
def test_transformers_pt_model_save_dependencies_without_accelerate(
|
|
text_generation_pipeline, model_path
|
|
):
|
|
mlflow.transformers.save_model(text_generation_pipeline, model_path)
|
|
_assert_pip_requirements(
|
|
model_path, mlflow.transformers.get_default_pip_requirements(text_generation_pipeline.model)
|
|
)
|
|
pip_requirements = _get_deps_from_requirement_file(model_path)
|
|
assert "tensorflow" not in pip_requirements
|
|
assert "accelerate" not in pip_requirements
|
|
assert "torch" in pip_requirements
|
|
|
|
|
|
def test_transformers_pt_model_log_without_conda_env_uses_default_env_with_expected_dependencies(
|
|
small_qa_pipeline,
|
|
):
|
|
artifact_path = "model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(small_qa_pipeline, name=artifact_path)
|
|
_assert_pip_requirements(
|
|
model_info.model_uri,
|
|
mlflow.transformers.get_default_pip_requirements(small_qa_pipeline.model),
|
|
)
|
|
pip_requirements = _get_deps_from_requirement_file(model_info.model_uri)
|
|
assert "tensorflow" not in pip_requirements
|
|
assert "torch" in pip_requirements
|
|
|
|
|
|
def test_log_model_with_code_paths(small_qa_pipeline):
|
|
artifact_path = "model"
|
|
with (
|
|
mlflow.start_run(),
|
|
mock.patch("mlflow.transformers._add_code_from_conf_to_system_path") as add_mock,
|
|
):
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline, name=artifact_path, code_paths=[__file__]
|
|
)
|
|
model_uri = model_info.model_uri
|
|
_compare_logged_code_paths(__file__, model_uri, mlflow.transformers.FLAVOR_NAME)
|
|
mlflow.transformers.load_model(model_uri)
|
|
add_mock.assert_called()
|
|
|
|
|
|
def test_non_existent_model_card_entry(small_qa_pipeline, model_path):
|
|
with mock.patch("mlflow.transformers._fetch_model_card", return_value=None):
|
|
mlflow.transformers.save_model(transformers_model=small_qa_pipeline, path=model_path)
|
|
|
|
contents = {item.name for item in model_path.iterdir()}
|
|
assert not contents.intersection({"model_card.txt", "model_card_data.yaml"})
|
|
|
|
|
|
def test_huggingface_hub_not_installed(small_qa_pipeline, model_path):
|
|
with mock.patch.dict("sys.modules", {"huggingface_hub": None}):
|
|
result = mlflow.transformers._fetch_model_card(small_qa_pipeline.model.name_or_path)
|
|
|
|
assert result is None
|
|
|
|
mlflow.transformers.save_model(transformers_model=small_qa_pipeline, path=model_path)
|
|
|
|
contents = {item.name for item in model_path.iterdir()}
|
|
assert not contents.intersection({"model_card.txt", "model_card_data.yaml"})
|
|
|
|
license_data = model_path.joinpath("LICENSE.txt").read_text()
|
|
assert license_data.rstrip().endswith("mobilebert-uncased-squad-v2")
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
_try_import_conversational_pipeline() is None,
|
|
reason="Conversation model is deprecated and removed.",
|
|
)
|
|
def test_save_pipeline_without_defined_components(small_conversational_model, model_path):
|
|
# This pipeline type explicitly does not have a configuration for an image_processor
|
|
with mlflow.start_run():
|
|
mlflow.transformers.save_model(
|
|
transformers_model=small_conversational_model, path=model_path
|
|
)
|
|
pipe = mlflow.transformers.load_model(model_path)
|
|
convo = transformers.Conversation("How are you today?")
|
|
convo = pipe(convo)
|
|
assert convo.generated_responses[-1] == "good"
|
|
|
|
|
|
@flaky()
|
|
def test_invalid_model_type_without_registered_name_does_not_save(model_path):
|
|
invalid_pipeline = transformers.pipeline(task="text-generation", model="gpt2")
|
|
del invalid_pipeline.model.name_or_path
|
|
|
|
with pytest.raises(MlflowException, match="The submitted model type"):
|
|
mlflow.transformers.save_model(transformers_model=invalid_pipeline, path=model_path)
|
|
|
|
|
|
def test_invalid_input_to_pyfunc_signature_output_wrapper_raises(component_multi_modal):
|
|
with pytest.raises(MlflowException, match="The pipeline type submitted is not a valid"):
|
|
mlflow.transformers.generate_signature_output(component_multi_modal["model"], "bogus")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"inference_payload",
|
|
[
|
|
({"question": "Who's house?", "context": "The house is owned by a man named Run."}),
|
|
([
|
|
{
|
|
"question": "What color is it?",
|
|
"context": "Some people said it was green but I know that it's definitely blue",
|
|
},
|
|
{
|
|
"question": "How do the wheels go?",
|
|
"context": "The wheels on the bus go round and round. Round and round.",
|
|
},
|
|
]),
|
|
([
|
|
{
|
|
"question": "What color is it?",
|
|
"context": "Some people said it was green but I know that it's pink.",
|
|
},
|
|
{
|
|
"context": "The people on the bus go up and down. Up and down.",
|
|
"question": "How do the people go?",
|
|
},
|
|
]),
|
|
],
|
|
)
|
|
def test_qa_pipeline_pyfunc_load_and_infer(small_qa_pipeline, model_path, inference_payload):
|
|
signature = infer_signature(
|
|
inference_payload,
|
|
mlflow.transformers.generate_signature_output(small_qa_pipeline, inference_payload),
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=small_qa_pipeline,
|
|
path=model_path,
|
|
signature=signature,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict(inference_payload)
|
|
|
|
assert isinstance(inference, list)
|
|
assert all(isinstance(element, str) for element in inference)
|
|
|
|
pd_input = (
|
|
pd.DataFrame([inference_payload])
|
|
if isinstance(inference_payload, dict)
|
|
else pd.DataFrame(inference_payload)
|
|
)
|
|
pd_inference = pyfunc_loaded.predict(pd_input)
|
|
|
|
assert isinstance(pd_inference, list)
|
|
assert all(isinstance(element, str) for element in inference)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"inference_payload",
|
|
[
|
|
image_url,
|
|
str(image_file_path),
|
|
"base64",
|
|
pytest.param(
|
|
"base64_encodebytes",
|
|
marks=pytest.mark.skipif(
|
|
Version(transformers.__version__) < Version("4.41"),
|
|
reason="base64 encodebytes feature not present",
|
|
),
|
|
),
|
|
],
|
|
)
|
|
def test_vision_pipeline_pyfunc_load_and_infer(small_vision_model, model_path, inference_payload):
|
|
if inference_payload == "base64":
|
|
inference_payload = base64.b64encode(image_file_path.read_bytes()).decode("utf-8")
|
|
elif inference_payload == "base64_encodebytes":
|
|
inference_payload = base64.encodebytes(image_file_path.read_bytes()).decode("utf-8")
|
|
signature = infer_signature(
|
|
inference_payload,
|
|
mlflow.transformers.generate_signature_output(small_vision_model, inference_payload),
|
|
)
|
|
mlflow.transformers.save_model(
|
|
transformers_model=small_vision_model,
|
|
path=model_path,
|
|
signature=signature,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
predictions = pyfunc_loaded.predict(inference_payload)
|
|
|
|
transformers_loaded_model = mlflow.transformers.load_model(model_path)
|
|
expected_predictions = transformers_loaded_model.predict(inference_payload)
|
|
assert list(predictions.to_dict("records")[0].values()) == expected_predictions
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "result"),
|
|
[
|
|
("muppet keyboard type", ["A man is typing a muppet on a keyboard."]),
|
|
(
|
|
["pencil draw paper", "pie apple eat"],
|
|
# NB: The result of this test case, without inference config overrides is:
|
|
# ["A man drawing on paper with pencil", "A man eating a pie with applies"]
|
|
# The inference config override forces additional insertion of more grammatically
|
|
# correct responses to validate that the inference config is being applied.
|
|
["A man draws a pencil on a paper.", "A man eats a pie of apples."],
|
|
),
|
|
],
|
|
)
|
|
def test_text2text_generation_pipeline_with_model_configs(
|
|
text2text_generation_pipeline, tmp_path, data, result
|
|
):
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(text2text_generation_pipeline, data)
|
|
)
|
|
|
|
model_config = {
|
|
"top_k": 2,
|
|
"num_beams": 5,
|
|
"max_length": 30,
|
|
"temperature": 0.62,
|
|
"top_p": 0.85,
|
|
"repetition_penalty": 1.15,
|
|
}
|
|
model_path1 = tmp_path.joinpath("model1")
|
|
mlflow.transformers.save_model(
|
|
text2text_generation_pipeline,
|
|
path=model_path1,
|
|
model_config=model_config,
|
|
signature=signature,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path1)
|
|
|
|
inference = pyfunc_loaded.predict(data)
|
|
|
|
assert inference == result
|
|
|
|
pd_input = pd.DataFrame([data]) if isinstance(data, str) else pd.DataFrame(data)
|
|
pd_inference = pyfunc_loaded.predict(pd_input)
|
|
assert pd_inference == result
|
|
|
|
model_path2 = tmp_path.joinpath("model2")
|
|
signature_with_params = infer_signature(
|
|
data,
|
|
mlflow.transformers.generate_signature_output(text2text_generation_pipeline, data),
|
|
model_config,
|
|
)
|
|
mlflow.transformers.save_model(
|
|
text2text_generation_pipeline,
|
|
path=model_path2,
|
|
signature=signature_with_params,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path2)
|
|
|
|
dict_inference = pyfunc_loaded.predict(
|
|
data,
|
|
params=model_config,
|
|
)
|
|
|
|
assert dict_inference == inference
|
|
|
|
|
|
def test_text2text_generation_pipeline_with_model_config_and_params(
|
|
text2text_generation_pipeline, model_path
|
|
):
|
|
data = "muppet keyboard type"
|
|
model_config = {
|
|
"top_k": 2,
|
|
"num_beams": 5,
|
|
"top_p": 0.85,
|
|
"repetition_penalty": 1.15,
|
|
"do_sample": True,
|
|
}
|
|
parameters = {"top_k": 3, "max_new_tokens": 30}
|
|
generated_output = mlflow.transformers.generate_signature_output(
|
|
text2text_generation_pipeline, data
|
|
)
|
|
signature = infer_signature(
|
|
data,
|
|
generated_output,
|
|
parameters,
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
text2text_generation_pipeline,
|
|
path=model_path,
|
|
model_config=model_config,
|
|
signature=signature,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
# model_config and default params are all applied
|
|
res = pyfunc_loaded.predict(data)
|
|
applied_params = model_config.copy()
|
|
applied_params.update(parameters)
|
|
res2 = pyfunc_loaded.predict(data, applied_params)
|
|
assert res == res2
|
|
|
|
assert res != pyfunc_loaded.predict(data, {"max_new_tokens": 3})
|
|
|
|
# Extra params are ignored
|
|
assert res == pyfunc_loaded.predict(data, {"extra_param": "extra_value"})
|
|
|
|
|
|
def test_text2text_generation_pipeline_with_params_success(
|
|
text2text_generation_pipeline, model_path
|
|
):
|
|
data = "muppet keyboard type"
|
|
parameters = {"top_k": 2, "num_beams": 5, "do_sample": True}
|
|
generated_output = mlflow.transformers.generate_signature_output(
|
|
text2text_generation_pipeline, data
|
|
)
|
|
signature = infer_signature(
|
|
data,
|
|
generated_output,
|
|
parameters,
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
text2text_generation_pipeline,
|
|
path=model_path,
|
|
signature=signature,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
# parameters saved with ModelSignature is applied by default
|
|
res = pyfunc_loaded.predict(data)
|
|
res2 = pyfunc_loaded.predict(data, parameters)
|
|
assert res == res2
|
|
|
|
|
|
def test_text2text_generation_pipeline_with_params_with_errors(
|
|
text2text_generation_pipeline, model_path
|
|
):
|
|
data = "muppet keyboard type"
|
|
parameters = {"top_k": 2, "num_beams": 5, "invalid_param": "invalid_param", "do_sample": True}
|
|
generated_output = mlflow.transformers.generate_signature_output(
|
|
text2text_generation_pipeline, data
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
text2text_generation_pipeline,
|
|
path=model_path,
|
|
signature=infer_signature(
|
|
data,
|
|
generated_output,
|
|
parameters,
|
|
),
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"The params provided to the `predict` method are "
|
|
r"not valid for pipeline Text2TextGenerationPipeline.",
|
|
):
|
|
pyfunc_loaded.predict(data, parameters)
|
|
|
|
# Type validation of params failure
|
|
with pytest.raises(MlflowException, match=r"Invalid parameters found"):
|
|
pyfunc_loaded.predict(data, {"top_k": "2"})
|
|
|
|
|
|
def test_text2text_generation_pipeline_with_inferred_schema(text2text_generation_pipeline):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(text2text_generation_pipeline, name="my_model")
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_info.model_uri)
|
|
|
|
assert pyfunc_loaded.predict("muppet board nails hammer")[0].startswith("A hammer")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_data",
|
|
[
|
|
({"answer": "something", "context": ["nothing", "that", "makes", "sense"]}),
|
|
([{"answer": ["42"], "context": "life"}, {"unmatched": "keys", "cause": "failure"}]),
|
|
],
|
|
)
|
|
def test_invalid_input_to_text2text_pipeline(text2text_generation_pipeline, invalid_data):
|
|
# Adding this validation test due to the fact that we're constructing the input to the
|
|
# Pipeline. The Pipeline requires a format of a pseudo-dict-like string. An example of
|
|
# a valid input string: "answer: green. context: grass is primarily green in color."
|
|
# We generate this string from a dict or generate a list of these strings from a list of
|
|
# dictionaries.
|
|
with pytest.raises(
|
|
MlflowException, match=r"An invalid type has been supplied: .+\. Please supply"
|
|
):
|
|
infer_signature(
|
|
invalid_data,
|
|
mlflow.transformers.generate_signature_output(
|
|
text2text_generation_pipeline, invalid_data
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data", ["Generative models are", (["Generative models are", "Computers are"])]
|
|
)
|
|
def test_text_generation_pipeline(text_generation_pipeline, model_path, data):
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(text_generation_pipeline, data)
|
|
)
|
|
|
|
model_config = {
|
|
"prefix": "software",
|
|
"top_k": 2,
|
|
"num_beams": 5,
|
|
"max_length": 30,
|
|
"temperature": 0.62,
|
|
"top_p": 0.85,
|
|
"repetition_penalty": 1.15,
|
|
}
|
|
mlflow.transformers.save_model(
|
|
text_generation_pipeline,
|
|
path=model_path,
|
|
model_config=model_config,
|
|
signature=signature,
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict(data)
|
|
|
|
if isinstance(data, list):
|
|
assert inference[0].startswith(data[0])
|
|
assert inference[1].startswith(data[1])
|
|
else:
|
|
assert inference[0].startswith(data)
|
|
|
|
pd_input = pd.DataFrame([data], index=[0]) if isinstance(data, str) else pd.DataFrame(data)
|
|
pd_inference = pyfunc_loaded.predict(pd_input)
|
|
|
|
if isinstance(data, list):
|
|
assert pd_inference[0].startswith(data[0])
|
|
assert pd_inference[1].startswith(data[1])
|
|
else:
|
|
assert pd_inference[0].startswith(data)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_data",
|
|
[
|
|
({"my_input": "something to predict"}),
|
|
([{"bogus_input": "invalid"}, "not_valid"]),
|
|
(["tell me a story", {"of": "a properly configured pipeline input"}]),
|
|
],
|
|
)
|
|
def test_invalid_input_to_text_generation_pipeline(text_generation_pipeline, invalid_data):
|
|
if isinstance(invalid_data, list):
|
|
match = "If supplying a list, all values must be of string type"
|
|
else:
|
|
match = "The input data is of an incorrect type"
|
|
with pytest.raises(MlflowException, match=match):
|
|
infer_signature(
|
|
invalid_data,
|
|
mlflow.transformers.generate_signature_output(text_generation_pipeline, invalid_data),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("inference_payload", "result"),
|
|
[
|
|
("Riding a <mask> on the beach is fun!", ["bike"]),
|
|
(["If I had <mask>, I would fly to the top of a mountain"], ["wings"]),
|
|
(
|
|
["I use stacks of <mask> to buy things", "I <mask> the whole bowl of cherries"],
|
|
["cash", "ate"],
|
|
),
|
|
],
|
|
)
|
|
def test_fill_mask_pipeline(fill_mask_pipeline, model_path, inference_payload, result):
|
|
signature = infer_signature(
|
|
inference_payload,
|
|
mlflow.transformers.generate_signature_output(fill_mask_pipeline, inference_payload),
|
|
)
|
|
|
|
mlflow.transformers.save_model(fill_mask_pipeline, path=model_path, signature=signature)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict(inference_payload)
|
|
assert inference == result
|
|
|
|
if len(inference_payload) > 1 and isinstance(inference_payload, list):
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in inference_payload])
|
|
elif isinstance(inference_payload, list) and len(inference_payload) == 1:
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in inference_payload], index=[0])
|
|
else:
|
|
pd_input = pd.DataFrame({"inputs": inference_payload}, index=[0])
|
|
|
|
pd_inference = pyfunc_loaded.predict(pd_input)
|
|
assert pd_inference == result
|
|
|
|
|
|
def test_fill_mask_pipeline_with_multiple_masks(fill_mask_pipeline, model_path):
|
|
data = ["I <mask> the whole <mask> of <mask>", "I <mask> the whole <mask> of <mask>"]
|
|
|
|
mlflow.transformers.save_model(fill_mask_pipeline, path=model_path)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict(data)
|
|
assert len(inference) == 2
|
|
assert all(len(value) == 3 for value in inference)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_data",
|
|
[
|
|
({"a": "b"}),
|
|
([{"a": "b"}, [{"a": "c"}]]),
|
|
],
|
|
)
|
|
def test_invalid_input_to_fill_mask_pipeline(fill_mask_pipeline, invalid_data):
|
|
if isinstance(invalid_data, list):
|
|
match = "Invalid data submission. Ensure all"
|
|
else:
|
|
match = "The input data is of an incorrect type"
|
|
with pytest.raises(MlflowException, match=match):
|
|
infer_signature(
|
|
invalid_data,
|
|
mlflow.transformers.generate_signature_output(fill_mask_pipeline, invalid_data),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data",
|
|
[
|
|
{
|
|
"sequences": "I love the latest update to this IDE!",
|
|
"candidate_labels": ["happy", "sad"],
|
|
},
|
|
{
|
|
"sequences": ["My dog loves to eat spaghetti", "My dog hates going to the vet"],
|
|
"candidate_labels": ["happy", "sad"],
|
|
"hypothesis_template": "This example talks about how the dog is {}",
|
|
},
|
|
],
|
|
)
|
|
def test_zero_shot_classification_pipeline(zero_shot_pipeline, model_path, data):
|
|
# NB: The list submission for this pipeline type can accept json-encoded lists or lists within
|
|
# the values of the dictionary.
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(zero_shot_pipeline, data)
|
|
)
|
|
|
|
mlflow.transformers.save_model(zero_shot_pipeline, model_path, signature=signature)
|
|
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
inference = loaded_pyfunc.predict(data)
|
|
|
|
assert isinstance(inference, pd.DataFrame)
|
|
if isinstance(data["sequences"], str):
|
|
assert len(inference) == len(data["candidate_labels"])
|
|
else:
|
|
assert len(inference) == len(data["sequences"]) * len(data["candidate_labels"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"query",
|
|
[
|
|
"What should we order more of?",
|
|
[
|
|
"What is our highest sales?",
|
|
"What should we order more of?",
|
|
],
|
|
],
|
|
)
|
|
def test_table_question_answering_pipeline(table_question_answering_pipeline, model_path, query):
|
|
table = {
|
|
"Fruit": ["Apples", "Bananas", "Oranges", "Watermelon", "Blueberries"],
|
|
"Sales": ["1230945.55", "86453.12", "11459.23", "8341.23", "2325.88"],
|
|
"Inventory": ["910", "4589", "11200", "80", "3459"],
|
|
}
|
|
json_table = json.dumps(table)
|
|
data = {"query": query, "table": json_table}
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(table_question_answering_pipeline, data)
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
table_question_answering_pipeline, model_path, signature=signature
|
|
)
|
|
loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = loaded.predict(data)
|
|
assert len(inference) == 1 if isinstance(query, str) else len(query)
|
|
|
|
pd_input = pd.DataFrame([data])
|
|
pd_inference = loaded.predict(pd_input)
|
|
assert pd_inference is not None
|
|
|
|
|
|
def test_custom_code_pipeline(custom_code_pipeline, model_path):
|
|
data = "hello"
|
|
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(custom_code_pipeline, data)
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
custom_code_pipeline,
|
|
path=model_path,
|
|
signature=signature,
|
|
)
|
|
|
|
# just test that it doesn't blow up when performing inference
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
pyfunc_pred = pyfunc_loaded.predict(data)
|
|
assert isinstance(pyfunc_pred[0][0], float)
|
|
|
|
transformers_loaded = mlflow.transformers.load_model(model_path)
|
|
transformers_pred = transformers_loaded(data)
|
|
assert pyfunc_pred[0][0] == transformers_pred[0][0][0]
|
|
|
|
|
|
def test_custom_components_pipeline(custom_components_pipeline, model_path):
|
|
data = "hello"
|
|
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(custom_components_pipeline, data)
|
|
)
|
|
|
|
components = {
|
|
"model": custom_components_pipeline.model,
|
|
"tokenizer": custom_components_pipeline.tokenizer,
|
|
"feature_extractor": custom_components_pipeline.feature_extractor,
|
|
}
|
|
|
|
# Pass the task explicitly rather than relying on inference from the Hub:
|
|
# the backing model's `pipeline_tag` is now `image-feature-extraction`, so
|
|
# task inference would build an unsupported pipeline type. The fixture
|
|
# pipeline is already built with the `feature-extraction` task.
|
|
mlflow.transformers.save_model(
|
|
transformers_model=components,
|
|
path=model_path,
|
|
signature=signature,
|
|
task=custom_components_pipeline.task,
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
pyfunc_pred = pyfunc_loaded.predict(data)
|
|
assert isinstance(pyfunc_pred[0][0], float)
|
|
|
|
transformers_loaded = mlflow.transformers.load_model(model_path)
|
|
transformers_pred = transformers_loaded(data)
|
|
assert pyfunc_pred[0][0] == transformers_pred[0][0][0]
|
|
|
|
# assert that all the reloaded components exist
|
|
# and have the same class name as pre-save
|
|
for name, component in components.items():
|
|
assert component.__class__.__name__ == getattr(transformers_loaded, name).__class__.__name__
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "result"),
|
|
[
|
|
("I've got a lovely bunch of coconuts!", ["Ich habe eine schöne Haufe von Kokos!"]),
|
|
(
|
|
[
|
|
"I am the very model of a modern major general",
|
|
"Once upon a time, there was a little turtle",
|
|
],
|
|
[
|
|
"Ich bin das Modell eines modernen Generals.",
|
|
"Einmal gab es eine kleine Schildkröte.",
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_translation_pipeline(translation_pipeline, model_path, data, result):
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(translation_pipeline, data)
|
|
)
|
|
|
|
mlflow.transformers.save_model(translation_pipeline, path=model_path, signature=signature)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
inference = pyfunc_loaded.predict(data)
|
|
assert inference == result
|
|
|
|
if len(data) > 1 and isinstance(data, list):
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in data])
|
|
elif isinstance(data, list) and len(data) == 1:
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in data], index=[0])
|
|
else:
|
|
pd_input = pd.DataFrame({"inputs": data}, index=[0])
|
|
|
|
pd_inference = pyfunc_loaded.predict(pd_input)
|
|
assert pd_inference == result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data",
|
|
[
|
|
"There once was a boy",
|
|
["Dolly isn't just a sheep anymore"],
|
|
["Baking cookies is quite easy", "Writing unittests is good for"],
|
|
],
|
|
)
|
|
def test_summarization_pipeline(summarizer_pipeline, model_path, data):
|
|
model_config = {
|
|
"top_k": 2,
|
|
"num_beams": 5,
|
|
"max_length": 90,
|
|
"temperature": 0.62,
|
|
"top_p": 0.85,
|
|
"repetition_penalty": 1.15,
|
|
}
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(summarizer_pipeline, data)
|
|
)
|
|
|
|
mlflow.transformers.save_model(
|
|
summarizer_pipeline, path=model_path, model_config=model_config, signature=signature
|
|
)
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict(data)
|
|
if isinstance(data, list) and len(data) > 1:
|
|
for i, entry in enumerate(data):
|
|
assert inference[i].strip().startswith(entry)
|
|
elif isinstance(data, list) and len(data) == 1:
|
|
assert inference[0].strip().startswith(data[0])
|
|
else:
|
|
assert inference[0].strip().startswith(data)
|
|
|
|
if len(data) > 1 and isinstance(data, list):
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in data])
|
|
elif isinstance(data, list) and len(data) == 1:
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in data], index=[0])
|
|
else:
|
|
pd_input = pd.DataFrame({"inputs": data}, index=[0])
|
|
|
|
pd_inference = pyfunc_loaded.predict(pd_input)
|
|
if isinstance(data, list) and len(data) > 1:
|
|
for i, entry in enumerate(data):
|
|
assert pd_inference[i].strip().startswith(entry)
|
|
elif isinstance(data, list) and len(data) == 1:
|
|
assert pd_inference[0].strip().startswith(data[0])
|
|
else:
|
|
assert pd_inference[0].strip().startswith(data)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data",
|
|
[
|
|
"I'm telling you that Han shot first!",
|
|
[
|
|
"I think this sushi might have gone off",
|
|
"That gym smells like feet, hot garbage, and sadness",
|
|
"I love that we have a moon",
|
|
],
|
|
[{"text": "test1", "text_pair": "test2"}],
|
|
[{"text": "test1", "text_pair": "pair1"}, {"text": "test2", "text_pair": "pair2"}],
|
|
],
|
|
)
|
|
def test_classifier_pipeline(text_classification_pipeline, model_path, data):
|
|
signature = infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(text_classification_pipeline, data)
|
|
)
|
|
mlflow.transformers.save_model(
|
|
text_classification_pipeline, path=model_path, signature=signature
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
inference = pyfunc_loaded.predict(data)
|
|
|
|
# verify that native transformers outputs match the pyfunc return values
|
|
native_inference = text_classification_pipeline(data)
|
|
inference_dict = inference.to_dict()
|
|
|
|
if isinstance(data, str):
|
|
assert len(inference) == 1
|
|
assert inference_dict["label"][0] == native_inference[0]["label"]
|
|
assert inference_dict["score"][0] == native_inference[0]["score"]
|
|
else:
|
|
assert len(inference) == len(data)
|
|
for key in ["score", "label"]:
|
|
for value in range(0, len(data)):
|
|
if key == "label":
|
|
assert inference_dict[key][value] == native_inference[value][key]
|
|
else:
|
|
assert math.isclose(
|
|
native_inference[value][key], inference_dict[key][value], rel_tol=1e-6
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "result"),
|
|
[
|
|
(
|
|
"I have a dog and his name is Willy!",
|
|
["PRON,VERB,DET,NOUN,CCONJ,PRON,NOUN,AUX,PROPN,PUNCT"],
|
|
),
|
|
(["I like turtles"], ["PRON,VERB,NOUN"]),
|
|
(
|
|
["We are the knights who say nee!", "Houston, we may have a problem."],
|
|
[
|
|
"PRON,AUX,DET,PROPN,PRON,VERB,INTJ,PUNCT",
|
|
"PROPN,PUNCT,PRON,AUX,VERB,DET,NOUN,PUNCT",
|
|
],
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("pipeline_name", ["ner_pipeline", "ner_pipeline_aggregation"])
|
|
def test_ner_pipeline(pipeline_name, model_path, data, result, request):
|
|
pipeline = request.getfixturevalue(pipeline_name)
|
|
|
|
signature = infer_signature(data, mlflow.transformers.generate_signature_output(pipeline, data))
|
|
|
|
mlflow.transformers.save_model(pipeline, model_path, signature=signature)
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
inference = loaded_pyfunc.predict(data)
|
|
|
|
assert inference == result
|
|
|
|
if len(data) > 1 and isinstance(data, list):
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in data])
|
|
elif isinstance(data, list) and len(data) == 1:
|
|
pd_input = pd.DataFrame([{"inputs": v} for v in data], index=[0])
|
|
else:
|
|
pd_input = pd.DataFrame({"inputs": data}, index=[0])
|
|
pd_inference = loaded_pyfunc.predict(pd_input)
|
|
assert pd_inference == result
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
_try_import_conversational_pipeline() is None,
|
|
reason="Conversation model is deprecated and removed.",
|
|
)
|
|
def test_conversational_pipeline(conversational_pipeline, model_path):
|
|
assert mlflow.transformers._is_conversational_pipeline(conversational_pipeline)
|
|
|
|
signature = infer_signature(
|
|
"Hi there!",
|
|
mlflow.transformers.generate_signature_output(conversational_pipeline, "Hi there!"),
|
|
)
|
|
|
|
mlflow.transformers.save_model(conversational_pipeline, model_path, signature=signature)
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
|
|
first_response = loaded_pyfunc.predict("What is the best way to get to Antarctica?")
|
|
|
|
assert first_response == "The best way would be to go to space."
|
|
|
|
second_response = loaded_pyfunc.predict("What kind of boat should I use?")
|
|
|
|
assert second_response == "The best way to get to space would be to reach out and touch it."
|
|
|
|
# Test that a new loaded instance has no context.
|
|
loaded_again_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
third_response = loaded_again_pyfunc.predict("What kind of boat should I use?")
|
|
|
|
assert third_response == "The one with the guns."
|
|
|
|
fourth_response = loaded_again_pyfunc.predict("Can I use it to go to the moon?")
|
|
|
|
assert fourth_response == "Sure."
|
|
|
|
|
|
def test_qa_pipeline_pyfunc_predict(small_qa_pipeline):
|
|
artifact_path = "qa_model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline,
|
|
name=artifact_path,
|
|
)
|
|
|
|
inference_payload = json.dumps({
|
|
"inputs": {
|
|
"question": [
|
|
"What color is it?",
|
|
"How do the people go?",
|
|
"What does the 'wolf' howl at?",
|
|
],
|
|
"context": [
|
|
"Some people said it was green but I know that it's pink.",
|
|
"The people on the bus go up and down. Up and down.",
|
|
"The pack of 'wolves' stood on the cliff and a 'lone wolf' howled at "
|
|
"the moon for hours.",
|
|
],
|
|
}
|
|
})
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert values.to_dict(orient="records") == [{0: "pink"}, {0: "up and down"}, {0: "the moon"}]
|
|
|
|
inference_payload = json.dumps({
|
|
"inputs": {
|
|
"question": "Who's house?",
|
|
"context": "The house is owned by a man named Run.",
|
|
}
|
|
})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert values.to_dict(orient="records") == [{0: "Run"}]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_image", "result"),
|
|
[
|
|
(str(image_file_path), False),
|
|
(image_url, False),
|
|
("base64", True),
|
|
("random string", False),
|
|
],
|
|
)
|
|
def test_vision_is_base64_image(input_image, result):
|
|
if input_image == "base64":
|
|
input_image = base64.b64encode(image_file_path.read_bytes()).decode("utf-8")
|
|
assert _TransformersWrapper.is_base64_image(input_image) == result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"inference_payload",
|
|
[
|
|
[str(image_file_path)],
|
|
[image_url],
|
|
"base64",
|
|
pytest.param(
|
|
"base64_encodebytes",
|
|
marks=pytest.mark.skipif(
|
|
Version(transformers.__version__) < Version("4.41"),
|
|
reason="base64 encodebytes feature not present",
|
|
),
|
|
),
|
|
],
|
|
)
|
|
def test_vision_pipeline_pyfunc_predict(small_vision_model, inference_payload):
|
|
if inference_payload == "base64":
|
|
inference_payload = [
|
|
base64.b64encode(image_file_path.read_bytes()).decode("utf-8"),
|
|
]
|
|
elif inference_payload == "base64_encodebytes":
|
|
inference_payload = [
|
|
base64.encodebytes(image_file_path.read_bytes()).decode("utf-8"),
|
|
]
|
|
artifact_path = "image_classification_model"
|
|
|
|
# Log the image classification model
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_vision_model,
|
|
name=artifact_path,
|
|
)
|
|
pyfunc_inference_payload = json.dumps({"inputs": inference_payload})
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=pyfunc_inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
|
|
predictions = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
transformers_loaded_model = mlflow.transformers.load_model(model_info.model_uri)
|
|
expected_predictions = transformers_loaded_model.predict(inference_payload)
|
|
|
|
assert [list(pred.values()) for pred in predictions.to_dict("records")] == expected_predictions
|
|
|
|
|
|
def test_classifier_pipeline_pyfunc_predict(text_classification_pipeline):
|
|
artifact_path = "text_classifier_model"
|
|
data = [
|
|
"I think this sushi might have gone off",
|
|
"That gym smells like feet, hot garbage, and sadness",
|
|
"I love that we have a moon",
|
|
"I 'love' debugging subprocesses",
|
|
'Quote "in" the string',
|
|
]
|
|
signature = infer_signature(data)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
text_classification_pipeline,
|
|
name=artifact_path,
|
|
signature=signature,
|
|
)
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=json.dumps({"inputs": data}),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.to_dict()) == 2
|
|
assert len(values.to_dict()["score"]) == 5
|
|
|
|
# test simple string input
|
|
inference_payload = json.dumps({"inputs": ["testing"]})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.to_dict()) == 2
|
|
assert len(values.to_dict()["score"]) == 1
|
|
|
|
# Test the alternate TextClassificationPipeline input structure where text_pair is used
|
|
# and ensure that model serving and direct native inference match
|
|
inference_data = [
|
|
{"text": "test1", "text_pair": "pair1"},
|
|
{"text": "test2", "text_pair": "pair2"},
|
|
{"text": "test 'quote", "text_pair": "pair 'quote'"},
|
|
]
|
|
signature = infer_signature(inference_data)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
text_classification_pipeline,
|
|
name=artifact_path,
|
|
signature=signature,
|
|
)
|
|
|
|
inference_payload = json.dumps({"inputs": inference_data})
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
values_dict = values.to_dict()
|
|
native_predict = text_classification_pipeline(inference_data)
|
|
|
|
# validate that the pyfunc served model registers text_pair in the same manner as native
|
|
for key in ["score", "label"]:
|
|
for value in [0, 1]:
|
|
if key == "label":
|
|
assert values_dict[key][value] == native_predict[value][key]
|
|
else:
|
|
assert math.isclose(
|
|
values_dict[key][value], native_predict[value][key], rel_tol=1e-6
|
|
)
|
|
|
|
|
|
def test_zero_shot_pipeline_pyfunc_predict(zero_shot_pipeline):
|
|
artifact_path = "zero_shot_classifier_model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
zero_shot_pipeline,
|
|
name=artifact_path,
|
|
)
|
|
model_uri = model_info.model_uri
|
|
|
|
inference_payload = json.dumps({
|
|
"inputs": {
|
|
"sequences": "My dog loves running through troughs of spaghetti with his mouth open",
|
|
"candidate_labels": ["happy", "sad"],
|
|
"hypothesis_template": "This example talks about how the dog is {}",
|
|
}
|
|
})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.to_dict()) == 3
|
|
assert len(values.to_dict()["labels"]) == 2
|
|
|
|
inference_payload = json.dumps({
|
|
"inputs": {
|
|
"sequences": [
|
|
"My dog loves to eat spaghetti",
|
|
"My dog hates going to the vet",
|
|
"My 'hamster' loves to play with my 'friendly' dog",
|
|
],
|
|
"candidate_labels": '["happy", "sad"]',
|
|
"hypothesis_template": "This example talks about how the dog is {}",
|
|
}
|
|
})
|
|
response = pyfunc_serve_and_score_model(
|
|
model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.to_dict()) == 3
|
|
assert len(values.to_dict()["labels"]) == 6
|
|
|
|
|
|
def test_table_question_answering_pyfunc_predict(table_question_answering_pipeline):
|
|
artifact_path = "table_qa_model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
table_question_answering_pipeline,
|
|
name=artifact_path,
|
|
)
|
|
|
|
table = {
|
|
"Fruit": ["Apples", "Bananas", "Oranges", "Watermelon 'small'", "Blueberries"],
|
|
"Sales": ["1230945.55", "86453.12", "11459.23", "8341.23", "2325.88"],
|
|
"Inventory": ["910", "4589", "11200", "80", "3459"],
|
|
}
|
|
|
|
inference_payload = json.dumps({
|
|
"inputs": {
|
|
"query": "What should we order more of?",
|
|
"table": table,
|
|
}
|
|
})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.to_dict(orient="records")) == 1
|
|
|
|
inference_payload = json.dumps({
|
|
"inputs": {
|
|
"query": [
|
|
"What is our highest sales?",
|
|
"What should we order more of?",
|
|
"Which 'fruit' has the 'highest' 'sales'?",
|
|
],
|
|
"table": table,
|
|
}
|
|
})
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.to_dict(orient="records")) == 3
|
|
|
|
|
|
def test_feature_extraction_pipeline(feature_extraction_pipeline):
|
|
sentences = ["hi", "hello"]
|
|
signature = infer_signature(
|
|
sentences,
|
|
mlflow.transformers.generate_signature_output(feature_extraction_pipeline, sentences),
|
|
)
|
|
|
|
artifact_path = "feature_extraction_pipeline"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
feature_extraction_pipeline,
|
|
name=artifact_path,
|
|
signature=signature,
|
|
input_example=["A sentence", "Another sentence"],
|
|
)
|
|
|
|
# Load as native
|
|
loaded_pipeline = mlflow.transformers.load_model(model_info.model_uri)
|
|
|
|
inference_single = "Testing"
|
|
inference_mult = ["Testing something", "Testing something else"]
|
|
|
|
pred = loaded_pipeline(inference_single)
|
|
assert len(pred[0][0]) > 10
|
|
assert isinstance(pred[0][0][0], float)
|
|
|
|
pred_multiple = loaded_pipeline(inference_mult)
|
|
assert len(pred_multiple[0][0]) > 2
|
|
assert isinstance(pred_multiple[0][0][0][0], float)
|
|
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_info.model_uri)
|
|
|
|
pyfunc_pred = loaded_pyfunc.predict(inference_single)
|
|
|
|
assert isinstance(pyfunc_pred, np.ndarray)
|
|
|
|
assert np.array_equal(np.array(pred[0]), pyfunc_pred)
|
|
|
|
pyfunc_pred_multiple = loaded_pyfunc.predict(inference_mult)
|
|
|
|
assert np.array_equal(np.array(pred_multiple[0][0]), pyfunc_pred_multiple)
|
|
|
|
|
|
def test_feature_extraction_pipeline_pyfunc_predict(feature_extraction_pipeline):
|
|
artifact_path = "feature_extraction"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
feature_extraction_pipeline,
|
|
name=artifact_path,
|
|
)
|
|
|
|
inference_payload = json.dumps({"inputs": ["sentence one", "sentence two"]})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert len(values.columns) == 384
|
|
assert len(values) == 4
|
|
|
|
inference_payload = json.dumps({"inputs": "sentence three"})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
assert response.status_code == 200
|
|
prediction = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
assert len(prediction.columns) == 384
|
|
assert len(prediction) == 4
|
|
|
|
|
|
def test_loading_unsupported_pipeline_type_as_pyfunc(small_multi_modal_pipeline, model_path):
|
|
mlflow.transformers.save_model(small_multi_modal_pipeline, model_path)
|
|
with pytest.raises(MlflowException, match='Model does not have the "python_function" flavor'):
|
|
mlflow.pyfunc.load_model(model_path)
|
|
|
|
|
|
def test_pyfunc_input_validations(mock_pyfunc_wrapper):
|
|
def ensure_raises(data, match):
|
|
with pytest.raises(MlflowException, match=match):
|
|
mock_pyfunc_wrapper._validate_str_or_list_str(data)
|
|
|
|
match1 = "The input data is of an incorrect type"
|
|
match2 = "If supplying a list, all values must"
|
|
ensure_raises({"a": "b"}, match1)
|
|
ensure_raises(("a", "b"), match1)
|
|
ensure_raises({"a", "b"}, match1)
|
|
ensure_raises(True, match1)
|
|
ensure_raises(12, match1)
|
|
ensure_raises([1, 2, 3], match2)
|
|
ensure_raises([{"a", "b"}], match2)
|
|
ensure_raises([["a", "b", "c'"]], match2)
|
|
ensure_raises([{"a": "b"}, {"a": "c"}], match2)
|
|
ensure_raises([[1], [2]], match2)
|
|
|
|
|
|
def test_pyfunc_json_encoded_dict_parsing(mock_pyfunc_wrapper):
|
|
plain_dict = {"a": "b", "b": "c"}
|
|
list_dict = [plain_dict, plain_dict]
|
|
|
|
plain_input = {"in": json.dumps(plain_dict)}
|
|
list_input = {"in": json.dumps(list_dict)}
|
|
|
|
plain_parsed = mock_pyfunc_wrapper._parse_json_encoded_dict_payload_to_dict(plain_input, "in")
|
|
assert plain_parsed == {"in": plain_dict}
|
|
|
|
list_parsed = mock_pyfunc_wrapper._parse_json_encoded_dict_payload_to_dict(list_input, "in")
|
|
assert list_parsed == {"in": list_dict}
|
|
|
|
invalid_parsed = mock_pyfunc_wrapper._parse_json_encoded_dict_payload_to_dict(
|
|
plain_input, "invalid"
|
|
)
|
|
assert invalid_parsed != {"in": plain_dict}
|
|
assert invalid_parsed == plain_input
|
|
|
|
|
|
def test_pyfunc_json_encoded_list_parsing(mock_pyfunc_wrapper):
|
|
plain_list = ["a", "b", "c"]
|
|
nested_list = [plain_list, plain_list]
|
|
list_dict = [{"a": "b"}, {"a": "c"}]
|
|
|
|
plain_input = {"in": json.dumps(plain_list)}
|
|
nested_input = {"in": json.dumps(nested_list)}
|
|
list_dict_input = {"in": json.dumps(list_dict)}
|
|
|
|
plain_parsed = mock_pyfunc_wrapper._parse_json_encoded_list(plain_input, "in")
|
|
assert plain_parsed == {"in": plain_list}
|
|
|
|
nested_parsed = mock_pyfunc_wrapper._parse_json_encoded_list(nested_input, "in")
|
|
assert nested_parsed == {"in": nested_list}
|
|
|
|
list_dict_parsed = mock_pyfunc_wrapper._parse_json_encoded_list(list_dict_input, "in")
|
|
assert list_dict_parsed == {"in": list_dict}
|
|
|
|
with pytest.raises(MlflowException, match="Invalid key in inference payload. The "):
|
|
mock_pyfunc_wrapper._parse_json_encoded_list(list_dict_input, "invalid")
|
|
|
|
|
|
def test_pyfunc_text_to_text_input(mock_pyfunc_wrapper):
|
|
text2text_input = {"context": "a", "answer": "b"}
|
|
parsed_input = mock_pyfunc_wrapper._parse_text2text_input(text2text_input)
|
|
assert parsed_input == "context: a answer: b"
|
|
|
|
text2text_input_list = [text2text_input, text2text_input]
|
|
parsed_input_list = mock_pyfunc_wrapper._parse_text2text_input(text2text_input_list)
|
|
assert parsed_input_list == ["context: a answer: b", "context: a answer: b"]
|
|
|
|
parsed_with_inputs = mock_pyfunc_wrapper._parse_text2text_input({"inputs": "a"})
|
|
assert parsed_with_inputs == ["a"]
|
|
|
|
parsed_str = mock_pyfunc_wrapper._parse_text2text_input("a")
|
|
assert parsed_str == "a"
|
|
|
|
parsed_list_str = mock_pyfunc_wrapper._parse_text2text_input(["a", "b"])
|
|
assert parsed_list_str == ["a", "b"]
|
|
|
|
with pytest.raises(MlflowException, match="An invalid type has been supplied"):
|
|
mock_pyfunc_wrapper._parse_text2text_input([1, 2, 3])
|
|
|
|
with pytest.raises(MlflowException, match="An invalid type has been supplied"):
|
|
mock_pyfunc_wrapper._parse_text2text_input([{"a": [{"b": "c"}]}])
|
|
|
|
|
|
def test_pyfunc_qa_input(mock_pyfunc_wrapper):
|
|
single_input = {"question": "a", "context": "b"}
|
|
parsed_single_input = mock_pyfunc_wrapper._parse_question_answer_input(single_input)
|
|
assert parsed_single_input == single_input
|
|
|
|
multi_input = [single_input, single_input]
|
|
parsed_multi_input = mock_pyfunc_wrapper._parse_question_answer_input(multi_input)
|
|
assert parsed_multi_input == multi_input
|
|
|
|
with pytest.raises(MlflowException, match="Invalid keys were submitted. Keys must"):
|
|
mock_pyfunc_wrapper._parse_question_answer_input({"q": "a", "c": "b"})
|
|
|
|
with pytest.raises(MlflowException, match="An invalid type has been supplied"):
|
|
mock_pyfunc_wrapper._parse_question_answer_input("a")
|
|
|
|
with pytest.raises(MlflowException, match="An invalid type has been supplied"):
|
|
mock_pyfunc_wrapper._parse_question_answer_input(["a", "b", "c"])
|
|
|
|
|
|
def test_list_of_dict_to_list_of_str_parsing(mock_pyfunc_wrapper):
|
|
# Test with a single list of dictionaries
|
|
output_data = [{"a": "foo"}, {"a": "bar"}, {"a": "baz"}]
|
|
expected_output = ["foo", "bar", "baz"]
|
|
assert (
|
|
mock_pyfunc_wrapper._parse_lists_of_dict_to_list_of_str(output_data, "a") == expected_output
|
|
)
|
|
|
|
# Test with a nested list of dictionaries
|
|
output_data = [
|
|
{"a": "foo", "b": [{"a": "bar"}]},
|
|
{"a": "baz", "b": [{"a": "qux"}]},
|
|
]
|
|
expected_output = ["foo", "bar", "baz", "qux"]
|
|
assert (
|
|
mock_pyfunc_wrapper._parse_lists_of_dict_to_list_of_str(output_data, "a") == expected_output
|
|
)
|
|
|
|
# Test with nested list with exclusion data
|
|
output_data = [
|
|
{"a": "valid", "b": [{"a": "another valid"}, {"b": "invalid"}]},
|
|
{"a": "valid 2", "b": [{"a": "another valid 2"}, {"c": "invalid"}]},
|
|
]
|
|
expected_output = ["valid", "another valid", "valid 2", "another valid 2"]
|
|
assert (
|
|
mock_pyfunc_wrapper._parse_lists_of_dict_to_list_of_str(output_data, "a") == expected_output
|
|
)
|
|
|
|
|
|
def test_parsing_tokenizer_output(mock_pyfunc_wrapper):
|
|
output_data = [{"a": "b"}, {"a": "c"}, {"a": "d"}]
|
|
expected_output = "b,c,d"
|
|
assert mock_pyfunc_wrapper._parse_tokenizer_output(output_data, {"a"}) == expected_output
|
|
|
|
output_data = [output_data, output_data]
|
|
expected_output = [expected_output, expected_output]
|
|
assert mock_pyfunc_wrapper._parse_tokenizer_output(output_data, {"a"}) == expected_output
|
|
|
|
|
|
def test_parse_list_of_multiple_dicts(mock_pyfunc_wrapper):
|
|
output_data = [{"a": "b", "d": "f"}, {"a": "z", "d": "g"}]
|
|
target_dict_key = "a"
|
|
expected_output = ["b"]
|
|
|
|
assert (
|
|
mock_pyfunc_wrapper._parse_list_of_multiple_dicts(output_data, target_dict_key)
|
|
== expected_output
|
|
)
|
|
|
|
output_data = [
|
|
[{"a": "c", "d": "q"}, {"a": "o", "d": "q"}, {"a": "d", "d": "q"}, {"a": "e", "d": "r"}],
|
|
[{"a": "m", "d": "s"}, {"a": "e", "d": "t"}],
|
|
]
|
|
target_dict_key = "a"
|
|
expected_output = ["c", "m"]
|
|
|
|
assert (
|
|
mock_pyfunc_wrapper._parse_list_of_multiple_dicts(output_data, target_dict_key)
|
|
== expected_output
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"pipeline_input",
|
|
"pipeline_output",
|
|
"expected_output",
|
|
"flavor_config",
|
|
"include_prompt",
|
|
"collapse_whitespace",
|
|
),
|
|
[
|
|
(
|
|
"What answers?",
|
|
[{"generated_text": "What answers?\n\nA collection of\n\nanswers"}],
|
|
"A collection of\n\nanswers",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
False,
|
|
False,
|
|
),
|
|
(
|
|
"What answers?",
|
|
[{"generated_text": "What answers?\n\nA collection of\n\nanswers"}],
|
|
"A collection of answers",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
False,
|
|
True,
|
|
),
|
|
(
|
|
"Hello!",
|
|
[{"generated_text": "Hello!\n\nHow are you?"}],
|
|
"How are you?",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
False,
|
|
False,
|
|
),
|
|
(
|
|
"Hello!",
|
|
[{"generated_text": "Hello!\n\nA: How are you?\n\n"}],
|
|
"How are you?",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
False,
|
|
True,
|
|
),
|
|
(
|
|
"Hello!",
|
|
[{"generated_text": "Hello!\n\nA: How are you?\n\n"}],
|
|
"Hello! A: How are you?",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
True,
|
|
True,
|
|
),
|
|
(
|
|
"Hello!",
|
|
[{"generated_text": "Hello!\n\nA: How\nare\nyou?\n\n"}],
|
|
"How\nare\nyou?\n\n",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
False,
|
|
False,
|
|
),
|
|
(
|
|
["Hi!", "What's up?"],
|
|
[[{"generated_text": "Hi!\n\nHello there"}, {"generated_text": "Not much, and you?"}]],
|
|
["Hello there", "Not much, and you?"],
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
False,
|
|
False,
|
|
),
|
|
# Tests disabling parsing of newline characters
|
|
(
|
|
["Hi!", "What's up?"],
|
|
[
|
|
[
|
|
{"generated_text": "Hi!\n\nHello there"},
|
|
{"generated_text": "What's up?\n\nNot much, and you?"},
|
|
]
|
|
],
|
|
["Hi!\n\nHello there", "What's up?\n\nNot much, and you?"],
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
True,
|
|
False,
|
|
),
|
|
(
|
|
"Hello!",
|
|
[{"generated_text": "Hello!\n\nHow are you?"}],
|
|
"Hello!\n\nHow are you?",
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
True,
|
|
False,
|
|
),
|
|
# Tests a standard TextGenerationPipeline output
|
|
(
|
|
["We like to", "Open the"],
|
|
[
|
|
[
|
|
{"generated_text": "We like to party"},
|
|
{"generated_text": "Open the door get on the floor everybody do the dinosaur"},
|
|
]
|
|
],
|
|
["We like to party", "Open the door get on the floor everybody do the dinosaur"],
|
|
{"instance_type": "TextGenerationPipeline"},
|
|
True,
|
|
True,
|
|
),
|
|
# Tests a standard TextGenerationPipeline output with setting "include_prompt" (noop)
|
|
(
|
|
["We like to", "Open the"],
|
|
[
|
|
[
|
|
{"generated_text": "We like to party"},
|
|
{"generated_text": "Open the door get on the floor everybody do the dinosaur"},
|
|
]
|
|
],
|
|
["We like to party", "Open the door get on the floor everybody do the dinosaur"],
|
|
{"instance_type": "TextGenerationPipeline"},
|
|
False,
|
|
False,
|
|
),
|
|
# Test TextGenerationPipeline removes whitespace
|
|
(
|
|
["We like to", "Open the"],
|
|
[
|
|
[
|
|
{"generated_text": " We like to party"},
|
|
{
|
|
"generated_text": "Open the door get on the floor everybody "
|
|
"do\nthe dinosaur"
|
|
},
|
|
]
|
|
],
|
|
["We like to party", "Open the door get on the floor everybody do the dinosaur"],
|
|
{"instance_type": "TextGenerationPipeline"},
|
|
False,
|
|
True,
|
|
),
|
|
],
|
|
)
|
|
def test_parse_input_from_instruction_pipeline(
|
|
mock_pyfunc_wrapper,
|
|
pipeline_input,
|
|
pipeline_output,
|
|
expected_output,
|
|
flavor_config,
|
|
include_prompt,
|
|
collapse_whitespace,
|
|
):
|
|
assert (
|
|
mock_pyfunc_wrapper._strip_input_from_response_in_instruction_pipelines(
|
|
pipeline_input,
|
|
pipeline_output,
|
|
"generated_text",
|
|
flavor_config,
|
|
include_prompt,
|
|
collapse_whitespace,
|
|
)
|
|
== expected_output
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"flavor_config",
|
|
[
|
|
{"instance_type": "InstructionTextGenerationPipeline"},
|
|
{"instance_type": "TextGenerationPipeline"},
|
|
],
|
|
)
|
|
def test_invalid_instruction_pipeline_parsing(mock_pyfunc_wrapper, flavor_config):
|
|
prompt = "What is your favorite boba flavor?"
|
|
|
|
bad_output = {"generated_text": ["Strawberry Milk Cap", "Honeydew with boba"]}
|
|
|
|
with pytest.raises(MlflowException, match="Unable to parse the pipeline output. Expected"):
|
|
mock_pyfunc_wrapper._strip_input_from_response_in_instruction_pipelines(
|
|
prompt, bad_output, "generated_text", flavor_config, True
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(RUNNING_IN_GITHUB_ACTIONS, reason=GITHUB_ACTIONS_SKIP_REASON)
|
|
def test_instructional_pipeline_no_prompt_in_output(model_path):
|
|
architecture = "databricks/dolly-v2-3b"
|
|
dolly = transformers.pipeline(model=architecture, trust_remote_code=True)
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=dolly,
|
|
path=model_path,
|
|
# Validate removal of prompt but inclusion of newlines by default
|
|
model_config={"max_length": 100, "include_prompt": False},
|
|
input_example="Hello, Dolly!",
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict("What is MLflow?")
|
|
|
|
assert not inference[0].startswith("What is MLflow?")
|
|
assert "\n" in inference[0]
|
|
|
|
|
|
@pytest.mark.skipif(RUNNING_IN_GITHUB_ACTIONS, reason=GITHUB_ACTIONS_SKIP_REASON)
|
|
def test_instructional_pipeline_no_prompt_in_output_and_removal_of_newlines(model_path):
|
|
architecture = "databricks/dolly-v2-3b"
|
|
dolly = transformers.pipeline(model=architecture, trust_remote_code=True)
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=dolly,
|
|
path=model_path,
|
|
# Validate removal of prompt but inclusion of newlines by default
|
|
model_config={"max_length": 100, "include_prompt": False, "collapse_whitespace": True},
|
|
input_example="Hello, Dolly!",
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict("What is MLflow?")
|
|
|
|
assert not inference[0].startswith("What is MLflow?")
|
|
assert "\n" not in inference[0]
|
|
|
|
|
|
@pytest.mark.skipif(RUNNING_IN_GITHUB_ACTIONS, reason=GITHUB_ACTIONS_SKIP_REASON)
|
|
def test_instructional_pipeline_with_prompt_in_output(model_path):
|
|
architecture = "databricks/dolly-v2-3b"
|
|
dolly = transformers.pipeline(model=architecture, trust_remote_code=True)
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=dolly,
|
|
path=model_path,
|
|
# test default propagation of `include_prompt`=True and `collapse_whitespace`=False
|
|
model_config={"max_length": 100},
|
|
input_example="Hello, Dolly!",
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict("What is MLflow?")
|
|
|
|
assert inference[0].startswith("What is MLflow?")
|
|
assert "\n\n" in inference[0]
|
|
|
|
|
|
def read_audio_data(format: str):
|
|
datasets_path = pathlib.Path(__file__).resolve().parent.parent.joinpath("datasets")
|
|
wav_file_path = datasets_path.joinpath("apollo11_launch.wav")
|
|
if format == "float":
|
|
audio, _ = librosa.load(wav_file_path, sr=16000)
|
|
return audio
|
|
elif format == "bytes":
|
|
return wav_file_path.read_bytes()
|
|
elif format == "file":
|
|
return wav_file_path.as_posix()
|
|
else:
|
|
raise ValueError(f"Invalid format: {format}")
|
|
|
|
|
|
@pytest.mark.parametrize("input_format", ["float", "bytes", "file"])
|
|
@pytest.mark.parametrize("with_input_example", [True, False])
|
|
def test_whisper_model_predict(model_path, whisper_pipeline, input_format, with_input_example):
|
|
if input_format == "float" and not with_input_example:
|
|
pytest.skip("If the input format is float, the default signature must be overridden.")
|
|
|
|
audio = read_audio_data(input_format)
|
|
mlflow.transformers.save_model(
|
|
transformers_model=whisper_pipeline,
|
|
path=model_path,
|
|
input_example=audio if with_input_example else None,
|
|
save_pretrained=False,
|
|
)
|
|
|
|
# 1. Single prediction with native transformer pipeline
|
|
loaded_pipeline = mlflow.transformers.load_model(model_path)
|
|
transcription = loaded_pipeline(audio)
|
|
assert transcription["text"].startswith(" 30")
|
|
# strip the leading space
|
|
expected_text = transcription["text"].lstrip()
|
|
|
|
# 2. Single prediction with Pyfunc
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
pyfunc_transcription = loaded_pyfunc.predict(audio)[0]
|
|
assert pyfunc_transcription == expected_text
|
|
|
|
# 3. Batch prediction with Pyfunc. Float input format is not supported for batch prediction,
|
|
# because our signature framework doesn't support a list of numpy array.
|
|
if input_format != "float":
|
|
batch_transcription = loaded_pyfunc.predict([audio, audio])
|
|
assert len(batch_transcription) == 2
|
|
assert all(ts == expected_text for ts in batch_transcription)
|
|
|
|
|
|
def test_whisper_model_serve_and_score(whisper_pipeline):
|
|
# Request payload to the model serving endpoint contains base64 encoded audio data
|
|
audio = read_audio_data("bytes")
|
|
encoded_audio = base64.b64encode(audio).decode("ascii")
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
whisper_pipeline,
|
|
name="whisper",
|
|
save_pretrained=False,
|
|
)
|
|
|
|
def _assert_response(response, length=1):
|
|
preds = json.loads(response.content.decode("utf-8"))["predictions"]
|
|
assert len(preds) == length
|
|
assert all(pred.startswith("30") for pred in preds)
|
|
|
|
with pyfunc_scoring_endpoint(
|
|
model_info.model_uri,
|
|
extra_args=["--env-manager", "local"],
|
|
) as endpoint:
|
|
content_type = pyfunc_scoring_server.CONTENT_TYPE_JSON
|
|
|
|
# Test payload with "inputs" key
|
|
inputs_dict = {"inputs": [encoded_audio]}
|
|
payload = json.dumps(inputs_dict)
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
_assert_response(response)
|
|
|
|
# Test payload with "dataframe_split" key
|
|
inference_df = pd.DataFrame(pd.Series([encoded_audio], name="audio_file"))
|
|
split_dict = {"dataframe_split": inference_df.to_dict(orient="split")}
|
|
payload = json.dumps(split_dict)
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
_assert_response(response)
|
|
|
|
# Test payload with "dataframe_records" key
|
|
records_dict = {"dataframe_records": inference_df.to_dict(orient="records")}
|
|
payload = json.dumps(records_dict)
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
_assert_response(response)
|
|
|
|
# Test batch prediction
|
|
inputs_dict = {"inputs": [encoded_audio, encoded_audio]}
|
|
payload = json.dumps(inputs_dict)
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
_assert_response(response, length=2)
|
|
|
|
# Scoring with audio file URI is not supported yet (pyfunc prediction works tho)
|
|
inputs_dict = {"inputs": [read_audio_data("file")]}
|
|
payload = json.dumps(inputs_dict)
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
response = json.loads(response.content.decode("utf-8"))
|
|
assert response["error_code"] == "INVALID_PARAMETER_VALUE"
|
|
assert "Failed to process the input audio data. Either" in response["message"]
|
|
|
|
|
|
# https://github.com/huggingface/transformers/commit/9c500015c556f9ddf6e7a7449d3f46b2e3ff8ea5
|
|
# caused a regression in beam search.
|
|
# https://github.com/huggingface/transformers/commit/a6b51e7341d702127a4a45f37439640840b5abf0
|
|
# fixed the regression but has not been released yet as of May 30, 2025.
|
|
@pytest.mark.skipif(
|
|
Version("4.52.0") <= Version(transformers.__version__) < Version("4.53.0"),
|
|
reason="Transformers 4.52 has a bug for beam search in whiper implementation",
|
|
)
|
|
def test_whisper_model_support_timestamps(whisper_pipeline):
|
|
# Request payload to the model serving endpoint contains base64 encoded audio data
|
|
audio = read_audio_data("bytes")
|
|
encoded_audio = base64.b64encode(audio).decode("ascii")
|
|
|
|
model_config = {
|
|
"return_timestamps": "word",
|
|
"chunk_length_s": 20,
|
|
"stride_length_s": [5, 3],
|
|
}
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
whisper_pipeline,
|
|
name="whisper_timestamps",
|
|
model_config=model_config,
|
|
input_example=(audio, model_config),
|
|
)
|
|
|
|
# Native transformers prediction as ground truth
|
|
gt = whisper_pipeline(audio, **model_config)
|
|
|
|
def _assert_prediction(pred):
|
|
assert pred["text"] == gt["text"]
|
|
assert len(pred["chunks"]) == len(gt["chunks"])
|
|
for pred_chunk, gt_chunk in zip(pred["chunks"], gt["chunks"]):
|
|
assert pred_chunk["text"] == gt_chunk["text"]
|
|
# Timestamps are tuples, but converted to list when serialized to JSON.
|
|
assert tuple(pred_chunk["timestamp"]) == gt_chunk["timestamp"]
|
|
|
|
# Prediction with Pyfunc
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_info.model_uri)
|
|
prediction = json.loads(loaded_pyfunc.predict(audio)[0])
|
|
_assert_prediction(prediction)
|
|
|
|
# Serve and score
|
|
with pyfunc_scoring_endpoint(
|
|
model_info.model_uri,
|
|
extra_args=["--env-manager", "local"],
|
|
) as endpoint:
|
|
content_type = pyfunc_scoring_server.CONTENT_TYPE_JSON
|
|
payload = json.dumps({"inputs": [encoded_audio]})
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
|
|
predictions = json.loads(response.content.decode("utf-8"))["predictions"]
|
|
# When return_timestamps is specified, the predictions list contains json
|
|
# serialized output from the pipeline.
|
|
_assert_prediction(json.loads(predictions[0]))
|
|
|
|
# Request with inference params
|
|
payload = json.dumps({
|
|
"inputs": [encoded_audio],
|
|
"model_config": model_config,
|
|
})
|
|
response = endpoint.invoke(payload, content_type=content_type)
|
|
predictions = json.loads(response.content.decode("utf-8"))["predictions"]
|
|
_assert_prediction(json.loads(predictions[0]))
|
|
|
|
|
|
def test_whisper_model_pyfunc_with_malformed_input(whisper_pipeline, model_path):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=whisper_pipeline,
|
|
path=model_path,
|
|
save_pretrained=False,
|
|
)
|
|
|
|
pyfunc_model = mlflow.pyfunc.load_model(model_path)
|
|
|
|
invalid_audio = b"This isn't a real audio file"
|
|
with pytest.raises(MlflowException, match="Failed to process the input audio data. Either"):
|
|
pyfunc_model.predict([invalid_audio])
|
|
|
|
bad_uri_msg = "An invalid string input was provided. String"
|
|
|
|
with pytest.raises(MlflowException, match=bad_uri_msg):
|
|
pyfunc_model.predict("An invalid path")
|
|
|
|
with pytest.raises(MlflowException, match=bad_uri_msg):
|
|
pyfunc_model.predict("//www.invalid.net/audio.wav")
|
|
|
|
with pytest.raises(MlflowException, match=bad_uri_msg):
|
|
pyfunc_model.predict("https:///my/audio.mp3")
|
|
|
|
|
|
@pytest.mark.parametrize("with_input_example", [True, False])
|
|
def test_audio_classification_pipeline(audio_classification_pipeline, with_input_example):
|
|
audio = read_audio_data("bytes")
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
audio_classification_pipeline,
|
|
name="audio_classification",
|
|
input_example=audio if with_input_example else None,
|
|
save_pretrained=False,
|
|
)
|
|
|
|
inference_payload = json.dumps({"inputs": [base64.b64encode(audio).decode("ascii")]})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
assert isinstance(values, pd.DataFrame)
|
|
assert len(values) > 1
|
|
assert list(values.columns) == ["score", "label"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_name",
|
|
[
|
|
"tiiuae/falcon-7b",
|
|
"openai-community/gpt2",
|
|
"PrunaAI/runwayml-stable-diffusion-v1-5-turbo-tiny-green-smashed",
|
|
],
|
|
)
|
|
def test_save_model_card_with_non_utf_characters(tmp_path, model_name):
|
|
# non-ascii unicode characters
|
|
test_text = (
|
|
"Emoji testing! \u2728 \U0001f600 \U0001f609 \U0001f606 "
|
|
"\U0001f970 \U0001f60e \U0001f917 \U0001f9d0"
|
|
)
|
|
|
|
card_data: ModelCard = huggingface_hub.ModelCard.load(model_name)
|
|
card_data.text = card_data.text + "\n\n" + test_text
|
|
custom_data = card_data.data.to_dict()
|
|
custom_data["emojis"] = test_text
|
|
|
|
card_data.data = huggingface_hub.CardData(**custom_data)
|
|
_write_card_data(card_data, tmp_path)
|
|
|
|
txt = tmp_path.joinpath(_CARD_TEXT_FILE_NAME).read_text()
|
|
assert txt == card_data.text
|
|
data = yaml.safe_load(tmp_path.joinpath(_CARD_DATA_FILE_NAME).read_text())
|
|
assert data == card_data.data.to_dict()
|
|
|
|
|
|
def test_vision_pipeline_pyfunc_predict_with_kwargs(small_vision_model):
|
|
artifact_path = "image_classification_model"
|
|
|
|
parameters = {
|
|
"top_k": 2,
|
|
}
|
|
inference_payload = json.dumps({
|
|
"inputs": [image_url],
|
|
"params": parameters,
|
|
})
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_vision_model,
|
|
name=artifact_path,
|
|
signature=infer_signature(
|
|
image_url,
|
|
mlflow.transformers.generate_signature_output(small_vision_model, image_url),
|
|
params=parameters,
|
|
),
|
|
)
|
|
model_uri = model_info.model_uri
|
|
transformers_loaded_model = mlflow.transformers.load_model(model_uri)
|
|
expected_predictions = transformers_loaded_model.predict(image_url)
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
|
|
predictions = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert (
|
|
list(predictions.to_dict("records")[0].values())
|
|
== expected_predictions[: parameters["top_k"]]
|
|
)
|
|
|
|
|
|
def test_qa_pipeline_pyfunc_predict_with_kwargs(small_qa_pipeline):
|
|
artifact_path = "qa_model"
|
|
data = {
|
|
"question": [
|
|
"What color is it?",
|
|
"What does the 'wolf' howl at?",
|
|
],
|
|
"context": [
|
|
"Some people said it was green but I know that it's pink.",
|
|
"The pack of 'wolves' stood on the cliff and a 'lone wolf' howled at "
|
|
"the moon for hours.",
|
|
],
|
|
}
|
|
parameters = {
|
|
"top_k": 2,
|
|
"max_answer_len": 5,
|
|
}
|
|
inference_payload = json.dumps({
|
|
"inputs": data,
|
|
"params": parameters,
|
|
})
|
|
output = mlflow.transformers.generate_signature_output(small_qa_pipeline, data)
|
|
signature_with_params = infer_signature(
|
|
data,
|
|
output,
|
|
parameters,
|
|
)
|
|
expected_signature = ModelSignature(
|
|
Schema([
|
|
ColSpec(Array(DataType.string), name="question"),
|
|
ColSpec(Array(DataType.string), name="context"),
|
|
]),
|
|
Schema([ColSpec(DataType.string)]),
|
|
ParamSchema([
|
|
ParamSpec("top_k", DataType.long, 2),
|
|
ParamSpec("max_answer_len", DataType.long, 5),
|
|
]),
|
|
)
|
|
assert signature_with_params == expected_signature
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline,
|
|
name=artifact_path,
|
|
signature=signature_with_params,
|
|
)
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert values.to_dict(orient="records") == [
|
|
{0: "pink"},
|
|
{0: "pink."},
|
|
{0: "the moon"},
|
|
{0: "moon"},
|
|
]
|
|
|
|
|
|
def test_uri_directory_renaming_handling_pipeline(model_path, text_classification_pipeline):
|
|
with mlflow.start_run():
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text_classification_pipeline, path=model_path
|
|
)
|
|
|
|
absolute_model_directory = os.path.join(model_path, "model")
|
|
renamed_to_old_convention = os.path.join(model_path, "pipeline")
|
|
os.rename(absolute_model_directory, renamed_to_old_convention)
|
|
|
|
# remove the 'model_binary' entries to emulate older versions of MLflow
|
|
mlmodel_file = os.path.join(model_path, "MLmodel")
|
|
with open(mlmodel_file) as yaml_file:
|
|
mlmodel = yaml.safe_load(yaml_file)
|
|
|
|
mlmodel["flavors"]["python_function"].pop("model_binary", None)
|
|
mlmodel["flavors"]["transformers"].pop("model_binary", None)
|
|
|
|
with open(mlmodel_file, "w") as yaml_file:
|
|
yaml.safe_dump(mlmodel, yaml_file)
|
|
|
|
loaded_model = mlflow.pyfunc.load_model(model_path)
|
|
|
|
prediction = loaded_model.predict("test")
|
|
assert isinstance(prediction, pd.DataFrame)
|
|
assert isinstance(prediction["label"][0], str)
|
|
|
|
|
|
def test_uri_directory_renaming_handling_components(model_path, text_classification_pipeline):
|
|
components = {
|
|
"tokenizer": text_classification_pipeline.tokenizer,
|
|
"model": text_classification_pipeline.model,
|
|
}
|
|
|
|
with mlflow.start_run():
|
|
mlflow.transformers.save_model(transformers_model=components, path=model_path)
|
|
|
|
absolute_model_directory = os.path.join(model_path, "model")
|
|
renamed_to_old_convention = os.path.join(model_path, "pipeline")
|
|
os.rename(absolute_model_directory, renamed_to_old_convention)
|
|
|
|
# remove the 'model_binary' entries to emulate older versions of MLflow
|
|
mlmodel_file = os.path.join(model_path, "MLmodel")
|
|
with open(mlmodel_file) as yaml_file:
|
|
mlmodel = yaml.safe_load(yaml_file)
|
|
|
|
mlmodel["flavors"]["python_function"].pop("model_binary", None)
|
|
mlmodel["flavors"]["transformers"].pop("model_binary", None)
|
|
|
|
with open(mlmodel_file, "w") as yaml_file:
|
|
yaml.safe_dump(mlmodel, yaml_file)
|
|
|
|
loaded_model = mlflow.pyfunc.load_model(model_path)
|
|
|
|
prediction = loaded_model.predict("test")
|
|
assert isinstance(prediction, pd.DataFrame)
|
|
assert isinstance(prediction["label"][0], str)
|
|
|
|
|
|
@skip_transformers_v5_or_later
|
|
def test_pyfunc_model_log_load_with_artifacts_snapshot():
|
|
architecture = "prajjwal1/bert-tiny"
|
|
tokenizer = transformers.AutoTokenizer.from_pretrained(architecture)
|
|
model = transformers.BertForQuestionAnswering.from_pretrained(architecture)
|
|
bert_tiny_pipeline = transformers.pipeline(
|
|
task="question-answering", model=model, tokenizer=tokenizer
|
|
)
|
|
|
|
class QAModel(mlflow.pyfunc.PythonModel):
|
|
def load_context(self, context):
|
|
"""
|
|
This method initializes the tokenizer and language model
|
|
using the specified snapshot location.
|
|
"""
|
|
snapshot_location = context.artifacts["bert-tiny-model"].removeprefix("hf:/")
|
|
# Initialize tokenizer and language model
|
|
tokenizer = transformers.AutoTokenizer.from_pretrained(snapshot_location)
|
|
model = transformers.BertForQuestionAnswering.from_pretrained(snapshot_location)
|
|
self.pipeline = transformers.pipeline(
|
|
task="question-answering", model=model, tokenizer=tokenizer
|
|
)
|
|
|
|
def predict(self, context, model_input, params=None):
|
|
question = model_input["question"][0]
|
|
if isinstance(question, np.ndarray):
|
|
question = question.item()
|
|
ctx = model_input["context"][0]
|
|
if isinstance(ctx, np.ndarray):
|
|
ctx = ctx.item()
|
|
return self.pipeline(question=question, context=ctx)
|
|
|
|
data = {"question": "Who's house?", "context": "The house is owned by Run."}
|
|
pyfunc_artifact_path = "question_answering_model"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name=pyfunc_artifact_path,
|
|
python_model=QAModel(),
|
|
artifacts={"bert-tiny-model": "hf:/prajjwal1/bert-tiny"},
|
|
input_example=data,
|
|
signature=infer_signature(
|
|
data, mlflow.transformers.generate_signature_output(bert_tiny_pipeline, data)
|
|
),
|
|
extra_pip_requirements=["transformers", "torch", "numpy"],
|
|
)
|
|
|
|
pyfunc_model_path = _download_artifact_from_uri(model_info.model_uri)
|
|
assert len(os.listdir(os.path.join(pyfunc_model_path, "artifacts"))) != 0
|
|
model_config = Model.load(os.path.join(pyfunc_model_path, "MLmodel"))
|
|
|
|
loaded_pyfunc_model = mlflow.pyfunc.load_model(model_uri=model_info.model_uri)
|
|
assert model_config.to_yaml() == loaded_pyfunc_model.metadata.to_yaml()
|
|
assert loaded_pyfunc_model.predict(data)["answer"] != ""
|
|
|
|
# Test model serving
|
|
inference_payload = json.dumps({"inputs": data})
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
|
|
assert values.to_dict(orient="records")[0]["answer"] != ""
|
|
|
|
|
|
def test_pyfunc_model_log_load_with_artifacts_snapshot_errors():
|
|
class TestModel(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return model_input
|
|
|
|
with mlflow.start_run():
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Failed to download snapshot from Hugging Face Hub "
|
|
r"with artifact_uri: hf:/invalid-repo-id.",
|
|
):
|
|
mlflow.pyfunc.log_model(
|
|
name="pyfunc_artifact_path",
|
|
python_model=TestModel(),
|
|
artifacts={"some-model": "hf:/invalid-repo-id"},
|
|
)
|
|
|
|
|
|
def test_model_distributed_across_devices():
|
|
mock_model = mock.Mock()
|
|
mock_model.device.type = "meta"
|
|
mock_model.hf_device_map = {
|
|
"layer1": mock.Mock(type="cpu"),
|
|
"layer2": mock.Mock(type="cpu"),
|
|
"layer3": mock.Mock(type="gpu"),
|
|
"layer4": mock.Mock(type="disk"),
|
|
}
|
|
|
|
assert _is_model_distributed_in_memory(mock_model)
|
|
|
|
|
|
def test_model_on_single_device():
|
|
mock_model = mock.Mock()
|
|
mock_model.device.type = "cpu"
|
|
mock_model.hf_device_map = {}
|
|
|
|
assert not _is_model_distributed_in_memory(mock_model)
|
|
|
|
|
|
@skip_transformers_v5_or_later
|
|
def test_basic_model_with_accelerate_device_mapping_fails_save(tmp_path, model_path):
|
|
task = "translation_en_to_de"
|
|
architecture = "t5-small"
|
|
model = transformers.T5ForConditionalGeneration.from_pretrained(
|
|
pretrained_model_name_or_path=architecture,
|
|
device_map={"shared": "cpu", "encoder": "cpu", "decoder": "disk", "lm_head": "disk"},
|
|
offload_folder=str(tmp_path / "weights"),
|
|
low_cpu_mem_usage=True,
|
|
)
|
|
|
|
tokenizer = transformers.T5TokenizerFast.from_pretrained(
|
|
pretrained_model_name_or_path=architecture, model_max_length=100
|
|
)
|
|
pipeline = transformers.pipeline(task=task, model=model, tokenizer=tokenizer)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="The model that is attempting to be saved has been loaded into memory",
|
|
):
|
|
mlflow.transformers.save_model(transformers_model=pipeline, path=model_path)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
Version(transformers.__version__) > Version("4.44.2"),
|
|
reason="Multi-task pipeline (t5) has a loading issue with Transformers 4.45.x. "
|
|
"See https://github.com/huggingface/transformers/issues/33398 for more details.",
|
|
)
|
|
def test_basic_model_with_accelerate_homogeneous_mapping_works(model_path):
|
|
task = "translation_en_to_de"
|
|
architecture = "t5-small"
|
|
model = transformers.T5ForConditionalGeneration.from_pretrained(
|
|
pretrained_model_name_or_path=architecture,
|
|
device_map={"shared": "cpu", "encoder": "cpu", "decoder": "cpu", "lm_head": "cpu"},
|
|
low_cpu_mem_usage=True,
|
|
)
|
|
|
|
tokenizer = transformers.T5TokenizerFast.from_pretrained(
|
|
pretrained_model_name_or_path=architecture, model_max_length=100
|
|
)
|
|
pipeline = transformers.pipeline(task=task, model=model, tokenizer=tokenizer)
|
|
|
|
mlflow.transformers.save_model(transformers_model=pipeline, path=model_path)
|
|
|
|
loaded = mlflow.transformers.load_model(model_path)
|
|
text = "Apples are delicious"
|
|
assert loaded(text) == pipeline(text)
|
|
|
|
|
|
def test_qa_model_model_size_bytes(small_qa_pipeline, tmp_path):
|
|
def _calculate_expected_size(path_or_dir):
|
|
# this helper function does not consider subdirectories
|
|
expected_size = 0
|
|
if path_or_dir.is_dir():
|
|
for path in path_or_dir.iterdir():
|
|
if not path.is_file():
|
|
continue
|
|
expected_size += path.stat().st_size
|
|
elif path_or_dir.is_file():
|
|
expected_size = path_or_dir.stat().st_size
|
|
return expected_size
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=small_qa_pipeline,
|
|
path=tmp_path,
|
|
)
|
|
|
|
# expected size only counts for files saved before the MLmodel file is saved
|
|
model_dir = tmp_path.joinpath("model")
|
|
tokenizer_dir = tmp_path.joinpath("components").joinpath("tokenizer")
|
|
expected_size = 0
|
|
for folder in [model_dir, tokenizer_dir]:
|
|
expected_size += _calculate_expected_size(folder)
|
|
other_files = ["model_card.md", "model_card_data.yaml", "LICENSE.txt"]
|
|
for file in other_files:
|
|
path = tmp_path.joinpath(file)
|
|
expected_size += _calculate_expected_size(path)
|
|
|
|
mlmodel = yaml.safe_load(tmp_path.joinpath("MLmodel").read_bytes())
|
|
assert mlmodel["model_size_bytes"] == expected_size
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("task", "input_example"),
|
|
[
|
|
("llm/v1/completions", None),
|
|
("llm/v1/chat", None),
|
|
(
|
|
"llm/v1/completions",
|
|
{
|
|
"prompt": "How to learn Python in 3 weeks?",
|
|
"max_tokens": 10,
|
|
"stop": "Python",
|
|
},
|
|
),
|
|
(
|
|
"llm/v1/chat",
|
|
{
|
|
"messages": [
|
|
{"role": "system", "content": "Hello, how are you?"},
|
|
],
|
|
"temperature": 0.5,
|
|
"max_tokens": 50,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
def test_text_generation_save_model_with_inference_task(
|
|
monkeypatch, task, input_example, text_generation_pipeline, model_path
|
|
):
|
|
# Strictly raise error during requirements inference for testing purposes
|
|
monkeypatch.setenv("MLFLOW_REQUIREMENTS_INFERENCE_RAISE_ERRORS", "true")
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text_generation_pipeline,
|
|
path=model_path,
|
|
task=task,
|
|
input_example=input_example,
|
|
)
|
|
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["inference_task"] == task
|
|
assert mlmodel["metadata"]["task"] == task
|
|
|
|
if input_example:
|
|
saved_input_example = json.loads(model_path.joinpath("input_example.json").read_text())
|
|
assert saved_input_example == input_example
|
|
|
|
|
|
def test_text_generation_save_model_with_invalid_inference_task(
|
|
text_generation_pipeline, model_path
|
|
):
|
|
with pytest.raises(
|
|
MlflowException, match=r"The task provided is invalid.*Must be.*llm/v1/completions"
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text_generation_pipeline,
|
|
path=model_path,
|
|
task="llm/v1/invalid",
|
|
)
|
|
|
|
|
|
def test_text_generation_log_model_with_mismatched_task(text_generation_pipeline):
|
|
with pytest.raises(
|
|
MlflowException, match=r"LLM v1 task type 'llm/v1/chat' is specified in metadata, but"
|
|
):
|
|
with mlflow.start_run():
|
|
mlflow.transformers.log_model(
|
|
text_generation_pipeline,
|
|
name="model",
|
|
# Task argument and metadata task are different
|
|
task=None,
|
|
metadata={"task": "llm/v1/chat"},
|
|
)
|
|
|
|
|
|
def test_text_generation_task_completions_predict_with_max_tokens(
|
|
text_generation_pipeline, model_path
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text_generation_pipeline,
|
|
path=model_path,
|
|
task="llm/v1/completions",
|
|
model_config={"max_tokens": 10},
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict(
|
|
{"prompt": "How to learn Python in 3 weeks?", "max_tokens": 10},
|
|
)
|
|
|
|
assert isinstance(inference[0], dict)
|
|
assert inference[0]["model"] == "distilgpt2"
|
|
assert inference[0]["object"] == "text_completion"
|
|
assert (
|
|
inference[0]["choices"][0]["finish_reason"] == "length"
|
|
and inference[0]["usage"]["completion_tokens"] == 10
|
|
) or (
|
|
inference[0]["choices"][0]["finish_reason"] == "stop"
|
|
and inference[0]["usage"]["completion_tokens"] < 10
|
|
)
|
|
|
|
# Override model_config with runtime params
|
|
inference = pyfunc_loaded.predict(
|
|
{"prompt": "How to learn Python in 3 weeks?", "max_tokens": 5},
|
|
)
|
|
assert 6 > inference[0]["usage"]["completion_tokens"] > 0
|
|
|
|
|
|
def test_text_generation_task_completions_predict_with_stop(text_generation_pipeline, model_path):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text_generation_pipeline,
|
|
path=model_path,
|
|
task="llm/v1/completions",
|
|
metadata={"foo": "bar"},
|
|
model_config={"stop": ["Python"], "max_tokens": 50},
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
inference = pyfunc_loaded.predict(
|
|
{"prompt": "How to learn Python in 3 weeks?"},
|
|
)
|
|
|
|
if "Python" not in inference[0]["choices"][0]["text"]:
|
|
pytest.skip(
|
|
"Model did not generate text containing 'Python', "
|
|
"skipping validation of stop parameter in inference"
|
|
)
|
|
|
|
assert (
|
|
inference[0]["choices"][0]["finish_reason"] == "stop"
|
|
and inference[0]["usage"]["completion_tokens"] < 50
|
|
) or (
|
|
inference[0]["choices"][0]["finish_reason"] == "length"
|
|
and inference[0]["usage"]["completion_tokens"] == 50
|
|
)
|
|
|
|
assert inference[0]["choices"][0]["text"].endswith("Python")
|
|
|
|
# Override model_config with runtime params
|
|
inference = pyfunc_loaded.predict(
|
|
{"prompt": "How to learn Python in 3 weeks?", "stop": ["Abracadabra"]},
|
|
)
|
|
|
|
response_text = inference[0]["choices"][0]["text"]
|
|
|
|
# Only check for early stopping if we stop on the word "Python".
|
|
# If we exhaust the token limit, there is a non-insignificant chance of
|
|
# terminating on the word due to max tokens, which should not count as
|
|
# a stop word abort if there are multiple instances of the word in the text.
|
|
if 0 < response_text.count("Python") < 2:
|
|
assert not inference[0]["choices"][0]["text"].endswith("Python")
|
|
|
|
|
|
def test_text_generation_task_completions_serve(text_generation_pipeline):
|
|
data = {"prompt": "How to learn Python in 3 weeks?"}
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
text_generation_pipeline,
|
|
name="model",
|
|
task="llm/v1/completions",
|
|
)
|
|
|
|
inference_payload = json.dumps({"inputs": data})
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
values = PredictionsResponse.from_json(response.content.decode("utf-8")).get_predictions()
|
|
output_dict = values.to_dict("records")[0]
|
|
assert output_dict["choices"][0]["text"] is not None
|
|
assert output_dict["choices"][0]["finish_reason"] == "stop"
|
|
assert output_dict["usage"]["prompt_tokens"] < 20
|
|
|
|
|
|
def test_llm_v1_task_embeddings_predict(feature_extraction_pipeline, model_path):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=feature_extraction_pipeline,
|
|
path=model_path,
|
|
input_examples=["Football", "Soccer"],
|
|
task="llm/v1/embeddings",
|
|
)
|
|
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["inference_task"] == "llm/v1/embeddings"
|
|
assert mlmodel["metadata"]["task"] == "llm/v1/embeddings"
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
# Predict with single string input
|
|
prediction = pyfunc_loaded.predict({"input": "A great day"})
|
|
assert prediction["object"] == "list"
|
|
assert len(prediction["data"]) == 1
|
|
assert prediction["data"][0]["object"] == "embedding"
|
|
assert prediction["usage"]["prompt_tokens"] == 5
|
|
assert len(prediction["data"][0]["embedding"]) == 384
|
|
|
|
# Predict with list of string input
|
|
prediction = pyfunc_loaded.predict({"input": ["A great day", "A bad day"]})
|
|
assert prediction["object"] == "list"
|
|
assert len(prediction["data"]) == 2
|
|
assert prediction["data"][0]["object"] == "embedding"
|
|
assert prediction["usage"]["prompt_tokens"] == 10
|
|
assert len(prediction["data"][0]["embedding"]) == 384
|
|
|
|
# Predict with pandas dataframe input
|
|
df = pd.DataFrame({"input": ["A great day", "A bad day", "A good day"]})
|
|
prediction = pyfunc_loaded.predict(df)
|
|
assert prediction["object"] == "list"
|
|
assert len(prediction["data"]) == 3
|
|
assert prediction["data"][0]["object"] == "embedding"
|
|
assert prediction["usage"]["prompt_tokens"] == 15
|
|
assert len(prediction["data"][0]["embedding"]) == 384
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"request_payload",
|
|
[
|
|
{"input": "A single string"},
|
|
{
|
|
"inputs": {"input": ["A list of strings"]},
|
|
},
|
|
],
|
|
)
|
|
def test_llm_v1_task_embeddings_serve(feature_extraction_pipeline, request_payload):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
feature_extraction_pipeline,
|
|
name="model",
|
|
input_examples=["Football", "Soccer"],
|
|
task="llm/v1/embeddings",
|
|
)
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=json.dumps(request_payload),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
response = json.loads(response.content.decode("utf-8"))
|
|
prediction = response["predictions"] if "inputs" in request_payload else response
|
|
|
|
assert prediction["object"] == "list"
|
|
assert len(prediction["data"]) == 1
|
|
assert prediction["data"][0]["object"] == "embedding"
|
|
assert len(prediction["data"][0]["embedding"]) == 384
|
|
|
|
|
|
def test_get_task_for_model():
|
|
with mock.patch("transformers.pipelines.get_task") as mock_get_task:
|
|
mock_get_task.return_value = "feature-extraction"
|
|
assert _get_task_for_model("model") == "feature-extraction"
|
|
|
|
# Some model task is not supported by Transformers pipeline yet. Then fall back
|
|
# to the default task if provided, otherwise raise an exception.
|
|
mock_get_task.return_value = "unsupported-task"
|
|
assert (
|
|
_get_task_for_model("model", default_task="feature-extraction") == "feature-extraction"
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match="Cannot construct transformers pipeline"):
|
|
_get_task_for_model("model")
|
|
|
|
# If get_task raises an exception, fall back to the default task if provided.
|
|
mock_get_task.side_effect = RuntimeError("Some error")
|
|
assert (
|
|
_get_task_for_model("model", default_task="feature-extraction") == "feature-extraction"
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match="The task could not be inferred"):
|
|
_get_task_for_model("model")
|
|
|
|
|
|
@skip_transformers_v5_or_later
|
|
def test_local_custom_model_save_and_load(text_generation_pipeline, model_path, tmp_path):
|
|
local_repo_path = tmp_path / "local_repo"
|
|
text_generation_pipeline.save_pretrained(local_repo_path)
|
|
|
|
locally_loaded_model = transformers.AutoModelForCausalLM.from_pretrained(local_repo_path)
|
|
tokenizer = transformers.AutoTokenizer.from_pretrained(
|
|
local_repo_path, chat_template=CHAT_TEMPLATE
|
|
)
|
|
model_dict = {"model": locally_loaded_model, "tokenizer": tokenizer}
|
|
|
|
# 1. Save local custom model without specifying task -> raises MlflowException
|
|
with pytest.raises(MlflowException, match=r"The task could not be inferred"):
|
|
mlflow.transformers.save_model(transformers_model=model_dict, path=model_path)
|
|
|
|
# 2. Save local custom model with task -> saves successfully
|
|
mlflow.transformers.save_model(
|
|
transformers_model=model_dict,
|
|
path=model_path,
|
|
task="text-generation",
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict("How to save Transformer model?")
|
|
assert isinstance(inference[0], str)
|
|
assert inference[0].startswith("How to save Transformer model?")
|
|
|
|
# 3. Save local custom model with LLM v1 chat inference task -> saves successfully
|
|
# with the corresponding Transformers task
|
|
shutil.rmtree(model_path)
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=model_dict,
|
|
path=model_path,
|
|
task="llm/v1/chat",
|
|
)
|
|
|
|
mlmodel = yaml.safe_load(model_path.joinpath("MLmodel").read_bytes())
|
|
flavor_config = mlmodel["flavors"]["transformers"]
|
|
assert flavor_config["task"] == "text-generation"
|
|
assert flavor_config["inference_task"] == "llm/v1/chat"
|
|
assert mlmodel["metadata"]["task"] == "llm/v1/chat"
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict({
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "How to save Transformer model?",
|
|
}
|
|
]
|
|
})
|
|
assert isinstance(inference[0], dict)
|
|
assert inference[0]["choices"][0]["message"]["role"] == "assistant"
|
|
|
|
|
|
def test_model_config_is_not_mutated_after_prediction(text2text_generation_pipeline):
|
|
model_config = {
|
|
"top_k": 2,
|
|
"num_beams": 5,
|
|
"max_length": 30,
|
|
"max_new_tokens": 500,
|
|
}
|
|
|
|
# Params will be used to override the values of model_config but should not mutate it
|
|
params = {
|
|
"top_k": 30,
|
|
"max_length": 500,
|
|
"max_new_tokens": 5,
|
|
}
|
|
|
|
pyfunc_model = _TransformersWrapper(text2text_generation_pipeline, model_config=model_config)
|
|
assert pyfunc_model.model_config["top_k"] == 2
|
|
|
|
prediction_output = pyfunc_model.predict(
|
|
"rocket moon ship astronaut space gravity", params=params
|
|
)
|
|
|
|
assert pyfunc_model.model_config["top_k"] == 2
|
|
assert pyfunc_model.model_config["num_beams"] == 5
|
|
assert pyfunc_model.model_config["max_length"] == 30
|
|
assert pyfunc_model.model_config["max_new_tokens"] == 500
|
|
assert len(prediction_output[0].split(" ")) <= 5
|
|
|
|
|
|
def test_text_generation_task_chat_predict(text_generation_pipeline, model_path):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=text_generation_pipeline,
|
|
path=model_path,
|
|
task="llm/v1/chat",
|
|
)
|
|
|
|
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
|
|
|
|
inference = pyfunc_loaded.predict({
|
|
"messages": [
|
|
{"role": "system", "content": "Hello, how can I help you today?"},
|
|
{"role": "user", "content": "How to learn Python in 3 weeks?"},
|
|
],
|
|
"max_tokens": 10,
|
|
})
|
|
|
|
assert inference[0]["choices"][0]["message"]["role"] == "assistant"
|
|
assert (
|
|
inference[0]["choices"][0]["finish_reason"] == "length"
|
|
and inference[0]["usage"]["completion_tokens"] == 10
|
|
) or (
|
|
inference[0]["choices"][0]["finish_reason"] == "stop"
|
|
and inference[0]["usage"]["completion_tokens"] < 10
|
|
)
|
|
|
|
|
|
def test_text_generation_task_chat_serve(text_generation_pipeline):
|
|
data = {
|
|
"messages": [
|
|
{"role": "user", "content": "How to learn Python in 3 weeks?"},
|
|
],
|
|
"max_tokens": 10,
|
|
}
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
text_generation_pipeline,
|
|
name="model",
|
|
task="llm/v1/chat",
|
|
)
|
|
|
|
inference_payload = json.dumps(data)
|
|
|
|
response = pyfunc_serve_and_score_model(
|
|
model_info.model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
|
|
output_dict = json.loads(response.content)[0]
|
|
assert output_dict["choices"][0]["message"] is not None
|
|
assert (
|
|
output_dict["choices"][0]["finish_reason"] == "length"
|
|
and output_dict["usage"]["completion_tokens"] == 10
|
|
) or (
|
|
output_dict["choices"][0]["finish_reason"] == "stop"
|
|
and output_dict["usage"]["completion_tokens"] < 10
|
|
)
|
|
assert output_dict["usage"]["prompt_tokens"] < 20
|
|
|
|
|
|
HF_COMMIT_HASH_PATTERN = re.compile(r"^[a-z0-9]{40}$")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_fixture", "input_example", "components"),
|
|
[
|
|
("text2text_generation_pipeline", "What is MLflow?", {"tokenizer"}),
|
|
("text_generation_pipeline", "What is MLflow?", {"tokenizer"}),
|
|
(
|
|
"small_vision_model",
|
|
image_url,
|
|
{"image_processor"} if IS_NEW_FEATURE_EXTRACTION_API else {"feature_extractor"},
|
|
),
|
|
(
|
|
"component_multi_modal",
|
|
{"text": "What is MLflow?", "image": image_url},
|
|
{"image_processor", "tokenizer"}
|
|
if IS_NEW_FEATURE_EXTRACTION_API
|
|
else {"feature_extractor", "tokenizer"},
|
|
),
|
|
("fill_mask_pipeline", "The quick brown <mask> jumps over the lazy dog.", {"tokenizer"}),
|
|
("whisper_pipeline", lambda: read_audio_data("bytes"), {"feature_extractor", "tokenizer"}),
|
|
("feature_extraction_pipeline", "What is MLflow?", {"tokenizer"}),
|
|
],
|
|
)
|
|
def test_save_and_load_pipeline_without_save_pretrained_false(
|
|
model_fixture, input_example, components, model_path, request
|
|
):
|
|
pipeline = request.getfixturevalue(model_fixture)
|
|
model = pipeline["model"] if isinstance(pipeline, dict) else pipeline.model
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=pipeline,
|
|
path=model_path,
|
|
save_pretrained=False,
|
|
)
|
|
|
|
# No weights should be saved
|
|
assert not model_path.joinpath("model").exists()
|
|
assert not model_path.joinpath("components").exists()
|
|
|
|
# Validate the contents of MLModel file
|
|
mlmodel = Model.load(str(model_path.joinpath("MLmodel")))
|
|
flavor_conf = mlmodel.flavors["transformers"]
|
|
assert "model_binary" not in flavor_conf
|
|
assert flavor_conf["source_model_name"] == model.name_or_path
|
|
assert HF_COMMIT_HASH_PATTERN.match(flavor_conf["source_model_revision"])
|
|
assert set(flavor_conf["components"]) == components
|
|
for c in components:
|
|
component = pipeline[c] if isinstance(pipeline, dict) else getattr(pipeline, c)
|
|
assert flavor_conf[f"{c}_name"] == getattr(component, "name_or_path", model.name_or_path)
|
|
assert HF_COMMIT_HASH_PATTERN.match(flavor_conf[f"{c}_revision"])
|
|
|
|
# Validate pyfunc load and prediction (if pyfunc supported)
|
|
if "python_function" in mlmodel.flavors:
|
|
if callable(input_example):
|
|
input_example = input_example()
|
|
mlflow.pyfunc.load_model(model_path).predict(input_example)
|
|
|
|
|
|
# Patch tempdir just to verify the invocation
|
|
def test_persist_pretrained_model(small_qa_pipeline):
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline,
|
|
name="model",
|
|
save_pretrained=False,
|
|
pip_requirements=["mlflow"], # For speed up logging
|
|
)
|
|
|
|
artifact_path = Path(mlflow.artifacts.download_artifacts(model_info.model_uri))
|
|
model_path = artifact_path / "model"
|
|
tokenizer_path = artifact_path / "components" / "tokenizer"
|
|
|
|
original_config = Model.load(artifact_path).flavors["transformers"]
|
|
assert "model_binary" not in original_config
|
|
assert "source_model_revision" in original_config
|
|
assert not model_path.exists()
|
|
assert not tokenizer_path.exists()
|
|
|
|
with mock.patch(
|
|
"mlflow.transformers.TempDir", side_effect=mlflow.utils.file_utils.TempDir
|
|
) as mock_tmpdir:
|
|
mlflow.transformers.persist_pretrained_model(model_info.model_uri)
|
|
mock_tmpdir.assert_called_once()
|
|
|
|
updated_config = Model.load(model_info.model_uri).flavors["transformers"]
|
|
assert "model_binary" in updated_config
|
|
assert "source_model_revision" not in updated_config
|
|
assert model_path.exists()
|
|
model_path_files = list(model_path.iterdir())
|
|
assert len(model_path_files) > 0
|
|
assert tokenizer_path.exists()
|
|
assert (tokenizer_path / "tokenizer.json").exists()
|
|
|
|
# Repeat persisting the model will no-op
|
|
with mock.patch(
|
|
"mlflow.transformers.TempDir", side_effect=mlflow.utils.file_utils.TempDir
|
|
) as mock_tmpdir:
|
|
mlflow.transformers.persist_pretrained_model(model_info.model_uri)
|
|
mock_tmpdir.assert_not_called()
|
|
|
|
|
|
def test_small_qa_pipeline_copy_metadata_in_databricks(
|
|
mock_is_in_databricks, small_qa_pipeline, tmp_path
|
|
):
|
|
artifact_path = "transformers"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
small_qa_pipeline,
|
|
name=artifact_path,
|
|
)
|
|
artifact_path = mlflow.artifacts.download_artifacts(
|
|
artifact_uri=model_info.model_uri, dst_path=tmp_path.as_posix()
|
|
)
|
|
|
|
# Metadata should be copied only in Databricks
|
|
metadata_path = os.path.join(artifact_path, "metadata")
|
|
if mock_is_in_databricks.return_value:
|
|
assert set(os.listdir(metadata_path)) == set(METADATA_FILES)
|
|
else:
|
|
assert not os.path.exists(metadata_path)
|
|
mock_is_in_databricks.assert_called_once()
|
|
|
|
|
|
def test_peft_pipeline_copy_metadata_in_databricks(mock_is_in_databricks, peft_pipeline, tmp_path):
|
|
artifact_path = "transformers"
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
peft_pipeline,
|
|
name=artifact_path,
|
|
)
|
|
|
|
artifact_path = mlflow.artifacts.download_artifacts(
|
|
artifact_uri=model_info.model_uri, dst_path=tmp_path.as_posix()
|
|
)
|
|
|
|
# Metadata should be copied only in Databricks
|
|
metadata_path = os.path.join(artifact_path, "metadata")
|
|
if mock_is_in_databricks.return_value:
|
|
assert set(os.listdir(metadata_path)) == set(METADATA_FILES)
|
|
else:
|
|
assert not os.path.exists(metadata_path)
|
|
mock_is_in_databricks.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("device", ["cpu", "cuda", 0, -1, None])
|
|
def test_device_param_on_load_model(device, small_qa_pipeline, model_path, monkeypatch):
|
|
mlflow.transformers.save_model(small_qa_pipeline, path=model_path)
|
|
conf = mlflow.transformers.load_model(model_path, return_type="components", device=device)
|
|
assert conf.get("device") == device
|
|
|
|
monkeypatch.setenv("MLFLOW_HUGGINGFACE_USE_DEVICE_MAP", "true")
|
|
if device is None:
|
|
conf = mlflow.transformers.load_model(model_path, return_type="components", device=device)
|
|
assert conf.get("device") is None
|
|
else:
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"The environment variable MLFLOW_HUGGINGFACE_USE_DEVICE_MAP is set to True, "
|
|
rf"but the `device` argument is provided with value {device}.",
|
|
):
|
|
mlflow.transformers.load_model(model_path, return_type="components", device=device)
|
|
|
|
|
|
@pytest.fixture
|
|
def local_checkpoint_path(tmp_path):
|
|
"""
|
|
Fixture to create a local model checkpoint for testing fine-tuning scenario.
|
|
"""
|
|
model = transformers.AutoModelForCausalLM.from_pretrained("distilgpt2")
|
|
|
|
class DummyDataset(torch.utils.data.Dataset):
|
|
def __getitem__(self, idx):
|
|
pass
|
|
|
|
def __len__(self):
|
|
return 1
|
|
|
|
# Create a trainer and save model, but not running the actual training
|
|
training_args = transformers.TrainingArguments(
|
|
output_dir=tmp_path / "result",
|
|
num_train_epochs=1,
|
|
per_device_train_batch_size=4,
|
|
report_to="none",
|
|
)
|
|
trainer = transformers.Trainer(model=model, args=training_args, train_dataset=DummyDataset())
|
|
|
|
checkpoint_path = tmp_path / "checkpoint"
|
|
trainer.save_model(checkpoint_path)
|
|
|
|
# The tokenizer should also be saved in the checkpoint
|
|
tokenizer = transformers.AutoTokenizer.from_pretrained(
|
|
# Chat template is required to test with llm/v1/chat task
|
|
"distilgpt2",
|
|
chat_template=CHAT_TEMPLATE,
|
|
)
|
|
tokenizer.save_pretrained(checkpoint_path)
|
|
|
|
return str(checkpoint_path)
|
|
|
|
|
|
def test_save_model_from_local_checkpoint(model_path, local_checkpoint_path):
|
|
with mock.patch("mlflow.transformers._logger") as mock_logger:
|
|
mlflow.transformers.save_model(
|
|
transformers_model=local_checkpoint_path,
|
|
task="text-generation",
|
|
path=model_path,
|
|
input_example=["What is MLflow?"],
|
|
)
|
|
|
|
logged_info = Model.load(model_path)
|
|
flavor_conf = logged_info.flavors["transformers"]
|
|
assert flavor_conf["source_model_name"] == local_checkpoint_path
|
|
assert flavor_conf["task"] == "text-generation"
|
|
if not IS_TRANSFORMERS_V5_OR_LATER:
|
|
assert flavor_conf["framework"] == "pt"
|
|
assert flavor_conf["instance_type"] == "TextGenerationPipeline"
|
|
expected_tokenizer_type = (
|
|
"GPT2Tokenizer" if IS_TRANSFORMERS_V5_OR_LATER else "GPT2TokenizerFast"
|
|
)
|
|
assert flavor_conf["tokenizer_type"] == expected_tokenizer_type
|
|
|
|
# Default task signature should be used
|
|
assert logged_info.signature.inputs == Schema([ColSpec(DataType.string)])
|
|
assert logged_info.signature.outputs == Schema([ColSpec(DataType.string)])
|
|
|
|
# Default requirements should be used
|
|
info_calls = mock_logger.info.call_args_list
|
|
assert any("A local checkpoint path or PEFT model" in c[0][0] for c in info_calls)
|
|
with model_path.joinpath("requirements.txt").open() as f:
|
|
reqs = {req.split("==")[0] for req in f.read().split("\n")}
|
|
assert reqs == {"mlflow", "accelerate", "transformers", "torch", "torchvision"}
|
|
|
|
# Load as native pipeline
|
|
loaded_pipeline = mlflow.transformers.load_model(model_path)
|
|
assert isinstance(loaded_pipeline, transformers.TextGenerationPipeline)
|
|
|
|
query = "What is MLflow?"
|
|
pred_native = loaded_pipeline(query)[0]
|
|
assert pred_native["generated_text"].startswith(query)
|
|
|
|
# Load as pyfunc
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
pred_pyfunc = loaded_pyfunc.predict(query)[0]
|
|
assert pred_pyfunc.startswith(query)
|
|
|
|
# Serve
|
|
response = pyfunc_serve_and_score_model(
|
|
model_path,
|
|
data=json.dumps({"inputs": [query]}),
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=["--env-manager", "local"],
|
|
)
|
|
pred_serve = json.loads(response.content.decode("utf-8"))
|
|
assert pred_serve["predictions"][0].startswith(query)
|
|
|
|
|
|
@skip_transformers_v5_or_later
|
|
def test_save_model_from_local_checkpoint_with_custom_tokenizer(model_path, local_checkpoint_path):
|
|
# When a custom tokenizer is also saved in the checkpoint, MLflow should save and load it.
|
|
tokenizer = transformers.AutoTokenizer.from_pretrained("distilroberta-base")
|
|
tokenizer.add_special_tokens({"additional_special_tokens": ["<sushi>"]})
|
|
tokenizer.save_pretrained(local_checkpoint_path)
|
|
|
|
mlflow.transformers.save_model(
|
|
transformers_model=local_checkpoint_path,
|
|
path=model_path,
|
|
task="text-generation",
|
|
input_example=["What is MLflow?"],
|
|
)
|
|
|
|
# The custom tokenizer should be loaded
|
|
loaded_pipeline = mlflow.transformers.load_model(model_path)
|
|
tokenizer = loaded_pipeline.tokenizer
|
|
assert tokenizer.special_tokens_map["additional_special_tokens"] == ["<sushi>"]
|
|
|
|
|
|
def test_save_model_from_local_checkpoint_with_llm_inference_task(
|
|
model_path, local_checkpoint_path
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=local_checkpoint_path,
|
|
path=model_path,
|
|
task="llm/v1/chat",
|
|
input_example=["What is MLflow?"],
|
|
)
|
|
|
|
logged_info = Model.load(model_path)
|
|
flavor_conf = logged_info.flavors["transformers"]
|
|
assert flavor_conf["source_model_name"] == local_checkpoint_path
|
|
assert flavor_conf["task"] == "text-generation"
|
|
assert flavor_conf["inference_task"] == "llm/v1/chat"
|
|
|
|
# Load as pyfunc
|
|
loaded_pyfunc = mlflow.pyfunc.load_model(model_path)
|
|
response = loaded_pyfunc.predict({
|
|
"messages": [
|
|
{"role": "system", "content": "Hello, how can I help you today?"},
|
|
{"role": "user", "content": "What is MLflow?"},
|
|
],
|
|
})
|
|
assert response[0]["choices"][0]["message"]["role"] == "assistant"
|
|
assert response[0]["choices"][0]["message"]["content"] is not None
|
|
|
|
|
|
def test_save_model_from_local_checkpoint_invalid_arguments(model_path, local_checkpoint_path):
|
|
with pytest.raises(MlflowException, match=r"The `task` argument must be specified"):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=local_checkpoint_path,
|
|
path=model_path,
|
|
)
|
|
|
|
with pytest.raises(
|
|
MlflowException, match=r"The `save_pretrained` argument must be set to True"
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model=local_checkpoint_path,
|
|
path=model_path,
|
|
task="fill-mask",
|
|
save_pretrained=False,
|
|
)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"The provided directory invalid path does not contain a config.json file.",
|
|
):
|
|
mlflow.transformers.save_model(
|
|
transformers_model="invalid path",
|
|
path=model_path,
|
|
task="fill-mask",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_fixture", "should_skip_validation"),
|
|
[
|
|
("local_checkpoint_path", True),
|
|
("fill_mask_pipeline", False),
|
|
],
|
|
)
|
|
def test_log_model_skip_validating_serving_input_for_local_checkpoint(
|
|
model_fixture,
|
|
should_skip_validation,
|
|
tmp_path,
|
|
request,
|
|
):
|
|
# input to avoid expensive computation
|
|
model = request.getfixturevalue(model_fixture)
|
|
with mock.patch("mlflow.models.utils._validate_serving_input") as mock_validate_input:
|
|
# Ensure mlflow skips serving input validation for local checkpoint
|
|
with mlflow.start_run():
|
|
model_info = mlflow.transformers.log_model(
|
|
model,
|
|
name="model",
|
|
task="fill-mask",
|
|
input_example=["How are you?"],
|
|
)
|
|
|
|
# Serving input should exist regardless of the skip validation
|
|
mlflow_model = Model.load(model_info.model_uri)
|
|
local_path = _download_artifact_from_uri(model_info.model_uri, output_path=tmp_path)
|
|
serving_input = mlflow_model.get_serving_input(local_path)
|
|
assert json.loads(serving_input) == {"inputs": ["How are you?"]}
|
|
|
|
if should_skip_validation:
|
|
mock_validate_input.assert_not_called()
|
|
else:
|
|
mock_validate_input.assert_called_once()
|