2416 lines
85 KiB
Python
2416 lines
85 KiB
Python
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import uuid
|
|
from typing import Any, NamedTuple
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
import sklearn
|
|
import sklearn.compose
|
|
import sklearn.datasets
|
|
import sklearn.impute
|
|
import sklearn.linear_model
|
|
import sklearn.pipeline
|
|
import sklearn.preprocessing
|
|
from mlflow_test_plugin.dummy_evaluator import Array2DEvaluationArtifact, DummyEvaluator
|
|
from PIL import Image, ImageChops
|
|
from pyspark.ml.linalg import Vectors
|
|
from pyspark.ml.regression import LinearRegression as SparkLinearRegression
|
|
from pyspark.sql import SparkSession
|
|
from sklearn.metrics import (
|
|
accuracy_score,
|
|
confusion_matrix,
|
|
mean_absolute_error,
|
|
mean_squared_error,
|
|
)
|
|
|
|
import mlflow
|
|
from mlflow import MlflowClient
|
|
from mlflow.data.evaluation_dataset import EvaluationDataset, _gen_md5_for_arraylike_obj
|
|
from mlflow.data.pandas_dataset import from_pandas
|
|
from mlflow.entities import Trace, TraceData
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.models.evaluation import (
|
|
EvaluationArtifact,
|
|
EvaluationResult,
|
|
ModelEvaluator,
|
|
evaluate,
|
|
)
|
|
from mlflow.models.evaluation.artifacts import ImageEvaluationArtifact
|
|
from mlflow.models.evaluation.base import (
|
|
_get_model_from_deployment_endpoint_uri,
|
|
_is_model_deployment_endpoint_uri,
|
|
_start_run_or_reuse_active_run,
|
|
resolve_evaluators_and_configs,
|
|
)
|
|
from mlflow.models.evaluation.evaluator_registry import _model_evaluation_registry
|
|
from mlflow.pyfunc import _ServedPyFuncModel
|
|
from mlflow.pyfunc.scoring_server.client import ScoringServerClient
|
|
from mlflow.tracing.constant import AssessmentMetadataKey, TraceMetadataKey
|
|
from mlflow.tracking.artifact_utils import get_artifact_uri
|
|
from mlflow.utils.file_utils import TempDir
|
|
|
|
from tests.tracing.helper import create_test_trace_info, get_traces
|
|
from tests.utils.test_file_utils import spark_session # noqa: F401
|
|
|
|
INFERENCE_FILE_NAME = "inference_inputs_outputs.json"
|
|
|
|
|
|
def get_iris():
|
|
iris = sklearn.datasets.load_iris()
|
|
return iris.data, iris.target
|
|
|
|
|
|
def get_diabetes_dataset():
|
|
data = sklearn.datasets.load_diabetes()
|
|
return data.data, data.target
|
|
|
|
|
|
def get_diabetes_spark_dataset():
|
|
data = sklearn.datasets.load_diabetes()
|
|
spark = SparkSession.builder.master("local[*]").getOrCreate()
|
|
rows = [
|
|
(Vectors.dense(features), float(label)) for features, label in zip(data.data, data.target)
|
|
]
|
|
return spark.createDataFrame(spark.sparkContext.parallelize(rows, 1), ["features", "label"])
|
|
|
|
|
|
def get_breast_cancer_dataset():
|
|
data = sklearn.datasets.load_breast_cancer()
|
|
return data.data, data.target
|
|
|
|
|
|
class RunData(NamedTuple):
|
|
params: dict[str, Any]
|
|
metrics: dict[str, Any]
|
|
tags: dict[str, Any]
|
|
artifacts: list[str]
|
|
|
|
|
|
def get_run_data(run_id):
|
|
client = MlflowClient()
|
|
data = client.get_run(run_id).data
|
|
artifacts = [f.path for f in client.list_artifacts(run_id)]
|
|
return RunData(params=data.params, metrics=data.metrics, tags=data.tags, artifacts=artifacts)
|
|
|
|
|
|
def get_run_datasets(run_id):
|
|
client = MlflowClient()
|
|
return client.get_run(run_id).inputs.dataset_inputs
|
|
|
|
|
|
def get_raw_tag(run_id, tag_name):
|
|
client = MlflowClient()
|
|
data = client.get_run(run_id).data
|
|
return data.tags[tag_name]
|
|
|
|
|
|
def get_local_artifact_path(run_id, artifact_path):
|
|
return get_artifact_uri(run_id, artifact_path).replace("file://", "")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def iris_dataset():
|
|
X, y = get_iris()
|
|
eval_X = X[0::3]
|
|
eval_y = y[0::3]
|
|
constructor_args = {"data": eval_X, "targets": eval_y, "name": "dataset"}
|
|
ds = EvaluationDataset(**constructor_args)
|
|
ds._constructor_args = constructor_args
|
|
return ds
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def diabetes_dataset():
|
|
X, y = get_diabetes_dataset()
|
|
eval_X = X[0::3]
|
|
eval_y = y[0::3]
|
|
constructor_args = {"data": eval_X, "targets": eval_y}
|
|
ds = EvaluationDataset(**constructor_args)
|
|
ds._constructor_args = constructor_args
|
|
return ds
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def diabetes_spark_dataset():
|
|
spark_df = get_diabetes_spark_dataset().sample(fraction=0.3, seed=1)
|
|
constructor_args = {"data": spark_df, "targets": "label"}
|
|
ds = EvaluationDataset(**constructor_args)
|
|
ds._constructor_args = constructor_args
|
|
return ds
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def breast_cancer_dataset():
|
|
X, y = get_breast_cancer_dataset()
|
|
eval_X = X[0::3]
|
|
eval_y = y[0::3]
|
|
constructor_args = {"data": eval_X, "targets": eval_y}
|
|
ds = EvaluationDataset(**constructor_args)
|
|
ds._constructor_args = constructor_args
|
|
return ds
|
|
|
|
|
|
def get_pipeline_model_dataset():
|
|
"""
|
|
The dataset tweaks the IRIS dataset by changing its first 2 features into categorical features,
|
|
and replace some feature values with NA values.
|
|
The dataset is prepared for a pipeline model, see `pipeline_model_uri`.
|
|
"""
|
|
X, y = get_iris()
|
|
|
|
def convert_num_to_label(x):
|
|
return f"v_{round(x)}"
|
|
|
|
f1 = np.array(list(map(convert_num_to_label, X[:, 0])))
|
|
f2 = np.array(list(map(convert_num_to_label, X[:, 1])))
|
|
f3 = X[:, 2]
|
|
f4 = X[:, 3]
|
|
|
|
f1[0::8] = None
|
|
f2[1::8] = None
|
|
f3[2::8] = np.nan
|
|
f4[3::8] = np.nan
|
|
|
|
data = pd.DataFrame({
|
|
"f1": f1,
|
|
"f2": f2,
|
|
"f3": f3,
|
|
"f4": f4,
|
|
"y": y,
|
|
})
|
|
return data, "y"
|
|
|
|
|
|
@pytest.fixture
|
|
def pipeline_model_uri():
|
|
return get_pipeline_model_uri()
|
|
|
|
|
|
def get_pipeline_model_uri():
|
|
"""
|
|
Create a pipeline model that transforms and trains on the dataset returned by
|
|
`get_pipeline_model_dataset`. The pipeline model imputes the missing values in
|
|
input dataset, encodes categorical features, and then trains a logistic regression
|
|
model.
|
|
"""
|
|
data, target_col = get_pipeline_model_dataset()
|
|
X = data.drop(target_col, axis=1)
|
|
y = data[target_col].to_numpy()
|
|
|
|
encoder = sklearn.preprocessing.OrdinalEncoder()
|
|
str_imputer = sklearn.impute.SimpleImputer(missing_values=None, strategy="most_frequent")
|
|
num_imputer = sklearn.impute.SimpleImputer(missing_values=np.nan, strategy="mean")
|
|
preproc_pipeline = sklearn.pipeline.Pipeline([
|
|
("imputer", str_imputer),
|
|
("encoder", encoder),
|
|
])
|
|
|
|
pipeline = sklearn.pipeline.Pipeline([
|
|
(
|
|
"transformer",
|
|
sklearn.compose.make_column_transformer(
|
|
(preproc_pipeline, ["f1", "f2"]),
|
|
(num_imputer, ["f3", "f4"]),
|
|
),
|
|
),
|
|
("clf", sklearn.linear_model.LogisticRegression()),
|
|
])
|
|
pipeline.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(
|
|
pipeline, name="pipeline_model", serialization_format="cloudpickle"
|
|
)
|
|
return model_info.model_uri
|
|
|
|
|
|
@pytest.fixture
|
|
def linear_regressor_model_uri():
|
|
return get_linear_regressor_model_uri()
|
|
|
|
|
|
def get_linear_regressor_model_uri():
|
|
X, y = get_diabetes_dataset()
|
|
reg = sklearn.linear_model.LinearRegression()
|
|
reg.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(reg, name="reg_model")
|
|
return model_info.model_uri
|
|
|
|
|
|
@pytest.fixture
|
|
def spark_linear_regressor_model_uri():
|
|
return get_spark_linear_regressor_model_uri()
|
|
|
|
|
|
def get_spark_linear_regressor_model_uri():
|
|
spark_df = get_diabetes_spark_dataset()
|
|
reg = SparkLinearRegression()
|
|
spark_reg_model = reg.fit(spark_df)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.spark.log_model(spark_reg_model, artifact_path="spark_reg_model")
|
|
return model_info.model_uri
|
|
|
|
|
|
@pytest.fixture
|
|
def multiclass_logistic_regressor_model_uri():
|
|
return multiclass_logistic_regressor_model_uri_by_max_iter(2)
|
|
|
|
|
|
def multiclass_logistic_regressor_model_uri_by_max_iter(max_iter):
|
|
X, y = get_iris()
|
|
clf = sklearn.linear_model.LogisticRegression(max_iter=max_iter)
|
|
clf.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(clf, name=f"clf_model_{max_iter}_iters")
|
|
return model_info.model_uri
|
|
|
|
|
|
@pytest.fixture
|
|
def binary_logistic_regressor_model_uri():
|
|
return get_binary_logistic_regressor_model_uri()
|
|
|
|
|
|
def get_binary_logistic_regressor_model_uri():
|
|
X, y = get_breast_cancer_dataset()
|
|
clf = sklearn.linear_model.LogisticRegression()
|
|
clf.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(clf, name="bin_clf_model")
|
|
return model_info.model_uri
|
|
|
|
|
|
@pytest.fixture
|
|
def svm_model_uri():
|
|
return get_svm_model_url()
|
|
|
|
|
|
def get_svm_model_url():
|
|
X, y = get_breast_cancer_dataset()
|
|
clf = sklearn.svm.LinearSVC()
|
|
clf.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(clf, name="svm_model")
|
|
return model_info.model_uri
|
|
|
|
|
|
@pytest.fixture
|
|
def iris_pandas_df_dataset():
|
|
X, y = get_iris()
|
|
eval_X = X[0::3]
|
|
eval_y = y[0::3]
|
|
data = pd.DataFrame({
|
|
"f1": eval_X[:, 0],
|
|
"f2": eval_X[:, 1],
|
|
"f3": eval_X[:, 2],
|
|
"f4": eval_X[:, 3],
|
|
"y": eval_y,
|
|
})
|
|
constructor_args = {"data": data, "targets": "y"}
|
|
ds = EvaluationDataset(**constructor_args)
|
|
ds._constructor_args = constructor_args
|
|
return ds
|
|
|
|
|
|
@pytest.fixture
|
|
def iris_pandas_df_num_cols_dataset():
|
|
X, y = get_iris()
|
|
eval_X = X[0::3]
|
|
eval_y = y[0::3]
|
|
data = pd.DataFrame(eval_X)
|
|
data["y"] = eval_y
|
|
constructor_args = {"data": data, "targets": "y"}
|
|
ds = EvaluationDataset(**constructor_args)
|
|
ds._constructor_args = constructor_args
|
|
return ds
|
|
|
|
|
|
def test_mlflow_evaluate_logs_traces():
|
|
eval_data = pd.DataFrame({
|
|
"inputs": [
|
|
"What is MLflow?",
|
|
"What is Spark?",
|
|
],
|
|
"ground_truth": ["What is MLflow?", "Not what is Spark?"],
|
|
})
|
|
|
|
@mlflow.trace
|
|
def model(inputs):
|
|
return inputs
|
|
|
|
with mlflow.start_run() as run:
|
|
evaluate(
|
|
model, eval_data, targets="ground_truth", extra_metrics=[mlflow.metrics.exact_match()]
|
|
)
|
|
assert len(get_traces()) == 1
|
|
assert run.info.run_id == get_traces()[0].info.request_metadata[TraceMetadataKey.SOURCE_RUN]
|
|
|
|
|
|
def test_pyfunc_evaluate_logs_traces():
|
|
class Model(mlflow.pyfunc.PythonModel):
|
|
@mlflow.trace()
|
|
def predict(self, context, model_input):
|
|
return self.add(model_input, model_input)
|
|
|
|
@mlflow.trace()
|
|
def add(self, x, y):
|
|
return x + y
|
|
|
|
eval_data = pd.DataFrame({
|
|
"inputs": [1, 2, 4],
|
|
"ground_truth": [2, 4, 8],
|
|
})
|
|
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(name="model", python_model=Model())
|
|
evaluate(
|
|
model_info.model_uri,
|
|
eval_data,
|
|
targets="ground_truth",
|
|
extra_metrics=[mlflow.metrics.exact_match()],
|
|
)
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
assert len(traces[0].data.spans) == 2
|
|
assert run.info.run_id == traces[0].info.request_metadata[TraceMetadataKey.SOURCE_RUN]
|
|
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_info.model_id
|
|
|
|
|
|
def test_classifier_evaluate(multiclass_logistic_regressor_model_uri, iris_dataset):
|
|
y_true = iris_dataset.labels_data
|
|
classifier_model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
y_pred = classifier_model.predict(iris_dataset.features_data)
|
|
expected_accuracy_score = accuracy_score(y_true, y_pred)
|
|
expected_metrics = {
|
|
"accuracy_score": expected_accuracy_score,
|
|
}
|
|
expected_saved_metrics = {
|
|
"accuracy_score": expected_accuracy_score,
|
|
}
|
|
|
|
expected_csv_artifact = confusion_matrix(y_true, y_pred)
|
|
cm_figure = sklearn.metrics.ConfusionMatrixDisplay.from_predictions(y_true, y_pred).figure_
|
|
img_buf = io.BytesIO()
|
|
cm_figure.savefig(img_buf)
|
|
img_buf.seek(0)
|
|
expected_image_artifact = Image.open(img_buf)
|
|
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="dummy_evaluator",
|
|
)
|
|
|
|
csv_artifact_name = "confusion_matrix"
|
|
saved_csv_artifact_path = get_local_artifact_path(run.info.run_id, csv_artifact_name + ".csv")
|
|
|
|
png_artifact_name = "confusion_matrix_image"
|
|
saved_png_artifact_path = get_local_artifact_path(run.info.run_id, png_artifact_name) + ".png"
|
|
|
|
_, saved_metrics, _, saved_artifacts = get_run_data(run.info.run_id)
|
|
assert saved_metrics == expected_saved_metrics
|
|
assert set(saved_artifacts) == {csv_artifact_name + ".csv", png_artifact_name + ".png"}
|
|
|
|
assert eval_result.metrics == expected_metrics
|
|
confusion_matrix_artifact = eval_result.artifacts[csv_artifact_name]
|
|
np.testing.assert_array_equal(confusion_matrix_artifact.content, expected_csv_artifact)
|
|
assert confusion_matrix_artifact.uri == get_artifact_uri(
|
|
run.info.run_id, csv_artifact_name + ".csv"
|
|
)
|
|
np.testing.assert_array_equal(
|
|
confusion_matrix_artifact._load(saved_csv_artifact_path), expected_csv_artifact
|
|
)
|
|
confusion_matrix_image_artifact = eval_result.artifacts[png_artifact_name]
|
|
assert (
|
|
ImageChops.difference(
|
|
confusion_matrix_image_artifact.content, expected_image_artifact
|
|
).getbbox()
|
|
is None
|
|
)
|
|
assert confusion_matrix_image_artifact.uri == get_artifact_uri(
|
|
run.info.run_id, png_artifact_name + ".png"
|
|
)
|
|
assert (
|
|
ImageChops.difference(
|
|
confusion_matrix_image_artifact._load(saved_png_artifact_path),
|
|
expected_image_artifact,
|
|
).getbbox()
|
|
is None
|
|
)
|
|
|
|
with TempDir() as temp_dir:
|
|
temp_dir_path = temp_dir.path()
|
|
eval_result.save(temp_dir_path)
|
|
|
|
with open(temp_dir.path("metrics.json")) as fp:
|
|
assert json.load(fp) == eval_result.metrics
|
|
|
|
with open(temp_dir.path("artifacts_metadata.json")) as fp:
|
|
json_dict = json.load(fp)
|
|
assert "confusion_matrix" in json_dict
|
|
assert json_dict["confusion_matrix"] == {
|
|
"uri": confusion_matrix_artifact.uri,
|
|
"class_name": "mlflow_test_plugin.dummy_evaluator.Array2DEvaluationArtifact",
|
|
}
|
|
|
|
assert "confusion_matrix_image" in json_dict
|
|
assert json_dict["confusion_matrix_image"] == {
|
|
"uri": confusion_matrix_image_artifact.uri,
|
|
"class_name": "mlflow.models.evaluation.artifacts.ImageEvaluationArtifact",
|
|
}
|
|
|
|
assert set(os.listdir(temp_dir.path("artifacts"))) == {
|
|
"confusion_matrix.csv",
|
|
"confusion_matrix_image.png",
|
|
}
|
|
|
|
loaded_eval_result = EvaluationResult.load(temp_dir_path)
|
|
assert loaded_eval_result.metrics == eval_result.metrics
|
|
loaded_confusion_matrix_artifact = loaded_eval_result.artifacts[csv_artifact_name]
|
|
assert confusion_matrix_artifact.uri == loaded_confusion_matrix_artifact.uri
|
|
np.testing.assert_array_equal(
|
|
confusion_matrix_artifact.content,
|
|
loaded_confusion_matrix_artifact.content,
|
|
)
|
|
loaded_confusion_matrix_image_artifact = loaded_eval_result.artifacts[png_artifact_name]
|
|
assert confusion_matrix_image_artifact.uri == loaded_confusion_matrix_image_artifact.uri
|
|
assert (
|
|
ImageChops.difference(
|
|
confusion_matrix_image_artifact.content,
|
|
loaded_confusion_matrix_image_artifact.content,
|
|
).getbbox()
|
|
is None
|
|
)
|
|
|
|
new_confusion_matrix_artifact = Array2DEvaluationArtifact(uri=confusion_matrix_artifact.uri)
|
|
new_confusion_matrix_artifact._load()
|
|
np.testing.assert_array_equal(
|
|
confusion_matrix_artifact.content,
|
|
new_confusion_matrix_artifact.content,
|
|
)
|
|
new_confusion_matrix_image_artifact = ImageEvaluationArtifact(
|
|
uri=confusion_matrix_image_artifact.uri
|
|
)
|
|
new_confusion_matrix_image_artifact._load()
|
|
np.testing.assert_array_equal(
|
|
confusion_matrix_image_artifact.content,
|
|
new_confusion_matrix_image_artifact.content,
|
|
)
|
|
|
|
|
|
def test_regressor_evaluate(linear_regressor_model_uri, diabetes_dataset):
|
|
y_true = diabetes_dataset.labels_data
|
|
regressor_model = mlflow.pyfunc.load_model(linear_regressor_model_uri)
|
|
y_pred = regressor_model.predict(diabetes_dataset.features_data)
|
|
expected_mae = mean_absolute_error(y_true, y_pred)
|
|
expected_mse = mean_squared_error(y_true, y_pred)
|
|
expected_metrics = {
|
|
"mean_absolute_error": expected_mae,
|
|
"mean_squared_error": expected_mse,
|
|
}
|
|
expected_saved_metrics = {
|
|
"mean_absolute_error": expected_mae,
|
|
"mean_squared_error": expected_mse,
|
|
}
|
|
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_dataset._constructor_args["targets"],
|
|
evaluators="dummy_evaluator",
|
|
)
|
|
_, saved_metrics, _, _ = get_run_data(run.info.run_id)
|
|
assert saved_metrics == expected_saved_metrics
|
|
assert eval_result.metrics == expected_metrics
|
|
|
|
|
|
def _load_diabetes_dataset_in_required_format(format):
|
|
data = sklearn.datasets.load_diabetes()
|
|
if format == "numpy":
|
|
return data.data, data.target
|
|
elif format == "pandas":
|
|
df = pd.DataFrame(data.data, columns=data.feature_names)
|
|
df["label"] = data.target
|
|
return df, "label"
|
|
elif format == "spark":
|
|
spark = SparkSession.builder.master("local[*]").getOrCreate()
|
|
panda_df = pd.DataFrame(data.data, columns=data.feature_names)
|
|
panda_df["label"] = data.target
|
|
spark_df = spark.createDataFrame(panda_df)
|
|
return spark_df, "label"
|
|
elif format == "list":
|
|
return data.data.tolist(), data.target.tolist()
|
|
else:
|
|
raise TypeError(
|
|
f"`format` must be one of 'numpy', 'pandas', 'spark' or 'list', but received {format}."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("data_format", ["list", "numpy", "pandas", "spark"])
|
|
def test_regressor_evaluation(linear_regressor_model_uri, data_format):
|
|
data, target = _load_diabetes_dataset_in_required_format(data_format)
|
|
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
linear_regressor_model_uri,
|
|
data=data,
|
|
targets=target,
|
|
model_type="regressor",
|
|
evaluators=["default"],
|
|
)
|
|
_, saved_metrics, _, _ = get_run_data(run.info.run_id)
|
|
|
|
for k, v in eval_result.metrics.items():
|
|
assert v == saved_metrics[k]
|
|
|
|
datasets = get_run_datasets(run.info.run_id)
|
|
assert len(datasets) == 1
|
|
assert len(datasets[0].tags) == 0
|
|
assert datasets[0].dataset.source_type == "code"
|
|
|
|
|
|
def test_pandas_df_regressor_evaluation_mlflow_dataset_with_metric_prefix(
|
|
linear_regressor_model_uri,
|
|
):
|
|
data = sklearn.datasets.load_diabetes()
|
|
df = pd.DataFrame(data.data, columns=data.feature_names)
|
|
df["y"] = data.target
|
|
mlflow_df = from_pandas(df=df, source="my_src", targets="y")
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
linear_regressor_model_uri,
|
|
data=mlflow_df,
|
|
model_type="regressor",
|
|
evaluators=["default"],
|
|
evaluator_config={
|
|
"default": {
|
|
"metric_prefix": "eval",
|
|
}
|
|
},
|
|
)
|
|
_, saved_metrics, _, _ = get_run_data(run.info.run_id)
|
|
|
|
for k, v in eval_result.metrics.items():
|
|
assert v == saved_metrics[k]
|
|
|
|
datasets = get_run_datasets(run.info.run_id)
|
|
assert len(datasets) == 1
|
|
assert datasets[0].tags[0].value == "eval"
|
|
|
|
|
|
def test_pandas_df_regressor_evaluation_mlflow_dataset(linear_regressor_model_uri):
|
|
data = sklearn.datasets.load_diabetes()
|
|
df = pd.DataFrame(data.data, columns=data.feature_names)
|
|
df["y"] = data.target
|
|
mlflow_df = from_pandas(df=df, source="my_src", targets="y")
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
linear_regressor_model_uri,
|
|
data=mlflow_df,
|
|
model_type="regressor",
|
|
evaluators=["default"],
|
|
)
|
|
_, saved_metrics, _, _ = get_run_data(run.info.run_id)
|
|
|
|
for k, v in eval_result.metrics.items():
|
|
assert v == saved_metrics[k]
|
|
|
|
datasets = get_run_datasets(run.info.run_id)
|
|
assert len(datasets) == 1
|
|
assert len(datasets[0].tags) == 0
|
|
|
|
|
|
def test_pandas_df_regressor_evaluation_mlflow_dataset_with_targets_from_dataset(
|
|
linear_regressor_model_uri,
|
|
):
|
|
data = sklearn.datasets.load_diabetes()
|
|
df = pd.DataFrame(data.data, columns=data.feature_names)
|
|
df["y"] = data.target
|
|
mlflow_df = from_pandas(df=df, source="my_src", targets="y")
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
linear_regressor_model_uri,
|
|
data=mlflow_df,
|
|
model_type="regressor",
|
|
evaluators=["default"],
|
|
)
|
|
_, saved_metrics, _, _ = get_run_data(run.info.run_id)
|
|
|
|
for k, v in eval_result.metrics.items():
|
|
assert v == saved_metrics[k]
|
|
|
|
datasets = get_run_datasets(run.info.run_id)
|
|
assert len(datasets) == 1
|
|
assert len(datasets[0].tags) == 0
|
|
|
|
|
|
def test_dataset_name():
|
|
X, y = get_iris()
|
|
d1 = EvaluationDataset(data=X, targets=y, name="a1")
|
|
assert d1.name == "a1"
|
|
d2 = EvaluationDataset(data=X, targets=y)
|
|
assert d2.name == d2.hash
|
|
|
|
|
|
def test_dataset_metadata():
|
|
X, y = get_iris()
|
|
d1 = EvaluationDataset(data=X, targets=y, name="a1", path="/path/to/a1")
|
|
assert d1._metadata == {
|
|
"hash": "6bdf4e119bf1a37e7907dfd9f0e68733",
|
|
"name": "a1",
|
|
"path": "/path/to/a1",
|
|
}
|
|
|
|
|
|
def test_gen_md5_for_arraylike_obj():
|
|
def get_md5(data):
|
|
md5_gen = hashlib.md5(usedforsecurity=False)
|
|
_gen_md5_for_arraylike_obj(md5_gen, data)
|
|
return md5_gen.hexdigest()
|
|
|
|
list0 = list(range(20))
|
|
list1 = [100] + list0[1:]
|
|
list2 = list0[:-1] + [100]
|
|
list3 = list0[:10] + [100] + list0[10:]
|
|
|
|
assert len({get_md5(list0), get_md5(list1), get_md5(list2), get_md5(list3)}) == 4
|
|
|
|
list4 = list0[:10] + [99] + list0[10:]
|
|
assert get_md5(list3) == get_md5(list4)
|
|
|
|
|
|
def test_gen_md5_for_arraylike_obj_with_pandas_df_using_float_idx_does_not_raise_keyerror():
|
|
float_indices = np.random.uniform(low=0.5, high=13.3, size=(10,))
|
|
df = pd.DataFrame(np.random.randn(10, 4), index=float_indices, columns=["A", "B", "C", "D"])
|
|
md5_gen = hashlib.md5(usedforsecurity=False)
|
|
assert _gen_md5_for_arraylike_obj(md5_gen, df) is None
|
|
|
|
|
|
def test_dataset_hash(
|
|
iris_dataset, iris_pandas_df_dataset, iris_pandas_df_num_cols_dataset, diabetes_spark_dataset
|
|
):
|
|
assert iris_dataset.hash == "99329a790dc483e7382c0d1d27aac3f3"
|
|
assert iris_pandas_df_dataset.hash == "799d4f50e2e353127f94a0e5300add06"
|
|
assert iris_pandas_df_num_cols_dataset.hash == "3c5fc56830a0646001253e25e17bdce4"
|
|
assert diabetes_spark_dataset.hash == "ebfb050519e7e5b463bd38b0c8d04243"
|
|
|
|
|
|
def test_trace_dataset_hash():
|
|
# Validates that a dataset containing Traces can be hashed.
|
|
df = pd.DataFrame({
|
|
"request": ["Hello"],
|
|
"trace": [Trace(info=create_test_trace_info("tr"), data=TraceData([]))],
|
|
})
|
|
dataset = EvaluationDataset(data=df)
|
|
assert dataset.hash == "757c14bf38aa42d36b93ccd70b1ea719"
|
|
# Hash of a dataset with a different column should be different
|
|
df2 = pd.DataFrame({
|
|
"request": ["Hi"],
|
|
"trace": [Trace(info=create_test_trace_info("tr"), data=TraceData([]))],
|
|
})
|
|
dataset2 = EvaluationDataset(data=df2)
|
|
assert dataset2.hash != dataset.hash
|
|
|
|
|
|
def test_dataset_with_pandas_dataframe():
|
|
data = pd.DataFrame({"f1": [1, 2], "f2": [3, 4], "f3": [5, 6], "label": [0, 1]})
|
|
eval_dataset = EvaluationDataset(data=data, targets="label")
|
|
|
|
assert list(eval_dataset.features_data.columns) == ["f1", "f2", "f3"]
|
|
np.testing.assert_array_equal(eval_dataset.features_data.f1.to_numpy(), [1, 2])
|
|
np.testing.assert_array_equal(eval_dataset.features_data.f2.to_numpy(), [3, 4])
|
|
np.testing.assert_array_equal(eval_dataset.features_data.f3.to_numpy(), [5, 6])
|
|
np.testing.assert_array_equal(eval_dataset.labels_data, [0, 1])
|
|
|
|
eval_dataset2 = EvaluationDataset(data=data, targets="label", feature_names=["f3", "f2"])
|
|
assert list(eval_dataset2.features_data.columns) == ["f3", "f2"]
|
|
np.testing.assert_array_equal(eval_dataset2.features_data.f2.to_numpy(), [3, 4])
|
|
np.testing.assert_array_equal(eval_dataset2.features_data.f3.to_numpy(), [5, 6])
|
|
|
|
|
|
def test_dataset_with_array_data():
|
|
features = [[1, 2], [3, 4]]
|
|
labels = [0, 1]
|
|
|
|
for input_data in [features, np.array(features)]:
|
|
eval_dataset1 = EvaluationDataset(data=input_data, targets=labels)
|
|
np.testing.assert_array_equal(eval_dataset1.features_data, features)
|
|
np.testing.assert_array_equal(eval_dataset1.labels_data, labels)
|
|
assert list(eval_dataset1.feature_names) == ["feature_1", "feature_2"]
|
|
|
|
assert EvaluationDataset(
|
|
data=input_data, targets=labels, feature_names=["a", "b"]
|
|
).feature_names == ["a", "b"]
|
|
|
|
with pytest.raises(MlflowException, match="all elements must have the same length"):
|
|
EvaluationDataset(data=[[1, 2], [3, 4, 5]], targets=labels)
|
|
|
|
|
|
def test_dataset_autogen_feature_names():
|
|
labels = [0]
|
|
eval_dataset2 = EvaluationDataset(data=[list(range(9))], targets=labels)
|
|
assert eval_dataset2.feature_names == [f"feature_{i + 1}" for i in range(9)]
|
|
|
|
eval_dataset2 = EvaluationDataset(data=[list(range(10))], targets=labels)
|
|
assert eval_dataset2.feature_names == [f"feature_{i + 1:02d}" for i in range(10)]
|
|
|
|
eval_dataset2 = EvaluationDataset(data=[list(range(99))], targets=labels)
|
|
assert eval_dataset2.feature_names == [f"feature_{i + 1:02d}" for i in range(99)]
|
|
|
|
eval_dataset2 = EvaluationDataset(data=[list(range(100))], targets=labels)
|
|
assert eval_dataset2.feature_names == [f"feature_{i + 1:03d}" for i in range(100)]
|
|
|
|
with pytest.raises(
|
|
MlflowException, match="features example rows must be the same length with labels array"
|
|
):
|
|
EvaluationDataset(data=[[1, 2], [3, 4]], targets=[1, 2, 3])
|
|
|
|
|
|
def test_dataset_from_spark_df(spark_session):
|
|
spark_df = spark_session.createDataFrame([(1.0, 2.0, 3.0)] * 10, ["f1", "f2", "y"])
|
|
with mock.patch.object(EvaluationDataset, "SPARK_DATAFRAME_LIMIT", 5):
|
|
dataset = EvaluationDataset(spark_df, targets="y")
|
|
assert list(dataset.features_data.columns) == ["f1", "f2"]
|
|
assert list(dataset.features_data["f1"]) == [1.0] * 5
|
|
assert list(dataset.features_data["f2"]) == [2.0] * 5
|
|
assert list(dataset.labels_data) == [3.0] * 5
|
|
|
|
|
|
def test_log_dataset_tag(iris_dataset, iris_pandas_df_dataset):
|
|
model_uuid = uuid.uuid4().hex
|
|
with mlflow.start_run() as run:
|
|
client = MlflowClient()
|
|
iris_dataset._log_dataset_tag(client, run.info.run_id, model_uuid=model_uuid)
|
|
_, _, tags, _ = get_run_data(run.info.run_id)
|
|
|
|
logged_meta1 = {**iris_dataset._metadata, "model": model_uuid}
|
|
logged_meta2 = {**iris_pandas_df_dataset._metadata, "model": model_uuid}
|
|
|
|
assert json.loads(tags["mlflow.datasets"]) == [logged_meta1]
|
|
|
|
raw_tag = get_raw_tag(run.info.run_id, "mlflow.datasets")
|
|
assert " " not in raw_tag # assert the tag string remove all whitespace chars.
|
|
|
|
# Test appending dataset tag
|
|
iris_pandas_df_dataset._log_dataset_tag(client, run.info.run_id, model_uuid=model_uuid)
|
|
_, _, tags, _ = get_run_data(run.info.run_id)
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
logged_meta1,
|
|
logged_meta2,
|
|
]
|
|
|
|
# Test log repetitive dataset
|
|
iris_dataset._log_dataset_tag(client, run.info.run_id, model_uuid=model_uuid)
|
|
_, _, tags, _ = get_run_data(run.info.run_id)
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
logged_meta1,
|
|
logged_meta2,
|
|
]
|
|
|
|
|
|
class FakeEvaluator1(ModelEvaluator):
|
|
@classmethod
|
|
def can_evaluate(cls, *, model_type, evaluator_config, **kwargs):
|
|
raise RuntimeError()
|
|
|
|
def evaluate(self, *, model, model_type, dataset, run_id, evaluator_config, **kwargs):
|
|
raise RuntimeError()
|
|
|
|
|
|
class FakeEvaluator2(ModelEvaluator):
|
|
@classmethod
|
|
def can_evaluate(cls, *, model_type, evaluator_config, **kwargs):
|
|
raise RuntimeError()
|
|
|
|
def evaluate(self, *, model, model_type, dataset, run_id, evaluator_config, **kwargs):
|
|
raise RuntimeError()
|
|
|
|
|
|
class FakeArtifact1(EvaluationArtifact):
|
|
def _save(self, output_artifact_path):
|
|
raise RuntimeError()
|
|
|
|
def _load_content_from_file(self, local_artifact_path):
|
|
raise RuntimeError()
|
|
|
|
|
|
class FakeArtifact2(EvaluationArtifact):
|
|
def _save(self, output_artifact_path):
|
|
raise RuntimeError()
|
|
|
|
def _load_content_from_file(self, local_artifact_path):
|
|
raise RuntimeError()
|
|
|
|
|
|
class PyFuncModelMatcher:
|
|
def __eq__(self, other):
|
|
return isinstance(other, mlflow.pyfunc.PyFuncModel)
|
|
|
|
|
|
class ModelPredictFuncMatcher:
|
|
def __eq__(self, other):
|
|
return callable(other)
|
|
|
|
|
|
def test_evaluator_evaluation_interface(multiclass_logistic_regressor_model_uri, iris_dataset):
|
|
with mock.patch.object(
|
|
_model_evaluation_registry, "_registry", {"test_evaluator1": FakeEvaluator1}
|
|
):
|
|
evaluator1_config = {"eval1_config_a": 3, "eval1_config_b": 4}
|
|
evaluator1_return_value = EvaluationResult(
|
|
metrics={"m1": 5, "m2": 6},
|
|
artifacts={"a1": FakeArtifact1(uri="uri1"), "a2": FakeArtifact2(uri="uri2")},
|
|
)
|
|
with (
|
|
mock.patch.object(
|
|
FakeEvaluator1, "can_evaluate", return_value=False
|
|
) as mock_can_evaluate,
|
|
mock.patch.object(
|
|
FakeEvaluator1, "evaluate", return_value=evaluator1_return_value
|
|
) as mock_evaluate,
|
|
):
|
|
with mlflow.start_run():
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="The model could not be evaluated by any of the registered evaluators",
|
|
):
|
|
evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
data=iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="test_evaluator1",
|
|
evaluator_config=evaluator1_config,
|
|
)
|
|
mock_can_evaluate.assert_called_once_with(
|
|
model_type="classifier", evaluator_config=evaluator1_config
|
|
)
|
|
mock_evaluate.assert_not_called()
|
|
with (
|
|
mock.patch.object(
|
|
FakeEvaluator1, "can_evaluate", return_value=True
|
|
) as mock_can_evaluate,
|
|
mock.patch.object(
|
|
FakeEvaluator1, "evaluate", return_value=evaluator1_return_value
|
|
) as mock_evaluate,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
eval1_result = evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="test_evaluator1",
|
|
evaluator_config=evaluator1_config,
|
|
extra_metrics=None,
|
|
)
|
|
assert eval1_result.metrics == evaluator1_return_value.metrics
|
|
assert eval1_result.artifacts == evaluator1_return_value.artifacts
|
|
|
|
mock_can_evaluate.assert_called_once_with(
|
|
model_type="classifier", evaluator_config=evaluator1_config
|
|
)
|
|
mock_evaluate.assert_called_once_with(
|
|
model=PyFuncModelMatcher(),
|
|
model_type="classifier",
|
|
model_id=multiclass_logistic_regressor_model_uri.split("/")[-1],
|
|
dataset=iris_dataset,
|
|
run_id=run.info.run_id,
|
|
evaluator_config=evaluator1_config,
|
|
extra_metrics=None,
|
|
custom_artifacts=None,
|
|
predictions=None,
|
|
)
|
|
|
|
|
|
def test_evaluate_with_multi_evaluators(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset,
|
|
):
|
|
with mock.patch.object(
|
|
_model_evaluation_registry,
|
|
"_registry",
|
|
{"test_evaluator1": FakeEvaluator1, "test_evaluator2": FakeEvaluator2},
|
|
):
|
|
evaluator1_config = {"eval1_config": 3}
|
|
evaluator2_config = {"eval2_config": 4}
|
|
evaluator1_return_value = EvaluationResult(
|
|
metrics={"m1": 5}, artifacts={"a1": FakeArtifact1(uri="uri1")}
|
|
)
|
|
|
|
evaluator2_return_value = EvaluationResult(
|
|
metrics={"m2": 6}, artifacts={"a2": FakeArtifact2(uri="uri2")}
|
|
)
|
|
|
|
def get_evaluate_call_arg(model, evaluator_config):
|
|
return {
|
|
"model": model,
|
|
"model_type": "classifier",
|
|
"model_id": model.model_id,
|
|
"dataset": iris_dataset,
|
|
"run_id": run.info.run_id,
|
|
"evaluator_config": evaluator_config,
|
|
"extra_metrics": None,
|
|
"custom_artifacts": None,
|
|
"predictions": None,
|
|
}
|
|
|
|
# evaluators = None is the case evaluators unspecified, it should fetch all registered
|
|
# evaluators, and the evaluation results should equal to the case of
|
|
# evaluators=["test_evaluator1", "test_evaluator2"]
|
|
for evaluators in [None, ["test_evaluator1", "test_evaluator2"]]:
|
|
with (
|
|
mock.patch.object(
|
|
FakeEvaluator1, "can_evaluate", return_value=True
|
|
) as mock_can_evaluate1,
|
|
mock.patch.object(
|
|
FakeEvaluator1, "evaluate", return_value=evaluator1_return_value
|
|
) as mock_evaluate1,
|
|
mock.patch.object(
|
|
FakeEvaluator2, "can_evaluate", return_value=True
|
|
) as mock_can_evaluate2,
|
|
mock.patch.object(
|
|
FakeEvaluator2, "evaluate", return_value=evaluator2_return_value
|
|
) as mock_evaluate2,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators=evaluators,
|
|
evaluator_config={
|
|
"test_evaluator1": evaluator1_config,
|
|
"test_evaluator2": evaluator2_config,
|
|
},
|
|
)
|
|
assert eval_result.metrics == {
|
|
**evaluator1_return_value.metrics,
|
|
**evaluator2_return_value.metrics,
|
|
}
|
|
assert eval_result.artifacts == {
|
|
**evaluator1_return_value.artifacts,
|
|
**evaluator2_return_value.artifacts,
|
|
}
|
|
mock_evaluate1.assert_called_once_with(
|
|
**get_evaluate_call_arg(
|
|
mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri),
|
|
evaluator1_config,
|
|
)
|
|
)
|
|
mock_can_evaluate1.assert_has_calls([
|
|
mock.call(model_type="classifier", evaluator_config=evaluator1_config)
|
|
])
|
|
mock_evaluate2.assert_called_once_with(
|
|
**get_evaluate_call_arg(
|
|
mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri),
|
|
evaluator2_config,
|
|
)
|
|
)
|
|
mock_can_evaluate2.assert_has_calls([
|
|
mock.call(model_type="classifier", evaluator_config=evaluator2_config)
|
|
])
|
|
|
|
|
|
def test_custom_evaluators_no_model_or_preds(multiclass_logistic_regressor_model_uri, iris_dataset):
|
|
"""
|
|
Tests that custom evaluators are called correctly when no model or predictions are provided
|
|
"""
|
|
with (
|
|
mock.patch.object(
|
|
_model_evaluation_registry, "_registry", {"test_evaluator1": FakeEvaluator1}
|
|
),
|
|
mock.patch.object(FakeEvaluator1, "can_evaluate", return_value=True) as mock_can_evaluate,
|
|
mock.patch.object(FakeEvaluator1, "evaluate") as mock_evaluate,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
evaluate(
|
|
model=None,
|
|
data=iris_dataset._constructor_args["data"],
|
|
predictions=None,
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="test_evaluator1",
|
|
evaluator_config=None,
|
|
extra_metrics=None,
|
|
)
|
|
|
|
mock_can_evaluate.assert_called_once_with(model_type="classifier", evaluator_config={})
|
|
mock_evaluate.assert_called_once_with(
|
|
model=None,
|
|
dataset=iris_dataset,
|
|
predictions=None,
|
|
model_type="classifier",
|
|
model_id=None,
|
|
run_id=run.info.run_id,
|
|
evaluator_config={},
|
|
extra_metrics=None,
|
|
custom_artifacts=None,
|
|
)
|
|
|
|
|
|
def test_start_run_or_reuse_active_run():
|
|
with _start_run_or_reuse_active_run() as run:
|
|
assert mlflow.active_run().info.run_id == run.info.run_id
|
|
|
|
with mlflow.start_run() as run:
|
|
active_run_id = run.info.run_id
|
|
|
|
with _start_run_or_reuse_active_run() as run:
|
|
assert run.info.run_id == active_run_id
|
|
|
|
with _start_run_or_reuse_active_run() as run:
|
|
assert run.info.run_id == active_run_id
|
|
|
|
|
|
def test_resolve_evaluators_and_configs():
|
|
from mlflow.models.evaluation.evaluators.classifier import ClassifierEvaluator
|
|
from mlflow.models.evaluation.evaluators.default import DefaultEvaluator
|
|
from mlflow.models.evaluation.evaluators.regressor import RegressorEvaluator
|
|
from mlflow.models.evaluation.evaluators.shap import ShapEvaluator
|
|
|
|
def assert_equal(actual, expected):
|
|
assert len(actual) == len(expected)
|
|
for actual_i, expected_i in zip(actual, expected):
|
|
assert actual_i.name == expected_i[0]
|
|
assert isinstance(actual_i.evaluator, expected_i[1])
|
|
assert actual_i.config == expected_i[2]
|
|
|
|
with mock.patch.object(
|
|
_model_evaluation_registry,
|
|
"_registry",
|
|
{"default": DefaultEvaluator},
|
|
):
|
|
assert_equal(
|
|
resolve_evaluators_and_configs(None, None), [("default", DefaultEvaluator, {})]
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(None, {"a": 3}),
|
|
expected=[("default", DefaultEvaluator, {"a": 3})],
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(None, {"default": {"a": 3}}),
|
|
expected=[("default", DefaultEvaluator, {"a": 3})],
|
|
)
|
|
|
|
# 1. evaluators is None -> only default evaluator is used
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(None, None),
|
|
expected=[("default", DefaultEvaluator, {})],
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(None, {"a": 3}),
|
|
expected=[("default", DefaultEvaluator, {"a": 3})],
|
|
)
|
|
|
|
# 2. evaluators is None and model type is classifier -> builtin classifier evaluators
|
|
# are used instead of the default. Also dummy evaluator can evaluate classifier.
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(
|
|
evaluators=None, evaluator_config={"a": 3}, model_type="classifier"
|
|
),
|
|
expected=[
|
|
("classifier", ClassifierEvaluator, {"a": 3}),
|
|
("shap", ShapEvaluator, {"a": 3}),
|
|
("dummy_evaluator", DummyEvaluator, {"a": 3}),
|
|
],
|
|
)
|
|
|
|
assert_equal(
|
|
resolve_evaluators_and_configs(
|
|
evaluators=None,
|
|
# config for a specific evaluator
|
|
evaluator_config={"shap": {"a": 3}},
|
|
model_type="classifier",
|
|
),
|
|
expected=[
|
|
("classifier", ClassifierEvaluator, {}),
|
|
("shap", ShapEvaluator, {"a": 3}),
|
|
("dummy_evaluator", DummyEvaluator, {}),
|
|
],
|
|
)
|
|
|
|
assert_equal(
|
|
resolve_evaluators_and_configs(
|
|
evaluators=None,
|
|
# config for a "default" copied to builtin evaluators
|
|
evaluator_config={"default": {"a": 3}},
|
|
model_type="classifier",
|
|
),
|
|
expected=[
|
|
("classifier", ClassifierEvaluator, {"a": 3}),
|
|
("shap", ShapEvaluator, {"a": 3}),
|
|
("dummy_evaluator", DummyEvaluator, {}),
|
|
],
|
|
)
|
|
|
|
# 3. evaluators is string -> the specified evaluator is used
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs("dummy_evaluator", {"a": 3}, "regressor"),
|
|
expected=[("dummy_evaluator", DummyEvaluator, {"a": 3})],
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs("default", {"a": 3}),
|
|
expected=[("default", DefaultEvaluator, {"a": 3})],
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs("default", {"a": 3}, "regressor"),
|
|
expected=[
|
|
("regressor", RegressorEvaluator, {"a": 3}),
|
|
("shap", ShapEvaluator, {"a": 3}),
|
|
],
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs("regressor", {"a": 3}, "regressor"),
|
|
expected=[("regressor", RegressorEvaluator, {"a": 3})],
|
|
)
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs("non-existing", {"a": 3}),
|
|
expected=[], # empty because not registered evaluator
|
|
)
|
|
|
|
# 4. evaluators is a list of strings -> the specified evaluators are used
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(
|
|
evaluators=["default", "dummy_evaluator"],
|
|
evaluator_config={"dummy_evaluator": {"a": 3}, "default": {"a": 5}},
|
|
model_type="classifier",
|
|
),
|
|
expected=[
|
|
("classifier", ClassifierEvaluator, {"a": 5}),
|
|
("shap", ShapEvaluator, {"a": 5}),
|
|
("dummy_evaluator", DummyEvaluator, {"a": 3}),
|
|
],
|
|
)
|
|
|
|
assert_equal(
|
|
actual=resolve_evaluators_and_configs(
|
|
evaluators=["regressor"],
|
|
evaluator_config={"regressor": {"a": 5}},
|
|
model_type="regressor",
|
|
),
|
|
expected=[("regressor", RegressorEvaluator, {"a": 5})],
|
|
)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="If `evaluators` argument is an evaluator name list, evaluator_config must",
|
|
):
|
|
resolve_evaluators_and_configs(["default", "dummy_evaluator"], {"abc": {"a": 3}})
|
|
|
|
|
|
def test_resolve_evaluators_raise_for_missing_databricks_agent_dependency():
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="Databricks Agents SDK must be installed to use the `databricks-agent` model type.",
|
|
):
|
|
resolve_evaluators_and_configs(
|
|
evaluators=None, evaluator_config=None, model_type="databricks-agent"
|
|
)
|
|
|
|
|
|
def test_evaluate_env_manager_params(multiclass_logistic_regressor_model_uri, iris_dataset):
|
|
model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
|
|
with mock.patch.object(
|
|
_model_evaluation_registry, "_registry", {"test_evaluator1": FakeEvaluator1}
|
|
):
|
|
with pytest.raises(MlflowException, match="The model argument must be a string URI"):
|
|
evaluate(
|
|
model,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators=None,
|
|
env_manager="virtualenv",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match="Invalid value for `env_manager`"):
|
|
evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators=None,
|
|
env_manager="manager",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("env_manager", ["virtualenv", "conda"])
|
|
def test_evaluate_restores_env(tmp_path, env_manager, iris_dataset):
|
|
class EnvRestoringTestModel(mlflow.pyfunc.PythonModel):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def predict(self, context, model_input, params=None):
|
|
pred_value = 1 if sklearn.__version__ == "1.4.2" else 0
|
|
|
|
return model_input.apply(lambda row: pred_value, axis=1)
|
|
|
|
class FakeEvauatorEnv(ModelEvaluator):
|
|
@classmethod
|
|
def can_evaluate(cls, *, model_type, evaluator_config, **kwargs):
|
|
return True
|
|
|
|
def evaluate(self, *, model, model_type, dataset, run_id, evaluator_config, **kwargs):
|
|
y = model.predict(pd.DataFrame(dataset.features_data))
|
|
return EvaluationResult(metrics={"test": y[0]}, artifacts={})
|
|
|
|
model_path = os.path.join(tmp_path, "model")
|
|
|
|
mlflow.pyfunc.save_model(
|
|
path=model_path,
|
|
python_model=EnvRestoringTestModel(),
|
|
pip_requirements=["scikit-learn==1.4.2"],
|
|
)
|
|
|
|
with mock.patch.object(
|
|
_model_evaluation_registry,
|
|
"_registry",
|
|
{"test_evaluator_env": FakeEvauatorEnv},
|
|
):
|
|
result = evaluate(
|
|
model_path,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators=None,
|
|
env_manager=env_manager,
|
|
)
|
|
assert result.metrics["test"] == 1
|
|
|
|
|
|
def test_evaluate_terminates_model_servers(multiclass_logistic_regressor_model_uri, iris_dataset):
|
|
# Mock the _load_model_or_server() results to avoid starting model servers
|
|
model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
client = ScoringServerClient("127.0.0.1", "8080")
|
|
served_model_1 = _ServedPyFuncModel(model_meta=model.metadata, client=client, server_pid=1)
|
|
served_model_2 = _ServedPyFuncModel(model_meta=model.metadata, client=client, server_pid=2)
|
|
|
|
with (
|
|
mock.patch.object(
|
|
_model_evaluation_registry,
|
|
"_registry",
|
|
{"test_evaluator1": FakeEvaluator1},
|
|
),
|
|
mock.patch.object(FakeEvaluator1, "can_evaluate", return_value=True),
|
|
mock.patch.object(
|
|
FakeEvaluator1, "evaluate", return_value=EvaluationResult(metrics={}, artifacts={})
|
|
),
|
|
mock.patch("mlflow.pyfunc._load_model_or_server") as server_loader,
|
|
mock.patch("os.kill") as os_mock,
|
|
):
|
|
server_loader.side_effect = [served_model_1, served_model_2]
|
|
evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators=None,
|
|
env_manager="virtualenv",
|
|
)
|
|
assert os_mock.call_count == 1
|
|
os_mock.assert_has_calls([mock.call(1, signal.SIGTERM)])
|
|
|
|
|
|
def test_evaluate_stdin_scoring_server():
|
|
X, y = sklearn.datasets.load_iris(return_X_y=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
model = sklearn.linear_model.LogisticRegression()
|
|
model.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
|
|
with mock.patch("mlflow.pyfunc.check_port_connectivity", return_value=False):
|
|
mlflow.evaluate(
|
|
model_info.model_uri,
|
|
X,
|
|
targets=y,
|
|
model_type="classifier",
|
|
evaluators=["default"],
|
|
env_manager="virtualenv",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("model_type", ["regressor", "classifier"])
|
|
def test_targets_is_required_for_regressor_and_classifier_models(model_type):
|
|
with pytest.raises(MlflowException, match="The targets argument must be specified"):
|
|
mlflow.evaluate(
|
|
"models:/test",
|
|
data=pd.DataFrame(),
|
|
model_type=model_type,
|
|
)
|
|
|
|
|
|
def test_evaluate_xgboost_classifier():
|
|
import xgboost as xgb
|
|
|
|
X, y = sklearn.datasets.load_iris(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
data = xgb.DMatrix(X, label=y)
|
|
model = xgb.train({"objective": "multi:softmax", "num_class": 3}, data, num_boost_round=5)
|
|
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.xgboost.log_model(model, name="model")
|
|
mlflow.evaluate(
|
|
model_info.model_uri,
|
|
X.assign(y=y),
|
|
targets="y",
|
|
model_type="classifier",
|
|
)
|
|
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert "accuracy_score" in run.data.metrics
|
|
assert "recall_score" in run.data.metrics
|
|
assert "precision_score" in run.data.metrics
|
|
assert "f1_score" in run.data.metrics
|
|
|
|
|
|
def test_evaluate_lightgbm_regressor():
|
|
import lightgbm as lgb
|
|
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
data = lgb.Dataset(X, label=y)
|
|
model = lgb.train({"objective": "regression"}, data, num_boost_round=5)
|
|
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.lightgbm.log_model(model, name="model")
|
|
mlflow.evaluate(
|
|
model_info.model_uri,
|
|
X.assign(y=y),
|
|
targets="y",
|
|
model_type="regressor",
|
|
)
|
|
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert "mean_absolute_error" in run.data.metrics
|
|
assert "mean_squared_error" in run.data.metrics
|
|
assert "root_mean_squared_error" in run.data.metrics
|
|
|
|
|
|
def test_evaluate_with_targets_error_handling():
|
|
import lightgbm as lgb
|
|
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
lgb_data = lgb.Dataset(X, label=y)
|
|
model = lgb.train({"objective": "regression"}, lgb_data, num_boost_round=5)
|
|
ERROR_TYPE_1 = (
|
|
"The top-level targets parameter should not be specified since a Dataset "
|
|
"is used. Please only specify the targets column name in the Dataset. For example: "
|
|
"`data = mlflow.data.from_pandas(df=X.assign(y=y), targets='y')`. "
|
|
"Meanwhile, please specify `mlflow.evaluate(..., targets=None, ...)`."
|
|
)
|
|
ERROR_TYPE_2 = (
|
|
"The targets column name must be specified in the provided Dataset "
|
|
"for regressor models. For example: "
|
|
"`data = mlflow.data.from_pandas(df=X.assign(y=y), targets='y')`"
|
|
)
|
|
ERROR_TYPE_3 = "The targets argument must be specified for regressor models."
|
|
|
|
pandas_dataset_no_targets = X
|
|
mlflow_dataset_no_targets = mlflow.data.from_pandas(df=X.assign(y=y))
|
|
mlflow_dataset_with_targets = mlflow.data.from_pandas(df=X.assign(y=y), targets="y")
|
|
|
|
with mlflow.start_run():
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_TYPE_1)):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=mlflow_dataset_with_targets,
|
|
model_type="regressor",
|
|
targets="y",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_TYPE_1)):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=mlflow_dataset_no_targets,
|
|
model_type="regressor",
|
|
targets="y",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_TYPE_1)):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=mlflow_dataset_with_targets,
|
|
model_type="question-answering",
|
|
targets="y",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_TYPE_1)):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=mlflow_dataset_no_targets,
|
|
model_type="question-answering",
|
|
targets="y",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_TYPE_2)):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=mlflow_dataset_no_targets,
|
|
model_type="regressor",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_TYPE_3)):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=pandas_dataset_no_targets,
|
|
model_type="regressor",
|
|
)
|
|
|
|
|
|
def test_evaluate_with_predictions_error_handling():
|
|
import lightgbm as lgb
|
|
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
lgb_data = lgb.Dataset(X, label=y)
|
|
model = lgb.train({"objective": "regression"}, lgb_data, num_boost_round=5)
|
|
mlflow_dataset_with_predictions = mlflow.data.from_pandas(
|
|
df=X.assign(y=y, model_output=y),
|
|
targets="y",
|
|
predictions="model_output",
|
|
)
|
|
with mlflow.start_run():
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="The predictions parameter should not be specified in the Dataset since a model "
|
|
"is specified. Please remove the predictions column from the Dataset.",
|
|
):
|
|
mlflow.evaluate(
|
|
model=model,
|
|
data=mlflow_dataset_with_predictions,
|
|
model_type="regressor",
|
|
)
|
|
|
|
|
|
def test_evaluate_with_function_input_single_output():
|
|
import lightgbm as lgb
|
|
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
data = lgb.Dataset(X, label=y)
|
|
model = lgb.train({"objective": "regression"}, data, num_boost_round=5)
|
|
|
|
def fn(X):
|
|
return model.predict(X)
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
fn,
|
|
X.assign(y=y),
|
|
targets="y",
|
|
model_type="regressor",
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert "mean_absolute_error" in run.data.metrics
|
|
assert "mean_squared_error" in run.data.metrics
|
|
assert "root_mean_squared_error" in run.data.metrics
|
|
|
|
|
|
def test_evaluate_with_loaded_pyfunc_model():
|
|
import lightgbm as lgb
|
|
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
data = lgb.Dataset(X, label=y)
|
|
model = lgb.train({"objective": "regression"}, data, num_boost_round=5)
|
|
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.lightgbm.log_model(model, name="model")
|
|
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
|
|
mlflow.evaluate(
|
|
loaded_model,
|
|
X.assign(y=y),
|
|
targets="y",
|
|
model_type="regressor",
|
|
)
|
|
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert "mean_absolute_error" in run.data.metrics
|
|
assert "mean_squared_error" in run.data.metrics
|
|
assert "root_mean_squared_error" in run.data.metrics
|
|
|
|
|
|
def test_evaluate_with_static_dataset_input_single_output():
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
data=X.assign(y=y, model_output=y),
|
|
targets="y",
|
|
predictions="model_output",
|
|
model_type="regressor",
|
|
)
|
|
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert "mean_absolute_error" in run.data.metrics
|
|
assert "mean_squared_error" in run.data.metrics
|
|
assert "root_mean_squared_error" in run.data.metrics
|
|
|
|
|
|
def test_evaluate_with_static_mlflow_dataset_input():
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
data = mlflow.data.from_pandas(
|
|
df=X.assign(y=y, model_output=y), targets="y", predictions="model_output"
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
data=data,
|
|
model_type="regressor",
|
|
)
|
|
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert "mean_absolute_error" in run.data.metrics
|
|
assert "mean_squared_error" in run.data.metrics
|
|
assert "root_mean_squared_error" in run.data.metrics
|
|
|
|
|
|
def test_evaluate_with_static_dataset_error_handling_pandas_dataframe():
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
with mlflow.start_run():
|
|
with pytest.raises(MlflowException, match="The data argument cannot be None."):
|
|
mlflow.evaluate(
|
|
data=None,
|
|
targets="y",
|
|
model_type="regressor",
|
|
)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="The specified pandas DataFrame does not contain the specified predictions"
|
|
" column 'prediction'.",
|
|
):
|
|
mlflow.evaluate(
|
|
data=X.assign(y=y, model_output=y),
|
|
targets="y",
|
|
predictions="prediction",
|
|
model_type="regressor",
|
|
)
|
|
|
|
|
|
def test_evaluate_with_static_dataset_error_handling_pandas_dataset():
|
|
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
dataset_with_predictions = mlflow.data.from_pandas(
|
|
df=X.assign(y=y, model_output=y), targets="y", predictions="model_output"
|
|
)
|
|
dataset_no_predictions = mlflow.data.from_pandas(df=X.assign(y=y, model_output=y), targets="y")
|
|
ERROR_MESSAGE = (
|
|
"The top-level predictions parameter should not be specified since a Dataset is "
|
|
"used. Please only specify the predictions column name in the Dataset. For example: "
|
|
"`data = mlflow.data.from_pandas(df=X.assign(y=y), predictions='y')`"
|
|
"Meanwhile, please specify `mlflow.evaluate(..., predictions=None, ...)`."
|
|
)
|
|
with mlflow.start_run():
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_MESSAGE)):
|
|
mlflow.evaluate(
|
|
data=dataset_with_predictions,
|
|
model_type="regressor",
|
|
predictions="model_output",
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=re.escape(ERROR_MESSAGE)):
|
|
mlflow.evaluate(
|
|
data=dataset_no_predictions,
|
|
model_type="regressor",
|
|
predictions="model_output",
|
|
)
|
|
|
|
|
|
def test_binary_classification_missing_minority_class_exception_override(
|
|
binary_logistic_regressor_model_uri, breast_cancer_dataset, monkeypatch
|
|
):
|
|
monkeypatch.setenv("_MLFLOW_EVALUATE_SUPPRESS_CLASSIFICATION_ERRORS", "True")
|
|
|
|
ds_targets = breast_cancer_dataset._constructor_args["targets"]
|
|
# Simulate a missing target label
|
|
ds_targets = np.where(ds_targets == 0, 1, ds_targets)
|
|
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=ds_targets,
|
|
evaluators=["default"],
|
|
)
|
|
_, saved_metrics, _, _ = get_run_data(run.info.run_id)
|
|
|
|
for key, saved_val in saved_metrics.items():
|
|
eval_val = eval_result.metrics[key]
|
|
# some nan fields are due to the class imbalance.
|
|
# for example, the roc_auc_score metric will return
|
|
# nan since we override all classes to `1` here
|
|
if np.isnan(saved_val):
|
|
assert np.isnan(eval_val)
|
|
else:
|
|
assert eval_val == saved_val
|
|
|
|
|
|
def test_multiclass_classification_missing_minority_class_exception_override(
|
|
multiclass_logistic_regressor_model_uri, iris_dataset, monkeypatch
|
|
):
|
|
monkeypatch.setenv("_MLFLOW_EVALUATE_SUPPRESS_CLASSIFICATION_ERRORS", "True")
|
|
|
|
ds_targets = iris_dataset._constructor_args["targets"]
|
|
# Simulate a missing target label
|
|
ds_targets = np.where(ds_targets == 0, 1, ds_targets)
|
|
|
|
with mlflow.start_run() as run:
|
|
eval_result = evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=ds_targets,
|
|
evaluators=["default"],
|
|
)
|
|
_, saved_metrics, _, saved_artifacts = get_run_data(run.info.run_id)
|
|
|
|
assert saved_metrics == eval_result.metrics
|
|
assert "shap_beeswarm_plot.png" not in saved_artifacts
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "is_endpoint_uri"),
|
|
[
|
|
("endpoints:/test", True),
|
|
("endpoints:///my-chat", True),
|
|
("models:/test", False),
|
|
(None, False),
|
|
],
|
|
)
|
|
def test_is_model_deployment_endpoint_uri(model, is_endpoint_uri):
|
|
assert _is_model_deployment_endpoint_uri(model) == is_endpoint_uri
|
|
|
|
|
|
_DUMMY_CHAT_RESPONSE = {
|
|
"id": "1",
|
|
"object": "text_completion",
|
|
"created": "2021-10-01T00:00:00.000000Z",
|
|
"model": "gpt-4o-mini",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"content": "This is a response",
|
|
"role": "assistant",
|
|
},
|
|
"finish_reason": "length",
|
|
}
|
|
],
|
|
"usage": {
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 1,
|
|
"total_tokens": 2,
|
|
},
|
|
}
|
|
|
|
_TEST_QUERY_LIST = ["What is MLflow?", "What is Spark?"]
|
|
_TEST_GT_LIST = [
|
|
"MLflow is an open-source platform for machine learning (ML).",
|
|
"Apache Spark is an open-source, distributed computing system.",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_data", "feature_names", "targets"),
|
|
[
|
|
# String input column
|
|
(
|
|
pd.DataFrame({"inputs": _TEST_QUERY_LIST, "ground_truth": _TEST_GT_LIST}),
|
|
None,
|
|
"ground_truth",
|
|
),
|
|
# String input column with feature_names
|
|
(
|
|
pd.DataFrame({"question": _TEST_QUERY_LIST, "ground_truth": _TEST_GT_LIST}),
|
|
["question"],
|
|
"ground_truth",
|
|
),
|
|
# Dictionary input column that contains message history
|
|
(
|
|
pd.DataFrame({
|
|
"inputs": [
|
|
{
|
|
"messages": [{"content": q, "role": "user"}],
|
|
"max_tokens": 10,
|
|
}
|
|
for q in _TEST_QUERY_LIST
|
|
],
|
|
"ground_truth": _TEST_GT_LIST,
|
|
}),
|
|
None,
|
|
"ground_truth",
|
|
),
|
|
# List of string
|
|
(
|
|
_TEST_QUERY_LIST,
|
|
None,
|
|
_TEST_GT_LIST,
|
|
),
|
|
# List of string with feature_names
|
|
(
|
|
_TEST_QUERY_LIST,
|
|
["question"],
|
|
_TEST_GT_LIST,
|
|
),
|
|
# List of string with feature_names and w/o targets
|
|
(
|
|
_TEST_QUERY_LIST,
|
|
["question"],
|
|
None,
|
|
),
|
|
# List of dictionary with feature_names
|
|
(
|
|
[
|
|
{
|
|
"messages": [{"content": q, "role": "user"}],
|
|
"max_tokens": 10,
|
|
}
|
|
for q in _TEST_QUERY_LIST
|
|
],
|
|
None,
|
|
_TEST_GT_LIST,
|
|
),
|
|
],
|
|
)
|
|
def test_evaluate_on_chat_model_endpoint(input_data, feature_names, targets):
|
|
with mock.patch("mlflow.deployments.get_deploy_client") as mock_deploy_client:
|
|
mock_deploy_client.return_value.predict.return_value = _DUMMY_CHAT_RESPONSE
|
|
mock_deploy_client.return_value.get_endpoint.return_value = {"task": "llm/v1/chat"}
|
|
|
|
with mlflow.start_run():
|
|
eval_result = mlflow.evaluate(
|
|
model="endpoints:/chat",
|
|
data=input_data,
|
|
model_type="question-answering",
|
|
feature_names=feature_names,
|
|
targets=targets,
|
|
inference_params={"max_tokens": 10, "temperature": 0.5},
|
|
)
|
|
|
|
# Validate the endpoint is called with correct payloads
|
|
call_args_list = mock_deploy_client.return_value.predict.call_args_list
|
|
expected_calls = [
|
|
mock.call(
|
|
endpoint="chat",
|
|
inputs={
|
|
"messages": [{"content": "What is MLflow?", "role": "user"}],
|
|
"max_tokens": 10,
|
|
"temperature": 0.5,
|
|
},
|
|
),
|
|
mock.call(
|
|
endpoint="chat",
|
|
inputs={
|
|
"messages": [{"content": "What is Spark?", "role": "user"}],
|
|
"max_tokens": 10,
|
|
"temperature": 0.5,
|
|
},
|
|
),
|
|
]
|
|
assert call_args_list == expected_calls
|
|
|
|
# Validate the evaluation metrics
|
|
expected_metrics_subset = {"toxicity/v1/ratio", "ari_grade_level/v1/mean"}
|
|
if targets:
|
|
expected_metrics_subset.add("exact_match/v1")
|
|
assert expected_metrics_subset.issubset(set(eval_result.metrics.keys()))
|
|
|
|
# Validate the model output is passed to the evaluator in the correct format (string)
|
|
eval_results_table = eval_result.tables["eval_results_table"]
|
|
assert eval_results_table["outputs"].equals(pd.Series(["This is a response"] * 2))
|
|
|
|
|
|
_DUMMY_COMPLETION_RESPONSE = {
|
|
"id": "1",
|
|
"object": "text_completion",
|
|
"created": "2021-10-01T00:00:00.000000Z",
|
|
"model": "gpt-4o-mini",
|
|
"choices": [{"index": 0, "text": "This is a response", "finish_reason": "length"}],
|
|
"usage": {
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 1,
|
|
"total_tokens": 2,
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_data", "feature_names"),
|
|
[
|
|
(pd.DataFrame({"inputs": _TEST_QUERY_LIST}), None),
|
|
(pd.DataFrame({"question": _TEST_QUERY_LIST}), ["question"]),
|
|
(pd.DataFrame({"inputs": [{"prompt": q} for q in _TEST_QUERY_LIST]}), None),
|
|
(_TEST_QUERY_LIST, None),
|
|
([{"prompt": q} for q in _TEST_QUERY_LIST], None),
|
|
],
|
|
)
|
|
def test_evaluate_on_completion_model_endpoint(input_data, feature_names):
|
|
with mock.patch("mlflow.deployments.get_deploy_client") as mock_deploy_client:
|
|
mock_deploy_client.return_value.predict.return_value = _DUMMY_COMPLETION_RESPONSE
|
|
mock_deploy_client.return_value.get_endpoint.return_value = {"task": "llm/v1/completions"}
|
|
|
|
with mlflow.start_run():
|
|
eval_result = mlflow.evaluate(
|
|
model="endpoints:/completions",
|
|
data=input_data,
|
|
inference_params={"max_tokens": 10},
|
|
model_type="text",
|
|
feature_names=feature_names,
|
|
)
|
|
|
|
# Validate the endpoint is called with correct payloads
|
|
call_args_list = mock_deploy_client.return_value.predict.call_args_list
|
|
expected_calls = [
|
|
mock.call(endpoint="completions", inputs={"prompt": "What is MLflow?", "max_tokens": 10}),
|
|
mock.call(endpoint="completions", inputs={"prompt": "What is Spark?", "max_tokens": 10}),
|
|
]
|
|
assert call_args_list == expected_calls
|
|
|
|
# Validate the evaluation metrics
|
|
expected_metrics_subset = {
|
|
"toxicity/v1/ratio",
|
|
"ari_grade_level/v1/mean",
|
|
"flesch_kincaid_grade_level/v1/mean",
|
|
}
|
|
assert expected_metrics_subset.issubset(set(eval_result.metrics.keys()))
|
|
|
|
# Validate the model output is passed to the evaluator in the correct format (string)
|
|
eval_results_table = eval_result.tables["eval_results_table"]
|
|
assert eval_results_table["outputs"].equals(pd.Series(["This is a response"] * 2))
|
|
|
|
|
|
def test_evaluate_on_model_endpoint_without_type():
|
|
with mock.patch("mlflow.deployments.get_deploy_client") as mock_deploy_client:
|
|
# An endpoint that does not have endpoint type. For such endpoint, we simply
|
|
# pass the input data to the endpoint without any modification and return
|
|
# the response as is.
|
|
mock_deploy_client.return_value.get_endpoint.return_value = {}
|
|
mock_deploy_client.return_value.predict.return_value = "This is a response"
|
|
|
|
input_data = pd.DataFrame({
|
|
"inputs": [
|
|
{
|
|
"messages": [{"content": q, "role": "user"}],
|
|
"max_tokens": 10,
|
|
}
|
|
for q in _TEST_QUERY_LIST
|
|
],
|
|
"ground_truth": _TEST_GT_LIST,
|
|
})
|
|
|
|
with mlflow.start_run():
|
|
eval_result = mlflow.evaluate(
|
|
model="endpoints:/random",
|
|
data=input_data,
|
|
model_type="question-answering",
|
|
targets="ground_truth",
|
|
inference_params={"max_tokens": 10, "temperature": 0.5},
|
|
)
|
|
|
|
# Validate the endpoint is called with correct payloads
|
|
call_args_list = mock_deploy_client.return_value.predict.call_args_list
|
|
expected_calls = [
|
|
mock.call(
|
|
endpoint="random",
|
|
inputs={
|
|
"messages": [{"content": "What is MLflow?", "role": "user"}],
|
|
"max_tokens": 10,
|
|
"temperature": 0.5,
|
|
},
|
|
),
|
|
mock.call(
|
|
endpoint="random",
|
|
inputs={
|
|
"messages": [{"content": "What is Spark?", "role": "user"}],
|
|
"max_tokens": 10,
|
|
"temperature": 0.5,
|
|
},
|
|
),
|
|
]
|
|
assert call_args_list == expected_calls
|
|
|
|
# Validate the evaluation metrics
|
|
expected_metrics_subset = {"toxicity/v1/ratio", "ari_grade_level/v1/mean", "exact_match/v1"}
|
|
assert expected_metrics_subset.issubset(set(eval_result.metrics.keys()))
|
|
|
|
# Validate the model output is passed to the evaluator in the correct format (string)
|
|
eval_results_table = eval_result.tables["eval_results_table"]
|
|
assert eval_results_table["outputs"].equals(pd.Series(["This is a response"] * 2))
|
|
|
|
|
|
def test_evaluate_on_model_endpoint_invalid_payload():
|
|
with mock.patch("mlflow.deployments.get_deploy_client") as mock_deploy_client:
|
|
# An endpoint that does not have endpoint type. For such endpoint, we simply
|
|
# pass the input data to the endpoint without any modification and return
|
|
# the response as is.
|
|
mock_deploy_client.return_value.get_endpoint.return_value = {}
|
|
mock_deploy_client.return_value.predict.side_effect = ValueError("Invalid payload")
|
|
|
|
input_data = pd.DataFrame({
|
|
"inputs": [{"invalid": "payload"}],
|
|
})
|
|
|
|
with pytest.raises(MlflowException, match="Failed to call the deployment endpoint"):
|
|
mlflow.evaluate(
|
|
model="endpoints:/random",
|
|
data=input_data,
|
|
model_type="question-answering",
|
|
inference_params={"max_tokens": 10, "temperature": 0.5},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_data", "error_message"),
|
|
[
|
|
# Extra input columns
|
|
(
|
|
pd.DataFrame({
|
|
"inputs": _TEST_QUERY_LIST,
|
|
"extra_input": ["a", "b"],
|
|
"ground_truth": _TEST_GT_LIST,
|
|
}),
|
|
"The number of input columns must be 1",
|
|
),
|
|
# Missing input columns
|
|
(
|
|
pd.DataFrame({"ground_truth": _TEST_GT_LIST}),
|
|
"The number of input columns must be 1",
|
|
),
|
|
# Input column not str or dict
|
|
(
|
|
pd.DataFrame({"inputs": [1, 2], "ground_truth": _TEST_GT_LIST}),
|
|
"Invalid input data type",
|
|
),
|
|
],
|
|
)
|
|
def test_evaluate_on_model_endpoint_invalid_input_data(input_data, error_message):
|
|
with mock.patch("mlflow.deployments.get_deploy_client") as mock_deploy_client:
|
|
mock_deploy_client.return_value.get_endpoint.return_value = {"task": "llm/v1/chat"}
|
|
|
|
with pytest.raises(MlflowException, match=error_message):
|
|
with mlflow.start_run():
|
|
mlflow.evaluate(
|
|
model="endpoints:/chat",
|
|
data=input_data,
|
|
model_type="question-answering",
|
|
targets="ground_truth",
|
|
inference_params={"max_tokens": 10, "temperature": 0.5},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_input",
|
|
[
|
|
# Case 1: Single chat dictionary.
|
|
# This is an expected input format from the Databricks RAG Evaluator.
|
|
{
|
|
"messages": [{"content": "What is MLflow?", "role": "user"}],
|
|
"max_tokens": 10,
|
|
},
|
|
# Case 2: List of chat dictionaries.
|
|
# This is not a typical input format from either default or Databricks RAG evaluators,
|
|
# but we support it for compatibility with the normal Pyfunc models.
|
|
[
|
|
{"messages": [{"content": "What is MLflow?", "role": "user"}]},
|
|
{"messages": [{"content": "What is Spark?", "role": "user"}]},
|
|
],
|
|
# Case 3: DataFrame with a column of dictionaries
|
|
pd.DataFrame({
|
|
"inputs": [
|
|
{
|
|
"messages": [{"content": "What is MLflow?", "role": "user"}],
|
|
"max_tokens": 10,
|
|
},
|
|
{
|
|
"messages": [{"content": "What is Spark?", "role": "user"}],
|
|
},
|
|
]
|
|
}),
|
|
# Case 4: DataFrame with a column of strings
|
|
pd.DataFrame({
|
|
"inputs": ["What is MLflow?", "What is Spark?"],
|
|
}),
|
|
],
|
|
)
|
|
def test_model_from_deployment_endpoint(model_input):
|
|
with mock.patch("mlflow.deployments.get_deploy_client") as mock_deploy_client:
|
|
mock_deploy_client.return_value.predict.return_value = _DUMMY_CHAT_RESPONSE
|
|
mock_deploy_client.return_value.get_endpoint.return_value = {"task": "llm/v1/chat"}
|
|
|
|
model = _get_model_from_deployment_endpoint_uri("endpoints:/chat")
|
|
|
|
response = model.predict(model_input)
|
|
|
|
if isinstance(model_input, dict):
|
|
assert mock_deploy_client.return_value.predict.call_count == 1
|
|
# Chat response should be unwrapped
|
|
assert response == "This is a response"
|
|
else:
|
|
assert mock_deploy_client.return_value.predict.call_count == 2
|
|
assert pd.Series(response).equals(pd.Series(["This is a response"] * 2))
|
|
|
|
|
|
def test_import_evaluation_dataset():
|
|
# This test is to validate both imports work at the same time
|
|
from mlflow.models.evaluation import EvaluationDataset
|
|
from mlflow.models.evaluation.base import EvaluationDataset # noqa: F401
|
|
|
|
|
|
def test_evaluate_shows_server_stdout_and_stderr_on_error(
|
|
linear_regressor_model_uri, diabetes_dataset
|
|
):
|
|
with mlflow.start_run():
|
|
server_proc = subprocess.Popen(
|
|
["echo", "test1324"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
)
|
|
with mock.patch(
|
|
"mlflow.pyfunc.backend.PyFuncBackend.serve",
|
|
return_value=server_proc,
|
|
) as mock_serve:
|
|
with pytest.raises(MlflowException, match="test1324"):
|
|
evaluate(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_dataset._constructor_args["targets"],
|
|
evaluators="dummy_evaluator",
|
|
env_manager="virtualenv",
|
|
)
|
|
mock_serve.assert_called_once()
|
|
|
|
|
|
def test_env_manager_set_on_served_pyfunc_model(multiclass_logistic_regressor_model_uri):
|
|
model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
client = ScoringServerClient("127.0.0.1", "8080")
|
|
served_model_1 = _ServedPyFuncModel(model_meta=model.metadata, client=client, server_pid=1)
|
|
served_model_1.env_manager = "virtualenv"
|
|
assert served_model_1.env_manager == "virtualenv"
|
|
|
|
|
|
def test_metrics_logged_to_model_on_evaluation(
|
|
multiclass_logistic_regressor_model_uri, iris_dataset
|
|
):
|
|
with mlflow.start_run():
|
|
# Log the model and retrieve its model_id
|
|
model_info = mlflow.sklearn.log_model(
|
|
mlflow.sklearn.load_model(multiclass_logistic_regressor_model_uri), name="model"
|
|
)
|
|
model_id = model_info.model_id
|
|
|
|
# Evaluate the model using its model_id
|
|
eval_result = mlflow.evaluate(
|
|
model=model_info.model_uri,
|
|
data=iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators=["default"],
|
|
)
|
|
|
|
# Retrieve metrics logged to the model
|
|
logged_model_metrics = mlflow.get_logged_model(model_id).metrics
|
|
|
|
# Ensure metrics are logged to the model
|
|
assert eval_result.metrics == {metric.key: metric.value for metric in logged_model_metrics}
|
|
|
|
# Validate that all metrics have the correct model_id in their metadata
|
|
assert all(metric.model_id == model_id for metric in logged_model_metrics)
|
|
|
|
|
|
def test_evaluate_with_model_id(iris_dataset):
|
|
# Create and log a model
|
|
with mlflow.start_run():
|
|
model = sklearn.linear_model.LogisticRegression()
|
|
model.fit(iris_dataset._constructor_args["data"], iris_dataset._constructor_args["targets"])
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
model_id = model_info.model_id
|
|
|
|
# Evaluate the model with the specified model ID
|
|
with mlflow.start_run():
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
model_id=model_id,
|
|
)
|
|
|
|
# Verify metrics were logged
|
|
assert result.metrics is not None
|
|
assert len(result.metrics) > 0
|
|
|
|
# Verify metrics are linked to the model ID
|
|
logged_model = mlflow.get_logged_model(model_id)
|
|
assert logged_model is not None
|
|
assert logged_model.model_id == model_id
|
|
|
|
# Convert metrics list to a dictionary for easier lookup
|
|
logged_metrics = {metric.key: metric.value for metric in logged_model.metrics}
|
|
|
|
# Verify each metric from the evaluation result matches the logged model metrics
|
|
for metric_name, metric_value in result.metrics.items():
|
|
assert metric_name in logged_metrics, (
|
|
f"Metric {metric_name} not found in logged model metrics"
|
|
)
|
|
assert logged_metrics[metric_name] == metric_value, (
|
|
f"Metric {metric_name} value mismatch: "
|
|
f"expected {metric_value}, got {logged_metrics[metric_name]}"
|
|
)
|
|
|
|
|
|
def test_evaluate_model_id_consistency_check(multiclass_logistic_regressor_model_uri, iris_dataset):
|
|
"""
|
|
Test that an error is thrown when the specified model_id contradicts the model's associated ID.
|
|
"""
|
|
# Create a model with a known model ID
|
|
with mlflow.start_run():
|
|
model = sklearn.linear_model.LogisticRegression()
|
|
model.fit(iris_dataset._constructor_args["data"], iris_dataset._constructor_args["targets"])
|
|
model_info = mlflow.sklearn.log_model(
|
|
model,
|
|
name="model",
|
|
)
|
|
model_uri = model_info.model_uri
|
|
model_id = model_info.model_uuid
|
|
|
|
# Test that specifying matching model_id works
|
|
evaluate(
|
|
model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
model_type="classifier",
|
|
model_id=model_id,
|
|
)
|
|
|
|
# Test that specifying different model_id raises
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
r"The specified value of the 'model_id' parameter '.*' "
|
|
r"contradicts the model_id '.*' associated with the model\. Please ensure "
|
|
r"they match or omit the 'model_id' parameter\."
|
|
),
|
|
):
|
|
evaluate(
|
|
model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
model_type="classifier",
|
|
model_id="different_model_id",
|
|
)
|
|
|
|
# Test that not specifying model_id works
|
|
evaluate(
|
|
model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
model_type="classifier",
|
|
)
|
|
|
|
|
|
def test_evaluate_log_metrics_to_active_model(iris_dataset):
|
|
# Set active model
|
|
mlflow.set_active_model(name="my-model")
|
|
active_model_id = mlflow.get_active_model_id()
|
|
|
|
model = sklearn.linear_model.LogisticRegression()
|
|
model.fit(iris_dataset._constructor_args["data"], iris_dataset._constructor_args["targets"])
|
|
eval_df = pd.DataFrame({
|
|
"inputs": iris_dataset._constructor_args["data"].tolist(),
|
|
"targets": iris_dataset._constructor_args["targets"],
|
|
"predictions": model.predict(iris_dataset._constructor_args["data"]),
|
|
})
|
|
|
|
eval_dataset = mlflow.data.from_pandas(
|
|
df=eval_df,
|
|
name="eval_dataset",
|
|
targets="targets",
|
|
predictions="predictions",
|
|
)
|
|
|
|
# Evaluate the model without model_id, active model_id should be used
|
|
with mlflow.start_run():
|
|
result = evaluate(
|
|
data=eval_dataset,
|
|
model_type="classifier",
|
|
)
|
|
|
|
# Verify metrics were logged
|
|
assert result.metrics is not None
|
|
assert len(result.metrics) > 0
|
|
|
|
# Verify metrics are linked to the active model ID
|
|
logged_model = mlflow.get_logged_model(active_model_id)
|
|
assert logged_model is not None
|
|
assert logged_model.model_id == active_model_id
|
|
|
|
# Convert metrics list to a dictionary for easier lookup
|
|
logged_metrics = {metric.key: metric.value for metric in logged_model.metrics}
|
|
|
|
# Verify each metric from the evaluation result matches the logged model metrics
|
|
assert logged_metrics.items() <= result.metrics.items()
|
|
|
|
|
|
def test_mlflow_evaluate_logs_traces_to_active_model():
|
|
eval_data = pd.DataFrame({
|
|
"inputs": [
|
|
"What is MLflow?",
|
|
"What is Spark?",
|
|
],
|
|
"ground_truth": ["What is MLflow?", "Not what is Spark?"],
|
|
})
|
|
|
|
@mlflow.trace
|
|
def model(inputs):
|
|
return inputs
|
|
|
|
# no model_id used when no active model is set or passed
|
|
evaluate(model, eval_data, targets="ground_truth", extra_metrics=[mlflow.metrics.exact_match()])
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
assert TraceMetadataKey.MODEL_ID not in traces[0].info.request_metadata
|
|
|
|
# no active model set and pass model_id explicitly
|
|
assert mlflow.get_active_model_id() is None
|
|
model_id = mlflow.create_external_model(name="my-model").model_id
|
|
evaluate(
|
|
model,
|
|
eval_data,
|
|
targets="ground_truth",
|
|
extra_metrics=[mlflow.metrics.exact_match()],
|
|
model_id=model_id,
|
|
)
|
|
traces = get_traces()
|
|
assert len(traces) == 2
|
|
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_id
|
|
|
|
# set active model
|
|
with mlflow.set_active_model(name="my-model") as active_model:
|
|
model_id = active_model.model_id
|
|
evaluate(
|
|
model, eval_data, targets="ground_truth", extra_metrics=[mlflow.metrics.exact_match()]
|
|
)
|
|
traces = get_traces()
|
|
assert len(traces) == 3
|
|
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_id
|
|
|
|
# pass model_id explicitly takes precedence over active model
|
|
assert mlflow.get_active_model_id() is not None
|
|
another_model_id = mlflow.create_external_model(name="another-model").model_id
|
|
evaluate(
|
|
model,
|
|
eval_data,
|
|
targets="ground_truth",
|
|
extra_metrics=[mlflow.metrics.exact_match()],
|
|
model_id=another_model_id,
|
|
)
|
|
traces = get_traces()
|
|
assert len(traces) == 4
|
|
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == another_model_id
|
|
|
|
# model_id of the passed model takes precedence over active model
|
|
assert mlflow.get_active_model_id() is not None
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=model,
|
|
input_example="What is MLflow?",
|
|
)
|
|
evaluate(
|
|
model_info.model_uri,
|
|
eval_data,
|
|
targets="ground_truth",
|
|
extra_metrics=[mlflow.metrics.exact_match()],
|
|
)
|
|
traces = get_traces()
|
|
assert len(traces) == 5
|
|
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_info.model_id
|
|
# TODO: test registered ModelVersion's model_id works after it's supported
|
|
|
|
|
|
def test_delete_run_deletes_assessments_with_source_run_id():
|
|
@mlflow.trace
|
|
def model(inputs):
|
|
return inputs
|
|
|
|
eval_data = pd.DataFrame({
|
|
"inputs": ["What is MLflow?"],
|
|
"ground_truth": ["MLflow is an ML platform."],
|
|
})
|
|
|
|
with mlflow.start_run() as run:
|
|
evaluate(
|
|
model, eval_data, targets="ground_truth", extra_metrics=[mlflow.metrics.exact_match()]
|
|
)
|
|
|
|
traces = get_traces()
|
|
assert len(traces) == 1
|
|
trace_id = traces[0].info.trace_id
|
|
|
|
# Log a feedback assessment linked to the run via sourceRunId metadata
|
|
linked_feedback = mlflow.log_feedback(
|
|
trace_id=trace_id,
|
|
name="eval_feedback",
|
|
value="good",
|
|
metadata={AssessmentMetadataKey.SOURCE_RUN_ID: run.info.run_id},
|
|
)
|
|
|
|
# Log another feedback assessment NOT linked to any run
|
|
unlinked_feedback = mlflow.log_feedback(
|
|
trace_id=trace_id,
|
|
name="unlinked_feedback",
|
|
value="also good",
|
|
)
|
|
|
|
# Verify both assessments exist
|
|
trace = mlflow.get_trace(trace_id)
|
|
assert len(trace.info.assessments) >= 2
|
|
assessment_ids = {a.assessment_id for a in trace.info.assessments}
|
|
assert linked_feedback.assessment_id in assessment_ids
|
|
assert unlinked_feedback.assessment_id in assessment_ids
|
|
|
|
# Delete the run
|
|
MlflowClient().delete_run(run.info.run_id)
|
|
|
|
# Verify the linked assessment was deleted but the unlinked one survives
|
|
trace = mlflow.get_trace(trace_id)
|
|
remaining_ids = {a.assessment_id for a in trace.info.assessments}
|
|
assert linked_feedback.assessment_id not in remaining_ids
|
|
assert unlinked_feedback.assessment_id in remaining_ids
|