985 lines
41 KiB
Python
985 lines
41 KiB
Python
import copy
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import pathlib
|
|
import pickle
|
|
import shutil
|
|
import tempfile
|
|
import traceback
|
|
from abc import abstractmethod
|
|
from typing import Any, Callable, NamedTuple, Optional
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
import mlflow
|
|
from mlflow import MlflowClient, MlflowException
|
|
from mlflow.data.evaluation_dataset import EvaluationDataset
|
|
from mlflow.entities.metric import Metric
|
|
from mlflow.metrics.base import MetricValue
|
|
from mlflow.models.evaluation.artifacts import (
|
|
CsvEvaluationArtifact,
|
|
ImageEvaluationArtifact,
|
|
JsonEvaluationArtifact,
|
|
NumpyEvaluationArtifact,
|
|
_infer_artifact_type_and_ext,
|
|
)
|
|
from mlflow.models.evaluation.base import EvaluationMetric, EvaluationResult, ModelEvaluator
|
|
from mlflow.models.evaluation.utils.metric import MetricDefinition
|
|
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
|
|
from mlflow.pyfunc import _ServedPyFuncModel
|
|
from mlflow.utils.file_utils import TempDir
|
|
from mlflow.utils.proto_json_utils import NumpyEncoder
|
|
from mlflow.utils.time import get_current_time_millis
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_EVAL_TABLE_FILE_NAME = "eval_results_table.json"
|
|
_TOKEN_COUNT_METRIC_NAME = "token_count"
|
|
_LATENCY_METRIC_NAME = "latency"
|
|
|
|
|
|
def _extract_raw_model(model):
|
|
if not getattr(model, "metadata", None):
|
|
return None, None
|
|
|
|
model_loader_module = model.metadata.flavors["python_function"]["loader_module"]
|
|
# If we load a model with mlflow.pyfunc.load_model, the model will be wrapped
|
|
# with a pyfunc wrapper. We need to extract the raw model so that shap
|
|
# explainer uses the raw model instead of the wrapper and skips data schema validation.
|
|
if model_loader_module in [
|
|
"mlflow.catboost",
|
|
"mlflow.sklearn",
|
|
"mlflow.xgboost",
|
|
] and not isinstance(model, _ServedPyFuncModel):
|
|
if hasattr(model._model_impl, "get_raw_model"):
|
|
return model_loader_module, model._model_impl.get_raw_model()
|
|
return model_loader_module, model._model_impl
|
|
else:
|
|
return model_loader_module, None
|
|
|
|
|
|
def _extract_output_and_other_columns(
|
|
model_predictions: list[Any] | dict[str, Any] | pd.DataFrame | pd.Series,
|
|
output_column_name: str | None,
|
|
) -> tuple[pd.Series, pd.DataFrame | None, str]:
|
|
y_pred = None
|
|
other_output_columns = None
|
|
ERROR_MISSING_OUTPUT_COLUMN_NAME = (
|
|
"Output column name is not specified for the multi-output model. "
|
|
"Please set the correct output column name using the `predictions` parameter."
|
|
)
|
|
|
|
if isinstance(model_predictions, list) and all(isinstance(p, dict) for p in model_predictions):
|
|
# Extract 'y_pred' and 'other_output_columns' from list of dictionaries
|
|
if output_column_name in model_predictions[0]:
|
|
y_pred = pd.Series(
|
|
[p.get(output_column_name) for p in model_predictions], name=output_column_name
|
|
)
|
|
other_output_columns = pd.DataFrame([
|
|
{k: v for k, v in p.items() if k != output_column_name} for p in model_predictions
|
|
])
|
|
elif len(model_predictions[0]) == 1:
|
|
# Set the only key as self.predictions and its value as self.y_pred
|
|
key, value = list(model_predictions[0].items())[0]
|
|
y_pred = pd.Series(value, name=key)
|
|
output_column_name = key
|
|
elif output_column_name is None:
|
|
raise MlflowException(
|
|
ERROR_MISSING_OUTPUT_COLUMN_NAME,
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
else:
|
|
raise MlflowException(
|
|
f"Output column name '{output_column_name}' is not found in the model "
|
|
f"predictions list: {model_predictions}. Please set the correct output column "
|
|
"name using the `predictions` parameter.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
elif isinstance(model_predictions, pd.DataFrame):
|
|
if output_column_name in model_predictions.columns:
|
|
y_pred = model_predictions[output_column_name]
|
|
other_output_columns = model_predictions.drop(columns=output_column_name)
|
|
elif len(model_predictions.columns) == 1:
|
|
output_column_name = model_predictions.columns[0]
|
|
y_pred = model_predictions[output_column_name]
|
|
elif output_column_name is None:
|
|
raise MlflowException(
|
|
ERROR_MISSING_OUTPUT_COLUMN_NAME,
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
else:
|
|
raise MlflowException(
|
|
f"Output column name '{output_column_name}' is not found in the model "
|
|
f"predictions dataframe {model_predictions.columns}. Please set the correct "
|
|
"output column name using the `predictions` parameter.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
elif isinstance(model_predictions, dict):
|
|
if output_column_name in model_predictions:
|
|
y_pred = pd.Series(model_predictions[output_column_name], name=output_column_name)
|
|
other_output_columns = pd.DataFrame({
|
|
k: v for k, v in model_predictions.items() if k != output_column_name
|
|
})
|
|
elif len(model_predictions) == 1:
|
|
key, value = list(model_predictions.items())[0]
|
|
y_pred = pd.Series(value, name=key)
|
|
output_column_name = key
|
|
elif output_column_name is None:
|
|
raise MlflowException(
|
|
ERROR_MISSING_OUTPUT_COLUMN_NAME,
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
else:
|
|
raise MlflowException(
|
|
f"Output column name '{output_column_name}' is not found in the "
|
|
f"model predictions dict {model_predictions}. Please set the correct "
|
|
"output column name using the `predictions` parameter.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
return (
|
|
y_pred if y_pred is not None else model_predictions,
|
|
other_output_columns,
|
|
output_column_name,
|
|
)
|
|
|
|
|
|
def _extract_predict_fn(model: Any) -> Callable[..., Any] | None:
|
|
"""
|
|
Extracts the predict function from the given model or raw_model.
|
|
|
|
Precedence order:
|
|
1. If raw_model is specified, its predict function is used.
|
|
2. If model is specified, its predict function is used.
|
|
3. If none of the above, predict function is None.
|
|
|
|
Args:
|
|
model: A model object that has a predict method.
|
|
raw_model: A raw model object that has a predict method.
|
|
|
|
Returns: The predict function.
|
|
"""
|
|
_, raw_model = _extract_raw_model(model)
|
|
predict_fn = None
|
|
|
|
if raw_model is not None:
|
|
predict_fn = raw_model.predict
|
|
try:
|
|
from mlflow.xgboost import _wrapped_xgboost_model_predict_fn
|
|
|
|
# Because shap evaluation will pass evaluation data in ndarray format
|
|
# (without feature names), if set validate_features=True it will raise error.
|
|
predict_fn = _wrapped_xgboost_model_predict_fn(raw_model, validate_features=False)
|
|
except ImportError:
|
|
pass
|
|
|
|
elif model is not None:
|
|
predict_fn = model.predict
|
|
|
|
return predict_fn
|
|
|
|
|
|
def _get_dataframe_with_renamed_columns(x, new_column_names):
|
|
"""
|
|
Downstream inference functions may expect a pd.DataFrame to be created from x. However,
|
|
if x is already a pd.DataFrame, and new_column_names != x.columns, we cannot simply call
|
|
pd.DataFrame(x, columns=new_column_names) because the resulting pd.DataFrame will contain
|
|
NaNs for every column in new_column_names that does not exist in x.columns. This function
|
|
instead creates a new pd.DataFrame object from x, and then explicitly renames the columns
|
|
to avoid NaNs.
|
|
|
|
Args:
|
|
x: A data object, such as a Pandas DataFrame, numPy array, or list
|
|
new_column_names: Column names for the output Pandas DataFrame
|
|
|
|
Returns:
|
|
A pd.DataFrame with x as data, with columns new_column_names
|
|
"""
|
|
df = pd.DataFrame(x)
|
|
return df.rename(columns=dict(zip(df.columns, new_column_names)))
|
|
|
|
|
|
def _get_aggregate_metrics_values(metrics):
|
|
return {name: MetricValue(aggregate_results={name: value}) for name, value in metrics.items()}
|
|
|
|
|
|
_matplotlib_config = {
|
|
"figure.dpi": 175,
|
|
"figure.figsize": [6.0, 4.0],
|
|
"figure.autolayout": True,
|
|
"font.size": 8,
|
|
}
|
|
|
|
|
|
class _CustomArtifact(NamedTuple):
|
|
"""
|
|
A namedtuple representing a custom artifact function and its properties.
|
|
|
|
function : the custom artifact function
|
|
name : the name of the custom artifact function
|
|
index : the index of the function in the ``custom_artifacts`` argument of mlflow.evaluate
|
|
artifacts_dir : the path to a temporary directory to store produced artifacts of the function
|
|
"""
|
|
|
|
function: Callable[..., Any]
|
|
name: str
|
|
index: int
|
|
artifacts_dir: str
|
|
|
|
|
|
def _is_valid_artifacts(artifacts):
|
|
return isinstance(artifacts, dict) and all(isinstance(k, str) for k in artifacts.keys())
|
|
|
|
|
|
def _evaluate_custom_artifacts(custom_artifact_tuple, eval_df, builtin_metrics):
|
|
"""
|
|
This function calls the `custom_artifact` function and performs validations on the returned
|
|
result to ensure that they are in the expected format. It will raise a MlflowException if
|
|
the result is not in the expected format.
|
|
|
|
Args:
|
|
custom_artifact_tuple: Containing a user provided function and its index in the
|
|
``custom_artifacts`` parameter of ``mlflow.evaluate``
|
|
eval_df: A Pandas dataframe object containing a prediction and a target column.
|
|
builtin_metrics: A dictionary of metrics produced by the default evaluator.
|
|
|
|
Returns:
|
|
A dictionary of artifacts.
|
|
"""
|
|
exception_header = (
|
|
f"Custom artifact function '{custom_artifact_tuple.name}' "
|
|
" at index {custom_artifact_tuple.index}"
|
|
" in the `custom_artifacts` parameter"
|
|
)
|
|
artifacts = custom_artifact_tuple.function(
|
|
eval_df, builtin_metrics, custom_artifact_tuple.artifacts_dir
|
|
)
|
|
|
|
if artifacts is None:
|
|
_logger.warning(f"{exception_header} returned None.")
|
|
return
|
|
|
|
if not _is_valid_artifacts(artifacts):
|
|
_logger.warning(
|
|
f"{exception_header} did not return artifacts as a dictionary of string artifact "
|
|
"names with their corresponding objects."
|
|
)
|
|
return
|
|
|
|
return artifacts
|
|
|
|
|
|
# TODO: Move this to the /evaluators directory
|
|
class BuiltInEvaluator(ModelEvaluator):
|
|
"""
|
|
The base class for all evaluators that are built-in to MLflow.
|
|
|
|
Each evaluator is responsible for implementing the `_evaluate()` method, which is called by
|
|
the `evaluate()` method of this base class. This class contains many helper methods used
|
|
across built-in evaluators, such as logging metrics, artifacts, and ordering metrics.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.client = MlflowClient()
|
|
|
|
@abstractmethod
|
|
def _evaluate(
|
|
self,
|
|
model: Optional["mlflow.pyfunc.PyFuncModel"],
|
|
extra_metrics: list[EvaluationMetric],
|
|
custom_artifacts=None,
|
|
**kwargs,
|
|
) -> EvaluationResult | None:
|
|
"""Implement the evaluation logic for each evaluator."""
|
|
|
|
def log_metrics(self):
|
|
"""
|
|
Helper method to log metrics into specified run.
|
|
"""
|
|
self._add_prefix_to_metrics()
|
|
|
|
timestamp = get_current_time_millis()
|
|
self.client.log_batch(
|
|
self.run_id,
|
|
metrics=[
|
|
Metric(
|
|
key=key,
|
|
value=value,
|
|
timestamp=timestamp,
|
|
step=0,
|
|
model_id=self.model_id,
|
|
dataset_name=self.dataset.name,
|
|
dataset_digest=self.dataset.digest,
|
|
run_id=self.run_id,
|
|
)
|
|
for key, value in self.aggregate_metrics.items()
|
|
],
|
|
)
|
|
|
|
def _log_image_artifact(
|
|
self,
|
|
do_plot,
|
|
artifact_name,
|
|
):
|
|
from matplotlib import pyplot
|
|
|
|
prefix = self.evaluator_config.get("metric_prefix", "")
|
|
artifact_file_name = f"{prefix}{artifact_name}.png"
|
|
artifact_file_local_path = self.temp_dir.path(artifact_file_name)
|
|
|
|
try:
|
|
pyplot.clf()
|
|
do_plot()
|
|
pyplot.savefig(artifact_file_local_path, bbox_inches="tight")
|
|
except Exception as e:
|
|
_logger.warning(f"Failed to log image artifact {artifact_name!r}: {e!r}")
|
|
else:
|
|
mlflow.log_artifact(artifact_file_local_path)
|
|
artifact = ImageEvaluationArtifact(uri=mlflow.get_artifact_uri(artifact_file_name))
|
|
artifact._load(artifact_file_local_path)
|
|
self.artifacts[artifact_name] = artifact
|
|
finally:
|
|
pyplot.close(pyplot.gcf())
|
|
|
|
def _evaluate_sklearn_model_score_if_scorable(self, model, y_true, sample_weights):
|
|
model_loader_module, raw_model = _extract_raw_model(model)
|
|
if model_loader_module == "mlflow.sklearn" and raw_model is not None:
|
|
try:
|
|
score = raw_model.score(
|
|
self.X.copy_to_avoid_mutation(), y_true, sample_weight=sample_weights
|
|
)
|
|
self.metrics_values.update(_get_aggregate_metrics_values({"score": score}))
|
|
except Exception as e:
|
|
_logger.warning(
|
|
f"Computing sklearn model score failed: {e!r}. Set logging level to "
|
|
"DEBUG to see the full traceback."
|
|
)
|
|
_logger.debug("", exc_info=True)
|
|
|
|
def _log_custom_metric_artifact(self, artifact_name, raw_artifact, custom_metric_tuple):
|
|
"""
|
|
This function logs and returns a custom metric artifact. Two cases:
|
|
- The provided artifact is a path to a file, the function will make a copy of it with
|
|
a formatted name in a temporary directory and call mlflow.log_artifact.
|
|
- Otherwise: will attempt to save the artifact to an temporary path with an inferred
|
|
type. Then call mlflow.log_artifact.
|
|
|
|
Args:
|
|
artifact_name: the name of the artifact
|
|
raw_artifact: the object representing the artifact
|
|
custom_metric_tuple: an instance of the _CustomMetric namedtuple
|
|
|
|
Returns:
|
|
EvaluationArtifact
|
|
"""
|
|
|
|
exception_and_warning_header = (
|
|
f"Custom artifact function '{custom_metric_tuple.name}' at index "
|
|
f"{custom_metric_tuple.index} in the `custom_artifacts` parameter"
|
|
)
|
|
|
|
inferred_from_path, inferred_type, inferred_ext = _infer_artifact_type_and_ext(
|
|
artifact_name, raw_artifact, custom_metric_tuple
|
|
)
|
|
artifact_file_local_path = self.temp_dir.path(artifact_name + inferred_ext)
|
|
|
|
if pathlib.Path(artifact_file_local_path).exists():
|
|
raise MlflowException(
|
|
f"{exception_and_warning_header} produced an artifact '{artifact_name}' that "
|
|
"cannot be logged because there already exists an artifact with the same name."
|
|
)
|
|
|
|
# ParquetEvaluationArtifact isn't explicitly stated here because such artifacts can only
|
|
# be supplied through file. Which is handled by the first if clause. This is because
|
|
# DataFrame objects default to be stored as CsvEvaluationArtifact.
|
|
if inferred_from_path:
|
|
shutil.copy2(raw_artifact, artifact_file_local_path)
|
|
elif inferred_type is JsonEvaluationArtifact:
|
|
with open(artifact_file_local_path, "w") as f:
|
|
if isinstance(raw_artifact, str):
|
|
f.write(raw_artifact)
|
|
else:
|
|
json.dump(raw_artifact, f, cls=NumpyEncoder)
|
|
elif inferred_type is CsvEvaluationArtifact:
|
|
raw_artifact.to_csv(artifact_file_local_path, index=False)
|
|
elif inferred_type is NumpyEvaluationArtifact:
|
|
np.save(artifact_file_local_path, raw_artifact, allow_pickle=False)
|
|
elif inferred_type is ImageEvaluationArtifact:
|
|
raw_artifact.savefig(artifact_file_local_path)
|
|
else:
|
|
# storing as pickle
|
|
try:
|
|
with open(artifact_file_local_path, "wb") as f:
|
|
pickle.dump(raw_artifact, f)
|
|
_logger.warning(
|
|
f"{exception_and_warning_header} produced an artifact '{artifact_name}'"
|
|
f" with type '{type(raw_artifact)}' that is logged as a pickle artifact."
|
|
)
|
|
except pickle.PickleError:
|
|
raise MlflowException(
|
|
f"{exception_and_warning_header} produced an unsupported artifact "
|
|
f"'{artifact_name}' with type '{type(raw_artifact)}' that cannot be pickled. "
|
|
"Supported object types for artifacts are:\n"
|
|
"- A string uri representing the file path to the artifact. MLflow"
|
|
" will infer the type of the artifact based on the file extension.\n"
|
|
"- A string representation of a JSON object. This will be saved as a "
|
|
".json artifact.\n"
|
|
"- Pandas DataFrame. This will be saved as a .csv artifact."
|
|
"- Numpy array. This will be saved as a .npy artifact."
|
|
"- Matplotlib Figure. This will be saved as an .png image artifact."
|
|
"- Other objects will be attempted to be pickled with default protocol."
|
|
)
|
|
|
|
mlflow.log_artifact(artifact_file_local_path)
|
|
artifact = inferred_type(uri=mlflow.get_artifact_uri(artifact_name + inferred_ext))
|
|
artifact._load(artifact_file_local_path)
|
|
return artifact
|
|
|
|
def _get_column_in_metrics_values(self, column):
|
|
for metric_name, metric_value in self.metrics_values.items():
|
|
if metric_name.split("/")[0] == column:
|
|
return metric_value
|
|
|
|
def _get_args_for_metrics(
|
|
self,
|
|
metric: MetricDefinition,
|
|
eval_df: pd.DataFrame,
|
|
input_df: pd.DataFrame,
|
|
other_output_df: pd.DataFrame | None,
|
|
) -> tuple[bool, list[str | pd.DataFrame]]:
|
|
"""
|
|
Given a metric_tuple, read the signature of the metric function and get the appropriate
|
|
arguments from the input/output columns, other calculated metrics, and evaluator_config.
|
|
|
|
Args:
|
|
metric: The metric definition containing a user provided function and its index
|
|
in the ``extra_metrics`` parameter of ``mlflow.evaluate``.
|
|
eval_df: The evaluation dataframe containing the prediction and target columns.
|
|
input_df: The input dataframe containing the features used to make predictions.
|
|
other_output_df: A dataframe containing all model output columns but the predictions.
|
|
|
|
Returns:
|
|
tuple: A tuple of (bool, list) where the bool indicates if the given metric can
|
|
be calculated with the given eval_df, metrics, and input_df.
|
|
- If the user is missing "targets" or "predictions" parameters when needed, or we
|
|
cannot find a column or metric for a parameter to the metric, return
|
|
(False, list of missing parameters)
|
|
- If all arguments to the metric function were found, return
|
|
(True, list of arguments).
|
|
"""
|
|
# deepcopying eval_df and builtin_metrics for each custom metric function call,
|
|
# in case the user modifies them inside their function(s).
|
|
eval_df_copy = eval_df.copy()
|
|
parameters = inspect.signature(metric.function).parameters
|
|
eval_fn_args = []
|
|
params_not_found = []
|
|
if len(parameters) == 2:
|
|
param_0_name, param_1_name = parameters.keys()
|
|
|
|
# eval_fn has parameters (eval_df, builtin_metrics) for backwards compatibility
|
|
if len(parameters) == 2 and param_0_name != "predictions" and param_1_name != "targets":
|
|
eval_fn_args.append(eval_df_copy)
|
|
self._update_aggregate_metrics()
|
|
eval_fn_args.append(copy.deepcopy(self.aggregate_metrics))
|
|
# eval_fn can have parameters like (predictions, targets, metrics, random_col)
|
|
else:
|
|
for param_name, param in parameters.items():
|
|
column = self.col_mapping.get(param_name, param_name)
|
|
|
|
if column in ("predictions", self.predictions, self.dataset.predictions_name):
|
|
eval_fn_args.append(eval_df_copy["prediction"])
|
|
elif column in ("targets", self.dataset.targets_name):
|
|
if "target" in eval_df_copy:
|
|
eval_fn_args.append(eval_df_copy["target"])
|
|
else:
|
|
if param.default == inspect.Parameter.empty:
|
|
params_not_found.append(param_name)
|
|
else:
|
|
eval_fn_args.append(param.default)
|
|
elif column == "metrics":
|
|
eval_fn_args.append(copy.deepcopy(self.metrics_values))
|
|
else:
|
|
# case when column passed in col_mapping contains the entire column
|
|
if not isinstance(column, str):
|
|
eval_fn_args.append(column)
|
|
|
|
# case column in col_mapping is string and the column value
|
|
# is part of the input_df
|
|
elif column in input_df.columns:
|
|
eval_fn_args.append(input_df[column])
|
|
|
|
# case column in col_mapping is string and the column value
|
|
# is part of the output_df(other than predictions)
|
|
elif other_output_df is not None and column in other_output_df.columns:
|
|
self.other_output_columns_for_eval.add(column)
|
|
eval_fn_args.append(other_output_df[column])
|
|
|
|
# case where the param is defined as part of the evaluator_config
|
|
elif column in self.evaluator_config:
|
|
eval_fn_args.append(self.evaluator_config.get(column))
|
|
|
|
# case where this is the name of another metric
|
|
elif metric_value := self._get_column_in_metrics_values(column):
|
|
eval_fn_args.append(metric_value)
|
|
|
|
# in the case that:
|
|
# the metric has not been calculated yet, but is scheduled to be calculated
|
|
# "before" this metric in self.ordered_metrics, we append None to indicate
|
|
# that there is not an error in the dependencies
|
|
elif column in [metric_tuple.name for metric_tuple in self.ordered_metrics]:
|
|
eval_fn_args.append(None)
|
|
|
|
elif param.default == inspect.Parameter.empty:
|
|
params_not_found.append(param_name)
|
|
else:
|
|
eval_fn_args.append(param.default)
|
|
|
|
if len(params_not_found) > 0:
|
|
return False, params_not_found
|
|
return True, eval_fn_args
|
|
|
|
def evaluate_and_log_custom_artifacts(
|
|
self,
|
|
custom_artifacts: list[_CustomArtifact],
|
|
prediction: pd.Series,
|
|
target: np.ndarray | None = None,
|
|
):
|
|
"""Evaluate custom artifacts provided by users."""
|
|
if not custom_artifacts:
|
|
return
|
|
|
|
eval_df = self._get_eval_df(prediction, target)
|
|
for index, custom_artifact in enumerate(custom_artifacts):
|
|
with tempfile.TemporaryDirectory() as artifacts_dir:
|
|
# deepcopying eval_df and builtin_metrics for each custom artifact function call,
|
|
# in case the user modifies them inside their function(s).
|
|
custom_artifact_tuple = _CustomArtifact(
|
|
function=custom_artifact,
|
|
index=index,
|
|
name=getattr(custom_artifact, "__name__", repr(custom_artifact)),
|
|
artifacts_dir=artifacts_dir,
|
|
)
|
|
artifact_results = _evaluate_custom_artifacts(
|
|
custom_artifact_tuple,
|
|
eval_df.copy(),
|
|
copy.deepcopy(self.metrics_values),
|
|
)
|
|
if artifact_results:
|
|
for artifact_name, raw_artifact in artifact_results.items():
|
|
self.artifacts[artifact_name] = self._log_custom_metric_artifact(
|
|
artifact_name,
|
|
raw_artifact,
|
|
custom_artifact_tuple,
|
|
)
|
|
|
|
def _get_error_message_missing_columns(self, metric_name, param_names):
|
|
error_message_parts = [f"Metric '{metric_name}' requires the following:"]
|
|
|
|
special_params = ["targets", "predictions"]
|
|
error_message_parts.extend(
|
|
f" - the '{param}' parameter needs to be specified"
|
|
for param in special_params
|
|
if param in param_names
|
|
)
|
|
|
|
if remaining_params := [param for param in param_names if param not in special_params]:
|
|
error_message_parts.append(
|
|
f" - missing columns {remaining_params} need to be defined or mapped"
|
|
)
|
|
|
|
return "\n".join(error_message_parts)
|
|
|
|
def _construct_error_message_for_malformed_metrics(
|
|
self, malformed_results, input_columns, output_columns
|
|
):
|
|
error_messages = [
|
|
self._get_error_message_missing_columns(metric_name, param_names)
|
|
for metric_name, param_names in malformed_results
|
|
]
|
|
joined_error_message = "\n".join(error_messages)
|
|
|
|
full_message = f"""Error: Metric calculation failed for the following metrics:
|
|
{joined_error_message}
|
|
|
|
Below are the existing column names for the input/output data:
|
|
Input Columns: {input_columns}
|
|
Output Columns: {output_columns}
|
|
|
|
To resolve this issue, you may need to:
|
|
- specify any required parameters
|
|
- if you are missing columns, check that there are no circular dependencies among your
|
|
metrics, and you may want to map them to an existing column using the following
|
|
configuration:
|
|
evaluator_config={{'col_mapping': {{<missing column name>: <existing column name>}}}}"""
|
|
|
|
return "\n".join(l.lstrip() for l in full_message.splitlines())
|
|
|
|
def _raise_exception_for_malformed_metrics(self, malformed_results, eval_df, other_output_df):
|
|
output_columns = [] if other_output_df is None else list(other_output_df.columns)
|
|
if self.predictions:
|
|
output_columns.append(self.predictions)
|
|
elif self.dataset.predictions_name:
|
|
output_columns.append(self.dataset.predictions_name)
|
|
else:
|
|
output_columns.append("predictions")
|
|
|
|
input_columns = list(self.X.copy_to_avoid_mutation().columns)
|
|
if "target" in eval_df:
|
|
if self.dataset.targets_name:
|
|
input_columns.append(self.dataset.targets_name)
|
|
else:
|
|
input_columns.append("targets")
|
|
|
|
error_message = self._construct_error_message_for_malformed_metrics(
|
|
malformed_results, input_columns, output_columns
|
|
)
|
|
|
|
raise MlflowException(error_message, error_code=INVALID_PARAMETER_VALUE)
|
|
|
|
def _get_eval_df(self, prediction: pd.Series, target: np.ndarray | None = None):
|
|
"""
|
|
Create a DataFrame with "prediction" and "target" columns.
|
|
|
|
This is a standard format that can be passed to the metric functions.
|
|
"""
|
|
eval_df = pd.DataFrame({"prediction": copy.deepcopy(prediction)})
|
|
if target is not None:
|
|
eval_df["target"] = target
|
|
return eval_df
|
|
|
|
def _order_metrics(
|
|
self,
|
|
metrics: list[EvaluationMetric],
|
|
eval_df: pd.DataFrame,
|
|
other_output_df: pd.DataFrame | None,
|
|
):
|
|
"""
|
|
Order the list metrics so they can be computed in sequence.
|
|
|
|
Some metrics might use the results of other metrics to compute their own results. This
|
|
function iteratively resolve this dependency, by checking if each metric can be computed
|
|
with the current available columns and metrics values.
|
|
"""
|
|
remaining_metrics = metrics
|
|
input_df = self.X.copy_to_avoid_mutation()
|
|
|
|
while len(remaining_metrics) > 0:
|
|
pending_metrics = []
|
|
failed_results = []
|
|
did_append_metric = False
|
|
for metric_tuple in remaining_metrics:
|
|
can_calculate, eval_fn_args = self._get_args_for_metrics(
|
|
metric_tuple, eval_df, input_df, other_output_df
|
|
)
|
|
if can_calculate:
|
|
self.ordered_metrics.append(metric_tuple)
|
|
did_append_metric = True
|
|
else: # cannot calculate the metric yet
|
|
pending_metrics.append(metric_tuple)
|
|
failed_results.append((metric_tuple.name, eval_fn_args))
|
|
|
|
# cant calculate any more metrics
|
|
if not did_append_metric:
|
|
self._raise_exception_for_malformed_metrics(
|
|
failed_results, eval_df, other_output_df
|
|
)
|
|
|
|
remaining_metrics = pending_metrics
|
|
|
|
return self.ordered_metrics
|
|
|
|
def _test_first_row(
|
|
self,
|
|
metrics: list[MetricDefinition],
|
|
eval_df: pd.DataFrame,
|
|
other_output_df: pd.DataFrame | None,
|
|
):
|
|
# test calculations on first row of eval_df
|
|
_logger.info("Testing metrics on first row...")
|
|
exceptions = []
|
|
first_row_df = eval_df.iloc[[0]]
|
|
first_row_input_df = self.X.copy_to_avoid_mutation().iloc[[0]]
|
|
for metric in metrics:
|
|
try:
|
|
_, eval_fn_args = self._get_args_for_metrics(
|
|
metric, first_row_df, first_row_input_df, other_output_df
|
|
)
|
|
if metric_value := metric.evaluate(eval_fn_args):
|
|
name = f"{metric.name}/{metric.version}" if metric.version else metric.name
|
|
self.metrics_values.update({name: metric_value})
|
|
except Exception as e:
|
|
stacktrace_str = traceback.format_exc()
|
|
if isinstance(e, MlflowException):
|
|
exceptions.append(
|
|
f"Metric '{metric.name}': Error:\n{e.message}\n{stacktrace_str}"
|
|
)
|
|
else:
|
|
exceptions.append(f"Metric '{metric.name}': Error:\n{e!r}\n{stacktrace_str}")
|
|
|
|
if len(exceptions) > 0:
|
|
raise MlflowException("\n".join(exceptions))
|
|
|
|
def evaluate_metrics(
|
|
self,
|
|
metrics: list[EvaluationMetric],
|
|
prediction: pd.Series,
|
|
target: np.ndarray | None = None,
|
|
other_output_df: pd.DataFrame | None = None,
|
|
):
|
|
"""
|
|
Evaluate the metrics on the given prediction and target data.
|
|
|
|
Args:
|
|
metrics: A list of metrics to evaluate.
|
|
prediction: A Pandas Series containing the predictions.
|
|
target: A numpy array containing the target values.
|
|
other_output_df: A Pandas DataFrame containing other output columns from the model.
|
|
|
|
Returns:
|
|
None, the metrics values are recorded in the self.metrics_values dictionary.
|
|
"""
|
|
|
|
eval_df = self._get_eval_df(prediction, target)
|
|
metrics = [
|
|
MetricDefinition.from_index_and_metric(i, metric) for i, metric in enumerate(metrics)
|
|
]
|
|
metrics = self._order_metrics(metrics, eval_df, other_output_df)
|
|
|
|
self._test_first_row(metrics, eval_df, other_output_df)
|
|
|
|
# calculate metrics for the full eval_df
|
|
input_df = self.X.copy_to_avoid_mutation()
|
|
for metric in metrics:
|
|
_, eval_fn_args = self._get_args_for_metrics(metric, eval_df, input_df, other_output_df)
|
|
if metric_value := metric.evaluate(eval_fn_args):
|
|
name = f"{metric.name}/{metric.version}" if metric.version else metric.name
|
|
self.metrics_values.update({name: metric_value})
|
|
|
|
def log_eval_table(self, y_pred, other_output_columns=None):
|
|
# only log eval table if there are per row metrics recorded
|
|
if not any(
|
|
metric_value.scores is not None or metric_value.justifications is not None
|
|
for _, metric_value in self.metrics_values.items()
|
|
):
|
|
return
|
|
|
|
metric_prefix = self.evaluator_config.get("metric_prefix", "")
|
|
if not isinstance(metric_prefix, str):
|
|
metric_prefix = ""
|
|
if isinstance(self.dataset.features_data, pd.DataFrame):
|
|
# Handle DataFrame case
|
|
if self.dataset.has_targets:
|
|
data = self.dataset.features_data.assign(**{
|
|
self.dataset.targets_name or "target": self.dataset.labels_data,
|
|
self.dataset.predictions_name or self.predictions or "outputs": y_pred,
|
|
})
|
|
else:
|
|
data = self.dataset.features_data.assign(outputs=y_pred)
|
|
else:
|
|
# Handle NumPy array case, converting it to a DataFrame
|
|
data = pd.DataFrame(self.dataset.features_data, columns=self.dataset.feature_names)
|
|
if self.dataset.has_targets:
|
|
data = data.assign(**{
|
|
self.dataset.targets_name or "target": self.dataset.labels_data,
|
|
self.dataset.predictions_name or self.predictions or "outputs": y_pred,
|
|
})
|
|
else:
|
|
data = data.assign(outputs=y_pred)
|
|
|
|
# Include other_output_columns used in evaluation to the eval table
|
|
if other_output_columns is not None and len(self.other_output_columns_for_eval) > 0:
|
|
for column in self.other_output_columns_for_eval:
|
|
data[column] = other_output_columns[column]
|
|
|
|
columns = {}
|
|
for metric_name, metric_value in self.metrics_values.items():
|
|
scores = metric_value.scores
|
|
justifications = metric_value.justifications
|
|
|
|
if scores:
|
|
if metric_name.startswith(metric_prefix) and metric_name[len(metric_prefix) :] in [
|
|
_TOKEN_COUNT_METRIC_NAME,
|
|
_LATENCY_METRIC_NAME,
|
|
]:
|
|
columns[metric_name] = scores
|
|
else:
|
|
columns[f"{metric_name}/score"] = scores
|
|
if justifications:
|
|
columns[f"{metric_name}/justification"] = justifications
|
|
data = data.assign(**columns)
|
|
artifact_file_name = f"{metric_prefix}{_EVAL_TABLE_FILE_NAME}"
|
|
mlflow.log_table(data, artifact_file=artifact_file_name)
|
|
if self.eval_results_path:
|
|
eval_table_spark = self.spark_session.createDataFrame(data)
|
|
try:
|
|
eval_table_spark.write.mode(self.eval_results_mode).option(
|
|
"mergeSchema", "true"
|
|
).format("delta").saveAsTable(self.eval_results_path)
|
|
except Exception as e:
|
|
_logger.info(f"Saving eval table to delta table failed. Reason: {e}")
|
|
|
|
name = _EVAL_TABLE_FILE_NAME.split(".", 1)[0]
|
|
self.artifacts[name] = JsonEvaluationArtifact(
|
|
uri=mlflow.get_artifact_uri(artifact_file_name)
|
|
)
|
|
|
|
def _update_aggregate_metrics(self):
|
|
self.aggregate_metrics = {}
|
|
for metric_name, metric_value in self.metrics_values.items():
|
|
if metric_value.aggregate_results:
|
|
for agg_name, agg_value in metric_value.aggregate_results.items():
|
|
if agg_value is not None:
|
|
if agg_name == metric_name.split("/")[0]:
|
|
self.aggregate_metrics[metric_name] = agg_value
|
|
else:
|
|
self.aggregate_metrics[f"{metric_name}/{agg_name}"] = agg_value
|
|
|
|
def _add_prefix_to_metrics(self):
|
|
def _prefix_value(value):
|
|
aggregate = (
|
|
{f"{prefix}{k}": v for k, v in value.aggregate_results.items()}
|
|
if value.aggregate_results
|
|
else None
|
|
)
|
|
return MetricValue(value.scores, value.justifications, aggregate)
|
|
|
|
if prefix := self.evaluator_config.get("metric_prefix"):
|
|
self.metrics_values = {
|
|
f"{prefix}{k}": _prefix_value(v) for k, v in self.metrics_values.items()
|
|
}
|
|
|
|
self._update_aggregate_metrics()
|
|
|
|
def evaluate(
|
|
self,
|
|
*,
|
|
model_type,
|
|
dataset,
|
|
run_id,
|
|
evaluator_config,
|
|
model: "mlflow.pyfunc.PyFuncModel" = None,
|
|
extra_metrics=None,
|
|
custom_artifacts=None,
|
|
predictions=None,
|
|
model_id=None,
|
|
**kwargs,
|
|
) -> EvaluationResult:
|
|
if model is None and predictions is None and dataset.predictions_data is None:
|
|
raise MlflowException(
|
|
message=(
|
|
"Either a model or set of predictions must be specified in order to use the"
|
|
" default evaluator. Either specify the `model` parameter, the `predictions`"
|
|
" parameter, an MLflow dataset containing the `predictions` column name"
|
|
" (via the `data` parameter), or a different evaluator (via the `evaluators`"
|
|
" parameter)."
|
|
),
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
self.artifacts = {}
|
|
self.aggregate_metrics = {}
|
|
self.metrics_values = {}
|
|
self.ordered_metrics = []
|
|
self.other_output_columns_for_eval = set()
|
|
|
|
self.dataset: EvaluationDataset = dataset
|
|
self.run_id = run_id
|
|
self.model_type = model_type
|
|
self.model_id = model_id
|
|
self.evaluator_config = evaluator_config
|
|
|
|
self.predictions = predictions
|
|
self.col_mapping = self.evaluator_config.get("col_mapping", {})
|
|
self.eval_results_path = self.evaluator_config.get("eval_results_path")
|
|
self.eval_results_mode = self.evaluator_config.get("eval_results_mode", "overwrite")
|
|
|
|
if self.eval_results_path:
|
|
from mlflow.utils._spark_utils import _get_active_spark_session
|
|
|
|
self.spark_session = _get_active_spark_session()
|
|
if not self.spark_session:
|
|
raise MlflowException(
|
|
message="eval_results_path is only supported in Spark environment. ",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if self.eval_results_mode not in ["overwrite", "append"]:
|
|
raise MlflowException(
|
|
message="eval_results_mode can only be 'overwrite' or 'append'. ",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if extra_metrics is None:
|
|
extra_metrics = []
|
|
|
|
bad_metrics = [
|
|
metric for metric in extra_metrics if not isinstance(metric, EvaluationMetric)
|
|
]
|
|
if len(bad_metrics) > 0:
|
|
message = "\n".join([
|
|
f"- Metric '{m}' has type '{type(m).__name__}'" for m in bad_metrics
|
|
])
|
|
raise MlflowException(
|
|
f"In the 'extra_metrics' parameter, the following metrics have the wrong type:\n"
|
|
f"{message}\n"
|
|
f"Please ensure that all extra metrics are instances of "
|
|
f"mlflow.metrics.EvaluationMetric."
|
|
)
|
|
|
|
import matplotlib
|
|
|
|
with TempDir() as temp_dir, matplotlib.rc_context(_matplotlib_config):
|
|
self.temp_dir = temp_dir
|
|
return self._evaluate(model, extra_metrics, custom_artifacts)
|
|
|
|
@property
|
|
def X(self) -> pd.DataFrame:
|
|
"""
|
|
The features (`X`) portion of the dataset, guarded against accidental mutations.
|
|
"""
|
|
return BuiltInEvaluator._MutationGuardedData(
|
|
_get_dataframe_with_renamed_columns(
|
|
self.dataset.features_data, self.dataset.feature_names
|
|
)
|
|
)
|
|
|
|
class _MutationGuardedData:
|
|
"""
|
|
Wrapper around a data object that requires explicit API calls to obtain either a copy
|
|
of the data object, or, in cases where the caller can guaranteed that the object will not
|
|
be mutated, the original data object.
|
|
"""
|
|
|
|
def __init__(self, data):
|
|
"""
|
|
Args:
|
|
data: A data object, such as a Pandas DataFrame, numPy array, or list.
|
|
"""
|
|
self._data = data
|
|
|
|
def copy_to_avoid_mutation(self):
|
|
"""
|
|
Obtain a copy of the data. This method should be called every time the data needs
|
|
to be used in a context where it may be subsequently mutated, guarding against
|
|
accidental reuse after mutation.
|
|
|
|
Returns:
|
|
A copy of the data object.
|
|
"""
|
|
if isinstance(self._data, pd.DataFrame):
|
|
return self._data.copy(deep=True)
|
|
else:
|
|
return copy.deepcopy(self._data)
|
|
|
|
def get_original(self):
|
|
"""
|
|
Obtain the original data object. This method should only be called if the caller
|
|
can guarantee that it will not mutate the data during subsequent operations.
|
|
|
|
Returns:
|
|
The original data object.
|
|
"""
|
|
return self._data
|