Files
mlflow--mlflow/tests/h2o/test_h2o_model_export.py
2026-07-13 13:22:34 +08:00

391 lines
14 KiB
Python

# pep8: disable=E501
import json
import os
from typing import Any, NamedTuple
from unittest import mock
import h2o
import numpy as np
import pandas as pd
import pytest
import yaml
from h2o.estimators.gbm import H2OGradientBoostingEstimator
from sklearn import datasets
import mlflow
import mlflow.h2o
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
from mlflow import pyfunc
from mlflow.models import Model, ModelSignature
from mlflow.models.utils import _read_example, load_serving_example
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.types import DataType
from mlflow.types.schema import ColSpec, Schema
from mlflow.utils.environment import _mlflow_conda_env
from mlflow.utils.file_utils import TempDir
from mlflow.utils.model_utils import _get_flavor_configuration
from tests.helper_functions import (
_assert_pip_requirements,
_compare_conda_env_requirements,
_compare_logged_code_paths,
_mlflow_major_version_string,
pyfunc_serve_and_score_model,
)
class ModelWithData(NamedTuple):
model: Any
inference_data: Any
@pytest.fixture
def h2o_iris_model():
h2o.init()
iris = datasets.load_iris()
data = h2o.H2OFrame({
"feature1": list(iris.data[:, 0]),
"feature2": list(iris.data[:, 1]),
"target": ([f"Flower {i}" for i in iris.target]),
})
train, test = data.split_frame(ratios=[0.7])
h2o_gbm = H2OGradientBoostingEstimator(ntrees=10, max_depth=6)
h2o_gbm.train(["feature1", "feature2"], "target", training_frame=train)
return ModelWithData(model=h2o_gbm, inference_data=test)
@pytest.fixture(scope="module")
def h2o_iris_model_signature():
return ModelSignature(
inputs=Schema([
ColSpec(name="feature1", type=DataType.double),
ColSpec(name="feature2", type=DataType.double),
ColSpec(name="target", type=DataType.string),
]),
outputs=Schema([
ColSpec(name="predict", type=DataType.string),
ColSpec(name="Flower 0", type=DataType.double),
ColSpec(name="Flower 1", type=DataType.double),
ColSpec(name="Flower 2", type=DataType.double),
]),
)
@pytest.fixture
def model_path(tmp_path):
return os.path.join(tmp_path, "model")
@pytest.fixture
def h2o_custom_env(tmp_path):
conda_env = os.path.join(tmp_path, "conda_env.yml")
_mlflow_conda_env(conda_env, additional_pip_deps=["h2o", "pytest"])
return conda_env
def test_model_save_load(h2o_iris_model, model_path):
h2o_model = h2o_iris_model.model
mlflow.h2o.save_model(h2o_model=h2o_model, path=model_path)
# Loading h2o model
h2o_model_loaded = mlflow.h2o.load_model(model_path)
assert all(
h2o_model_loaded.predict(h2o_iris_model.inference_data).as_data_frame()
== h2o_model.predict(h2o_iris_model.inference_data).as_data_frame()
)
# Loading pyfunc model
pyfunc_loaded = mlflow.pyfunc.load_model(model_path)
assert all(
pyfunc_loaded.predict(h2o_iris_model.inference_data.as_data_frame())
== h2o_model.predict(h2o_iris_model.inference_data).as_data_frame()
)
def test_signature_and_examples_are_saved_correctly(h2o_iris_model, h2o_iris_model_signature):
model = h2o_iris_model.model
example_ = h2o_iris_model.inference_data.as_data_frame().head(3)
for signature in (None, h2o_iris_model_signature):
for example in (None, example_):
with TempDir() as tmp:
path = tmp.path("model")
mlflow.h2o.save_model(model, path=path, signature=signature, input_example=example)
mlflow_model = Model.load(path)
if signature is None and example is None:
assert mlflow_model.signature is None
else:
assert mlflow_model.signature == h2o_iris_model_signature
if example is None:
assert mlflow_model.saved_input_example_info is None
else:
assert all((_read_example(mlflow_model, path) == example).all())
def test_model_log(h2o_iris_model):
h2o_model = h2o_iris_model.model
try:
artifact_path = "gbm_model"
model_info = mlflow.h2o.log_model(h2o_model, name=artifact_path)
# Load model
h2o_model_loaded = mlflow.h2o.load_model(model_uri=model_info.model_uri)
assert all(
h2o_model_loaded.predict(h2o_iris_model.inference_data).as_data_frame()
== h2o_model.predict(h2o_iris_model.inference_data).as_data_frame()
)
finally:
mlflow.end_run()
def test_model_load_succeeds_with_missing_data_key_when_data_exists_at_default_path(
h2o_iris_model, model_path
):
"""
This is a backwards compatibility test to ensure that models saved in MLflow version <= 0.7.0
can be loaded successfully. These models are missing the `data` flavor configuration key.
"""
h2o_model = h2o_iris_model.model
mlflow.h2o.save_model(h2o_model=h2o_model, path=model_path)
model_conf_path = os.path.join(model_path, "MLmodel")
model_conf = Model.load(model_conf_path)
flavor_conf = model_conf.flavors.get(mlflow.h2o.FLAVOR_NAME, None)
assert flavor_conf is not None
del flavor_conf["data"]
model_conf.save(model_conf_path)
h2o_model_loaded = mlflow.h2o.load_model(model_path)
assert all(
h2o_model_loaded.predict(h2o_iris_model.inference_data).as_data_frame()
== h2o_model.predict(h2o_iris_model.inference_data).as_data_frame()
)
def test_model_save_persists_specified_conda_env_in_mlflow_model_directory(
h2o_iris_model, model_path, h2o_custom_env
):
mlflow.h2o.save_model(h2o_model=h2o_iris_model.model, path=model_path, conda_env=h2o_custom_env)
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV]["conda"])
assert os.path.exists(saved_conda_env_path)
assert saved_conda_env_path != h2o_custom_env
with open(h2o_custom_env) as f:
h2o_custom_env_text = f.read()
with open(saved_conda_env_path) as f:
saved_conda_env_text = f.read()
assert saved_conda_env_text == h2o_custom_env_text
def test_model_save_persists_requirements_in_mlflow_model_directory(
h2o_iris_model, model_path, h2o_custom_env
):
mlflow.h2o.save_model(h2o_model=h2o_iris_model.model, path=model_path, conda_env=h2o_custom_env)
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
_compare_conda_env_requirements(h2o_custom_env, saved_pip_req_path)
def test_log_model_with_pip_requirements(h2o_iris_model, tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name="model", pip_requirements=str(req_file)
)
_assert_pip_requirements(model_info.model_uri, [expected_mlflow_version, "a"], strict=True)
# List of requirements
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model,
name="model",
pip_requirements=[f"-r {req_file}", "b"],
)
_assert_pip_requirements(
model_info.model_uri, [expected_mlflow_version, "a", "b"], strict=True
)
# Constraints file
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name="model", pip_requirements=[f"-c {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, "b", "-c constraints.txt"],
["a"],
strict=True,
)
def test_log_model_with_extra_pip_requirements(h2o_iris_model, tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
default_reqs = mlflow.h2o.get_default_pip_requirements()
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name="model", extra_pip_requirements=str(req_file)
)
_assert_pip_requirements(
model_info.model_uri, [expected_mlflow_version, *default_reqs, "a"]
)
# List of requirements
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name="model", extra_pip_requirements=[f"-r {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri, [expected_mlflow_version, *default_reqs, "a", "b"]
)
# Constraints file
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name="model", extra_pip_requirements=[f"-c {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, *default_reqs, "b", "-c constraints.txt"],
["a"],
)
def test_model_save_accepts_conda_env_as_dict(h2o_iris_model, model_path):
conda_env = dict(mlflow.h2o.get_default_conda_env())
conda_env["dependencies"].append("pytest")
mlflow.h2o.save_model(h2o_model=h2o_iris_model.model, path=model_path, conda_env=conda_env)
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV]["conda"])
assert os.path.exists(saved_conda_env_path)
with open(saved_conda_env_path) as f:
saved_conda_env_parsed = yaml.safe_load(f)
assert saved_conda_env_parsed == conda_env
def test_model_log_persists_specified_conda_env_in_mlflow_model_directory(
h2o_iris_model, h2o_custom_env
):
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name=artifact_path, conda_env=h2o_custom_env
)
model_path = _download_artifact_from_uri(model_info.model_uri)
pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV]["conda"])
assert os.path.exists(saved_conda_env_path)
assert saved_conda_env_path != h2o_custom_env
with open(h2o_custom_env) as f:
h2o_custom_env_text = f.read()
with open(saved_conda_env_path) as f:
saved_conda_env_text = f.read()
assert saved_conda_env_text == h2o_custom_env_text
def test_model_log_persists_requirements_in_mlflow_model_directory(h2o_iris_model, h2o_custom_env):
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name=artifact_path, conda_env=h2o_custom_env
)
model_path = _download_artifact_from_uri(model_info.model_uri)
saved_pip_req_path = os.path.join(model_path, "requirements.txt")
_compare_conda_env_requirements(h2o_custom_env, saved_pip_req_path)
def test_model_save_without_specified_conda_env_uses_default_env_with_expected_dependencies(
h2o_iris_model, model_path
):
mlflow.h2o.save_model(h2o_model=h2o_iris_model.model, path=model_path)
_assert_pip_requirements(model_path, mlflow.h2o.get_default_pip_requirements())
def test_model_log_without_specified_conda_env_uses_default_env_with_expected_dependencies(
h2o_iris_model,
):
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.h2o.log_model(h2o_iris_model.model, name=artifact_path)
_assert_pip_requirements(model_info.model_uri, mlflow.h2o.get_default_pip_requirements())
def test_pyfunc_serve_and_score(h2o_iris_model):
model, inference_dataframe = h2o_iris_model
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
model, name=artifact_path, input_example=inference_dataframe.as_data_frame()
)
inference_payload = load_serving_example(model_info.model_uri)
resp = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
)
decoded_json = json.loads(resp.content.decode("utf-8"))
scores = pd.DataFrame(data=decoded_json["predictions"]).drop("predict", axis=1)
preds = model.predict(inference_dataframe).as_data_frame().drop("predict", axis=1)
np.testing.assert_array_almost_equal(scores, preds)
def test_log_model_with_code_paths(h2o_iris_model):
with (
mlflow.start_run(),
mock.patch("mlflow.h2o._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name="model_uri", code_paths=[__file__]
)
_compare_logged_code_paths(__file__, model_info.model_uri, mlflow.h2o.FLAVOR_NAME)
mlflow.h2o.load_model(model_info.model_uri)
add_mock.assert_called()
def test_model_save_load_with_metadata(h2o_iris_model, model_path):
mlflow.h2o.save_model(
h2o_iris_model.model, path=model_path, metadata={"metadata_key": "metadata_value"}
)
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_path)
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
def test_model_log_with_metadata(h2o_iris_model):
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model,
name="model",
metadata={"metadata_key": "metadata_value"},
)
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_info.model_uri)
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
def test_model_log_with_signature_inference(h2o_iris_model, h2o_iris_model_signature):
artifact_path = "model"
example = h2o_iris_model.inference_data.as_data_frame().head(3)
with mlflow.start_run():
model_info = mlflow.h2o.log_model(
h2o_iris_model.model, name=artifact_path, input_example=example
)
mlflow_model = Model.load(model_info.model_uri)
assert mlflow_model.signature == h2o_iris_model_signature