4565 lines
161 KiB
Python
4565 lines
161 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
from os.path import join as path_join
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
from matplotlib.figure import Figure
|
|
from PIL import Image, ImageChops
|
|
from pyspark.ml.linalg import Vectors
|
|
from pyspark.sql import SparkSession
|
|
from sklearn.datasets import load_breast_cancer, load_diabetes, load_iris
|
|
from sklearn.linear_model import LinearRegression, LogisticRegression
|
|
from sklearn.metrics import (
|
|
average_precision_score,
|
|
f1_score,
|
|
precision_score,
|
|
recall_score,
|
|
roc_auc_score,
|
|
)
|
|
from sklearn.pipeline import Pipeline
|
|
from sklearn.preprocessing import FunctionTransformer
|
|
from sklearn.svm import LinearSVC
|
|
|
|
import mlflow
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.metrics import (
|
|
MetricValue,
|
|
flesch_kincaid_grade_level,
|
|
make_metric,
|
|
toxicity,
|
|
)
|
|
from mlflow.metrics.genai import model_utils
|
|
from mlflow.metrics.genai.base import EvaluationExample
|
|
from mlflow.metrics.genai.genai_metric import (
|
|
_GENAI_CUSTOM_METRICS_FILE_NAME,
|
|
make_genai_metric_from_prompt,
|
|
retrieve_custom_metrics,
|
|
)
|
|
from mlflow.metrics.genai.metric_definitions import answer_similarity
|
|
from mlflow.models import Model
|
|
from mlflow.models.evaluation.artifacts import (
|
|
CsvEvaluationArtifact,
|
|
ImageEvaluationArtifact,
|
|
JsonEvaluationArtifact,
|
|
NumpyEvaluationArtifact,
|
|
ParquetEvaluationArtifact,
|
|
PickleEvaluationArtifact,
|
|
TextEvaluationArtifact,
|
|
)
|
|
from mlflow.models.evaluation.base import evaluate
|
|
from mlflow.models.evaluation.default_evaluator import (
|
|
_CustomArtifact,
|
|
_evaluate_custom_artifacts,
|
|
_extract_output_and_other_columns,
|
|
_extract_predict_fn,
|
|
_extract_raw_model,
|
|
_get_aggregate_metrics_values,
|
|
)
|
|
from mlflow.models.evaluation.evaluators.classifier import (
|
|
_extract_predict_fn_and_predict_proba_fn,
|
|
_gen_classifier_curve,
|
|
_get_binary_classifier_metrics,
|
|
_get_binary_sum_up_label_pred_prob,
|
|
_get_multiclass_classifier_metrics,
|
|
_infer_model_type_by_labels,
|
|
)
|
|
from mlflow.models.evaluation.evaluators.regressor import _get_regressor_metrics
|
|
from mlflow.models.evaluation.evaluators.shap import _compute_df_mode_or_mean
|
|
from mlflow.models.evaluation.utils.metric import MetricDefinition
|
|
|
|
from tests.evaluate.test_evaluation import (
|
|
binary_logistic_regressor_model_uri, # noqa: F401
|
|
breast_cancer_dataset, # noqa: F401
|
|
diabetes_dataset, # noqa: F401
|
|
diabetes_spark_dataset, # noqa: F401
|
|
get_pipeline_model_dataset,
|
|
get_run_data,
|
|
iris_dataset, # noqa: F401
|
|
iris_pandas_df_dataset, # noqa: F401
|
|
iris_pandas_df_num_cols_dataset, # noqa: F401
|
|
linear_regressor_model_uri, # noqa: F401
|
|
multiclass_logistic_regressor_model_uri, # noqa: F401
|
|
pipeline_model_uri, # noqa: F401
|
|
spark_linear_regressor_model_uri, # noqa: F401
|
|
svm_model_uri, # noqa: F401
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def suppress_dummy_evaluator():
|
|
"""
|
|
Dummy evaluator is registered by the test plugin and used in
|
|
test_evaluation.py, but we don't want it to be used in this test.
|
|
|
|
This fixture suppress dummy evaluator for the duration of each test.
|
|
"""
|
|
from mlflow.models.evaluation.evaluator_registry import _model_evaluation_registry
|
|
|
|
dummy_evaluator = _model_evaluation_registry._registry.pop("dummy_evaluator")
|
|
|
|
yield
|
|
|
|
_model_evaluation_registry._registry["dummy_evaluator"] = dummy_evaluator
|
|
|
|
|
|
def assert_dict_equal(d1, d2, rtol):
|
|
for k in d1:
|
|
assert k in d2
|
|
assert np.isclose(d1[k], d2[k], rtol=rtol)
|
|
|
|
|
|
def assert_metrics_equal(actual, expected):
|
|
for metric_key in expected:
|
|
assert np.isclose(expected[metric_key], actual[metric_key], rtol=1e-3)
|
|
|
|
|
|
@pytest.mark.parametrize("use_sample_weights", [False, True])
|
|
@pytest.mark.parametrize("evaluators", ["default", ["regressor", "shap"], None])
|
|
def test_regressor_evaluation(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset,
|
|
use_sample_weights,
|
|
evaluators,
|
|
):
|
|
sample_weights = (
|
|
np.random.rand(len(diabetes_dataset.labels_data)) if use_sample_weights else None
|
|
)
|
|
|
|
evaluator_config = {"sample_weights": sample_weights} if use_sample_weights else {}
|
|
|
|
if isinstance(evaluators, list):
|
|
evaluator_config = dict.fromkeys(evaluators, evaluator_config)
|
|
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_dataset._constructor_args["targets"],
|
|
evaluators=evaluators,
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
|
|
_, metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(linear_regressor_model_uri)
|
|
|
|
y = diabetes_dataset.labels_data
|
|
y_pred = model.predict(diabetes_dataset.features_data)
|
|
|
|
expected_metrics = _get_regressor_metrics(y, y_pred, sample_weights=sample_weights)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
diabetes_dataset.features_data, diabetes_dataset.labels_data, sample_weight=sample_weights
|
|
)
|
|
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
{**diabetes_dataset._metadata, "name": "dataset", "model": model.metadata.model_uuid}
|
|
]
|
|
|
|
for metric_key, expected_metric_val in expected_metrics.items():
|
|
assert np.isclose(
|
|
expected_metric_val,
|
|
metrics[metric_key],
|
|
rtol=1e-3,
|
|
)
|
|
assert np.isclose(expected_metric_val, result.metrics[metric_key], rtol=1e-3)
|
|
|
|
assert set(artifacts) == {
|
|
"shap_beeswarm_plot.png",
|
|
"shap_feature_importance_plot.png",
|
|
"shap_summary_plot.png",
|
|
}
|
|
|
|
assert result.artifacts.keys() == {
|
|
"shap_beeswarm_plot",
|
|
"shap_feature_importance_plot",
|
|
"shap_summary_plot",
|
|
}
|
|
|
|
|
|
def test_regressor_evaluation_disable_logging_metrics_and_artifacts(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, logged_metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(linear_regressor_model_uri)
|
|
|
|
y = diabetes_dataset.labels_data
|
|
y_pred = model.predict(diabetes_dataset.features_data)
|
|
|
|
expected_metrics = _get_regressor_metrics(y, y_pred, sample_weights=None)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
diabetes_dataset.features_data, diabetes_dataset.labels_data
|
|
)
|
|
|
|
assert_metrics_equal(result.metrics, expected_metrics)
|
|
assert "mlflow.datassets" not in tags
|
|
|
|
|
|
def test_regressor_evaluation_with_int_targets(
|
|
linear_regressor_model_uri, diabetes_dataset, tmp_path
|
|
):
|
|
with mlflow.start_run():
|
|
result = evaluate(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_dataset._constructor_args["targets"].astype(np.int64),
|
|
evaluators="default",
|
|
)
|
|
result.save(tmp_path)
|
|
|
|
|
|
@pytest.mark.parametrize("use_sample_weights", [True, False])
|
|
@pytest.mark.parametrize("evaluators", ["default", ["classifier", "shap"], None])
|
|
def test_multi_classifier_evaluation(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset,
|
|
use_sample_weights,
|
|
evaluators,
|
|
):
|
|
sample_weights = np.random.rand(len(iris_dataset.labels_data)) if use_sample_weights else None
|
|
evaluator_config = {"sample_weights": sample_weights} if use_sample_weights else {}
|
|
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
|
|
_, metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
|
|
predict_fn, predict_proba_fn = _extract_predict_fn_and_predict_proba_fn(model)
|
|
y = iris_dataset.labels_data
|
|
y_pred = predict_fn(iris_dataset.features_data)
|
|
y_probs = predict_proba_fn(iris_dataset.features_data)
|
|
|
|
expected_metrics = _get_multiclass_classifier_metrics(
|
|
y_true=y, y_pred=y_pred, y_proba=y_probs, sample_weights=sample_weights
|
|
)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
iris_dataset.features_data, iris_dataset.labels_data, sample_weight=sample_weights
|
|
)
|
|
|
|
for metric_key, expected_metric_val in expected_metrics.items():
|
|
assert np.isclose(expected_metric_val, metrics[metric_key], rtol=1e-3)
|
|
assert np.isclose(expected_metric_val, result.metrics[metric_key], rtol=1e-3)
|
|
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
{**iris_dataset._metadata, "name": "dataset", "model": model.metadata.model_uuid}
|
|
]
|
|
|
|
assert set(artifacts) == {
|
|
"shap_beeswarm_plot.png",
|
|
"per_class_metrics.csv",
|
|
"roc_curve_plot.png",
|
|
"precision_recall_curve_plot.png",
|
|
"shap_feature_importance_plot.png",
|
|
"confusion_matrix.png",
|
|
"shap_summary_plot.png",
|
|
"calibration_curve_plot.png",
|
|
}
|
|
assert result.artifacts.keys() == {
|
|
"per_class_metrics",
|
|
"roc_curve_plot",
|
|
"precision_recall_curve_plot",
|
|
"confusion_matrix",
|
|
"shap_beeswarm_plot",
|
|
"shap_summary_plot",
|
|
"shap_feature_importance_plot",
|
|
"calibration_curve_plot",
|
|
}
|
|
|
|
|
|
def test_multi_classifier_evaluation_disable_logging_metrics_and_artifacts(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
multiclass_logistic_regressor_model_uri,
|
|
iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, logged_metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
|
|
predict_fn, predict_proba_fn = _extract_predict_fn_and_predict_proba_fn(model)
|
|
y = iris_dataset.labels_data
|
|
y_pred = predict_fn(iris_dataset.features_data)
|
|
y_probs = predict_proba_fn(iris_dataset.features_data)
|
|
|
|
expected_metrics = _get_multiclass_classifier_metrics(
|
|
y_true=y, y_pred=y_pred, y_proba=y_probs, sample_weights=None
|
|
)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
iris_dataset.features_data, iris_dataset.labels_data
|
|
)
|
|
|
|
assert_metrics_equal(result.metrics, expected_metrics)
|
|
assert "mlflow.datassets" not in tags
|
|
|
|
|
|
def test_bin_classifier_evaluation(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=breast_cancer_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
evaluator_config={"sample_weights": None},
|
|
)
|
|
|
|
_, metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(binary_logistic_regressor_model_uri)
|
|
|
|
predict_fn, predict_proba_fn = _extract_predict_fn_and_predict_proba_fn(model)
|
|
y = breast_cancer_dataset.labels_data
|
|
y_pred = predict_fn(breast_cancer_dataset.features_data)
|
|
y_probs = predict_proba_fn(breast_cancer_dataset.features_data)
|
|
|
|
expected_metrics = _get_binary_classifier_metrics(y_true=y, y_pred=y_pred, y_proba=y_probs)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
breast_cancer_dataset.features_data,
|
|
breast_cancer_dataset.labels_data,
|
|
)
|
|
|
|
for metric_key, expected_metric_val in expected_metrics.items():
|
|
assert np.isclose(
|
|
expected_metric_val,
|
|
metrics[metric_key],
|
|
rtol=1e-3,
|
|
)
|
|
assert np.isclose(expected_metric_val, result.metrics[metric_key], rtol=1e-3)
|
|
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
{**breast_cancer_dataset._metadata, "name": "dataset", "model": model.metadata.model_uuid}
|
|
]
|
|
|
|
assert set(artifacts) == {
|
|
"shap_feature_importance_plot.png",
|
|
"lift_curve_plot.png",
|
|
"shap_beeswarm_plot.png",
|
|
"precision_recall_curve_plot.png",
|
|
"confusion_matrix.png",
|
|
"shap_summary_plot.png",
|
|
"roc_curve_plot.png",
|
|
"calibration_curve_plot.png",
|
|
}
|
|
assert result.artifacts.keys() == {
|
|
"roc_curve_plot",
|
|
"precision_recall_curve_plot",
|
|
"lift_curve_plot",
|
|
"confusion_matrix",
|
|
"shap_beeswarm_plot",
|
|
"shap_summary_plot",
|
|
"shap_feature_importance_plot",
|
|
"calibration_curve_plot",
|
|
}
|
|
|
|
|
|
def test_bin_classifier_evaluation_disable_logging_metrics_and_artifacts(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=breast_cancer_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, logged_metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(binary_logistic_regressor_model_uri)
|
|
|
|
_, raw_model = _extract_raw_model(model)
|
|
predict_fn, predict_proba_fn = _extract_predict_fn_and_predict_proba_fn(model)
|
|
y = breast_cancer_dataset.labels_data
|
|
y_pred = predict_fn(breast_cancer_dataset.features_data)
|
|
y_probs = predict_proba_fn(breast_cancer_dataset.features_data)
|
|
|
|
expected_metrics = _get_binary_classifier_metrics(
|
|
y_true=y, y_pred=y_pred, y_proba=y_probs, sample_weights=None
|
|
)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
breast_cancer_dataset.features_data, breast_cancer_dataset.labels_data
|
|
)
|
|
|
|
assert_metrics_equal(result.metrics, expected_metrics)
|
|
assert "mlflow.datassets" not in tags
|
|
|
|
|
|
def test_spark_regressor_model_evaluation(
|
|
spark_linear_regressor_model_uri,
|
|
diabetes_spark_dataset,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
spark_linear_regressor_model_uri,
|
|
diabetes_spark_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_spark_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(spark_linear_regressor_model_uri)
|
|
|
|
X = diabetes_spark_dataset.features_data
|
|
y = diabetes_spark_dataset.labels_data
|
|
y_pred = model.predict(X)
|
|
|
|
expected_metrics = _get_regressor_metrics(y, y_pred, sample_weights=None)
|
|
|
|
for metric_key, expected_metric_val in expected_metrics.items():
|
|
assert np.isclose(
|
|
expected_metric_val,
|
|
metrics[metric_key],
|
|
rtol=1e-3,
|
|
)
|
|
assert np.isclose(expected_metric_val, result.metrics[metric_key], rtol=1e-3)
|
|
|
|
model = mlflow.pyfunc.load_model(spark_linear_regressor_model_uri)
|
|
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
{**diabetes_spark_dataset._metadata, "name": "dataset", "model": model.metadata.model_uuid}
|
|
]
|
|
|
|
assert set(artifacts) == set()
|
|
assert result.artifacts == {}
|
|
|
|
|
|
def test_spark_regressor_model_evaluation_disable_logging_metrics_and_artifacts(
|
|
spark_linear_regressor_model_uri,
|
|
diabetes_spark_dataset,
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
spark_linear_regressor_model_uri,
|
|
diabetes_spark_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_spark_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, logged_metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(spark_linear_regressor_model_uri)
|
|
|
|
X = diabetes_spark_dataset.features_data
|
|
y = diabetes_spark_dataset.labels_data
|
|
y_pred = model.predict(X)
|
|
|
|
expected_metrics = _get_regressor_metrics(y, y_pred, sample_weights=None)
|
|
assert_metrics_equal(result.metrics, expected_metrics)
|
|
|
|
|
|
def test_static_spark_dataset_evaluation():
|
|
data = load_diabetes()
|
|
spark = SparkSession.builder.master("local[*]").getOrCreate()
|
|
rows = [
|
|
(Vectors.dense(features), float(label), float(label))
|
|
for features, label in zip(data.data, data.target)
|
|
]
|
|
spark_dataframe = spark.createDataFrame(
|
|
spark.sparkContext.parallelize(rows, 1), ["features", "label", "model_output"]
|
|
)
|
|
with mlflow.start_run():
|
|
mlflow.evaluate(
|
|
data=spark_dataframe,
|
|
targets="label",
|
|
predictions="model_output",
|
|
model_type="regressor",
|
|
)
|
|
run_id = mlflow.active_run().info.run_id
|
|
|
|
computed_eval_metrics = mlflow.get_run(run_id).data.metrics
|
|
assert "mean_squared_error" in computed_eval_metrics
|
|
|
|
|
|
def test_svm_classifier_evaluation(svm_model_uri, breast_cancer_dataset):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
svm_model_uri,
|
|
breast_cancer_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=breast_cancer_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(svm_model_uri)
|
|
|
|
predict_fn, _ = _extract_predict_fn_and_predict_proba_fn(model)
|
|
y = breast_cancer_dataset.labels_data
|
|
y_pred = predict_fn(breast_cancer_dataset.features_data)
|
|
|
|
expected_metrics = _get_binary_classifier_metrics(y_true=y, y_pred=y_pred, sample_weights=None)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
breast_cancer_dataset.features_data, breast_cancer_dataset.labels_data
|
|
)
|
|
|
|
for metric_key, expected_metric_val in expected_metrics.items():
|
|
assert np.isclose(
|
|
expected_metric_val,
|
|
metrics[metric_key],
|
|
rtol=1e-3,
|
|
)
|
|
assert np.isclose(expected_metric_val, result.metrics[metric_key], rtol=1e-3)
|
|
|
|
assert json.loads(tags["mlflow.datasets"]) == [
|
|
{**breast_cancer_dataset._metadata, "name": "dataset", "model": model.metadata.model_uuid}
|
|
]
|
|
|
|
assert set(artifacts) == {
|
|
"confusion_matrix.png",
|
|
"shap_feature_importance_plot.png",
|
|
"shap_beeswarm_plot.png",
|
|
"shap_summary_plot.png",
|
|
}
|
|
assert result.artifacts.keys() == {
|
|
"confusion_matrix",
|
|
"shap_beeswarm_plot",
|
|
"shap_summary_plot",
|
|
"shap_feature_importance_plot",
|
|
}
|
|
|
|
|
|
def _evaluate_explainer_with_exceptions(model_uri, dataset):
|
|
with mlflow.start_run():
|
|
evaluate(
|
|
model_uri,
|
|
dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"ignore_exceptions": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_default_explainer_pandas_df_str_cols(
|
|
multiclass_logistic_regressor_model_uri, iris_pandas_df_dataset
|
|
):
|
|
_evaluate_explainer_with_exceptions(
|
|
multiclass_logistic_regressor_model_uri, iris_pandas_df_dataset
|
|
)
|
|
|
|
|
|
def test_default_explainer_pandas_df_num_cols(
|
|
multiclass_logistic_regressor_model_uri, iris_pandas_df_num_cols_dataset
|
|
):
|
|
_evaluate_explainer_with_exceptions(
|
|
multiclass_logistic_regressor_model_uri, iris_pandas_df_num_cols_dataset
|
|
)
|
|
|
|
|
|
def test_svm_classifier_evaluation_disable_logging_metrics_and_artifacts(
|
|
svm_model_uri, breast_cancer_dataset
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
svm_model_uri,
|
|
breast_cancer_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=breast_cancer_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
|
|
_, logged_metrics, tags, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(svm_model_uri)
|
|
|
|
_, raw_model = _extract_raw_model(model)
|
|
predict_fn, _ = _extract_predict_fn_and_predict_proba_fn(model)
|
|
y = breast_cancer_dataset.labels_data
|
|
y_pred = predict_fn(breast_cancer_dataset.features_data)
|
|
|
|
expected_metrics = _get_binary_classifier_metrics(y_true=y, y_pred=y_pred, sample_weights=None)
|
|
expected_metrics["score"] = model._model_impl.score(
|
|
breast_cancer_dataset.features_data, breast_cancer_dataset.labels_data
|
|
)
|
|
|
|
assert_metrics_equal(result.metrics, expected_metrics)
|
|
assert "mlflow.datassets" not in tags
|
|
|
|
|
|
def test_pipeline_model_kernel_explainer_on_categorical_features(pipeline_model_uri):
|
|
from mlflow.models.evaluation._shap_patch import _PatchedKernelExplainer
|
|
|
|
data, target_col = get_pipeline_model_dataset()
|
|
with mlflow.start_run() as run:
|
|
evaluate(
|
|
pipeline_model_uri,
|
|
data[0::3],
|
|
model_type="classifier",
|
|
targets=target_col,
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"explainability_algorithm": "kernel",
|
|
"log_explainer": True,
|
|
},
|
|
)
|
|
run_id = run.info.run_id
|
|
run_data = get_run_data(run_id)
|
|
assert {
|
|
# TODO: Uncomment once https://github.com/shap/shap/issues/3901 is fixed
|
|
# "shap_beeswarm_plot.png",
|
|
"shap_feature_importance_plot.png",
|
|
"shap_summary_plot.png",
|
|
}.issubset(run_data.artifacts)
|
|
|
|
# TODO: add `and name='explainer'` once sqlAlchemyStore search_logged_models supports it
|
|
model = mlflow.last_logged_model()
|
|
explainer = mlflow.shap.load_explainer(model.model_uri)
|
|
assert isinstance(explainer, _PatchedKernelExplainer)
|
|
|
|
|
|
def test_compute_df_mode_or_mean():
|
|
df = pd.DataFrame({
|
|
"a": [2.0, 2.0, 5.0],
|
|
"b": [3, 3, 5],
|
|
"c": [2.0, 2.0, 6.5],
|
|
"d": [True, False, True],
|
|
"e": ["abc", "b", "abc"],
|
|
"f": [1.5, 2.5, np.nan],
|
|
"g": ["ab", "ab", None],
|
|
"h": pd.Series([2.0, 2.0, 6.5], dtype="category"),
|
|
})
|
|
result = _compute_df_mode_or_mean(df)
|
|
assert result == {
|
|
"a": 2,
|
|
"b": 3,
|
|
"c": 3.5,
|
|
"d": True,
|
|
"e": "abc",
|
|
"f": 2.0,
|
|
"g": "ab",
|
|
"h": 2.0,
|
|
}
|
|
|
|
# Test on dataframe that all columns are continuous.
|
|
df2 = pd.DataFrame({
|
|
"c": [2.0, 2.0, 6.5],
|
|
"f": [1.5, 2.5, np.nan],
|
|
})
|
|
assert _compute_df_mode_or_mean(df2) == {"c": 3.5, "f": 2.0}
|
|
|
|
# Test on dataframe that all columns are not continuous.
|
|
df2 = pd.DataFrame({
|
|
"d": [True, False, True],
|
|
"g": ["ab", "ab", None],
|
|
})
|
|
assert _compute_df_mode_or_mean(df2) == {"d": True, "g": "ab"}
|
|
|
|
|
|
def test_infer_model_type_by_labels():
|
|
assert _infer_model_type_by_labels(["a", "b"]) == "classifier"
|
|
assert _infer_model_type_by_labels([True, False]) == "classifier"
|
|
assert _infer_model_type_by_labels([1, 2.5]) == "regressor"
|
|
assert _infer_model_type_by_labels(pd.Series(["a", "b"], dtype="category")) == "classifier"
|
|
assert _infer_model_type_by_labels(pd.Series([1.5, 2.5], dtype="category")) == "classifier"
|
|
assert _infer_model_type_by_labels([1, 2, 3]) is None
|
|
|
|
|
|
def test_extract_raw_model_and_predict_fn(
|
|
binary_logistic_regressor_model_uri, breast_cancer_dataset
|
|
):
|
|
model = mlflow.pyfunc.load_model(binary_logistic_regressor_model_uri)
|
|
|
|
model_loader_module, raw_model = _extract_raw_model(model)
|
|
predict_fn, predict_proba_fn = _extract_predict_fn_and_predict_proba_fn(model)
|
|
|
|
assert model_loader_module == "mlflow.sklearn"
|
|
assert isinstance(raw_model, LogisticRegression)
|
|
np.testing.assert_allclose(
|
|
predict_fn(breast_cancer_dataset.features_data),
|
|
raw_model.predict(breast_cancer_dataset.features_data),
|
|
)
|
|
np.testing.assert_allclose(
|
|
predict_proba_fn(breast_cancer_dataset.features_data),
|
|
raw_model.predict_proba(breast_cancer_dataset.features_data),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("use_sample_weights", [True, False])
|
|
def test_get_regressor_metrics(use_sample_weights):
|
|
y = [1.1, 2.1, -3.5]
|
|
y_pred = [1.5, 2.0, -3.0]
|
|
sample_weights = [1, 2, 3] if use_sample_weights else None
|
|
|
|
metrics = _get_regressor_metrics(y, y_pred, sample_weights)
|
|
|
|
if use_sample_weights:
|
|
expected_metrics = {
|
|
"example_count": 3,
|
|
"mean_absolute_error": 0.35000000000000003,
|
|
"mean_squared_error": 0.155,
|
|
"root_mean_squared_error": 0.39370039370059057,
|
|
"sum_on_target": -5.199999999999999,
|
|
"mean_on_target": -1.7333333333333332,
|
|
"r2_score": 0.9780003154076644,
|
|
"max_error": 0.5,
|
|
"mean_absolute_percentage_error": 0.1479076479076479,
|
|
}
|
|
else:
|
|
expected_metrics = {
|
|
"example_count": 3,
|
|
"mean_absolute_error": 0.3333333333333333,
|
|
"mean_squared_error": 0.13999999999999999,
|
|
"root_mean_squared_error": 0.3741657386773941,
|
|
"sum_on_target": -0.2999999999999998,
|
|
"mean_on_target": -0.09999999999999994,
|
|
"r2_score": 0.976457399103139,
|
|
"max_error": 0.5,
|
|
"mean_absolute_percentage_error": 0.18470418470418468,
|
|
}
|
|
|
|
assert_dict_equal(metrics, expected_metrics, rtol=1e-3)
|
|
|
|
|
|
def test_get_binary_sum_up_label_pred_prob():
|
|
y = [0, 1, 2]
|
|
y_pred = [0, 2, 1]
|
|
y_probs = [[0.7, 0.1, 0.2], [0.2, 0.3, 0.5], [0.25, 0.4, 0.35]]
|
|
|
|
results = []
|
|
for idx, label in enumerate([0, 1, 2]):
|
|
y_bin, y_pred_bin, y_prob_bin = _get_binary_sum_up_label_pred_prob(
|
|
idx, label, y, y_pred, y_probs
|
|
)
|
|
results.append((list(y_bin), list(y_pred_bin), list(y_prob_bin)))
|
|
|
|
assert results == [
|
|
([1, 0, 0], [1, 0, 0], [0.7, 0.2, 0.25]),
|
|
([0, 1, 0], [0, 0, 1], [0.1, 0.3, 0.4]),
|
|
([0, 0, 1], [0, 1, 0], [0.2, 0.5, 0.35]),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("use_sample_weights", [True, False])
|
|
def test_get_binary_classifier_metrics(use_sample_weights):
|
|
y = [0, 1, 0, 1, 0, 1, 0, 1, 1, 0]
|
|
y_pred = [0, 1, 1, 0, 1, 1, 0, 1, 1, 0]
|
|
sample_weights = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 1, 1, 1, 1] if use_sample_weights else None
|
|
|
|
if use_sample_weights:
|
|
expected_metrics = {
|
|
"example_count": 10,
|
|
"true_negatives": 3,
|
|
"true_positives": 4,
|
|
"false_negatives": 1,
|
|
"false_positives": 2,
|
|
"accuracy_score": 0.9347826086956524,
|
|
"f1_score": 0.9361702127659577,
|
|
"precision_score": 0.9166666666666667,
|
|
"recall_score": 0.9565217391304349,
|
|
}
|
|
else:
|
|
expected_metrics = {
|
|
"example_count": 10,
|
|
"true_negatives": 3,
|
|
"true_positives": 4,
|
|
"false_negatives": 1,
|
|
"false_positives": 2,
|
|
"accuracy_score": 0.7,
|
|
"f1_score": 0.7272727272727272,
|
|
"precision_score": 0.6666666666666666,
|
|
"recall_score": 0.8,
|
|
}
|
|
|
|
metrics = _get_binary_classifier_metrics(y_true=y, y_pred=y_pred, sample_weights=sample_weights)
|
|
assert_dict_equal(metrics, expected_metrics, rtol=1e-3)
|
|
|
|
|
|
@pytest.mark.parametrize("use_sample_weights", [True, False])
|
|
def test_get_multiclass_classifier_metrics(use_sample_weights):
|
|
y = [0, 1, 2, 1, 2]
|
|
y_pred = [0, 2, 1, 1, 0]
|
|
y_probs = [
|
|
[0.7, 0.1, 0.2],
|
|
[0.2, 0.3, 0.5],
|
|
[0.25, 0.4, 0.35],
|
|
[0.3, 0.4, 0.3],
|
|
[0.8, 0.1, 0.1],
|
|
]
|
|
sample_weights = [1, 0.1, 0.1, 1, 0.1] if use_sample_weights else None
|
|
|
|
if use_sample_weights:
|
|
expected_metrics = {
|
|
"example_count": 5,
|
|
"accuracy_score": 0.8695652173913042,
|
|
"f1_score": 0.8488612836438922,
|
|
"log_loss": 0.7515668165194579,
|
|
"precision_score": 0.8300395256916996,
|
|
"recall_score": 0.8695652173913042,
|
|
"roc_auc": 0.8992673992673993,
|
|
}
|
|
else:
|
|
expected_metrics = {
|
|
"example_count": 5,
|
|
"accuracy_score": 0.4,
|
|
"f1_score": 0.3333333333333333,
|
|
"log_loss": 1.1658691395263094,
|
|
"precision_score": 0.3,
|
|
"recall_score": 0.4,
|
|
"roc_auc": 0.5833333333333334,
|
|
}
|
|
|
|
metrics = _get_multiclass_classifier_metrics(
|
|
y_true=y, y_pred=y_pred, y_proba=y_probs, labels=[0, 1, 2], sample_weights=sample_weights
|
|
)
|
|
assert_dict_equal(metrics, expected_metrics, 1e-3)
|
|
|
|
|
|
def test_gen_binary_precision_recall_curve_no_sample_weights():
|
|
y = [0, 1, 0, 1, 0, 1, 0, 1, 1, 0]
|
|
y_prob = [0.1, 0.9, 0.8, 0.2, 0.7, 0.8, 0.3, 0.6, 0.65, 0.4]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=True,
|
|
y=y,
|
|
y_probs=y_prob,
|
|
labels=[0, 1],
|
|
pos_label=1,
|
|
curve_type="pr",
|
|
sample_weights=None,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][1],
|
|
np.array([1.0, 1.0, 0.8, 0.8, 0.8, 0.6, 0.4, 0.4, 0.2, 0.0]),
|
|
rtol=1e-3,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][2],
|
|
np.array([0.5, 0.55555556, 0.5, 0.57142857, 0.66666667, 0.6, 0.5, 0.66666667, 1.0, 1.0]),
|
|
rtol=1e-3,
|
|
)
|
|
assert results.plot_fn_args["xlabel"] == "Recall (Positive label: 1)"
|
|
assert results.plot_fn_args["ylabel"] == "Precision (Positive label: 1)"
|
|
assert results.plot_fn_args["title"] == "Precision recall curve"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
assert np.isclose(results.auc, 0.69777777, rtol=1e-3)
|
|
|
|
|
|
def test_gen_binary_precision_recall_curve_with_sample_weights():
|
|
y = [0, 1, 0, 1, 0, 1, 0, 1, 1, 0]
|
|
y_prob = [0.1, 0.9, 0.8, 0.2, 0.7, 0.8, 0.3, 0.6, 0.65, 0.4]
|
|
sample_weights = [0.5, 0.5, 0.5, 0.5, 0.5, 1, 1, 1, 0.1, 0.1]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=True,
|
|
y=y,
|
|
y_probs=y_prob,
|
|
labels=[0, 1],
|
|
pos_label=1,
|
|
curve_type="pr",
|
|
sample_weights=sample_weights,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][1],
|
|
np.array([
|
|
1.0,
|
|
1.0,
|
|
0.83870968,
|
|
0.83870968,
|
|
0.83870968,
|
|
0.51612903,
|
|
0.48387097,
|
|
0.48387097,
|
|
0.16129032,
|
|
0.0,
|
|
]),
|
|
rtol=1e-3,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][2],
|
|
np.array([
|
|
0.54386,
|
|
0.59615385,
|
|
0.55319149,
|
|
0.7027027,
|
|
0.72222222,
|
|
0.61538462,
|
|
0.6,
|
|
0.75,
|
|
1.0,
|
|
1.0,
|
|
]),
|
|
rtol=1e-3,
|
|
)
|
|
assert results.plot_fn_args["xlabel"] == "Recall (Positive label: 1)"
|
|
assert results.plot_fn_args["ylabel"] == "Precision (Positive label: 1)"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
assert np.isclose(results.auc, 0.7522056796250345, rtol=1e-3)
|
|
|
|
|
|
def test_gen_binary_roc_curve_no_sample_weights():
|
|
y = [0, 1, 0, 1, 0, 1, 0, 1, 1, 0]
|
|
y_prob = [0.1, 0.9, 0.8, 0.2, 0.7, 0.8, 0.3, 0.6, 0.65, 0.4]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=True,
|
|
y=y,
|
|
y_probs=y_prob,
|
|
labels=[0, 1],
|
|
pos_label=1,
|
|
curve_type="roc",
|
|
sample_weights=None,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][1],
|
|
np.array([0.0, 0.0, 0.2, 0.4, 0.4, 0.8, 0.8, 1.0]),
|
|
rtol=1e-3,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][2],
|
|
np.array([0.0, 0.2, 0.4, 0.4, 0.8, 0.8, 1.0, 1.0]),
|
|
rtol=1e-3,
|
|
)
|
|
assert results.plot_fn_args["xlabel"] == "False Positive Rate (Positive label: 1)"
|
|
assert results.plot_fn_args["ylabel"] == "True Positive Rate (Positive label: 1)"
|
|
assert results.plot_fn_args["title"] == "ROC curve"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
assert np.isclose(results.auc, 0.66, rtol=1e-3)
|
|
|
|
|
|
def test_gen_binary_roc_curve_with_sample_weights():
|
|
y = [0, 1, 0, 1, 0, 1, 0, 1, 1, 0]
|
|
y_prob = [0.1, 0.9, 0.8, 0.2, 0.7, 0.8, 0.3, 0.6, 0.65, 0.4]
|
|
sample_weights = [0.5, 0.5, 0.5, 0.5, 0.5, 1, 1, 1, 0.1, 0.1]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=True,
|
|
y=y,
|
|
y_probs=y_prob,
|
|
labels=[0, 1],
|
|
pos_label=1,
|
|
curve_type="roc",
|
|
sample_weights=sample_weights,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][1],
|
|
np.array([
|
|
0.0,
|
|
0.0,
|
|
0.19230769,
|
|
0.38461538,
|
|
0.38461538,
|
|
0.38461538,
|
|
0.42307692,
|
|
0.80769231,
|
|
0.80769231,
|
|
1.0,
|
|
]),
|
|
rtol=1e-3,
|
|
)
|
|
np.testing.assert_allclose(
|
|
results.plot_fn_args["data_series"][0][2],
|
|
np.array([
|
|
0.0,
|
|
0.16129032,
|
|
0.48387097,
|
|
0.48387097,
|
|
0.51612903,
|
|
0.83870968,
|
|
0.83870968,
|
|
0.83870968,
|
|
1.0,
|
|
1.0,
|
|
]),
|
|
rtol=1e-3,
|
|
)
|
|
assert results.plot_fn_args["xlabel"] == "False Positive Rate (Positive label: 1)"
|
|
assert results.plot_fn_args["ylabel"] == "True Positive Rate (Positive label: 1)"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
assert np.isclose(results.auc, 0.702, rtol=1e-3)
|
|
|
|
|
|
def test_gen_multiclass_precision_recall_curve_no_sample_weights():
|
|
y = [0, 1, 2, 1, 2]
|
|
y_probs = [
|
|
[0.7, 0.1, 0.2],
|
|
[0.2, 0.3, 0.5],
|
|
[0.25, 0.4, 0.35],
|
|
[0.3, 0.4, 0.3],
|
|
[0.8, 0.1, 0.1],
|
|
]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=False,
|
|
y=y,
|
|
y_probs=y_probs,
|
|
labels=[0, 1, 2],
|
|
pos_label=None,
|
|
curve_type="pr",
|
|
sample_weights=None,
|
|
)
|
|
expected_x_data_list = [
|
|
[1.0, 1.0, 1.0, 1.0, 0.0, 0.0],
|
|
[1.0, 1.0, 0.5, 0.0],
|
|
[1.0, 0.5, 0.5, 0.5, 0.0, 0.0],
|
|
]
|
|
expected_y_data_list = [
|
|
[0.2, 0.25, 0.333333, 0.5, 0.0, 1.0],
|
|
[0.4, 0.66666667, 0.5, 1.0],
|
|
[0.4, 0.25, 0.33333333, 0.5, 0.0, 1.0],
|
|
]
|
|
line_labels = ["label=0,AP=0.500", "label=1,AP=0.583", "label=2,AP=0.450"]
|
|
for index, (name, x_data, y_data) in enumerate(results.plot_fn_args["data_series"]):
|
|
assert name == line_labels[index]
|
|
np.testing.assert_allclose(x_data, expected_x_data_list[index], rtol=1e-3)
|
|
np.testing.assert_allclose(y_data, expected_y_data_list[index], rtol=1e-3)
|
|
|
|
assert results.plot_fn_args["xlabel"] == "Recall"
|
|
assert results.plot_fn_args["ylabel"] == "Precision"
|
|
assert results.plot_fn_args["title"] == "Precision recall curve"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
|
|
expected_auc = [0.5, 0.583333, 0.45]
|
|
np.testing.assert_allclose(results.auc, expected_auc, rtol=1e-3)
|
|
|
|
|
|
def test_gen_multiclass_precision_recall_curve_with_sample_weights():
|
|
y = [0, 1, 2, 1, 2]
|
|
y_probs = [
|
|
[0.7, 0.1, 0.2],
|
|
[0.2, 0.3, 0.5],
|
|
[0.25, 0.4, 0.35],
|
|
[0.3, 0.4, 0.3],
|
|
[0.8, 0.1, 0.1],
|
|
]
|
|
sample_weights = [0.5, 0.5, 0.5, 0.25, 0.75]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=False,
|
|
y=y,
|
|
y_probs=y_probs,
|
|
labels=[0, 1, 2],
|
|
pos_label=None,
|
|
curve_type="pr",
|
|
sample_weights=sample_weights,
|
|
)
|
|
expected_x_data_list = [
|
|
[1.0, 1.0, 1.0, 1.0, 0.0, 0.0],
|
|
[1.0, 1.0, 0.333333, 0.0],
|
|
[1.0, 0.4, 0.4, 0.4, 0.0, 0.0],
|
|
]
|
|
expected_y_data_list = [
|
|
[0.2, 0.25, 0.333333, 0.4, 0.0, 1.0],
|
|
[0.3, 0.6, 0.333333, 1.0],
|
|
[0.5, 0.285714, 0.4, 0.5, 0.0, 1.0],
|
|
]
|
|
line_labels = ["label=0,AP=0.400", "label=1,AP=0.511", "label=2,AP=0.500"]
|
|
for index, (name, x_data, y_data) in enumerate(results.plot_fn_args["data_series"]):
|
|
assert name == line_labels[index]
|
|
np.testing.assert_allclose(x_data, expected_x_data_list[index], rtol=1e-3)
|
|
np.testing.assert_allclose(y_data, expected_y_data_list[index], rtol=1e-3)
|
|
|
|
assert results.plot_fn_args["xlabel"] == "Recall"
|
|
assert results.plot_fn_args["ylabel"] == "Precision"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
|
|
expected_auc = [0.4, 0.511111, 0.5]
|
|
np.testing.assert_allclose(results.auc, expected_auc, rtol=1e-3)
|
|
|
|
|
|
def test_gen_multiclass_roc_curve_no_sample_weights():
|
|
y = [0, 1, 2, 1, 2]
|
|
y_probs = [
|
|
[0.7, 0.1, 0.2],
|
|
[0.2, 0.3, 0.5],
|
|
[0.25, 0.4, 0.35],
|
|
[0.3, 0.4, 0.3],
|
|
[0.8, 0.1, 0.1],
|
|
]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=False,
|
|
y=y,
|
|
y_probs=y_probs,
|
|
labels=[0, 1, 2],
|
|
pos_label=None,
|
|
curve_type="roc",
|
|
sample_weights=None,
|
|
)
|
|
|
|
expected_x_data_list = [
|
|
[0.0, 0.25, 0.25, 1.0],
|
|
[0.0, 0.33333333, 0.33333333, 1.0],
|
|
[0.0, 0.33333333, 0.33333333, 1.0, 1.0],
|
|
]
|
|
expected_y_data_list = [[0.0, 0.0, 1.0, 1.0], [0.0, 0.5, 1.0, 1.0], [0.0, 0.0, 0.5, 0.5, 1.0]]
|
|
line_labels = ["label=0,AUC=0.750", "label=1,AUC=0.750", "label=2,AUC=0.333"]
|
|
for index, (name, x_data, y_data) in enumerate(results.plot_fn_args["data_series"]):
|
|
assert name == line_labels[index]
|
|
np.testing.assert_allclose(x_data, expected_x_data_list[index], rtol=1e-3)
|
|
np.testing.assert_allclose(y_data, expected_y_data_list[index], rtol=1e-3)
|
|
|
|
assert results.plot_fn_args["xlabel"] == "False Positive Rate"
|
|
assert results.plot_fn_args["ylabel"] == "True Positive Rate"
|
|
assert results.plot_fn_args["title"] == "ROC curve"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
|
|
expected_auc = [0.75, 0.75, 0.3333]
|
|
np.testing.assert_allclose(results.auc, expected_auc, rtol=1e-3)
|
|
|
|
|
|
def test_gen_multiclass_roc_curve_with_sample_weights():
|
|
y = [0, 1, 2, 1, 2]
|
|
y_probs = [
|
|
[0.7, 0.1, 0.2],
|
|
[0.2, 0.3, 0.5],
|
|
[0.25, 0.4, 0.35],
|
|
[0.3, 0.4, 0.3],
|
|
[0.8, 0.1, 0.1],
|
|
]
|
|
sample_weights = [0.5, 0.5, 0.5, 0.25, 0.75]
|
|
|
|
results = _gen_classifier_curve(
|
|
is_binomial=False,
|
|
y=y,
|
|
y_probs=y_probs,
|
|
labels=[0, 1, 2],
|
|
pos_label=None,
|
|
curve_type="roc",
|
|
sample_weights=sample_weights,
|
|
)
|
|
|
|
expected_x_data_list = [
|
|
[0.0, 0.375, 0.375, 0.5, 1.0],
|
|
[0.0, 0.285714, 0.285714, 1.0],
|
|
[0.0, 0.4, 0.4, 0.6, 1.0, 1.0],
|
|
]
|
|
expected_y_data_list = [
|
|
[0.0, 0.0, 1.0, 1.0, 1.0],
|
|
[0.0, 0.333333, 1.0, 1.0],
|
|
[0.0, 0.0, 0.4, 0.4, 0.4, 1.0],
|
|
]
|
|
line_labels = ["label=0,AUC=0.625", "label=1,AUC=0.762", "label=2,AUC=0.240"]
|
|
for index, (name, x_data, y_data) in enumerate(results.plot_fn_args["data_series"]):
|
|
assert name == line_labels[index]
|
|
np.testing.assert_allclose(x_data, expected_x_data_list[index], rtol=1e-3)
|
|
np.testing.assert_allclose(y_data, expected_y_data_list[index], rtol=1e-3)
|
|
|
|
assert results.plot_fn_args["xlabel"] == "False Positive Rate"
|
|
assert results.plot_fn_args["ylabel"] == "True Positive Rate"
|
|
assert results.plot_fn_args["line_kwargs"] == {"drawstyle": "steps-post", "linewidth": 1}
|
|
|
|
expected_auc = [0.625, 0.761905, 0.24]
|
|
np.testing.assert_allclose(results.auc, expected_auc, rtol=1e-3)
|
|
|
|
|
|
def test_evaluate_metric_backwards_compatible():
|
|
eval_df = pd.DataFrame({"prediction": [1.2, 1.9, 3.2], "target": [1, 2, 3]})
|
|
builtin_metrics = _get_regressor_metrics(
|
|
eval_df["target"], eval_df["prediction"], sample_weights=None
|
|
)
|
|
metrics = _get_aggregate_metrics_values(builtin_metrics)
|
|
|
|
def old_fn(eval_df, builtin_metrics):
|
|
return builtin_metrics["mean_absolute_error"] * 1.5
|
|
|
|
eval_fn_args = [eval_df, builtin_metrics]
|
|
res_metric = MetricDefinition(old_fn, "old_fn", 0).evaluate(eval_fn_args)
|
|
assert res_metric.scores is None
|
|
assert res_metric.justifications is None
|
|
assert res_metric.aggregate_results["old_fn"] == builtin_metrics["mean_absolute_error"] * 1.5
|
|
|
|
new_eval_fn_args = [eval_df, None, metrics]
|
|
|
|
def new_fn(predictions, targets=None, metrics=None):
|
|
return metrics["mean_absolute_error"].aggregate_results["mean_absolute_error"] * 1.5
|
|
|
|
res_metric = MetricDefinition(new_fn, "new_fn", 0).evaluate(new_eval_fn_args)
|
|
assert res_metric.scores is None
|
|
assert res_metric.justifications is None
|
|
assert res_metric.aggregate_results["new_fn"] == builtin_metrics["mean_absolute_error"] * 1.5
|
|
|
|
|
|
def test_evaluate_custom_metric_incorrect_return_formats():
|
|
eval_df = pd.DataFrame({"prediction": [1.2, 1.9, 3.2], "target": [1, 2, 3]})
|
|
builtin_metrics = _get_regressor_metrics(
|
|
eval_df["target"], eval_df["prediction"], sample_weights=None
|
|
)
|
|
eval_fn_args = [eval_df, builtin_metrics]
|
|
|
|
# Import the module directly to avoid mock.patch import issues
|
|
from mlflow.models.evaluation.utils import metric as metric_module
|
|
|
|
def dummy_fn(*_):
|
|
pass
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
MetricDefinition(dummy_fn, "dummy_fn", 0, None).evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
"Did not log metric 'dummy_fn' at index 0 in the `extra_metrics` parameter"
|
|
" because it returned None."
|
|
)
|
|
|
|
def incorrect_return_type(*_):
|
|
return ["stuff"], 3
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
metric = MetricDefinition(incorrect_return_type, incorrect_return_type.__name__, 0)
|
|
metric.evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{incorrect_return_type.__name__}' at index 0 in the "
|
|
"`extra_metrics` parameter because it did not return a MetricValue."
|
|
)
|
|
|
|
def non_list_scores(*_):
|
|
return MetricValue(scores=5)
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
MetricDefinition(non_list_scores, non_list_scores.__name__, 0).evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{non_list_scores.__name__}' at index 0 in the "
|
|
"`extra_metrics` parameter because it must return MetricValue with scores as a list."
|
|
)
|
|
|
|
def non_numeric_scores(*_):
|
|
return MetricValue(scores=[{"val": "string"}])
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
MetricDefinition(non_numeric_scores, non_numeric_scores.__name__, 0).evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{non_numeric_scores.__name__}' at index 0 in the `extra_metrics`"
|
|
" parameter because it must return MetricValue with numeric or string scores."
|
|
)
|
|
|
|
def non_list_justifications(*_):
|
|
return MetricValue(justifications="string")
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
metric = MetricDefinition(non_list_justifications, non_list_justifications.__name__, 0)
|
|
metric.evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{non_list_justifications.__name__}' at index 0 in the "
|
|
"`extra_metrics` parameter because it must return MetricValue with justifications "
|
|
"as a list."
|
|
)
|
|
|
|
def non_str_justifications(*_):
|
|
return MetricValue(justifications=[3, 4])
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
metric = MetricDefinition(non_str_justifications, non_str_justifications.__name__, 0)
|
|
metric.evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{non_str_justifications.__name__}' at index 0 in the "
|
|
"`extra_metrics` parameter because it must return MetricValue with string "
|
|
"justifications."
|
|
)
|
|
|
|
def non_dict_aggregates(*_):
|
|
return MetricValue(aggregate_results=[5.0, 4.0])
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
metric = MetricDefinition(non_dict_aggregates, non_dict_aggregates.__name__, 0)
|
|
metric.evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{non_dict_aggregates.__name__}' at index 0 in the "
|
|
"`extra_metrics` parameter because it must return MetricValue with aggregate_results "
|
|
"as a dict."
|
|
)
|
|
|
|
def wrong_type_aggregates(*_):
|
|
return MetricValue(aggregate_results={"toxicity": 0.0, "hi": "hi"})
|
|
|
|
with mock.patch.object(metric_module._logger, "warning") as mock_warning:
|
|
metric = MetricDefinition(wrong_type_aggregates, wrong_type_aggregates.__name__, 0)
|
|
metric.evaluate(eval_fn_args)
|
|
mock_warning.assert_called_once_with(
|
|
f"Did not log metric '{wrong_type_aggregates.__name__}' at index 0 in the "
|
|
"`extra_metrics` parameter because it must return MetricValue with aggregate_results "
|
|
"with str keys and numeric values."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fn",
|
|
[
|
|
(
|
|
lambda eval_df, _: MetricValue(
|
|
scores=eval_df["prediction"].tolist(),
|
|
aggregate_results={"prediction_sum": sum(eval_df["prediction"])},
|
|
)
|
|
),
|
|
(
|
|
lambda eval_df, _: MetricValue(
|
|
scores=eval_df["prediction"].tolist()[:-1] + [None],
|
|
aggregate_results={"prediction_sum": None, "another_aggregate": 5.0},
|
|
)
|
|
),
|
|
],
|
|
)
|
|
def test_evaluate_custom_metric_lambda(fn):
|
|
eval_df = pd.DataFrame({"prediction": [1.2, 1.9, 3.2], "target": [1, 2, 3]})
|
|
builtin_metrics = _get_regressor_metrics(
|
|
eval_df["target"], eval_df["prediction"], sample_weights=None
|
|
)
|
|
metrics = _get_aggregate_metrics_values(builtin_metrics)
|
|
eval_fn_args = [eval_df, metrics]
|
|
with mock.patch("mlflow.models.evaluation.default_evaluator._logger.warning") as mock_warning:
|
|
MetricDefinition(fn, "<lambda>", 0).evaluate(eval_fn_args)
|
|
mock_warning.assert_not_called()
|
|
|
|
|
|
def test_evaluate_custom_metric_success():
|
|
eval_df = pd.DataFrame({"prediction": [1.2, 1.9, 3.2], "target": [1, 2, 3]})
|
|
builtin_metrics = _get_regressor_metrics(
|
|
eval_df["target"], eval_df["prediction"], sample_weights=None
|
|
)
|
|
|
|
def example_count_times_1_point_5(predictions, targets=None, metrics=None):
|
|
return MetricValue(
|
|
scores=[score * 1.5 for score in predictions.tolist()],
|
|
justifications=["justification"] * len(predictions),
|
|
aggregate_results={
|
|
"example_count_times_1_point_5": metrics["example_count"].aggregate_results[
|
|
"example_count"
|
|
]
|
|
* 1.5
|
|
},
|
|
)
|
|
|
|
eval_fn_args = [eval_df["prediction"], None, _get_aggregate_metrics_values(builtin_metrics)]
|
|
res_metric = MetricDefinition(example_count_times_1_point_5, "", 0).evaluate(eval_fn_args)
|
|
assert (
|
|
res_metric.aggregate_results["example_count_times_1_point_5"]
|
|
== builtin_metrics["example_count"] * 1.5
|
|
)
|
|
assert res_metric.scores == [score * 1.5 for score in eval_df["prediction"].tolist()]
|
|
assert res_metric.justifications == ["justification"] * len(eval_df["prediction"])
|
|
|
|
|
|
def test_evaluate_custom_artifacts_success():
|
|
eval_df = pd.DataFrame({"prediction": [1.2, 1.9, 3.2], "target": [1, 2, 3]})
|
|
metrics = _get_regressor_metrics(eval_df["target"], eval_df["prediction"], sample_weights=None)
|
|
|
|
def example_custom_artifacts(given_df, _given_metrics, _artifact_dir):
|
|
return {
|
|
"pred_target_abs_diff": np.abs(given_df["prediction"] - given_df["target"]),
|
|
"example_dictionary_artifact": {"a": 1, "b": 2},
|
|
}
|
|
|
|
res_artifacts = _evaluate_custom_artifacts(
|
|
_CustomArtifact(example_custom_artifacts, "", 0, ""), eval_df, metrics
|
|
)
|
|
|
|
assert isinstance(res_artifacts, dict)
|
|
assert "pred_target_abs_diff" in res_artifacts
|
|
pd.testing.assert_series_equal(
|
|
res_artifacts["pred_target_abs_diff"], np.abs(eval_df["prediction"] - eval_df["target"])
|
|
)
|
|
|
|
assert "example_dictionary_artifact" in res_artifacts
|
|
assert res_artifacts["example_dictionary_artifact"] == {"a": 1, "b": 2}
|
|
|
|
|
|
def _get_results_for_custom_metrics_tests(
|
|
model_uri, dataset, *, extra_metrics=None, custom_artifacts=None
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
model_uri,
|
|
dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
extra_metrics=extra_metrics,
|
|
custom_artifacts=custom_artifacts,
|
|
)
|
|
_, metrics, _, artifacts = get_run_data(run.info.run_id)
|
|
return result, metrics, artifacts
|
|
|
|
|
|
def test_custom_metric_produced_multiple_artifacts_with_same_name_throw_exception(
|
|
binary_logistic_regressor_model_uri, breast_cancer_dataset
|
|
):
|
|
def example_custom_artifact_1(_, __, ___):
|
|
return {"test_json_artifact": {"a": 2, "b": [1, 2]}}
|
|
|
|
def example_custom_artifact_2(_, __, ___):
|
|
return {"test_json_artifact": {"a": 3, "b": [1, 2]}}
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="cannot be logged because there already exists an artifact with the same name",
|
|
):
|
|
_get_results_for_custom_metrics_tests(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset,
|
|
custom_artifacts=[
|
|
example_custom_artifact_1,
|
|
example_custom_artifact_2,
|
|
],
|
|
)
|
|
|
|
|
|
def test_custom_metric_mixed(binary_logistic_regressor_model_uri, breast_cancer_dataset):
|
|
def true_count(predictions, targets=None, metrics=None):
|
|
true_negatives = metrics["true_negatives"].aggregate_results["true_negatives"]
|
|
true_positives = metrics["true_positives"].aggregate_results["true_positives"]
|
|
return MetricValue(aggregate_results={"true_count": true_negatives + true_positives})
|
|
|
|
def positive_count(eval_df, _metrics):
|
|
return MetricValue(aggregate_results={"positive_count": np.sum(eval_df["prediction"])})
|
|
|
|
def example_custom_artifact(_eval_df, _given_metrics, tmp_path):
|
|
df = pd.DataFrame({"a": [1, 2, 3]})
|
|
df.to_csv(path_join(tmp_path, "user_logged_df.csv"), index=False)
|
|
np_array = np.array([1, 2, 3, 4, 5])
|
|
np.save(path_join(tmp_path, "arr.npy"), np_array)
|
|
return {
|
|
"test_json_artifact": {"a": 3, "b": [1, 2]},
|
|
"test_npy_artifact": path_join(tmp_path, "arr.npy"),
|
|
}
|
|
|
|
result, metrics, artifacts = _get_results_for_custom_metrics_tests(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset,
|
|
extra_metrics=[
|
|
make_metric(eval_fn=true_count, greater_is_better=True),
|
|
make_metric(eval_fn=positive_count, greater_is_better=True),
|
|
],
|
|
custom_artifacts=[example_custom_artifact],
|
|
)
|
|
|
|
model = mlflow.pyfunc.load_model(binary_logistic_regressor_model_uri)
|
|
|
|
predict_fn = _extract_predict_fn(model)
|
|
y = breast_cancer_dataset.labels_data
|
|
y_pred = predict_fn(breast_cancer_dataset.features_data)
|
|
|
|
expected_metrics = _get_binary_classifier_metrics(y_true=y, y_pred=y_pred, sample_weights=None)
|
|
|
|
assert "true_count" in metrics
|
|
assert np.isclose(
|
|
metrics["true_count"],
|
|
expected_metrics["true_negatives"] + expected_metrics["true_positives"],
|
|
rtol=1e-3,
|
|
)
|
|
assert "true_count" in result.metrics
|
|
assert np.isclose(
|
|
result.metrics["true_count"],
|
|
expected_metrics["true_negatives"] + expected_metrics["true_positives"],
|
|
rtol=1e-3,
|
|
)
|
|
|
|
assert "positive_count" in metrics
|
|
assert np.isclose(metrics["positive_count"], np.sum(y_pred), rtol=1e-3)
|
|
assert "positive_count" in result.metrics
|
|
assert np.isclose(result.metrics["positive_count"], np.sum(y_pred), rtol=1e-3)
|
|
|
|
assert "test_json_artifact" in result.artifacts
|
|
assert "test_json_artifact.json" in artifacts
|
|
assert isinstance(result.artifacts["test_json_artifact"], JsonEvaluationArtifact)
|
|
assert result.artifacts["test_json_artifact"].content == {"a": 3, "b": [1, 2]}
|
|
|
|
assert "test_npy_artifact" in result.artifacts
|
|
assert "test_npy_artifact.npy" in artifacts
|
|
assert isinstance(result.artifacts["test_npy_artifact"], NumpyEvaluationArtifact)
|
|
np.testing.assert_array_equal(
|
|
result.artifacts["test_npy_artifact"].content, np.array([1, 2, 3, 4, 5])
|
|
)
|
|
|
|
|
|
def test_custom_metric_logs_artifacts_from_paths(
|
|
binary_logistic_regressor_model_uri, breast_cancer_dataset, tmp_path
|
|
):
|
|
fig_x = 8.0
|
|
fig_y = 5.0
|
|
fig_dpi = 100.0
|
|
img_formats = ("png", "jpeg", "jpg")
|
|
|
|
def example_custom_artifact(_, __, tmp_path):
|
|
example_artifacts = {}
|
|
|
|
# images
|
|
for ext in img_formats:
|
|
fig = Figure(figsize=(fig_x, fig_y), dpi=fig_dpi)
|
|
ax = fig.subplots()
|
|
ax.plot([1, 2, 3])
|
|
fig.savefig(path_join(tmp_path, f"test.{ext}"), format=ext)
|
|
example_artifacts[f"test_{ext}_artifact"] = path_join(tmp_path, f"test.{ext}")
|
|
|
|
# json
|
|
with open(path_join(tmp_path, "test.json"), "w") as f:
|
|
json.dump([1, 2, 3], f)
|
|
example_artifacts["test_json_artifact"] = path_join(tmp_path, "test.json")
|
|
|
|
# numpy
|
|
np_array = np.array([1, 2, 3, 4, 5])
|
|
np.save(path_join(tmp_path, "test.npy"), np_array)
|
|
example_artifacts["test_npy_artifact"] = path_join(tmp_path, "test.npy")
|
|
|
|
# csv
|
|
df = pd.DataFrame({"a": [1, 2, 3]})
|
|
df.to_csv(path_join(tmp_path, "test.csv"), index=False)
|
|
example_artifacts["test_csv_artifact"] = path_join(tmp_path, "test.csv")
|
|
|
|
# parquet
|
|
df = pd.DataFrame({"test": [1, 2, 3]})
|
|
df.to_parquet(path_join(tmp_path, "test.parquet"))
|
|
example_artifacts["test_parquet_artifact"] = path_join(tmp_path, "test.parquet")
|
|
|
|
# text
|
|
with open(path_join(tmp_path, "test.txt"), "w") as f:
|
|
f.write("hello world")
|
|
example_artifacts["test_text_artifact"] = path_join(tmp_path, "test.txt")
|
|
|
|
return example_artifacts
|
|
|
|
result, _, artifacts = _get_results_for_custom_metrics_tests(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset,
|
|
custom_artifacts=[example_custom_artifact],
|
|
)
|
|
|
|
for img_ext in img_formats:
|
|
assert f"test_{img_ext}_artifact" in result.artifacts
|
|
assert f"test_{img_ext}_artifact.{img_ext}" in artifacts
|
|
assert isinstance(result.artifacts[f"test_{img_ext}_artifact"], ImageEvaluationArtifact)
|
|
|
|
fig = Figure(figsize=(fig_x, fig_y), dpi=fig_dpi)
|
|
ax = fig.subplots()
|
|
ax.plot([1, 2, 3])
|
|
fig.savefig(path_join(tmp_path, f"test.{img_ext}"), format=img_ext)
|
|
|
|
saved_img = Image.open(path_join(tmp_path, f"test.{img_ext}"))
|
|
result_img = result.artifacts[f"test_{img_ext}_artifact"].content
|
|
|
|
for img in (saved_img, result_img):
|
|
img_ext_qualified = "jpeg" if img_ext == "jpg" else img_ext
|
|
assert img.format.lower() == img_ext_qualified
|
|
assert img.size == (fig_x * fig_dpi, fig_y * fig_dpi)
|
|
assert pytest.approx(img.info.get("dpi"), 0.001) == (fig_dpi, fig_dpi)
|
|
|
|
assert "test_json_artifact" in result.artifacts
|
|
assert "test_json_artifact.json" in artifacts
|
|
assert isinstance(result.artifacts["test_json_artifact"], JsonEvaluationArtifact)
|
|
assert result.artifacts["test_json_artifact"].content == [1, 2, 3]
|
|
|
|
assert "test_npy_artifact" in result.artifacts
|
|
assert "test_npy_artifact.npy" in artifacts
|
|
assert isinstance(result.artifacts["test_npy_artifact"], NumpyEvaluationArtifact)
|
|
np.testing.assert_array_equal(
|
|
result.artifacts["test_npy_artifact"].content, np.array([1, 2, 3, 4, 5])
|
|
)
|
|
|
|
assert "test_csv_artifact" in result.artifacts
|
|
assert "test_csv_artifact.csv" in artifacts
|
|
assert isinstance(result.artifacts["test_csv_artifact"], CsvEvaluationArtifact)
|
|
pd.testing.assert_frame_equal(
|
|
result.artifacts["test_csv_artifact"].content, pd.DataFrame({"a": [1, 2, 3]})
|
|
)
|
|
|
|
assert "test_parquet_artifact" in result.artifacts
|
|
assert "test_parquet_artifact.parquet" in artifacts
|
|
assert isinstance(result.artifacts["test_parquet_artifact"], ParquetEvaluationArtifact)
|
|
pd.testing.assert_frame_equal(
|
|
result.artifacts["test_parquet_artifact"].content, pd.DataFrame({"test": [1, 2, 3]})
|
|
)
|
|
|
|
assert "test_text_artifact" in result.artifacts
|
|
assert "test_text_artifact.txt" in artifacts
|
|
assert isinstance(result.artifacts["test_text_artifact"], TextEvaluationArtifact)
|
|
assert result.artifacts["test_text_artifact"].content == "hello world"
|
|
|
|
|
|
class _ExampleToBePickledObject:
|
|
def __init__(self):
|
|
self.a = [1, 2, 3]
|
|
self.b = "hello"
|
|
|
|
def __eq__(self, o: object) -> bool:
|
|
return self.a == o.a and self.b == o.b
|
|
|
|
|
|
def test_custom_metric_logs_artifacts_from_objects(
|
|
binary_logistic_regressor_model_uri, breast_cancer_dataset
|
|
):
|
|
fig = Figure()
|
|
ax = fig.subplots()
|
|
ax.plot([1, 2, 3])
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf)
|
|
buf.seek(0)
|
|
img = Image.open(buf)
|
|
|
|
def example_custom_artifacts(_, __, ___):
|
|
return {
|
|
"test_image_artifact": fig,
|
|
"test_json_artifact": {
|
|
"list": [1, 2, 3],
|
|
"numpy_int": np.int64(0),
|
|
"numpy_float": np.float64(0.5),
|
|
},
|
|
"test_npy_artifact": np.array([1, 2, 3, 4, 5]),
|
|
"test_csv_artifact": pd.DataFrame({"a": [1, 2, 3]}),
|
|
"test_json_text_artifact": '{"a": [1, 2, 3], "c": 3.4}',
|
|
"test_pickled_artifact": _ExampleToBePickledObject(),
|
|
}
|
|
|
|
result, _, artifacts = _get_results_for_custom_metrics_tests(
|
|
binary_logistic_regressor_model_uri,
|
|
breast_cancer_dataset,
|
|
custom_artifacts=[example_custom_artifacts],
|
|
)
|
|
|
|
assert "test_image_artifact" in result.artifacts
|
|
assert "test_image_artifact.png" in artifacts
|
|
assert isinstance(result.artifacts["test_image_artifact"], ImageEvaluationArtifact)
|
|
img_diff = ImageChops.difference(result.artifacts["test_image_artifact"].content, img).getbbox()
|
|
assert img_diff is None
|
|
|
|
assert "test_json_artifact" in result.artifacts
|
|
assert "test_json_artifact.json" in artifacts
|
|
assert isinstance(result.artifacts["test_json_artifact"], JsonEvaluationArtifact)
|
|
assert result.artifacts["test_json_artifact"].content == {
|
|
"list": [1, 2, 3],
|
|
"numpy_int": 0,
|
|
"numpy_float": 0.5,
|
|
}
|
|
|
|
assert "test_npy_artifact" in result.artifacts
|
|
assert "test_npy_artifact.npy" in artifacts
|
|
assert isinstance(result.artifacts["test_npy_artifact"], NumpyEvaluationArtifact)
|
|
np.testing.assert_array_equal(
|
|
result.artifacts["test_npy_artifact"].content, np.array([1, 2, 3, 4, 5])
|
|
)
|
|
|
|
assert "test_csv_artifact" in result.artifacts
|
|
assert "test_csv_artifact.csv" in artifacts
|
|
assert isinstance(result.artifacts["test_csv_artifact"], CsvEvaluationArtifact)
|
|
pd.testing.assert_frame_equal(
|
|
result.artifacts["test_csv_artifact"].content, pd.DataFrame({"a": [1, 2, 3]})
|
|
)
|
|
|
|
assert "test_json_text_artifact" in result.artifacts
|
|
assert "test_json_text_artifact.json" in artifacts
|
|
assert isinstance(result.artifacts["test_json_text_artifact"], JsonEvaluationArtifact)
|
|
assert result.artifacts["test_json_text_artifact"].content == {"a": [1, 2, 3], "c": 3.4}
|
|
|
|
assert "test_pickled_artifact" in result.artifacts
|
|
assert "test_pickled_artifact.pickle" in artifacts
|
|
assert isinstance(result.artifacts["test_pickled_artifact"], PickleEvaluationArtifact)
|
|
assert result.artifacts["test_pickled_artifact"].content == _ExampleToBePickledObject()
|
|
|
|
|
|
def test_evaluate_sklearn_model_score_skip_when_not_scorable(
|
|
linear_regressor_model_uri, diabetes_dataset
|
|
):
|
|
with mock.patch(
|
|
"sklearn.linear_model.LinearRegression.score",
|
|
side_effect=RuntimeError("LinearRegression.score failed"),
|
|
) as mock_score:
|
|
with mlflow.start_run():
|
|
result = evaluate(
|
|
linear_regressor_model_uri,
|
|
diabetes_dataset._constructor_args["data"],
|
|
model_type="regressor",
|
|
targets=diabetes_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
mock_score.assert_called_once()
|
|
assert "score" not in result.metrics
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model",
|
|
[LogisticRegression(), LinearRegression()],
|
|
)
|
|
def test_autologging_is_disabled_during_evaluate(model):
|
|
mlflow.sklearn.autolog()
|
|
try:
|
|
X, y = load_iris(as_frame=True, return_X_y=True)
|
|
with mlflow.start_run() as run:
|
|
model.fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier" if isinstance(model, LogisticRegression) else "regressor",
|
|
targets="target",
|
|
evaluators="default",
|
|
)
|
|
|
|
run_data = get_run_data(run.info.run_id)
|
|
duplicate_metrics = []
|
|
for evaluate_metric_key in result.metrics.keys():
|
|
matched_keys = [k for k in run_data.metrics.keys() if k.startswith(evaluate_metric_key)]
|
|
if len(matched_keys) > 1:
|
|
duplicate_metrics += matched_keys
|
|
assert duplicate_metrics == []
|
|
finally:
|
|
mlflow.sklearn.autolog(disable=True)
|
|
|
|
|
|
def test_evaluation_works_with_model_pipelines_that_modify_input_data():
|
|
iris = load_iris()
|
|
X = pd.DataFrame(iris.data, columns=["0", "1", "2", "3"])
|
|
y = pd.Series(iris.target)
|
|
|
|
def add_feature(df):
|
|
df["newfeature"] = 1
|
|
return df
|
|
|
|
# Define a transformer that modifies input data by adding an extra feature column
|
|
add_feature_transformer = FunctionTransformer(add_feature, validate=False)
|
|
model_pipeline = Pipeline(
|
|
steps=[("add_feature", add_feature_transformer), ("predict", LogisticRegression())]
|
|
)
|
|
model_pipeline.fit(X, y)
|
|
|
|
with mlflow.start_run() as run:
|
|
pipeline_model_uri = mlflow.sklearn.log_model(
|
|
model_pipeline, name="model", serialization_format="cloudpickle"
|
|
).model_uri
|
|
|
|
evaluation_data = pd.DataFrame(load_iris().data, columns=["0", "1", "2", "3"])
|
|
evaluation_data["labels"] = load_iris().target
|
|
|
|
evaluate(
|
|
pipeline_model_uri,
|
|
evaluation_data,
|
|
model_type="regressor",
|
|
targets="labels",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"log_model_explainability": True,
|
|
# Use the kernel explainability algorithm, which fails if there is a mismatch
|
|
# between the number of features in the input dataset and the number of features
|
|
# expected by the model
|
|
"explainability_algorithm": "kernel",
|
|
},
|
|
)
|
|
|
|
_, _, _, artifacts = get_run_data(run.info.run_id)
|
|
assert set(artifacts) >= {
|
|
# TODO: Uncomment once https://github.com/shap/shap/issues/3901 is fixed
|
|
# "shap_beeswarm_plot.png",
|
|
"shap_feature_importance_plot.png",
|
|
"shap_summary_plot.png",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ["train_", None])
|
|
def test_evaluation_metric_name_configs(prefix):
|
|
X, y = load_iris(as_frame=True, return_X_y=True)
|
|
with mlflow.start_run() as run:
|
|
model = LogisticRegression()
|
|
model.fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier" if isinstance(model, LogisticRegression) else "regressor",
|
|
targets="target",
|
|
evaluators="default",
|
|
evaluator_config={"metric_prefix": prefix},
|
|
)
|
|
|
|
_, metrics, _, _ = get_run_data(run.info.run_id)
|
|
assert len(metrics) > 0
|
|
|
|
if prefix is not None:
|
|
assert f"{prefix}accuracy_score" in metrics
|
|
assert f"{prefix}log_loss" in metrics
|
|
assert f"{prefix}score" in metrics
|
|
|
|
assert f"{prefix}accuracy_score" in result.metrics
|
|
assert f"{prefix}log_loss" in result.metrics
|
|
assert f"{prefix}score" in result.metrics
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"env_manager",
|
|
["virtualenv", "conda"],
|
|
)
|
|
def test_evaluation_with_env_restoration(
|
|
multiclass_logistic_regressor_model_uri, iris_dataset, env_manager
|
|
):
|
|
with mlflow.start_run() as run:
|
|
result = evaluate(
|
|
model=multiclass_logistic_regressor_model_uri,
|
|
data=iris_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=iris_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
env_manager=env_manager,
|
|
)
|
|
|
|
_, metrics, _, artifacts = get_run_data(run.info.run_id)
|
|
|
|
model = mlflow.pyfunc.load_model(multiclass_logistic_regressor_model_uri)
|
|
y = iris_dataset.labels_data
|
|
y_pred = model.predict(iris_dataset.features_data)
|
|
|
|
expected_metrics = _get_multiclass_classifier_metrics(y_true=y, y_pred=y_pred, y_proba=None)
|
|
|
|
for metric_key, expected_metric_val in expected_metrics.items():
|
|
assert np.isclose(expected_metric_val, metrics[metric_key], rtol=1e-3)
|
|
assert np.isclose(expected_metric_val, result.metrics[metric_key], rtol=1e-3)
|
|
|
|
assert set(artifacts) == {
|
|
"per_class_metrics.csv",
|
|
"confusion_matrix.png",
|
|
}
|
|
assert result.artifacts.keys() == {
|
|
"per_class_metrics",
|
|
"confusion_matrix",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("pos_label", [None, 0, 1, 2])
|
|
def test_evaluation_binary_classification_with_pos_label(pos_label):
|
|
X, y = load_breast_cancer(as_frame=True, return_X_y=True)
|
|
X = X.iloc[:, :4].head(100)
|
|
y = y.head(len(X))
|
|
if pos_label == 2:
|
|
y = [2 if trg == 1 else trg for trg in y]
|
|
elif pos_label is None:
|
|
# Use a different positive class other than the 1 to verify
|
|
# that an unspecified `pos_label` doesn't cause problems
|
|
# for binary classification tasks with nonstandard labels
|
|
y = [10 if trg == 1 else trg for trg in y]
|
|
with mlflow.start_run():
|
|
model = LogisticRegression()
|
|
model.fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier",
|
|
targets="target",
|
|
evaluators="default",
|
|
evaluator_config=None if pos_label is None else {"pos_label": pos_label},
|
|
)
|
|
y_pred = model.predict(X)
|
|
pl = 10 if pos_label is None else pos_label
|
|
precision = precision_score(y, y_pred, pos_label=pl)
|
|
recall = recall_score(y, y_pred, pos_label=pl)
|
|
f1 = f1_score(y, y_pred, pos_label=pl)
|
|
np.testing.assert_allclose(result.metrics["precision_score"], precision)
|
|
np.testing.assert_allclose(result.metrics["recall_score"], recall)
|
|
np.testing.assert_allclose(result.metrics["f1_score"], f1)
|
|
|
|
|
|
@pytest.mark.parametrize("pos_label", [0, 1])
|
|
def test_evaluation_binary_classification_curve_auc_respects_pos_label(pos_label):
|
|
X, y = load_breast_cancer(as_frame=True, return_X_y=True)
|
|
X = X.iloc[:, :4].head(100)
|
|
y = y.head(len(X))
|
|
with mlflow.start_run():
|
|
model = LogisticRegression()
|
|
model.fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier",
|
|
targets="target",
|
|
evaluators="default",
|
|
evaluator_config={"pos_label": pos_label},
|
|
)
|
|
# The curve metrics must be computed against the probability column of pos_label,
|
|
# not a hardcoded column. Verify roc_auc and precision_recall_auc match the values
|
|
# sklearn computes for the configured positive class.
|
|
pos_col = list(model.classes_).index(pos_label)
|
|
y_score = model.predict_proba(X)[:, pos_col]
|
|
expected_roc_auc = roc_auc_score(y == pos_label, y_score)
|
|
expected_pr_auc = average_precision_score(y == pos_label, y_score)
|
|
np.testing.assert_allclose(result.metrics["roc_auc"], expected_roc_auc, rtol=1e-3)
|
|
np.testing.assert_allclose(result.metrics["precision_recall_auc"], expected_pr_auc, rtol=1e-3)
|
|
|
|
|
|
@pytest.mark.parametrize("average", [None, "weighted", "macro", "micro"])
|
|
def test_evaluation_multiclass_classification_with_average(average):
|
|
X, y = load_iris(as_frame=True, return_X_y=True)
|
|
with mlflow.start_run():
|
|
model = LogisticRegression()
|
|
model.fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier",
|
|
targets="target",
|
|
evaluators="default",
|
|
evaluator_config=None if average is None else {"average": average},
|
|
)
|
|
y_pred = model.predict(X)
|
|
avg = average or "weighted"
|
|
precision = precision_score(y, y_pred, average=avg)
|
|
recall = recall_score(y, y_pred, average=avg)
|
|
f1 = f1_score(y, y_pred, average=avg)
|
|
np.testing.assert_allclose(result.metrics["precision_score"], precision)
|
|
np.testing.assert_allclose(result.metrics["recall_score"], recall)
|
|
np.testing.assert_allclose(result.metrics["f1_score"], f1)
|
|
|
|
|
|
def test_custom_metrics():
|
|
X, y = load_iris(as_frame=True, return_X_y=True)
|
|
with mlflow.start_run():
|
|
model = LogisticRegression().fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier",
|
|
targets="target",
|
|
evaluators="default",
|
|
extra_metrics=[
|
|
make_metric(
|
|
eval_fn=lambda _eval_df, _builtin_metrics: MetricValue(
|
|
aggregate_results={"cm": 1.0}
|
|
),
|
|
name="cm",
|
|
greater_is_better=True,
|
|
long_name="custom_metric",
|
|
)
|
|
],
|
|
evaluator_config={"log_model_explainability": False}, # For faster evaluation
|
|
)
|
|
np.testing.assert_allclose(result.metrics["cm"], 1.0)
|
|
|
|
|
|
def test_custom_artifacts():
|
|
X, y = load_iris(as_frame=True, return_X_y=True)
|
|
with mlflow.start_run():
|
|
model = LogisticRegression().fit(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model")
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
X.assign(target=y),
|
|
model_type="classifier",
|
|
targets="target",
|
|
evaluators="default",
|
|
custom_artifacts=[
|
|
lambda *_args, **_kwargs: {"custom_artifact": {"k": "v"}},
|
|
],
|
|
evaluator_config={"log_model_explainability": False}, # For faster evaluation
|
|
)
|
|
custom_artifact = result.artifacts["custom_artifact"]
|
|
path = custom_artifact.uri.removeprefix("file://")
|
|
assert json.loads(Path(path).read_text()) == {"k": "v"}
|
|
|
|
|
|
def test_make_metric_name_inference():
|
|
def metric(_df, _metrics):
|
|
return 1
|
|
|
|
eval_metric = make_metric(eval_fn=metric, greater_is_better=True)
|
|
assert eval_metric.name == "metric"
|
|
|
|
eval_metric = make_metric(eval_fn=metric, greater_is_better=True, name="my_metric")
|
|
assert eval_metric.name == "my_metric"
|
|
|
|
eval_metric = make_metric(
|
|
eval_fn=lambda _df, _metrics: 0, greater_is_better=True, name="metric"
|
|
)
|
|
assert eval_metric.name == "metric"
|
|
|
|
with pytest.raises(
|
|
MlflowException, match="`name` must be specified if `eval_fn` is a lambda function."
|
|
):
|
|
make_metric(eval_fn=lambda _df, _metrics: 0, greater_is_better=True)
|
|
|
|
class Callable:
|
|
def __call__(self, _df, _metrics):
|
|
return 1
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="`name` must be specified if `eval_fn` does not have a `__name__` attribute.",
|
|
):
|
|
make_metric(eval_fn=Callable(), greater_is_better=True)
|
|
|
|
|
|
def language_model(inputs: list[str]) -> list[str]:
|
|
return inputs
|
|
|
|
|
|
def validate_question_answering_logged_data(
|
|
logged_data, with_targets=True, predictions_name="outputs"
|
|
):
|
|
columns = {
|
|
"question",
|
|
predictions_name,
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"token_count",
|
|
}
|
|
if with_targets:
|
|
columns.update({"answer"})
|
|
|
|
assert set(logged_data.columns.tolist()) == columns
|
|
|
|
assert logged_data["question"].tolist() == ["words random", "This is a sentence."]
|
|
assert logged_data[predictions_name].tolist() == ["words random", "This is a sentence."]
|
|
assert logged_data["toxicity/v1/score"][0] < 0.5
|
|
assert logged_data["toxicity/v1/score"][1] < 0.5
|
|
assert all(
|
|
isinstance(grade, float) for grade in logged_data["flesch_kincaid_grade_level/v1/score"]
|
|
)
|
|
assert all(isinstance(grade, float) for grade in logged_data["ari_grade_level/v1/score"])
|
|
assert all(isinstance(grade, int) for grade in logged_data["token_count"])
|
|
|
|
if with_targets:
|
|
assert logged_data["answer"].tolist() == ["words random", "This is a sentence."]
|
|
|
|
|
|
def test_missing_args_raises_exception():
|
|
def dummy_fn1(param_1, param_2, targets, metrics):
|
|
pass
|
|
|
|
def dummy_fn2(param_3, param_4, builtin_metrics):
|
|
pass
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"question": ["a", "b"], "answer": ["a", "b"]})
|
|
|
|
metric_1 = make_metric(name="metric_1", eval_fn=dummy_fn1, greater_is_better=True)
|
|
metric_2 = make_metric(name="metric_2", eval_fn=dummy_fn2, greater_is_better=True)
|
|
|
|
error_message = (
|
|
r"Error: Metric calculation failed for the following metrics:\n"
|
|
r"Metric 'metric_1' requires the following:\n"
|
|
r"- the 'targets' parameter needs to be specified\n"
|
|
r"- missing columns \['param_1', 'param_2'\] need to be defined or mapped\n"
|
|
r"Metric 'metric_2' requires the following:\n"
|
|
r"- missing columns \['param_3', 'builtin_metrics'\] need to be defined or mapped\n\n"
|
|
r"Below are the existing column names for the input/output data:\n"
|
|
r"Input Columns: \['question', 'answer'\]\n"
|
|
r"Output Columns: \['predictions'\]\n\n"
|
|
)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=error_message,
|
|
):
|
|
with mlflow.start_run():
|
|
mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
evaluators="default",
|
|
model_type="question-answering",
|
|
extra_metrics=[metric_1, metric_2],
|
|
evaluator_config={"col_mapping": {"param_4": "question"}},
|
|
)
|
|
|
|
|
|
def test_evaluate_question_answering_with_targets():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="answer",
|
|
model_type="question-answering",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_question_answering_logged_data(logged_data)
|
|
assert set(results.metrics.keys()) == set(
|
|
get_question_answering_metrics_keys(with_targets=True)
|
|
)
|
|
assert results.metrics["exact_match/v1"] == 1.0
|
|
|
|
|
|
def test_evaluate_question_answering_on_static_dataset_with_targets():
|
|
with mlflow.start_run() as run:
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
"pred": ["words random", "This is a sentence."],
|
|
})
|
|
results = mlflow.evaluate(
|
|
data=data,
|
|
targets="answer",
|
|
predictions="pred",
|
|
model_type="question-answering",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_question_answering_logged_data(logged_data, predictions_name="pred")
|
|
assert set(results.metrics.keys()) == {
|
|
"toxicity/v1/variance",
|
|
"toxicity/v1/ratio",
|
|
"toxicity/v1/mean",
|
|
"flesch_kincaid_grade_level/v1/variance",
|
|
"ari_grade_level/v1/p90",
|
|
"flesch_kincaid_grade_level/v1/p90",
|
|
"flesch_kincaid_grade_level/v1/mean",
|
|
"ari_grade_level/v1/mean",
|
|
"ari_grade_level/v1/variance",
|
|
"exact_match/v1",
|
|
"toxicity/v1/p90",
|
|
}
|
|
assert results.metrics["exact_match/v1"] == 1.0
|
|
assert results.metrics["toxicity/v1/ratio"] == 0.0
|
|
|
|
|
|
def question_classifier(inputs):
|
|
return inputs["question"].map({"a": 0, "b": 1})
|
|
|
|
|
|
def test_evaluate_question_answering_with_numerical_targets():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=question_classifier,
|
|
input_example=pd.DataFrame({"question": ["a", "b"]}),
|
|
)
|
|
data = pd.DataFrame({"question": ["a", "b"], "answer": [0, 1]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="answer",
|
|
model_type="question-answering",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
pd.testing.assert_frame_equal(
|
|
logged_data.drop("token_count", axis=1),
|
|
data.assign(outputs=[0, 1]),
|
|
)
|
|
assert results.metrics == {"exact_match/v1": 1.0}
|
|
|
|
|
|
def test_evaluate_question_answering_without_targets():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"question": ["words random", "This is a sentence."]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="question-answering",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_question_answering_logged_data(logged_data, False)
|
|
assert set(results.metrics.keys()) == set(
|
|
get_question_answering_metrics_keys(with_targets=False)
|
|
)
|
|
|
|
|
|
def validate_text_summarization_logged_data(logged_data, with_targets=True):
|
|
columns = {
|
|
"text",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"token_count",
|
|
}
|
|
if with_targets:
|
|
columns.update({
|
|
"summary",
|
|
"rouge1/v1/score",
|
|
"rouge2/v1/score",
|
|
"rougeL/v1/score",
|
|
"rougeLsum/v1/score",
|
|
})
|
|
|
|
assert set(logged_data.columns.tolist()) == columns
|
|
|
|
assert logged_data["text"].tolist() == ["a", "b"]
|
|
assert logged_data["outputs"].tolist() == ["a", "b"]
|
|
assert logged_data["toxicity/v1/score"][0] < 0.5
|
|
assert logged_data["toxicity/v1/score"][1] < 0.5
|
|
assert all(
|
|
isinstance(grade, float) for grade in logged_data["flesch_kincaid_grade_level/v1/score"]
|
|
)
|
|
assert all(isinstance(grade, float) for grade in logged_data["ari_grade_level/v1/score"])
|
|
assert all(isinstance(grade, int) for grade in logged_data["token_count"])
|
|
|
|
if with_targets:
|
|
assert logged_data["summary"].tolist() == ["a", "b"]
|
|
assert logged_data["rouge1/v1/score"].tolist() == [1.0, 1.0]
|
|
assert logged_data["rouge2/v1/score"].tolist() == [0.0, 0.0]
|
|
assert logged_data["rougeL/v1/score"].tolist() == [1.0, 1.0]
|
|
assert logged_data["rougeLsum/v1/score"].tolist() == [1.0, 1.0]
|
|
|
|
|
|
def get_text_metrics_keys():
|
|
metric_names = ["toxicity", "flesch_kincaid_grade_level", "ari_grade_level"]
|
|
standard_aggregations = ["mean", "variance", "p90"]
|
|
version = "v1"
|
|
|
|
metrics_keys = [
|
|
f"{metric}/{version}/{agg}" for metric in metric_names for agg in standard_aggregations
|
|
]
|
|
additional_aggregations = ["toxicity/v1/ratio"]
|
|
return metrics_keys + additional_aggregations
|
|
|
|
|
|
def get_text_summarization_metrics_keys(with_targets=False):
|
|
if with_targets:
|
|
metric_names = ["rouge1", "rouge2", "rougeL", "rougeLsum"]
|
|
standard_aggregations = ["mean", "variance", "p90"]
|
|
version = "v1"
|
|
|
|
metrics_keys = [
|
|
f"{metric}/{version}/{agg}" for metric in metric_names for agg in standard_aggregations
|
|
]
|
|
else:
|
|
metrics_keys = []
|
|
return get_text_metrics_keys() + metrics_keys
|
|
|
|
|
|
def get_question_answering_metrics_keys(with_targets=False):
|
|
metrics_keys = ["exact_match/v1"] if with_targets else []
|
|
return get_text_metrics_keys() + metrics_keys
|
|
|
|
|
|
def test_evaluate_text_summarization_with_targets():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["a", "b"], "summary": ["a", "b"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="summary",
|
|
model_type="text-summarization",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_text_summarization_logged_data(logged_data)
|
|
|
|
metrics = results.metrics
|
|
assert set(metrics.keys()) == set(get_text_summarization_metrics_keys(with_targets=True))
|
|
|
|
|
|
def test_evaluate_text_summarization_with_targets_no_type_hints():
|
|
def another_language_model(x):
|
|
x.rename(columns={"text": "outputs"}, inplace=True)
|
|
return x
|
|
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=another_language_model,
|
|
input_example=pd.DataFrame({"text": ["a", "b"]}),
|
|
)
|
|
data = pd.DataFrame({"text": ["a", "b"], "summary": ["a", "b"]})
|
|
results = evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="summary",
|
|
model_type="text-summarization",
|
|
evaluators="default",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_text_summarization_logged_data(logged_data)
|
|
|
|
metrics = results.metrics
|
|
assert set(metrics.keys()) == set(get_text_summarization_metrics_keys(with_targets=True))
|
|
|
|
|
|
def test_evaluate_text_summarization_without_targets():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["a", "b"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="text-summarization",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_text_summarization_logged_data(logged_data, with_targets=False)
|
|
|
|
assert set(results.metrics.keys()) == set(
|
|
get_text_summarization_metrics_keys(with_targets=False)
|
|
)
|
|
|
|
|
|
def test_evaluate_text_summarization_fails_to_load_evaluate_metrics():
|
|
from mlflow.metrics.metric_definitions import _cached_evaluate_load
|
|
|
|
_cached_evaluate_load.cache_clear()
|
|
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
|
|
data = pd.DataFrame({"text": ["a", "b"], "summary": ["a", "b"]})
|
|
with mock.patch(
|
|
"mlflow.metrics.metric_definitions._cached_evaluate_load",
|
|
side_effect=ImportError("mocked error"),
|
|
) as mock_load:
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="summary",
|
|
model_type="text-summarization",
|
|
)
|
|
mock_load.assert_any_call("rouge")
|
|
mock_load.assert_any_call("toxicity", module_type="measurement")
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"text",
|
|
"summary",
|
|
"outputs",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"token_count",
|
|
}
|
|
assert logged_data["text"].tolist() == ["a", "b"]
|
|
assert logged_data["summary"].tolist() == ["a", "b"]
|
|
assert logged_data["outputs"].tolist() == ["a", "b"]
|
|
|
|
|
|
def test_evaluate_text_and_text_metrics():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["sentence not", "All women are bad."]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="text",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"text",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"token_count",
|
|
}
|
|
assert logged_data["text"].tolist() == ["sentence not", "All women are bad."]
|
|
assert logged_data["outputs"].tolist() == ["sentence not", "All women are bad."]
|
|
# Hateful sentiments should be marked as toxic
|
|
assert logged_data["toxicity/v1/score"][0] < 0.5
|
|
assert logged_data["toxicity/v1/score"][1] > 0.5
|
|
# Simple sentences should have a low grade level.
|
|
assert logged_data["flesch_kincaid_grade_level/v1/score"][1] < 4
|
|
assert logged_data["ari_grade_level/v1/score"][1] < 4
|
|
assert set(results.metrics.keys()) == set(get_text_metrics_keys())
|
|
|
|
|
|
def very_toxic(predictions, targets=None, metrics=None):
|
|
new_scores = [1.0 if score > 0.9 else 0.0 for score in metrics["toxicity/v1"].scores]
|
|
return MetricValue(
|
|
scores=new_scores,
|
|
justifications=["toxic" if score == 1.0 else "not toxic" for score in new_scores],
|
|
aggregate_results={"ratio": sum(new_scores) / len(new_scores)},
|
|
)
|
|
|
|
|
|
def per_row_metric(predictions, targets=None, metrics=None):
|
|
return MetricValue(scores=[1] * len(predictions))
|
|
|
|
|
|
def test_evaluate_text_custom_metrics():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["a", "b"], "target": ["a", "b"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="target",
|
|
model_type="text",
|
|
extra_metrics=[
|
|
make_metric(eval_fn=very_toxic, greater_is_better=True, version="v2"),
|
|
make_metric(eval_fn=per_row_metric, greater_is_better=False, name="no_version"),
|
|
],
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
|
|
assert "very_toxic/v2/score" in logged_data.columns.tolist()
|
|
assert "very_toxic/v2/justification" in logged_data.columns.tolist()
|
|
assert all(isinstance(score, float) for score in logged_data["very_toxic/v2/score"])
|
|
assert all(
|
|
isinstance(justification, str)
|
|
for justification in logged_data["very_toxic/v2/justification"]
|
|
)
|
|
assert "very_toxic/v2/ratio" in set(results.metrics.keys())
|
|
assert "no_version/score" in logged_data.columns.tolist()
|
|
|
|
|
|
@pytest.mark.parametrize("metric_prefix", ["train_", None])
|
|
def test_eval_results_table_json_can_be_prefixed_with_metric_prefix(metric_prefix):
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["a", "b"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="text",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"metric_prefix": metric_prefix,
|
|
},
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
|
|
if metric_prefix is None:
|
|
metric_prefix = ""
|
|
|
|
assert f"{metric_prefix}eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"text",
|
|
"outputs",
|
|
f"{metric_prefix}toxicity/v1/score",
|
|
f"{metric_prefix}flesch_kincaid_grade_level/v1/score",
|
|
f"{metric_prefix}ari_grade_level/v1/score",
|
|
f"{metric_prefix}token_count",
|
|
}
|
|
|
|
|
|
def test_default_evaluator_for_pyfunc_model(breast_cancer_dataset):
|
|
data = load_breast_cancer()
|
|
raw_model = LinearSVC()
|
|
raw_model.fit(data.data, data.target)
|
|
|
|
mlflow_model = Model()
|
|
mlflow.pyfunc.add_to_model(mlflow_model, loader_module="mlflow.sklearn")
|
|
pyfunc_model = mlflow.pyfunc.PyFuncModel(model_meta=mlflow_model, model_impl=raw_model)
|
|
|
|
with mlflow.start_run() as run:
|
|
evaluate(
|
|
pyfunc_model,
|
|
breast_cancer_dataset._constructor_args["data"],
|
|
model_type="classifier",
|
|
targets=breast_cancer_dataset._constructor_args["targets"],
|
|
evaluators="default",
|
|
)
|
|
run_data = get_run_data(run.info.run_id)
|
|
assert set(run_data.artifacts) == {
|
|
"confusion_matrix.png",
|
|
"shap_feature_importance_plot.png",
|
|
"shap_beeswarm_plot.png",
|
|
"shap_summary_plot.png",
|
|
}
|
|
|
|
|
|
def test_extracting_output_and_other_columns():
|
|
data_dict = {
|
|
"text": ["text_a", "text_b"],
|
|
"target": ["target_a", "target_b"],
|
|
"other": ["other_a", "other_b"],
|
|
}
|
|
data_df = pd.DataFrame(data_dict)
|
|
data_list_dict = [
|
|
{
|
|
"text": "text_a",
|
|
"target": "target_a",
|
|
"other": "other_a",
|
|
},
|
|
{
|
|
"text": "text_b",
|
|
"target": "target_b",
|
|
"other": "other_b",
|
|
},
|
|
]
|
|
data_list = (["data_a", "data_b"],)
|
|
data_dict_text = {
|
|
"text": ["text_a", "text_b"],
|
|
}
|
|
|
|
output1, other1, prediction_col1 = _extract_output_and_other_columns(data_dict, "target")
|
|
output2, other2, prediction_col2 = _extract_output_and_other_columns(data_df, "target")
|
|
output3, other3, prediction_col3 = _extract_output_and_other_columns(data_list_dict, "target")
|
|
output4, other4, prediction_col4 = _extract_output_and_other_columns(data_list, None)
|
|
output5, other5, prediction_col5 = _extract_output_and_other_columns(pd.Series(data_list), None)
|
|
output6, other6, prediction_col6 = _extract_output_and_other_columns(data_dict_text, None)
|
|
output7, other7, prediction_col7 = _extract_output_and_other_columns(
|
|
pd.DataFrame(data_dict_text), None
|
|
)
|
|
|
|
assert output1.equals(data_df["target"])
|
|
assert other1.equals(data_df.drop(columns=["target"]))
|
|
assert prediction_col1 == "target"
|
|
assert output2.equals(data_df["target"])
|
|
assert other2.equals(data_df.drop(columns=["target"]))
|
|
assert prediction_col2 == "target"
|
|
assert output3.equals(data_df["target"])
|
|
assert other3.equals(data_df.drop(columns=["target"]))
|
|
assert prediction_col3 == "target"
|
|
assert output4 == data_list
|
|
assert other4 is None
|
|
assert prediction_col4 is None
|
|
assert output5.equals(pd.Series(data_list))
|
|
assert other5 is None
|
|
assert prediction_col5 is None
|
|
assert output6.equals(pd.Series(data_dict_text["text"]))
|
|
assert other6 is None
|
|
assert prediction_col6 == "text"
|
|
assert output7.equals(pd.Series(data_dict_text["text"]))
|
|
assert other7 is None
|
|
assert prediction_col7 == "text"
|
|
|
|
|
|
def language_model_with_context(inputs: list[str]) -> list[dict[str, str]]:
|
|
return [
|
|
{
|
|
"context": f"context_{input}",
|
|
"output": input,
|
|
}
|
|
for input in inputs
|
|
]
|
|
|
|
|
|
def test_constructing_eval_df_for_custom_metrics():
|
|
test_eval_df_value = pd.DataFrame({
|
|
"predictions": ["text_a", "text_b"],
|
|
"targets": ["target_a", "target_b"],
|
|
"inputs": ["text_a", "text_b"],
|
|
"truth": ["truth_a", "truth_b"],
|
|
"context": ["context_text_a", "context_text_b"],
|
|
})
|
|
|
|
def example_custom_artifact(_, __, ___):
|
|
return {"test_json_artifact": {"a": 2, "b": [1, 2]}}
|
|
|
|
def test_eval_df(predictions, targets, metrics, inputs, truth, context):
|
|
global eval_df_value
|
|
eval_df_value = pd.DataFrame({
|
|
"predictions": predictions,
|
|
"targets": targets,
|
|
"inputs": inputs,
|
|
"truth": truth,
|
|
"context": context,
|
|
})
|
|
return predictions.eq(targets).mean()
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=language_model_with_context,
|
|
input_example=["a", "b"],
|
|
)
|
|
data = pd.DataFrame({
|
|
"text": ["text_a", "text_b"],
|
|
"truth": ["truth_a", "truth_b"],
|
|
"targets": ["target_a", "target_b"],
|
|
})
|
|
eval_results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="targets",
|
|
predictions="output",
|
|
model_type="text",
|
|
extra_metrics=[make_metric(eval_fn=test_eval_df, greater_is_better=True)],
|
|
custom_artifacts=[example_custom_artifact],
|
|
evaluators="default",
|
|
evaluator_config={"col_mapping": {"inputs": "text"}},
|
|
)
|
|
|
|
assert eval_df_value.equals(test_eval_df_value)
|
|
assert len(eval_results.artifacts) == 2
|
|
assert len(eval_results.tables) == 1
|
|
assert eval_results.tables["eval_results_table"].columns.tolist() == [
|
|
"text",
|
|
"truth",
|
|
"targets",
|
|
"output",
|
|
"context",
|
|
"token_count",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
]
|
|
|
|
|
|
def test_evaluate_no_model_or_predictions_specified():
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"truth": ["words random", "This is a sentence."],
|
|
})
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
"Either a model or set of predictions must be specified in order to use the"
|
|
" default evaluator"
|
|
),
|
|
):
|
|
mlflow.evaluate(
|
|
data=data,
|
|
targets="truth",
|
|
model_type="regressor",
|
|
evaluators="default",
|
|
)
|
|
|
|
|
|
def test_evaluate_no_model_and_predictions_specified_with_unsupported_data_type():
|
|
X = np.random.random((5, 5))
|
|
y = np.random.random(5)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="If predictions is specified, data must be one of the following types",
|
|
):
|
|
mlflow.evaluate(
|
|
data=X,
|
|
targets=y,
|
|
predictions="model_output",
|
|
model_type="regressor",
|
|
evaluators="default",
|
|
)
|
|
|
|
|
|
def test_evaluate_no_model_type():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["Hello world", "My name is MLflow"]})
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="The extra_metrics argument must be specified model_type is None.",
|
|
):
|
|
mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
)
|
|
|
|
|
|
def test_evaluate_no_model_type_with_builtin_metric():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["Hello world", "My name is MLflow"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
extra_metrics=[mlflow.metrics.toxicity()],
|
|
)
|
|
assert results.metrics.keys() == {
|
|
"toxicity/v1/mean",
|
|
"toxicity/v1/variance",
|
|
"toxicity/v1/p90",
|
|
"toxicity/v1/ratio",
|
|
}
|
|
assert len(results.tables) == 1
|
|
assert results.tables["eval_results_table"].columns.tolist() == [
|
|
"text",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
]
|
|
|
|
|
|
def test_evaluate_no_model_type_with_custom_metric():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["Hello world", "My name is MLflow"]})
|
|
from mlflow.metrics import make_metric
|
|
from mlflow.metrics.base import standard_aggregations
|
|
|
|
def word_count_eval(predictions, targets=None, metrics=None):
|
|
scores = [len(prediction.split(" ")) for prediction in predictions]
|
|
return MetricValue(
|
|
scores=scores,
|
|
aggregate_results=standard_aggregations(scores),
|
|
)
|
|
|
|
word_count = make_metric(eval_fn=word_count_eval, greater_is_better=True, name="word_count")
|
|
|
|
results = mlflow.evaluate(model_info.model_uri, data, extra_metrics=[word_count])
|
|
assert results.metrics.keys() == {
|
|
"word_count/mean",
|
|
"word_count/variance",
|
|
"word_count/p90",
|
|
}
|
|
assert results.metrics["word_count/mean"] == 3.0
|
|
assert len(results.tables) == 1
|
|
assert results.tables["eval_results_table"].columns.tolist() == [
|
|
"text",
|
|
"outputs",
|
|
"word_count/score",
|
|
]
|
|
|
|
|
|
def multi_output_model(inputs):
|
|
return pd.DataFrame({
|
|
"answer": ["words random", "This is a sentence."],
|
|
"source": ["words random", "This is a sentence."],
|
|
})
|
|
|
|
|
|
def test_default_metrics_as_extra_metrics():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=multi_output_model, input_example=["a"]
|
|
)
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"truth": ["words random", "This is a sentence."],
|
|
})
|
|
results = evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="truth",
|
|
predictions="answer",
|
|
model_type="question-answering",
|
|
extra_metrics=[
|
|
mlflow.metrics.exact_match(),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
assert "exact_match/v1" in results.metrics.keys()
|
|
|
|
|
|
def test_default_metrics_as_extra_metrics_static_dataset():
|
|
with mlflow.start_run() as run:
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"truth": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
"source": ["words random", "This is a sentence."],
|
|
})
|
|
results = evaluate(
|
|
data=data,
|
|
targets="truth",
|
|
predictions="answer",
|
|
model_type="question-answering",
|
|
extra_metrics=[
|
|
mlflow.metrics.flesch_kincaid_grade_level(),
|
|
mlflow.metrics.ari_grade_level(),
|
|
mlflow.metrics.toxicity(),
|
|
mlflow.metrics.exact_match(),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
for metric in ["toxicity", "ari_grade_level", "flesch_kincaid_grade_level"]:
|
|
for measure in ["mean", "p90", "variance"]:
|
|
assert f"{metric}/v1/{measure}" in results.metrics.keys()
|
|
assert "exact_match/v1" in results.metrics.keys()
|
|
|
|
|
|
def test_derived_metrics_basic_dependency_graph():
|
|
def metric_1(predictions, targets, metrics):
|
|
return MetricValue(
|
|
scores=[0, 1],
|
|
justifications=["first justification", "second justification"],
|
|
aggregate_results={"aggregate": 0.5},
|
|
)
|
|
|
|
def metric_2(predictions, targets, metrics, metric_1):
|
|
return MetricValue(
|
|
scores=[score * 5 for score in metric_1.scores],
|
|
justifications=[
|
|
"metric_2: " + justification for justification in metric_1.justifications
|
|
],
|
|
aggregate_results={
|
|
**metric_1.aggregate_results,
|
|
**metrics["toxicity/v1"].aggregate_results,
|
|
},
|
|
)
|
|
|
|
def metric_3(predictions, targets, metric_1, metric_2):
|
|
return MetricValue(
|
|
scores=[score - 1 for score in metric_2.scores],
|
|
justifications=metric_1.justifications,
|
|
aggregate_results=metric_2.aggregate_results,
|
|
)
|
|
|
|
with mlflow.start_run():
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"truth": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
})
|
|
results = evaluate(
|
|
data=data,
|
|
targets="truth",
|
|
predictions="answer",
|
|
model_type="text",
|
|
extra_metrics=[
|
|
make_metric(eval_fn=metric_1, greater_is_better=True, version="v1"),
|
|
make_metric(eval_fn=metric_2, greater_is_better=True, version="v2"),
|
|
make_metric(eval_fn=metric_3, greater_is_better=True),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"question",
|
|
"truth",
|
|
"answer",
|
|
"token_count",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"metric_1/v1/score",
|
|
"metric_2/v2/score",
|
|
"metric_3/score",
|
|
"metric_1/v1/justification",
|
|
"metric_2/v2/justification",
|
|
"metric_3/justification",
|
|
}
|
|
|
|
assert logged_data["metric_1/v1/score"].tolist() == [0, 1]
|
|
assert logged_data["metric_2/v2/score"].tolist() == [0, 5]
|
|
assert logged_data["metric_3/score"].tolist() == [-1, 4]
|
|
assert logged_data["metric_1/v1/justification"].tolist() == [
|
|
"first justification",
|
|
"second justification",
|
|
]
|
|
assert logged_data["metric_2/v2/justification"].tolist() == [
|
|
"metric_2: first justification",
|
|
"metric_2: second justification",
|
|
]
|
|
assert logged_data["metric_3/justification"].tolist() == [
|
|
"first justification",
|
|
"second justification",
|
|
]
|
|
|
|
assert results.metrics["metric_1/v1/aggregate"] == 0.5
|
|
assert results.metrics["metric_2/v2/aggregate"] == 0.5
|
|
assert results.metrics["metric_3/aggregate"] == 0.5
|
|
assert "metric_2/v2/mean" in results.metrics.keys()
|
|
assert "metric_2/v2/variance" in results.metrics.keys()
|
|
assert "metric_2/v2/p90" in results.metrics.keys()
|
|
assert "metric_3/mean" in results.metrics.keys()
|
|
assert "metric_3/variance" in results.metrics.keys()
|
|
assert "metric_3/p90" in results.metrics.keys()
|
|
|
|
|
|
def test_derived_metrics_complicated_dependency_graph():
|
|
def metric_1(predictions, targets, metric_2, metric_3, metric_6):
|
|
assert metric_2.scores == [2, 3]
|
|
assert metric_3.scores == [3, 4]
|
|
assert metric_6.scores == [6, 7]
|
|
return MetricValue(scores=[1, 2])
|
|
|
|
def metric_2(predictions, targets):
|
|
return MetricValue(scores=[2, 3])
|
|
|
|
def metric_3(predictions, targets, metric_4, metric_5):
|
|
assert metric_4.scores == [4, 5]
|
|
assert metric_5.scores == [5, 6]
|
|
return MetricValue(scores=[3, 4])
|
|
|
|
def metric_4(predictions, targets, metric_6):
|
|
assert metric_6.scores == [6, 7]
|
|
return MetricValue(scores=[4, 5])
|
|
|
|
def metric_5(predictions, targets, metric_4, metric_6):
|
|
assert metric_4.scores == [4, 5]
|
|
assert metric_6.scores == [6, 7]
|
|
return MetricValue(scores=[5, 6])
|
|
|
|
def metric_6(predictions, targets, metric_2):
|
|
assert metric_2.scores == [2, 3]
|
|
return MetricValue(scores=[6, 7])
|
|
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"truth": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
})
|
|
|
|
with mlflow.start_run():
|
|
results = evaluate(
|
|
data=data,
|
|
predictions="answer",
|
|
targets="truth",
|
|
extra_metrics=[
|
|
make_metric(eval_fn=metric_1, greater_is_better=True, version="v1"),
|
|
make_metric(eval_fn=metric_2, greater_is_better=True, version="v2"),
|
|
make_metric(eval_fn=metric_3, greater_is_better=True),
|
|
make_metric(eval_fn=metric_4, greater_is_better=True),
|
|
make_metric(eval_fn=metric_5, greater_is_better=True, version="v1"),
|
|
make_metric(eval_fn=metric_6, greater_is_better=True, version="v3"),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"question",
|
|
"truth",
|
|
"answer",
|
|
"metric_1/v1/score",
|
|
"metric_2/v2/score",
|
|
"metric_3/score",
|
|
"metric_4/score",
|
|
"metric_5/v1/score",
|
|
"metric_6/v3/score",
|
|
}
|
|
|
|
assert logged_data["metric_1/v1/score"].tolist() == [1, 2]
|
|
assert logged_data["metric_2/v2/score"].tolist() == [2, 3]
|
|
assert logged_data["metric_3/score"].tolist() == [3, 4]
|
|
assert logged_data["metric_4/score"].tolist() == [4, 5]
|
|
assert logged_data["metric_5/v1/score"].tolist() == [5, 6]
|
|
assert logged_data["metric_6/v3/score"].tolist() == [6, 7]
|
|
|
|
def metric_7(predictions, targets, metric_8, metric_9):
|
|
return MetricValue(scores=[7, 8])
|
|
|
|
def metric_8(predictions, targets, metric_11):
|
|
return MetricValue(scores=[8, 9])
|
|
|
|
def metric_9(predictions, targets):
|
|
return MetricValue(scores=[9, 10])
|
|
|
|
def metric_10(predictions, targets, metric_9):
|
|
return MetricValue(scores=[10, 11])
|
|
|
|
def metric_11(predictions, targets, metric_7, metric_10):
|
|
return MetricValue(scores=[11, 12])
|
|
|
|
error_message = r"Error: Metric calculation failed for the following metrics:\n"
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=error_message,
|
|
):
|
|
with mlflow.start_run():
|
|
mlflow.evaluate(
|
|
data=data,
|
|
predictions="answer",
|
|
targets="truth",
|
|
model_type="text",
|
|
extra_metrics=[
|
|
make_metric(eval_fn=metric_7, greater_is_better=True),
|
|
make_metric(eval_fn=metric_8, greater_is_better=True),
|
|
make_metric(eval_fn=metric_9, greater_is_better=True),
|
|
make_metric(eval_fn=metric_10, greater_is_better=True),
|
|
make_metric(eval_fn=metric_11, greater_is_better=True),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
|
|
def test_derived_metrics_circular_dependencies_raises_exception():
|
|
def metric_1(predictions, targets, metric_2):
|
|
return 0
|
|
|
|
def metric_2(predictions, targets, metric_3):
|
|
return 0
|
|
|
|
def metric_3(predictions, targets, metric_1):
|
|
return 0
|
|
|
|
error_message = r"Error: Metric calculation failed for the following metrics:\n"
|
|
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
})
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=error_message,
|
|
):
|
|
with mlflow.start_run():
|
|
mlflow.evaluate(
|
|
data=data,
|
|
predictions="answer",
|
|
model_type="text",
|
|
extra_metrics=[
|
|
make_metric(eval_fn=metric_1, greater_is_better=True),
|
|
make_metric(eval_fn=metric_2, greater_is_better=True),
|
|
make_metric(eval_fn=metric_3, greater_is_better=True),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
|
|
def test_derived_metrics_missing_dependencies_raises_exception():
|
|
def metric_1(predictions, targets, metric_2):
|
|
return 0
|
|
|
|
def metric_2(predictions, targets, metric_3):
|
|
return 0
|
|
|
|
error_message = r"Error: Metric calculation failed for the following metrics:\n"
|
|
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"answer": ["words random", "This is a sentence."],
|
|
})
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=error_message,
|
|
):
|
|
with mlflow.start_run():
|
|
mlflow.evaluate(
|
|
data=data,
|
|
predictions="answer",
|
|
model_type="text",
|
|
extra_metrics=[
|
|
make_metric(eval_fn=metric_1, greater_is_better=True),
|
|
make_metric(eval_fn=metric_2, greater_is_better=True),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
|
|
def test_custom_metric_bad_names():
|
|
def metric_fn(predictions, targets):
|
|
return 0
|
|
|
|
error_message = re.escape(
|
|
"Invalid metric name 'metric/with/slash'. Metric names cannot include "
|
|
"forward slashes ('/')."
|
|
)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=error_message,
|
|
):
|
|
make_metric(eval_fn=metric_fn, name="metric/with/slash", greater_is_better=True)
|
|
|
|
with mock.patch("mlflow.models.evaluation.base._logger.warning") as mock_warning:
|
|
make_metric(eval_fn=metric_fn, name="bad-metric-name", greater_is_better=True)
|
|
mock_warning.assert_called_once_with(
|
|
"The metric name 'bad-metric-name' provided is not a valid Python identifier, which "
|
|
"will prevent its use as a base metric for derived metrics. Please use a valid "
|
|
"identifier to enable creation of derived metrics that use the given metric."
|
|
)
|
|
|
|
with mock.patch("mlflow.models.evaluation.base._logger.warning") as mock_warning:
|
|
make_metric(eval_fn=metric_fn, name="None", greater_is_better=True)
|
|
mock_warning.assert_called_once_with(
|
|
"The metric name 'None' is a reserved Python keyword, which will "
|
|
"prevent its use as a base metric for derived metrics. Please use a valid identifier "
|
|
"to enable creation of derived metrics that use the given metric."
|
|
)
|
|
|
|
with mock.patch("mlflow.models.evaluation.base._logger.warning") as mock_warning:
|
|
make_metric(eval_fn=metric_fn, name="predictions", greater_is_better=True)
|
|
mock_warning.assert_called_once_with(
|
|
"The metric name 'predictions' is used as a special parameter in MLflow metrics, which "
|
|
"will prevent its use as a base metric for derived metrics. Please use a different "
|
|
"name to enable creation of derived metrics that use the given metric."
|
|
)
|
|
|
|
|
|
def test_multi_output_model_error_handling():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=multi_output_model, input_example=["a"]
|
|
)
|
|
data = pd.DataFrame({
|
|
"question": ["words random", "This is a sentence."],
|
|
"truth": ["words random", "This is a sentence."],
|
|
})
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="Output column name is not specified for the multi-output model.",
|
|
):
|
|
evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="truth",
|
|
model_type="question-answering",
|
|
extra_metrics=[
|
|
mlflow.metrics.flesch_kincaid_grade_level(),
|
|
mlflow.metrics.ari_grade_level(),
|
|
mlflow.metrics.toxicity(),
|
|
mlflow.metrics.exact_match(),
|
|
],
|
|
evaluators="default",
|
|
)
|
|
|
|
|
|
def test_invalid_extra_metrics():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["Hello world", "My name is MLflow"]})
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="Please ensure that all extra metrics are instances of "
|
|
"mlflow.metrics.EvaluationMetric.",
|
|
):
|
|
mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="text",
|
|
evaluators="default",
|
|
extra_metrics=[mlflow.metrics.latency],
|
|
)
|
|
|
|
|
|
def test_evaluate_with_latency():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["sentence not", "Hello world."]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="text",
|
|
evaluators="default",
|
|
extra_metrics=[mlflow.metrics.latency()],
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"text",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"latency",
|
|
"token_count",
|
|
}
|
|
assert all(isinstance(grade, float) for grade in logged_data["latency"])
|
|
|
|
|
|
def test_evaluate_with_latency_and_pd_series():
|
|
with mlflow.start_run() as run:
|
|
|
|
def pd_series_model(inputs: list[str]) -> pd.Series:
|
|
return pd.Series(inputs)
|
|
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=pd_series_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["input text", "random text"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
model_type="text",
|
|
evaluators="default",
|
|
extra_metrics=[mlflow.metrics.latency()],
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"text",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"latency",
|
|
"token_count",
|
|
}
|
|
|
|
|
|
def test_evaluate_with_latency_static_dataset():
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=language_model, input_example=["a", "b"])
|
|
data = pd.DataFrame({
|
|
"text": ["foo", "bar"],
|
|
"model_output": ["FOO", "BAR"],
|
|
})
|
|
results = mlflow.evaluate(
|
|
data=data,
|
|
model_type="text",
|
|
evaluators="default",
|
|
predictions="model_output",
|
|
extra_metrics=[mlflow.metrics.latency()],
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
assert set(logged_data.columns.tolist()) == {
|
|
"text",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
"flesch_kincaid_grade_level/v1/score",
|
|
"ari_grade_level/v1/score",
|
|
"latency",
|
|
"token_count",
|
|
}
|
|
assert all(isinstance(grade, float) for grade in logged_data["latency"])
|
|
assert all(grade == 0.0 for grade in logged_data["latency"])
|
|
|
|
|
|
properly_formatted_openai_response1 = """\
|
|
{
|
|
"score": 3,
|
|
"justification": "justification"
|
|
}"""
|
|
|
|
|
|
def test_evaluate_with_correctness():
|
|
metric = mlflow.metrics.genai.make_genai_metric(
|
|
name="correctness",
|
|
definition=(
|
|
"Correctness refers to how well the generated output matches "
|
|
"or aligns with the reference or ground truth text that is considered "
|
|
"accurate and appropriate for the given input. The ground truth serves as "
|
|
"a benchmark against which the provided output is compared to determine the "
|
|
"level of accuracy and fidelity."
|
|
),
|
|
grading_prompt=(
|
|
"Correctness: If the answer correctly answer the question, below "
|
|
"are the details for different scores: "
|
|
"- Score 0: the answer is completely incorrect, doesn't mention anything about "
|
|
"the question or is completely contrary to the correct answer. "
|
|
"- Score 1: the answer provides some relevance to the question and answer "
|
|
"one aspect of the question correctly. "
|
|
"- Score 2: the answer mostly answer the question but is missing or hallucinating "
|
|
"on one critical aspect. "
|
|
"- Score 4: the answer correctly answer the question and not missing any "
|
|
"major aspect"
|
|
),
|
|
examples=[],
|
|
version="v1",
|
|
model="openai:/gpt-4o-mini",
|
|
grading_context_columns=["ground_truth"],
|
|
parameters={"temperature": 0.0},
|
|
aggregations=["mean", "variance", "p90"],
|
|
greater_is_better=True,
|
|
)
|
|
|
|
with mock.patch.object(
|
|
model_utils,
|
|
"score_model_on_payload",
|
|
return_value=properly_formatted_openai_response1,
|
|
):
|
|
with mlflow.start_run():
|
|
eval_df = pd.DataFrame({
|
|
"inputs": [
|
|
"What is MLflow?",
|
|
"What is Spark?",
|
|
"What is Python?",
|
|
],
|
|
"ground_truth": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
"prediction": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
})
|
|
results = mlflow.evaluate(
|
|
data=eval_df,
|
|
evaluators="default",
|
|
targets="ground_truth",
|
|
predictions="prediction",
|
|
extra_metrics=[metric],
|
|
)
|
|
|
|
assert results.metrics == {
|
|
"correctness/v1/mean": 3.0,
|
|
"correctness/v1/variance": 0.0,
|
|
"correctness/v1/p90": 3.0,
|
|
}
|
|
|
|
|
|
def test_evaluate_custom_metrics_string_values():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["Hello world", "My name is MLflow"]})
|
|
results = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
extra_metrics=[
|
|
make_metric(
|
|
eval_fn=lambda predictions, metrics, eval_config: MetricValue(
|
|
aggregate_results={"eval_config_value_average": eval_config}
|
|
),
|
|
name="cm",
|
|
greater_is_better=True,
|
|
long_name="custom_metric",
|
|
)
|
|
],
|
|
evaluators="default",
|
|
evaluator_config={"eval_config": 3},
|
|
)
|
|
assert results.metrics["cm/eval_config_value_average"] == 3
|
|
|
|
|
|
def validate_retriever_logged_data(logged_data, k=3):
|
|
columns = {
|
|
"question",
|
|
"retrieved_context",
|
|
f"precision_at_{k}/score",
|
|
f"recall_at_{k}/score",
|
|
f"ndcg_at_{k}/score",
|
|
"ground_truth",
|
|
}
|
|
|
|
assert set(logged_data.columns.tolist()) == columns
|
|
|
|
assert logged_data["question"].tolist() == ["q1?", "q1?", "q1?"]
|
|
assert logged_data["retrieved_context"].tolist() == [["doc1", "doc3", "doc2"]] * 3
|
|
assert (logged_data[f"precision_at_{k}/score"] <= 1).all()
|
|
assert (logged_data[f"recall_at_{k}/score"] <= 1).all()
|
|
assert (logged_data[f"ndcg_at_{k}/score"] <= 1).all()
|
|
assert logged_data["ground_truth"].tolist() == [["doc1", "doc2"]] * 3
|
|
|
|
|
|
def test_evaluate_retriever():
|
|
X = pd.DataFrame({"question": ["q1?"] * 3, "ground_truth": [["doc1", "doc2"]] * 3})
|
|
|
|
def fn(X):
|
|
return pd.DataFrame({"retrieved_context": [["doc1", "doc3", "doc2"]] * len(X)})
|
|
|
|
with mlflow.start_run() as run:
|
|
results = mlflow.evaluate(
|
|
model=fn,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"k": 3,
|
|
},
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_3/mean": 2 / 3,
|
|
"precision_at_3/variance": 0,
|
|
"precision_at_3/p90": 2 / 3,
|
|
"recall_at_3/mean": 1.0,
|
|
"recall_at_3/p90": 1.0,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/p90": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/variance": 0.0,
|
|
}
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_retriever_logged_data(logged_data)
|
|
|
|
# test with a big k to ensure we use min(k, len(retrieved_chunks))
|
|
with mlflow.start_run() as run:
|
|
results = mlflow.evaluate(
|
|
model=fn,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"retriever_k": 6,
|
|
},
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_6/mean": 2 / 3,
|
|
"precision_at_6/variance": 0,
|
|
"precision_at_6/p90": 2 / 3,
|
|
"recall_at_6/mean": 1.0,
|
|
"recall_at_6/p90": 1.0,
|
|
"recall_at_6/variance": 0.0,
|
|
"ndcg_at_6/mean": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_6/p90": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_6/variance": 0.0,
|
|
}
|
|
|
|
# test with default k
|
|
with mlflow.start_run() as run:
|
|
results = mlflow.evaluate(
|
|
model=fn,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_3/mean": 2 / 3,
|
|
"precision_at_3/variance": 0,
|
|
"precision_at_3/p90": 2 / 3,
|
|
"recall_at_3/mean": 1.0,
|
|
"recall_at_3/p90": 1.0,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/p90": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/variance": 0.0,
|
|
}
|
|
|
|
# test with multiple chunks from same doc
|
|
def fn2(X):
|
|
return pd.DataFrame({"retrieved_context": [["doc1", "doc1", "doc3"]] * len(X)})
|
|
|
|
X = pd.DataFrame({"question": ["q1?"] * 3, "ground_truth": [["doc1", "doc3"]] * 3})
|
|
|
|
with mlflow.start_run() as run:
|
|
results = mlflow.evaluate(
|
|
model=fn2,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
evaluator_config={
|
|
"default": {
|
|
"retriever_k": 3,
|
|
}
|
|
},
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_3/mean": 1,
|
|
"precision_at_3/p90": 1,
|
|
"precision_at_3/variance": 0.0,
|
|
"recall_at_3/mean": 1.0,
|
|
"recall_at_3/p90": 1.0,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": 1.0,
|
|
"ndcg_at_3/p90": 1.0,
|
|
"ndcg_at_3/variance": 0.0,
|
|
}
|
|
|
|
# test with empty retrieved doc
|
|
def fn3(X):
|
|
return pd.DataFrame({"output": [[]] * len(X)})
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
model=fn3,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
evaluator_config={
|
|
"default": {
|
|
"retriever_k": 4,
|
|
}
|
|
},
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_4/mean": 0,
|
|
"precision_at_4/p90": 0,
|
|
"precision_at_4/variance": 0,
|
|
"recall_at_4/mean": 0,
|
|
"recall_at_4/p90": 0,
|
|
"recall_at_4/variance": 0,
|
|
"ndcg_at_4/mean": 0.0,
|
|
"ndcg_at_4/p90": 0.0,
|
|
"ndcg_at_4/variance": 0.0,
|
|
}
|
|
|
|
# test with a static dataset
|
|
X_1 = pd.DataFrame({
|
|
"question": [["q1?"]] * 3,
|
|
"targets_param": [["doc1", "doc2"]] * 3,
|
|
"predictions_param": [["doc1", "doc4", "doc5"]] * 3,
|
|
})
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
data=X_1,
|
|
predictions="predictions_param",
|
|
targets="targets_param",
|
|
model_type="retriever",
|
|
extra_metrics=[mlflow.metrics.precision_at_k(4), mlflow.metrics.recall_at_k(4)],
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_3/mean": 1 / 3,
|
|
"precision_at_3/p90": 1 / 3,
|
|
"precision_at_3/variance": 0.0,
|
|
"recall_at_3/mean": 0.5,
|
|
"recall_at_3/p90": 0.5,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": pytest.approx(0.6131471927654585),
|
|
"ndcg_at_3/p90": pytest.approx(0.6131471927654585),
|
|
"ndcg_at_3/variance": 0.0,
|
|
"precision_at_4/mean": 1 / 3,
|
|
"precision_at_4/p90": 1 / 3,
|
|
"precision_at_4/variance": 0.0,
|
|
"recall_at_4/mean": 0.5,
|
|
"recall_at_4/p90": 0.5,
|
|
"recall_at_4/variance": 0.0,
|
|
}
|
|
|
|
# test to make sure it silently fails with invalid k
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
data=X_1,
|
|
predictions="predictions_param",
|
|
targets="targets_param",
|
|
model_type="retriever",
|
|
extra_metrics=[mlflow.metrics.precision_at_k(-1)],
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {
|
|
"precision_at_3/mean": 1 / 3,
|
|
"precision_at_3/p90": 1 / 3,
|
|
"precision_at_3/variance": 0.0,
|
|
"recall_at_3/mean": 0.5,
|
|
"recall_at_3/p90": 0.5,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": pytest.approx(0.6131471927654585),
|
|
"ndcg_at_3/p90": pytest.approx(0.6131471927654585),
|
|
"ndcg_at_3/variance": 0.0,
|
|
}
|
|
|
|
# silent failure with evaluator_config method too!
|
|
with mlflow.start_run() as run:
|
|
mlflow.evaluate(
|
|
data=X_1,
|
|
predictions="predictions_param",
|
|
targets="targets_param",
|
|
model_type="retriever",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"retriever_k": -1,
|
|
},
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert run.data.metrics == {}
|
|
|
|
|
|
def test_evaluate_retriever_builtin_metrics_no_model_type():
|
|
X = pd.DataFrame({"question": ["q1?"] * 3, "ground_truth": [["doc1", "doc2"]] * 3})
|
|
|
|
def fn(X):
|
|
return {"retrieved_context": [["doc1", "doc3", "doc2"]] * len(X)}
|
|
|
|
with mlflow.start_run() as run:
|
|
results = mlflow.evaluate(
|
|
model=fn,
|
|
data=X,
|
|
targets="ground_truth",
|
|
extra_metrics=[
|
|
mlflow.metrics.precision_at_k(4),
|
|
mlflow.metrics.recall_at_k(4),
|
|
mlflow.metrics.ndcg_at_k(4),
|
|
],
|
|
)
|
|
run = mlflow.get_run(run.info.run_id)
|
|
assert (
|
|
run.data.metrics
|
|
== results.metrics
|
|
== {
|
|
"precision_at_4/mean": 2 / 3,
|
|
"precision_at_4/p90": 2 / 3,
|
|
"precision_at_4/variance": 0.0,
|
|
"recall_at_4/mean": 1.0,
|
|
"recall_at_4/p90": 1.0,
|
|
"recall_at_4/variance": 0.0,
|
|
"ndcg_at_4/mean": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_4/p90": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_4/variance": 0.0,
|
|
}
|
|
)
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert "eval_results_table.json" in artifacts
|
|
logged_data = pd.DataFrame(**results.artifacts["eval_results_table"].content)
|
|
validate_retriever_logged_data(logged_data, 4)
|
|
|
|
|
|
def test_evaluate_retriever_with_numpy_array_values():
|
|
X = pd.DataFrame({"question": ["q1?"] * 3, "ground_truth": [np.array(["doc1", "doc2"])] * 3})
|
|
|
|
def fn(X):
|
|
return pd.DataFrame({"retrieved_context": [np.array(["doc1", "doc3", "doc2"])] * len(X)})
|
|
|
|
with mlflow.start_run():
|
|
results = mlflow.evaluate(
|
|
model=fn,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"k": 3,
|
|
},
|
|
)
|
|
assert results.metrics == {
|
|
"precision_at_3/mean": 2 / 3,
|
|
"precision_at_3/p90": 2 / 3,
|
|
"precision_at_3/variance": 0.0,
|
|
"recall_at_3/mean": 1.0,
|
|
"recall_at_3/p90": 1.0,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/p90": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/variance": 0.0,
|
|
}
|
|
|
|
|
|
def test_evaluate_retriever_with_ints():
|
|
X = pd.DataFrame({"question": ["q1?"] * 3, "ground_truth": [[1, 2]] * 3})
|
|
|
|
def fn(X):
|
|
return pd.DataFrame({"retrieved_context": [np.array([1, 3, 2])] * len(X)})
|
|
|
|
with mlflow.start_run():
|
|
results = mlflow.evaluate(
|
|
model=fn,
|
|
data=X,
|
|
targets="ground_truth",
|
|
model_type="retriever",
|
|
evaluators="default",
|
|
evaluator_config={
|
|
"k": 3,
|
|
},
|
|
)
|
|
assert results.metrics == {
|
|
"precision_at_3/mean": 2 / 3,
|
|
"precision_at_3/p90": 2 / 3,
|
|
"precision_at_3/variance": 0.0,
|
|
"recall_at_3/mean": 1.0,
|
|
"recall_at_3/p90": 1.0,
|
|
"recall_at_3/variance": 0.0,
|
|
"ndcg_at_3/mean": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/p90": pytest.approx(0.9197207891481877),
|
|
"ndcg_at_3/variance": 0.0,
|
|
}
|
|
|
|
|
|
def test_evaluate_with_numpy_array():
|
|
data = [
|
|
["What is MLflow?"],
|
|
]
|
|
ground_truth = [
|
|
"MLflow is an open-source platform for managing the end-to-end machine learning",
|
|
]
|
|
|
|
with mlflow.start_run():
|
|
logged_model = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
results = mlflow.evaluate(
|
|
logged_model.model_uri,
|
|
data,
|
|
targets=ground_truth,
|
|
extra_metrics=[mlflow.metrics.toxicity()],
|
|
)
|
|
|
|
assert results.metrics.keys() == {
|
|
"toxicity/v1/mean",
|
|
"toxicity/v1/variance",
|
|
"toxicity/v1/p90",
|
|
"toxicity/v1/ratio",
|
|
}
|
|
assert len(results.tables) == 1
|
|
assert results.tables["eval_results_table"].columns.tolist() == [
|
|
"feature_1",
|
|
"target",
|
|
"outputs",
|
|
"toxicity/v1/score",
|
|
]
|
|
|
|
|
|
def test_target_prediction_col_mapping():
|
|
metric = mlflow.metrics.genai.make_genai_metric(
|
|
name="correctness",
|
|
definition=(
|
|
"Correctness refers to how well the generated output matches "
|
|
"or aligns with the reference or ground truth text that is considered "
|
|
"accurate and appropriate for the given input. The ground truth serves as "
|
|
"a benchmark against which the provided output is compared to determine the "
|
|
"level of accuracy and fidelity."
|
|
),
|
|
grading_prompt=(
|
|
"Correctness: If the answer correctly answer the question, below "
|
|
"are the details for different scores: "
|
|
"- Score 0: the answer is completely incorrect, doesn't mention anything about "
|
|
"the question or is completely contrary to the correct answer. "
|
|
"- Score 1: the answer provides some relevance to the question and answer "
|
|
"one aspect of the question correctly. "
|
|
"- Score 2: the answer mostly answer the question but is missing or hallucinating "
|
|
"on one critical aspect. "
|
|
"- Score 3: the answer correctly answer the question and not missing any "
|
|
"major aspect"
|
|
),
|
|
examples=[],
|
|
version="v1",
|
|
model="openai:/gpt-4",
|
|
grading_context_columns=["renamed_ground_truth"],
|
|
parameters={"temperature": 0.0},
|
|
aggregations=["mean", "variance", "p90"],
|
|
greater_is_better=True,
|
|
)
|
|
|
|
with mock.patch.object(
|
|
model_utils,
|
|
"score_model_on_payload",
|
|
return_value=properly_formatted_openai_response1,
|
|
):
|
|
with mlflow.start_run():
|
|
eval_df = pd.DataFrame({
|
|
"inputs": [
|
|
"What is MLflow?",
|
|
"What is Spark?",
|
|
"What is Python?",
|
|
],
|
|
"ground_truth": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
"prediction": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
})
|
|
results = mlflow.evaluate(
|
|
data=eval_df,
|
|
evaluators="default",
|
|
targets="renamed_ground_truth",
|
|
predictions="prediction",
|
|
extra_metrics=[metric],
|
|
evaluator_config={"col_mapping": {"renamed_ground_truth": "ground_truth"}},
|
|
)
|
|
|
|
assert results.metrics == {
|
|
"correctness/v1/mean": 3.0,
|
|
"correctness/v1/variance": 0.0,
|
|
"correctness/v1/p90": 3.0,
|
|
}
|
|
|
|
|
|
def test_precanned_metrics_work():
|
|
metric = mlflow.metrics.rouge1()
|
|
with mlflow.start_run():
|
|
eval_df = pd.DataFrame({
|
|
"inputs": [
|
|
"What is MLflow?",
|
|
"What is Spark?",
|
|
"What is Python?",
|
|
],
|
|
"ground_truth": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
"prediction": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
})
|
|
|
|
results = mlflow.evaluate(
|
|
data=eval_df,
|
|
evaluators="default",
|
|
predictions="prediction",
|
|
extra_metrics=[metric],
|
|
evaluator_config={
|
|
"col_mapping": {
|
|
"targets": "ground_truth",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert results.metrics == {
|
|
"rouge1/v1/mean": 1.0,
|
|
"rouge1/v1/variance": 0.0,
|
|
"rouge1/v1/p90": 1.0,
|
|
}
|
|
|
|
|
|
def test_precanned_bleu_metrics_work():
|
|
metric = mlflow.metrics.bleu()
|
|
with mlflow.start_run():
|
|
eval_df = pd.DataFrame({
|
|
"inputs": [
|
|
"What is MLflow?",
|
|
"What is Spark?",
|
|
"What is Python?",
|
|
],
|
|
"ground_truth": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
"prediction": [
|
|
"MLflow is an open-source platform",
|
|
"Apache Spark is an open-source, distributed computing system",
|
|
"Python is a high-level programming language",
|
|
],
|
|
})
|
|
|
|
results = mlflow.evaluate(
|
|
data=eval_df,
|
|
evaluators="default",
|
|
predictions="prediction",
|
|
extra_metrics=[metric],
|
|
evaluator_config={
|
|
"col_mapping": {
|
|
"targets": "ground_truth",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert results.metrics == {
|
|
"bleu/v1/mean": 1.0,
|
|
"bleu/v1/variance": 0.0,
|
|
"bleu/v1/p90": 1.0,
|
|
}
|
|
|
|
|
|
def test_evaluate_custom_metric_with_string_type():
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a", "b"]
|
|
)
|
|
data = pd.DataFrame({"text": ["Hello world", "My name is MLflow"]})
|
|
from mlflow.metrics import make_metric
|
|
|
|
def word_count_eval(predictions):
|
|
scores = []
|
|
avg = 0
|
|
aggregate_results = {}
|
|
for prediction in predictions:
|
|
scores.append(prediction)
|
|
avg += len(prediction.split(" "))
|
|
|
|
avg /= len(predictions)
|
|
aggregate_results["avg_len"] = avg
|
|
|
|
return MetricValue(
|
|
scores=scores,
|
|
aggregate_results=aggregate_results,
|
|
)
|
|
|
|
word_count = make_metric(eval_fn=word_count_eval, greater_is_better=True, name="word_count")
|
|
|
|
results = mlflow.evaluate(model_info.model_uri, data, extra_metrics=[word_count])
|
|
assert results.metrics["word_count/avg_len"] == 3.0
|
|
assert len(results.tables) == 1
|
|
assert results.tables["eval_results_table"].columns.tolist() == [
|
|
"text",
|
|
"outputs",
|
|
"word_count/score",
|
|
]
|
|
pd.testing.assert_series_equal(
|
|
results.tables["eval_results_table"]["word_count/score"],
|
|
data["text"],
|
|
check_names=False,
|
|
)
|
|
|
|
|
|
def test_do_not_log_built_in_metrics_as_artifacts():
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a"]
|
|
)
|
|
data = pd.DataFrame({
|
|
"inputs": ["words random", "This is a sentence."],
|
|
"ground_truth": ["words random", "This is a sentence."],
|
|
})
|
|
evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="ground_truth",
|
|
predictions="answer",
|
|
model_type="question-answering",
|
|
evaluators="default",
|
|
extra_metrics=[
|
|
toxicity(),
|
|
flesch_kincaid_grade_level(),
|
|
],
|
|
)
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert _GENAI_CUSTOM_METRICS_FILE_NAME not in artifacts
|
|
|
|
results = retrieve_custom_metrics(run_id=run.info.run_id)
|
|
assert len(results) == 0
|
|
|
|
|
|
def test_log_genai_custom_metrics_as_artifacts(monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a"]
|
|
)
|
|
data = pd.DataFrame({
|
|
"inputs": ["words random", "This is a sentence."],
|
|
"ground_truth": ["words random", "This is a sentence."],
|
|
})
|
|
example = EvaluationExample(
|
|
input="What is MLflow?",
|
|
output="MLflow is an open-source platform for managing machine learning workflows.",
|
|
score=4,
|
|
justification="test",
|
|
grading_context={"targets": "test"},
|
|
)
|
|
# This simulates the code path for metrics created from make_genai_metric
|
|
answer_similarity_metric = answer_similarity(
|
|
model="gateway:/gpt-4o-mini", examples=[example]
|
|
)
|
|
another_custom_metric = make_genai_metric_from_prompt(
|
|
name="another custom llm judge",
|
|
judge_prompt="This is another custom judge prompt.",
|
|
greater_is_better=False,
|
|
parameters={"temperature": 0.0},
|
|
)
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="ground_truth",
|
|
predictions="answer",
|
|
model_type="question-answering",
|
|
evaluators="default",
|
|
extra_metrics=[
|
|
answer_similarity_metric,
|
|
another_custom_metric,
|
|
],
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert _GENAI_CUSTOM_METRICS_FILE_NAME in artifacts
|
|
|
|
table = result.tables[os.path.splitext(_GENAI_CUSTOM_METRICS_FILE_NAME)[0]]
|
|
assert table.loc[0, "name"] == "answer_similarity"
|
|
assert table.loc[0, "version"] == "v1"
|
|
assert table.loc[1, "name"] == "another custom llm judge"
|
|
assert table.loc[1, "version"] == ""
|
|
assert table["version"].dtype == "object"
|
|
|
|
results = retrieve_custom_metrics(run.info.run_id)
|
|
assert len(results) == 2
|
|
assert [r.name for r in results] == ["answer_similarity", "another custom llm judge"]
|
|
|
|
results = retrieve_custom_metrics(run_id=run.info.run_id, name="another custom llm judge")
|
|
assert len(results) == 1
|
|
assert results[0].name == "another custom llm judge"
|
|
|
|
results = retrieve_custom_metrics(run_id=run.info.run_id, version="v1")
|
|
assert len(results) == 1
|
|
assert results[0].name == "answer_similarity"
|
|
|
|
results = retrieve_custom_metrics(
|
|
run_id=run.info.run_id, name="answer_similarity", version="v1"
|
|
)
|
|
assert len(results) == 1
|
|
assert results[0].name == "answer_similarity"
|
|
|
|
results = retrieve_custom_metrics(run_id=run.info.run_id, name="do not match")
|
|
assert len(results) == 0
|
|
|
|
results = retrieve_custom_metrics(run_id=run.info.run_id, version="do not match")
|
|
assert len(results) == 0
|
|
|
|
|
|
def test_all_genai_custom_metrics_are_from_user_prompt(monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
|
with mlflow.start_run() as run:
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model", python_model=language_model, input_example=["a"]
|
|
)
|
|
data = pd.DataFrame({
|
|
"inputs": ["words random", "This is a sentence."],
|
|
"ground_truth": ["words random", "This is a sentence."],
|
|
"custom_column": ["test", "test"],
|
|
})
|
|
custom_metric = make_genai_metric_from_prompt(
|
|
name="custom llm judge",
|
|
judge_prompt="This is a custom judge prompt. {custom_column}.",
|
|
greater_is_better=False,
|
|
parameters={"temperature": 0.0},
|
|
)
|
|
another_custom_metric = make_genai_metric_from_prompt(
|
|
name="another custom llm judge",
|
|
judge_prompt="This is another custom judge prompt. {custom_column}.",
|
|
greater_is_better=False,
|
|
parameters={"temperature": 0.7},
|
|
)
|
|
result = evaluate(
|
|
model_info.model_uri,
|
|
data,
|
|
targets="ground_truth",
|
|
predictions="answer",
|
|
model_type="question-answering",
|
|
evaluators="default",
|
|
extra_metrics=[
|
|
custom_metric,
|
|
another_custom_metric,
|
|
],
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert _GENAI_CUSTOM_METRICS_FILE_NAME in artifacts
|
|
|
|
table = result.tables[os.path.splitext(_GENAI_CUSTOM_METRICS_FILE_NAME)[0]]
|
|
assert table.loc[0, "name"] == "custom llm judge"
|
|
assert table.loc[1, "name"] == "another custom llm judge"
|
|
assert table.loc[0, "version"] == ""
|
|
assert table.loc[1, "version"] == ""
|
|
assert table["version"].dtype == "object"
|
|
|
|
|
|
def test_xgboost_model_evaluate_work_with_shap_explainer():
|
|
import shap
|
|
import xgboost
|
|
from sklearn.model_selection import train_test_split
|
|
|
|
mlflow.xgboost.autolog(log_input_examples=True)
|
|
X, y = shap.datasets.adult()
|
|
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42)
|
|
|
|
xgb_model = xgboost.XGBClassifier()
|
|
with mlflow.start_run() as run:
|
|
xgb_model.fit(X_train, y_train)
|
|
|
|
logged_models = mlflow.search_logged_models(
|
|
filter_string=f"source_run_id='{run.info.run_id}'", output_format="list"
|
|
)
|
|
model_uri = logged_models[0].model_uri
|
|
eval_data = X_test
|
|
eval_data["label"] = y_test
|
|
|
|
with mock.patch("mlflow.models.evaluation.evaluators.shap._logger.warning") as mock_warning:
|
|
mlflow.evaluate(
|
|
model_uri,
|
|
eval_data,
|
|
targets="label",
|
|
model_type="classifier",
|
|
evaluators=["default"],
|
|
)
|
|
assert not any(
|
|
"Shap evaluation failed." in call_arg[0]
|
|
for call_arg in mock_warning.call_args or []
|
|
if isinstance(call_arg, tuple)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"evaluator_config",
|
|
[
|
|
None,
|
|
{"default": {"pos_label": 1}},
|
|
{"default": {"label_list": [0, 1]}},
|
|
{"default": {"label_list": [0, 1], "pos_label": 1}},
|
|
],
|
|
)
|
|
def test_evaluate_binary_classifier_calculate_label_list_correctly(evaluator_config):
|
|
data = pd.DataFrame({"target": [0, 0, 1, 0], "prediction": [0, 1, 0, 0]})
|
|
|
|
result = mlflow.evaluate(
|
|
data=data,
|
|
model_type="classifier",
|
|
targets="target",
|
|
predictions="prediction",
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
metrics_set = {
|
|
"true_negatives",
|
|
"false_positives",
|
|
"false_negatives",
|
|
"true_positives",
|
|
"example_count",
|
|
"accuracy_score",
|
|
"recall_score",
|
|
"precision_score",
|
|
"f1_score",
|
|
}
|
|
assert metrics_set.issubset(result.metrics)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("evaluator_config", "data"),
|
|
[
|
|
(None, {"target": [1, 0, 1, 1], "prediction": [1, 2, 0, 0]}),
|
|
(
|
|
{"default": {"label_list": [0, 1, 2]}},
|
|
{"target": [1, 0, 1, 1], "prediction": [1, 2, 0, 0]},
|
|
),
|
|
(
|
|
{"default": {"label_list": [0, 1, 2], "pos_label": 1}},
|
|
{"target": [0, 0, 0, 0], "prediction": [0, 0, 0, 0]},
|
|
),
|
|
],
|
|
)
|
|
def test_evaluate_multi_classifier_calculate_label_list_correctly(
|
|
evaluator_config, data, monkeypatch
|
|
):
|
|
monkeypatch.setenv("_MLFLOW_EVALUATE_SUPPRESS_CLASSIFICATION_ERRORS", "true")
|
|
result = mlflow.evaluate(
|
|
data=pd.DataFrame(data),
|
|
model_type="classifier",
|
|
targets="target",
|
|
predictions="prediction",
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
metrics_set = {
|
|
"example_count",
|
|
"accuracy_score",
|
|
"recall_score",
|
|
"precision_score",
|
|
"f1_score",
|
|
}
|
|
assert metrics_set.issubset(result.metrics)
|
|
assert {"true_negatives", "false_positives", "false_negatives", "true_positives"}.isdisjoint(
|
|
result.metrics
|
|
)
|
|
|
|
|
|
def test_evaluate_errors_invalid_pos_label():
|
|
data = pd.DataFrame({"target": [0, 0, 1, 0], "prediction": [0, 1, 0, 0]})
|
|
with pytest.raises(MlflowException, match=r"'pos_label' 1 must exist in 'label_list'"):
|
|
mlflow.evaluate(
|
|
data=data,
|
|
model_type="classifier",
|
|
targets="target",
|
|
predictions="prediction",
|
|
evaluator_config={"default": {"pos_label": 1, "label_list": [0]}},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_output", "predictions"),
|
|
[
|
|
(pd.DataFrame({"output": [0, 1, 2]}), None),
|
|
(pd.DataFrame({"output_1": [0, 1, 2], "output_2": [4, 5, 6]}), "output_1"),
|
|
(pd.Series([0, 1, 2]), None),
|
|
],
|
|
)
|
|
def test_regressor_returning_pandas_object(model_output, predictions):
|
|
class Model(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input):
|
|
return model_output
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(name="model", python_model=Model())
|
|
result = mlflow.evaluate(
|
|
model_info.model_uri,
|
|
data=pd.DataFrame({
|
|
"input": [0, 1, 2],
|
|
"output": [0, 1, 2],
|
|
}),
|
|
targets="output",
|
|
model_type="regressor",
|
|
predictions=predictions,
|
|
evaluators=["regressor"],
|
|
)
|
|
assert result.metrics == {
|
|
"example_count": 3,
|
|
"max_error": 0,
|
|
"mean_absolute_error": 0.0,
|
|
"mean_absolute_percentage_error": 0.0,
|
|
"mean_on_target": 1.0,
|
|
"mean_squared_error": 0.0,
|
|
"r2_score": 1.0,
|
|
"root_mean_squared_error": 0.0,
|
|
"sum_on_target": 3,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"data",
|
|
"evaluator_config",
|
|
"expected_metrics",
|
|
"expected_artifacts",
|
|
"description",
|
|
),
|
|
[
|
|
# Binary classification with single class data + explicit labels
|
|
(
|
|
pd.DataFrame({"target": [0, 0, 0, 0], "prediction": [0, 0, 0, 0]}),
|
|
{"label_list": [0, 1]},
|
|
{
|
|
"accuracy_score": 1.0,
|
|
"true_negatives": 4,
|
|
"false_positives": 0,
|
|
"false_negatives": 0,
|
|
"true_positives": 0,
|
|
},
|
|
{"confusion_matrix": True},
|
|
"single_class_with_explicit_labels",
|
|
),
|
|
# Normal binary classification
|
|
(
|
|
pd.DataFrame({"target": [0, 1, 0, 1], "prediction": [0, 1, 1, 0]}),
|
|
{},
|
|
{
|
|
"accuracy_score": 0.5,
|
|
"true_negatives": 1,
|
|
"false_positives": 1,
|
|
"false_negatives": 1,
|
|
"true_positives": 1,
|
|
},
|
|
{"confusion_matrix": True},
|
|
"binary_classification",
|
|
),
|
|
# Multiclass with string labels
|
|
(
|
|
pd.DataFrame({
|
|
"target": ["cat", "dog", "bird", "cat", "dog", "bird"],
|
|
"prediction": ["cat", "dog", "cat", "dog", "bird", "bird"],
|
|
}),
|
|
{},
|
|
{"accuracy_score": 0.5},
|
|
{"per_class_metrics": True, "confusion_matrix": True},
|
|
"multiclass_string_labels",
|
|
),
|
|
# Multiclass with missing class in data
|
|
(
|
|
pd.DataFrame({
|
|
"target": ["cat", "dog", "cat", "dog"],
|
|
"prediction": ["cat", "dog", "dog", "cat"],
|
|
}),
|
|
{"label_list": ["cat", "dog", "bird"]},
|
|
{"accuracy_score": 0.5},
|
|
{"per_class_metrics": True},
|
|
"multiclass_missing_class",
|
|
),
|
|
# Multiclass with numeric labels
|
|
(
|
|
pd.DataFrame({
|
|
"target": [0, 1, 2, 0, 1],
|
|
"prediction": [0, 1, 1, 2, 1],
|
|
}),
|
|
{"label_list": [0, 1, 2]},
|
|
{"accuracy_score": 0.6},
|
|
{"per_class_metrics": True},
|
|
"multiclass_numeric_labels",
|
|
),
|
|
# Auto-inferred binary with string labels
|
|
(
|
|
pd.DataFrame({"target": ["x", "y", "x", "y"], "prediction": ["x", "y", "y", "x"]}),
|
|
{},
|
|
{"accuracy_score": 0.5},
|
|
{"confusion_matrix": True},
|
|
"binary_auto_inferred_strings",
|
|
),
|
|
],
|
|
)
|
|
def test_classifier_evaluation_scenarios(
|
|
data, evaluator_config, expected_metrics, expected_artifacts, description
|
|
):
|
|
result = mlflow.evaluate(
|
|
data=data,
|
|
targets="target",
|
|
predictions="prediction",
|
|
model_type="classifier",
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
|
|
# Verify evaluation completed successfully
|
|
assert result is not None
|
|
assert "accuracy_score" in result.metrics
|
|
|
|
# Check specific expected metrics
|
|
for metric_name, expected_value in expected_metrics.items():
|
|
if isinstance(expected_value, float):
|
|
assert abs(result.metrics[metric_name] - expected_value) < 1e-6, (
|
|
f"Metric {metric_name} mismatch"
|
|
)
|
|
else:
|
|
assert result.metrics[metric_name] == expected_value, f"Metric {metric_name} mismatch"
|
|
|
|
# Check expected artifacts
|
|
for artifact_name, should_exist in expected_artifacts.items():
|
|
if should_exist:
|
|
assert artifact_name in result.artifacts, f"Missing artifact: {artifact_name}"
|
|
|
|
# Special validations for per-class metrics
|
|
if "per_class_metrics" in expected_artifacts:
|
|
per_class_df = result.artifacts["per_class_metrics"].content
|
|
# Verify structure
|
|
assert "positive_class" in per_class_df.columns
|
|
required_columns = {
|
|
"true_negatives",
|
|
"false_positives",
|
|
"false_negatives",
|
|
"true_positives",
|
|
}
|
|
assert required_columns.issubset(set(per_class_df.columns))
|
|
|
|
# Verify consistency: each row should sum to total number of samples
|
|
for _, row in per_class_df.iterrows():
|
|
total = sum(row[col] for col in required_columns)
|
|
assert total == len(data), (
|
|
f"Confusion matrix sum mismatch for class {row['positive_class']}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"data",
|
|
"evaluator_config",
|
|
"expected_error",
|
|
"error_message_pattern",
|
|
"description",
|
|
),
|
|
[
|
|
# Single class without explicit labels
|
|
(
|
|
pd.DataFrame({"target": [0, 0, 0, 0], "prediction": [0, 0, 0, 0]}),
|
|
{},
|
|
MlflowException,
|
|
(
|
|
"Evaluation dataset for classification must contain at least two unique "
|
|
"labels, but only 1 unique labels were found\\."
|
|
),
|
|
"single_class_no_labels",
|
|
),
|
|
# Invalid pos_label
|
|
(
|
|
pd.DataFrame({"target": [0, 1, 0, 1], "prediction": [0, 1, 1, 0]}),
|
|
{"label_list": [0, 1], "pos_label": 2},
|
|
MlflowException,
|
|
"'pos_label' 2 must exist in 'label_list'",
|
|
"invalid_pos_label",
|
|
),
|
|
# Single element label_list
|
|
(
|
|
pd.DataFrame({"target": [1, 1, 1, 1], "prediction": [1, 1, 1, 1]}),
|
|
{"label_list": [1]},
|
|
MlflowException,
|
|
(
|
|
"Evaluation dataset for classification must contain at least two unique "
|
|
"labels, but only 1 unique labels were found\\."
|
|
),
|
|
"single_element_label_list",
|
|
),
|
|
# Empty label_list
|
|
(
|
|
pd.DataFrame({"target": [0, 1, 0, 1], "prediction": [0, 1, 1, 0]}),
|
|
{"label_list": []},
|
|
MlflowException,
|
|
(
|
|
"Evaluation dataset for classification must contain at least two unique "
|
|
"labels, but only 0 unique labels were found\\."
|
|
),
|
|
"empty_label_list",
|
|
),
|
|
],
|
|
)
|
|
def test_classifier_evaluation_error_conditions(
|
|
data, evaluator_config, expected_error, error_message_pattern, description
|
|
):
|
|
with pytest.raises(expected_error, match=error_message_pattern):
|
|
mlflow.evaluate(
|
|
data=data,
|
|
targets="target",
|
|
predictions="prediction",
|
|
model_type="classifier",
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"data",
|
|
"evaluator_config",
|
|
"expected_binary_metrics",
|
|
"expected_classes",
|
|
"description",
|
|
),
|
|
[
|
|
# Binary with explicit labels and pos_label
|
|
(
|
|
pd.DataFrame({"target": [0, 1, 0, 1], "prediction": [0, 1, 1, 0]}),
|
|
{"label_list": [0, 1], "pos_label": 1},
|
|
True, # Should have binary metrics
|
|
2, # Two classes
|
|
"binary_explicit_pos_label",
|
|
),
|
|
# Multiclass (3 classes)
|
|
(
|
|
pd.DataFrame({"target": [0, 1, 2, 0, 1], "prediction": [0, 1, 1, 2, 1]}),
|
|
{"label_list": [0, 1, 2]},
|
|
False, # Should NOT have binary metrics
|
|
3, # Three classes
|
|
"multiclass_three_classes",
|
|
),
|
|
# Auto-inferred binary
|
|
(
|
|
pd.DataFrame({"target": ["x", "y", "x", "y"], "prediction": ["x", "y", "y", "x"]}),
|
|
{},
|
|
True, # Should have binary metrics (auto-inferred)
|
|
2, # Two classes
|
|
"binary_auto_inferred",
|
|
),
|
|
],
|
|
)
|
|
def test_label_validation_and_classification_type(
|
|
data, evaluator_config, expected_binary_metrics, expected_classes, description
|
|
):
|
|
result = mlflow.evaluate(
|
|
data=data,
|
|
targets="target",
|
|
predictions="prediction",
|
|
model_type="classifier",
|
|
evaluator_config=evaluator_config,
|
|
)
|
|
|
|
assert result is not None
|
|
assert "accuracy_score" in result.metrics
|
|
|
|
# Check if binary metrics are present based on classification type
|
|
binary_metric_names = {
|
|
"true_negatives",
|
|
"false_positives",
|
|
"false_negatives",
|
|
"true_positives",
|
|
}
|
|
has_binary_metrics = all(metric in result.metrics for metric in binary_metric_names)
|
|
|
|
assert has_binary_metrics == expected_binary_metrics, (
|
|
f"Binary metrics presence mismatch for {description}"
|
|
)
|
|
|
|
# For multiclass, check per-class metrics
|
|
if not expected_binary_metrics:
|
|
assert "per_class_metrics" in result.artifacts
|
|
per_class_df = result.artifacts["per_class_metrics"].content
|
|
assert len(per_class_df) == expected_classes
|
|
|
|
|
|
def test_multiclass_per_class_metrics_with_missing_class_failure():
|
|
"""
|
|
Critical test demonstrating why labels=[0,1] is essential in per-class metrics.
|
|
|
|
This test validates that the hardcoded labels=[0,1] in per-class metrics calculation
|
|
prevents crashes when classes are missing from evaluation data.
|
|
"""
|
|
# Create multiclass data where class 'C' is completely missing from evaluation
|
|
data = pd.DataFrame({
|
|
"target": ["A", "B", "A", "A", "B", "A", "B", "A"], # Only A and B present
|
|
"prediction": ["A", "B", "A", "A", "B", "A", "B", "A"], # Only A and B predicted
|
|
})
|
|
|
|
# Model was trained on A, B, C but evaluation data missing C
|
|
label_list = ["A", "B", "C"] # C missing from actual data!
|
|
|
|
# This should work with proper labels=[0,1] hardcoding
|
|
result = mlflow.evaluate(
|
|
data=data,
|
|
targets="target",
|
|
predictions="prediction",
|
|
model_type="classifier",
|
|
evaluator_config={"label_list": label_list},
|
|
)
|
|
|
|
# Verify the evaluation completed successfully
|
|
assert result is not None
|
|
assert "per_class_metrics" in result.artifacts
|
|
|
|
# Check that per-class metrics were computed for all classes
|
|
per_class_df = result.artifacts["per_class_metrics"].content
|
|
assert len(per_class_df) == 3 # Should have metrics for A, B, C
|
|
assert set(per_class_df["positive_class"]) == {"A", "B", "C"}
|
|
|
|
# Verify class C has proper zero metrics (since it's missing from data)
|
|
class_c_metrics = per_class_df[per_class_df["positive_class"] == "C"].iloc[0]
|
|
assert class_c_metrics["true_negatives"] == 8 # All samples are negative for C
|
|
assert class_c_metrics["false_positives"] == 0 # No false positives
|
|
assert class_c_metrics["false_negatives"] == 0 # No false negatives
|
|
assert class_c_metrics["true_positives"] == 0 # No true positives
|