import datetime import logging import operator import os import sys import warnings from collections import namedtuple from pathlib import Path from typing import Any import mlflow from mlflow.exceptions import MlflowException from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE from mlflow.recipes.cards import BaseCard from mlflow.recipes.step import BaseStep, StepClass from mlflow.recipes.steps.train import TrainStep from mlflow.recipes.utils.execution import get_step_output_path from mlflow.recipes.utils.metrics import ( _get_builtin_metrics, _get_custom_metrics, _get_extended_task, _get_model_type_from_template, _get_primary_metric, _load_custom_metrics, transform_multiclass_metric, ) from mlflow.recipes.utils.step import get_merged_eval_metrics, validate_classification_config from mlflow.recipes.utils.tracking import ( TrackingConfig, apply_recipe_tracking_config, get_recipe_tracking_config, get_run_tags_env_vars, ) from mlflow.tracking.fluent import _get_experiment_id, _set_experiment_primary_metric from mlflow.utils.databricks_utils import get_databricks_env_vars, get_databricks_run_url from mlflow.utils.string_utils import strip_prefix _logger = logging.getLogger(__name__) _FEATURE_IMPORTANCE_PLOT_FILE = "feature_importance.png" _VALIDATION_METRIC_PREFIX = "val_" MetricValidationResult = namedtuple( "MetricValidationResult", ["metric", "greater_is_better", "value", "threshold", "validated"] ) class EvaluateStep(BaseStep): def __init__(self, step_config: dict[str, Any], recipe_root: str) -> None: super().__init__(step_config, recipe_root) self.tracking_config = TrackingConfig.from_dict(self.step_config) def _validate_and_apply_step_config(self): self.target_col = self.step_config.get("target_col") if self.target_col is None: raise MlflowException( "Missing target_col config in recipe config.", error_code=INVALID_PARAMETER_VALUE, ) self.recipe = self.step_config.get("recipe") if self.recipe is None: raise MlflowException( "Missing recipe config in recipe config.", error_code=INVALID_PARAMETER_VALUE, ) self.positive_class = self.step_config.get("positive_class") self.extended_task = _get_extended_task(self.recipe, self.positive_class) self.model_validation_status = "UNKNOWN" self.primary_metric = _get_primary_metric( self.step_config.get("primary_metric"), self.extended_task ) self.user_defined_custom_metrics = { metric.name: metric for metric in _get_custom_metrics(self.step_config, self.extended_task) } self.evaluation_metrics = { metric.name: metric for metric in _get_builtin_metrics(self.extended_task) } self.evaluation_metrics.update(self.user_defined_custom_metrics) if self.primary_metric is not None and self.primary_metric not in self.evaluation_metrics: raise MlflowException( f"The primary metric '{self.primary_metric}' is a custom metric, but its" " corresponding custom metric configuration is missing from `recipe.yaml`.", error_code=INVALID_PARAMETER_VALUE, ) def _validate_validation_criteria(self): """ Validates validation criteria don't contain undefined metrics """ val_metrics = {vc["metric"] for vc in self.step_config.get("validation_criteria", [])} if not val_metrics: return undefined_metrics = val_metrics.difference(self.evaluation_metrics.keys()) if undefined_metrics: raise MlflowException( f"Validation criteria contain undefined metrics: {sorted(undefined_metrics)}", error_code=INVALID_PARAMETER_VALUE, ) def _check_validation_criteria(self, metrics, validation_criteria): """ return a list of `MetricValidationResult` tuple instances. """ summary = [] for val_criterion in validation_criteria: metric_name = val_criterion["metric"] metric_val = metrics.get(metric_name) if metric_val is None: raise MlflowException( f"The metric {metric_name} is defined in the recipe's validation criteria" " but was not returned from mlflow evaluation.", error_code=INVALID_PARAMETER_VALUE, ) greater_is_better = self.evaluation_metrics[metric_name].greater_is_better comp_func = operator.ge if greater_is_better else operator.le threshold = val_criterion["threshold"] validated = comp_func(metric_val, threshold) summary.append( MetricValidationResult( metric=metric_name, greater_is_better=greater_is_better, value=metric_val, threshold=threshold, validated=validated, ) ) return summary def _run(self, output_directory): def my_warn(*args, **kwargs): timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") stacklevel = 1 if "stacklevel" not in kwargs else kwargs["stacklevel"] frame = sys._getframe(stacklevel) filename = frame.f_code.co_filename lineno = frame.f_lineno message = f"{timestamp} {filename}:{lineno}: {args[0]}\n" with open(os.path.join(output_directory, "warning_logs.txt"), "a") as f: f.write(message) original_warn = warnings.warn warnings.warn = my_warn try: import pandas as pd with open(os.path.join(output_directory, "warning_logs.txt"), "w"): pass self._validate_validation_criteria() test_df_path = get_step_output_path( recipe_root_path=self.recipe_root, step_name="split", relative_path="test.parquet", ) test_df = pd.read_parquet(test_df_path) validate_classification_config(self.task, self.positive_class, test_df, self.target_col) validation_df_path = get_step_output_path( recipe_root_path=self.recipe_root, step_name="split", relative_path="validation.parquet", ) validation_df = pd.read_parquet(validation_df_path) run_id_path = get_step_output_path( recipe_root_path=self.recipe_root, step_name="train", relative_path="run_id", ) run_id = Path(run_id_path).read_text() model_uri = get_step_output_path( recipe_root_path=self.recipe_root, step_name="train", relative_path=TrainStep.SKLEARN_MODEL_ARTIFACT_RELATIVE_PATH, ) apply_recipe_tracking_config(self.tracking_config) exp_id = _get_experiment_id() primary_metric_greater_is_better = self.evaluation_metrics[ self.primary_metric ].greater_is_better _set_experiment_primary_metric( exp_id, f"test_{self.primary_metric}", primary_metric_greater_is_better ) with mlflow.start_run(run_id=run_id): eval_metrics = {} for dataset_name, dataset, evaluator_config in ( ( "validation", validation_df, { "explainability_algorithm": "kernel", "explainability_nsamples": 10, "metric_prefix": _VALIDATION_METRIC_PREFIX, }, ), ( "test", test_df, { "log_model_explainability": False, "metric_prefix": "test_", }, ), ): if self.extended_task == "classification/binary": evaluator_config["pos_label"] = self.positive_class eval_result = mlflow.evaluate( model=model_uri, data=dataset, targets=self.target_col, model_type=_get_model_type_from_template(self.recipe), evaluators="default", extra_metrics=_load_custom_metrics( self.recipe_root, self.evaluation_metrics.values(), ), evaluator_config=evaluator_config, ) eval_result.save(os.path.join(output_directory, f"eval_{dataset_name}")) eval_metrics[dataset_name] = { transform_multiclass_metric( strip_prefix(k, evaluator_config["metric_prefix"]), self.extended_task ): v for k, v in eval_result.metrics.items() } validation_results = self._validate_model(eval_metrics, output_directory) card = self._build_profiles_and_card( run_id, model_uri, eval_metrics, validation_results, output_directory ) card.save_as_html(output_directory) self._log_step_card(run_id, self.name) return card finally: warnings.warn = original_warn def _validate_model(self, eval_metrics, output_directory): validation_criteria = self.step_config.get("validation_criteria") validation_results = None if validation_criteria: validation_results = self._check_validation_criteria( eval_metrics["test"], validation_criteria ) self.model_validation_status = ( "VALIDATED" if all(cr.validated for cr in validation_results) else "REJECTED" ) else: self.model_validation_status = "UNKNOWN" Path(output_directory, "model_validation_status").write_text(self.model_validation_status) return validation_results def _build_profiles_and_card( self, run_id, model_uri, eval_metrics, validation_results, output_directory ): """ Constructs data profiles of predictions and errors and a step card instance corresponding to the current evaluate step state. Args: run_id: The ID of the MLflow Run to which to log model evaluation results. model_uri: The URI of the model being evaluated. eval_metrics: The evaluation result keyed by dataset name from `mlflow.evaluate`. validation_results: A list of `MetricValidationResult` instances. output_directory: Output directory used by the evaluate step. """ import pandas as pd # Build card card = BaseCard(self.recipe_name, self.name) # Tab 0: model performance summary. metric_df = ( get_merged_eval_metrics( eval_metrics, ordered_metric_names=[self.primary_metric, *self.user_defined_custom_metrics], ) .reset_index() .rename(columns={"index": "Metric"}) ) def row_style(row): if row.Metric == self.primary_metric or row.Metric in self.user_defined_custom_metrics: return pd.Series("font-weight: bold", row.index) else: return pd.Series("", row.index) metric_table_html = BaseCard.render_table( metric_df.style.format({"training": "{:.6g}", "validation": "{:.6g}"}).apply( row_style, axis=1 ) ) card.add_tab( "Model Performance (Test)", "
{f.read()}")
# Tab 4: Run summary.
run_summary_card_tab = card.add_tab(
"Run Summary",
"{{ RUN_ID }} "
+ "{{ MODEL_URI }}"
+ "{{ VALIDATION_STATUS }}"
+ "{{ EXE_DURATION }}"
+ "{{ LAST_UPDATE_TIME }}",
).add_markdown(
"VALIDATION_STATUS", f"**Validation status:** `{self.model_validation_status}`"
)
run_url = get_databricks_run_url(
tracking_uri=mlflow.get_tracking_uri(),
run_id=run_id,
)
model_uri = f"runs:/{run_id}/train/{TrainStep.MODEL_ARTIFACT_RELATIVE_PATH}"
model_url = get_databricks_run_url(
tracking_uri=mlflow.get_tracking_uri(),
run_id=run_id,
artifact_path=f"train/{TrainStep.MODEL_ARTIFACT_RELATIVE_PATH}",
)
if run_url is not None:
run_summary_card_tab.add_html(
"RUN_ID", f"MLflow Run ID: {run_id}