1041 lines
38 KiB
Python
1041 lines
38 KiB
Python
import inspect
|
|
import logging
|
|
import pkgutil
|
|
import platform
|
|
import warnings
|
|
from copy import deepcopy
|
|
from importlib import import_module
|
|
from numbers import Number
|
|
from operator import itemgetter
|
|
from typing import Any, Callable, NamedTuple
|
|
|
|
import numpy as np
|
|
from packaging.version import Version
|
|
|
|
from mlflow import MlflowClient
|
|
from mlflow.entities.dataset_input import DatasetInput
|
|
from mlflow.entities.input_tag import InputTag
|
|
from mlflow.tracking.fluent import MLFLOW_DATASET_CONTEXT
|
|
from mlflow.utils.arguments_utils import _get_arg_names
|
|
from mlflow.utils.file_utils import TempDir
|
|
from mlflow.utils.mlflow_tags import MLFLOW_PARENT_RUN_ID
|
|
from mlflow.utils.time import get_current_time_millis
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# The prefix to note that all calculated metrics and artifacts are solely based on training datasets
|
|
_TRAINING_PREFIX = "training_"
|
|
|
|
_SAMPLE_WEIGHT = "sample_weight"
|
|
|
|
|
|
# _SklearnArtifact represents a artifact (e.g confusion matrix) that will be computed and
|
|
# logged during the autologging routine for a particular model type (eg, classifier, regressor).
|
|
class _SklearnArtifact(NamedTuple):
|
|
name: str
|
|
function: Callable[..., Any]
|
|
arguments: dict[str, Any]
|
|
title: str
|
|
|
|
|
|
# _SklearnMetric represents a metric (e.g, precision_score) that will be computed and
|
|
# logged during the autologging routine for a particular model type (eg, classifier, regressor).
|
|
class _SklearnMetric(NamedTuple):
|
|
name: str
|
|
function: Callable[..., Any]
|
|
arguments: dict[str, Any]
|
|
|
|
|
|
def _gen_xgboost_sklearn_estimators_to_patch():
|
|
import xgboost as xgb
|
|
|
|
all_classes = inspect.getmembers(xgb.sklearn, inspect.isclass)
|
|
base_class = xgb.sklearn.XGBModel
|
|
sklearn_estimators = []
|
|
for _, class_object in all_classes:
|
|
if issubclass(class_object, base_class) and class_object != base_class:
|
|
sklearn_estimators.append(class_object)
|
|
|
|
return sklearn_estimators
|
|
|
|
|
|
def _gen_lightgbm_sklearn_estimators_to_patch():
|
|
import lightgbm as lgb
|
|
|
|
import mlflow.lightgbm
|
|
|
|
all_classes = inspect.getmembers(lgb.sklearn, inspect.isclass)
|
|
base_class = lgb.sklearn._LGBMModelBase
|
|
sklearn_estimators = []
|
|
for _, class_object in all_classes:
|
|
package_name = class_object.__module__.split(".")[0]
|
|
if (
|
|
package_name == mlflow.lightgbm.FLAVOR_NAME
|
|
and issubclass(class_object, base_class)
|
|
and class_object != base_class
|
|
):
|
|
sklearn_estimators.append(class_object)
|
|
|
|
return sklearn_estimators
|
|
|
|
|
|
def _get_estimator_info_tags(estimator):
|
|
"""
|
|
Returns:
|
|
A dictionary of MLflow run tag keys and values describing the specified estimator.
|
|
"""
|
|
return {
|
|
"estimator_name": estimator.__class__.__name__,
|
|
"estimator_class": (estimator.__class__.__module__ + "." + estimator.__class__.__name__),
|
|
}
|
|
|
|
|
|
def _get_X_y_and_sample_weight(fit_func, fit_args, fit_kwargs):
|
|
"""
|
|
Get a tuple of (X, y, sample_weight) in the following steps.
|
|
|
|
1. Extract X and y from fit_args and fit_kwargs.
|
|
2. If the sample_weight argument exists in fit_func,
|
|
extract it from fit_args or fit_kwargs and return (X, y, sample_weight),
|
|
otherwise return (X, y)
|
|
|
|
Args:
|
|
fit_func: A fit function object.
|
|
fit_args: Positional arguments given to fit_func.
|
|
fit_kwargs: Keyword arguments given to fit_func.
|
|
|
|
Returns:
|
|
A tuple of either (X, y, sample_weight), where `y` and `sample_weight` may be
|
|
`None` if the specified `fit_args` and `fit_kwargs` do not specify labels or
|
|
a sample weighting. Copies of `X` and `y` are made in order to avoid mutation
|
|
of the dataset during training.
|
|
"""
|
|
|
|
def _get_Xy(args, kwargs, X_var_name, y_var_name):
|
|
# corresponds to: model.fit(X, y)
|
|
if len(args) >= 2:
|
|
return args[:2]
|
|
|
|
# corresponds to: model.fit(X, <y_var_name>=y)
|
|
if len(args) == 1:
|
|
return args[0], kwargs.get(y_var_name)
|
|
|
|
# corresponds to: model.fit(<X_var_name>=X, <y_var_name>=y)
|
|
return kwargs[X_var_name], kwargs.get(y_var_name)
|
|
|
|
def _get_sample_weight(arg_names, args, kwargs):
|
|
sample_weight_index = arg_names.index(_SAMPLE_WEIGHT)
|
|
|
|
# corresponds to: model.fit(X, y, ..., sample_weight)
|
|
if len(args) > sample_weight_index:
|
|
return args[sample_weight_index]
|
|
|
|
# corresponds to: model.fit(X, y, ..., sample_weight=sample_weight)
|
|
if _SAMPLE_WEIGHT in kwargs:
|
|
return kwargs[_SAMPLE_WEIGHT]
|
|
|
|
return None
|
|
|
|
fit_arg_names = _get_arg_names(fit_func)
|
|
# In most cases, X_var_name and y_var_name become "X" and "y", respectively.
|
|
# However, certain sklearn models use different variable names for X and y.
|
|
# E.g., see: https://scikit-learn.org/stable/modules/generated/sklearn.multioutput.MultiOutputClassifier.html#sklearn.multioutput.MultiOutputClassifier.fit
|
|
X_var_name, y_var_name = fit_arg_names[:2]
|
|
X, y = _get_Xy(fit_args, fit_kwargs, X_var_name, y_var_name)
|
|
if X is not None:
|
|
X = deepcopy(X)
|
|
if y is not None:
|
|
y = deepcopy(y)
|
|
sample_weight = (
|
|
_get_sample_weight(fit_arg_names, fit_args, fit_kwargs)
|
|
if (_SAMPLE_WEIGHT in fit_arg_names)
|
|
else None
|
|
)
|
|
|
|
return (X, y, sample_weight)
|
|
|
|
|
|
def _get_metrics_value_dict(metrics_list):
|
|
metric_value_dict = {}
|
|
for metric in metrics_list:
|
|
try:
|
|
metric_value = metric.function(**metric.arguments)
|
|
except Exception as e:
|
|
_log_warning_for_metrics(metric.name, metric.function, e)
|
|
else:
|
|
metric_value_dict[metric.name] = metric_value
|
|
return metric_value_dict
|
|
|
|
|
|
def _get_classifier_metrics(fitted_estimator, prefix, X, y_true, sample_weight, pos_label):
|
|
"""
|
|
Compute and record various common metrics for classifiers
|
|
|
|
For (1) precision score:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_score.html
|
|
(2) recall score:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.recall_score.html
|
|
(3) f1_score:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.f1_score.html
|
|
By default, when `pos_label` is not specified (passed in as `None`), we set `average`
|
|
to `weighted` to compute the weighted score of these metrics.
|
|
When the `pos_label` is specified (not `None`), we set `average` to `binary`.
|
|
|
|
For (4) accuracy score:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.accuracy_score.html
|
|
we choose the parameter `normalize` to be `True` to output the percentage of accuracy,
|
|
as opposed to `False` that outputs the absolute correct number of sample prediction
|
|
|
|
We log additional metrics if certain classifier has method `predict_proba`
|
|
(5) log loss:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.log_loss.html
|
|
(6) roc_auc_score:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.roc_auc_score.html
|
|
By default, for roc_auc_score, we pick `average` to be `weighted`, `multi_class` to be `ovo`,
|
|
to make the output more insensitive to dataset imbalance.
|
|
|
|
Steps:
|
|
1. Extract X and y_true from fit_args and fit_kwargs, and compute y_pred.
|
|
2. If the sample_weight argument exists in fit_func (accuracy_score by default
|
|
has sample_weight), extract it from fit_args or fit_kwargs as
|
|
(y_true, y_pred, ...... sample_weight), otherwise as (y_true, y_pred, ......)
|
|
3. return a dictionary of metric(name, value)
|
|
|
|
Args:
|
|
fitted_estimator: The already fitted classifier
|
|
fit_args: Positional arguments given to fit_func.
|
|
fit_kwargs: Keyword arguments given to fit_func.
|
|
|
|
Returns:
|
|
dictionary of (function name, computed value)
|
|
"""
|
|
import sklearn
|
|
|
|
average = "weighted" if pos_label is None else "binary"
|
|
y_pred = fitted_estimator.predict(X)
|
|
|
|
classifier_metrics = [
|
|
_SklearnMetric(
|
|
name=prefix + "precision_score",
|
|
function=sklearn.metrics.precision_score,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"pos_label": pos_label,
|
|
"average": average,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
),
|
|
_SklearnMetric(
|
|
name=prefix + "recall_score",
|
|
function=sklearn.metrics.recall_score,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"pos_label": pos_label,
|
|
"average": average,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
),
|
|
_SklearnMetric(
|
|
name=prefix + "f1_score",
|
|
function=sklearn.metrics.f1_score,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"pos_label": pos_label,
|
|
"average": average,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
),
|
|
_SklearnMetric(
|
|
name=prefix + "accuracy_score",
|
|
function=sklearn.metrics.accuracy_score,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"normalize": True,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
),
|
|
]
|
|
|
|
if hasattr(fitted_estimator, "predict_proba"):
|
|
y_pred_proba = fitted_estimator.predict_proba(X)
|
|
classifier_metrics.extend([
|
|
_SklearnMetric(
|
|
name=prefix + "log_loss",
|
|
function=sklearn.metrics.log_loss,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred_proba,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
),
|
|
])
|
|
|
|
if _is_metric_supported("roc_auc_score"):
|
|
# For binary case, the parameter `y_score` expect scores must be
|
|
# the scores of the class with the greater label.
|
|
if len(y_pred_proba[0]) == 2:
|
|
y_pred_proba = y_pred_proba[:, 1]
|
|
|
|
classifier_metrics.extend([
|
|
_SklearnMetric(
|
|
name=prefix + "roc_auc",
|
|
function=sklearn.metrics.roc_auc_score,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_score": y_pred_proba,
|
|
"average": "weighted",
|
|
"sample_weight": sample_weight,
|
|
"multi_class": "ovo",
|
|
},
|
|
),
|
|
])
|
|
|
|
return _get_metrics_value_dict(classifier_metrics)
|
|
|
|
|
|
def _get_class_labels_from_estimator(estimator):
|
|
"""
|
|
Extracts class labels from `estimator` if `estimator.classes` is available.
|
|
"""
|
|
return estimator.classes_ if hasattr(estimator, "classes_") else None
|
|
|
|
|
|
def _get_classifier_artifacts(fitted_estimator, prefix, X, y_true, sample_weight):
|
|
"""
|
|
Draw and record various common artifacts for classifier
|
|
|
|
For all classifiers, we always log:
|
|
(1) confusion matrix:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.plot_confusion_matrix.html
|
|
|
|
For only binary classifiers, we will log:
|
|
(2) precision recall curve:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.plot_precision_recall_curve.html
|
|
(3) roc curve:
|
|
https://scikit-learn.org/stable/auto_examples/model_selection/plot_roc.html
|
|
|
|
Steps:
|
|
1. Extract X and y_true from fit_args and fit_kwargs, and split into train & test datasets.
|
|
2. If the sample_weight argument exists in fit_func (accuracy_score by default
|
|
has sample_weight), extract it from fit_args or fit_kwargs as
|
|
(y_true, y_pred, sample_weight, multioutput), otherwise as (y_true, y_pred, multioutput)
|
|
3. return a list of artifacts path to be logged
|
|
|
|
Args:
|
|
fitted_estimator: The already fitted regressor
|
|
fit_args: Positional arguments given to fit_func.
|
|
fit_kwargs: Keyword arguments given to fit_func.
|
|
|
|
Returns:
|
|
List of artifacts to be logged
|
|
"""
|
|
import sklearn
|
|
|
|
if not _is_plotting_supported():
|
|
return []
|
|
|
|
is_plot_function_deprecated = Version(sklearn.__version__) >= Version("1.0")
|
|
|
|
def plot_confusion_matrix(*args, **kwargs):
|
|
import matplotlib
|
|
import matplotlib.pyplot as plt
|
|
|
|
class_labels = _get_class_labels_from_estimator(fitted_estimator)
|
|
if class_labels is None:
|
|
class_labels = set(y_true)
|
|
|
|
with matplotlib.rc_context({
|
|
"font.size": min(8.0, 50.0 / len(class_labels)),
|
|
"axes.labelsize": 8.0,
|
|
"figure.dpi": 175,
|
|
}):
|
|
_, ax = plt.subplots(1, 1, figsize=(6.0, 4.0))
|
|
return (
|
|
sklearn.metrics.ConfusionMatrixDisplay.from_estimator(*args, **kwargs, ax=ax)
|
|
if is_plot_function_deprecated
|
|
else sklearn.metrics.plot_confusion_matrix(*args, **kwargs, ax=ax)
|
|
)
|
|
|
|
y_true_arg_name = "y" if is_plot_function_deprecated else "y_true"
|
|
classifier_artifacts = [
|
|
_SklearnArtifact(
|
|
name=prefix + "confusion_matrix",
|
|
function=plot_confusion_matrix,
|
|
arguments=dict(
|
|
estimator=fitted_estimator,
|
|
X=X,
|
|
sample_weight=sample_weight,
|
|
normalize="true",
|
|
cmap="Blues",
|
|
**{y_true_arg_name: y_true},
|
|
),
|
|
title="Normalized confusion matrix",
|
|
),
|
|
]
|
|
|
|
# The plot_roc_curve and plot_precision_recall_curve can only be
|
|
# supported for binary classifier
|
|
if len(set(y_true)) == 2:
|
|
classifier_artifacts.extend([
|
|
_SklearnArtifact(
|
|
name=prefix + "roc_curve",
|
|
function=sklearn.metrics.RocCurveDisplay.from_estimator
|
|
if is_plot_function_deprecated
|
|
else sklearn.metrics.plot_roc_curve,
|
|
arguments={
|
|
"estimator": fitted_estimator,
|
|
"X": X,
|
|
"y": y_true,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
title="ROC curve",
|
|
),
|
|
_SklearnArtifact(
|
|
name=prefix + "precision_recall_curve",
|
|
function=sklearn.metrics.PrecisionRecallDisplay.from_estimator
|
|
if is_plot_function_deprecated
|
|
else sklearn.metrics.plot_precision_recall_curve,
|
|
arguments={
|
|
"estimator": fitted_estimator,
|
|
"X": X,
|
|
"y": y_true,
|
|
"sample_weight": sample_weight,
|
|
},
|
|
title="Precision recall curve",
|
|
),
|
|
])
|
|
|
|
return classifier_artifacts
|
|
|
|
|
|
def _get_regressor_metrics(fitted_estimator, prefix, X, y_true, sample_weight):
|
|
"""
|
|
Compute and record various common metrics for regressors
|
|
|
|
For (1) (root) mean squared error:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.mean_squared_error.html
|
|
(2) mean absolute error:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.mean_absolute_error.html
|
|
(3) r2 score:
|
|
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.r2_score.html
|
|
By default, we choose the parameter `multioutput` to be `uniform_average`
|
|
to average outputs with uniform weight.
|
|
|
|
Steps:
|
|
1. Extract X and y_true from fit_args and fit_kwargs, and compute y_pred.
|
|
2. If the sample_weight argument exists in fit_func (accuracy_score by default
|
|
has sample_weight), extract it from fit_args or fit_kwargs as
|
|
(y_true, y_pred, sample_weight, multioutput), otherwise as (y_true, y_pred, multioutput)
|
|
3. return a dictionary of metric(name, value)
|
|
|
|
Args:
|
|
fitted_estimator: The already fitted regressor
|
|
fit_args: Positional arguments given to fit_func.
|
|
fit_kwargs: Keyword arguments given to fit_func.
|
|
|
|
Returns:
|
|
dictionary of (function name, computed value)
|
|
"""
|
|
import sklearn
|
|
|
|
y_pred = fitted_estimator.predict(X)
|
|
|
|
regressor_metrics = [
|
|
_SklearnMetric(
|
|
name=prefix + "mean_squared_error",
|
|
function=sklearn.metrics.mean_squared_error,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"sample_weight": sample_weight,
|
|
"multioutput": "uniform_average",
|
|
},
|
|
),
|
|
_SklearnMetric(
|
|
name=prefix + "mean_absolute_error",
|
|
function=sklearn.metrics.mean_absolute_error,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"sample_weight": sample_weight,
|
|
"multioutput": "uniform_average",
|
|
},
|
|
),
|
|
_SklearnMetric(
|
|
name=prefix + "r2_score",
|
|
function=sklearn.metrics.r2_score,
|
|
arguments={
|
|
"y_true": y_true,
|
|
"y_pred": y_pred,
|
|
"sample_weight": sample_weight,
|
|
"multioutput": "uniform_average",
|
|
},
|
|
),
|
|
]
|
|
|
|
# To be compatible with older versions of scikit-learn (below 0.22.2), where
|
|
# `sklearn.metrics.mean_squared_error` does not have "squared" parameter to calculate `rmse`,
|
|
# we compute it through np.sqrt(<value of mse>)
|
|
metrics_value_dict = _get_metrics_value_dict(regressor_metrics)
|
|
metrics_value_dict[prefix + "root_mean_squared_error"] = np.sqrt(
|
|
metrics_value_dict[prefix + "mean_squared_error"]
|
|
)
|
|
|
|
return metrics_value_dict
|
|
|
|
|
|
def _log_warning_for_metrics(func_name, func_call, err):
|
|
msg = (
|
|
func_call.__qualname__
|
|
+ " failed. The metric "
|
|
+ func_name
|
|
+ " will not be recorded."
|
|
+ " Metric error: "
|
|
+ str(err)
|
|
)
|
|
_logger.warning(msg)
|
|
|
|
|
|
def _log_warning_for_artifacts(func_name, func_call, err):
|
|
msg = (
|
|
func_call.__qualname__
|
|
+ " failed. The artifact "
|
|
+ func_name
|
|
+ " will not be recorded."
|
|
+ " Artifact error: "
|
|
+ str(err)
|
|
)
|
|
_logger.warning(msg)
|
|
|
|
|
|
def _log_specialized_estimator_content(
|
|
autologging_client,
|
|
fitted_estimator,
|
|
run_id,
|
|
prefix,
|
|
X,
|
|
y_true,
|
|
sample_weight,
|
|
pos_label,
|
|
model_id,
|
|
dataset,
|
|
):
|
|
import sklearn
|
|
|
|
metrics = {}
|
|
|
|
if y_true is not None:
|
|
try:
|
|
if sklearn.base.is_classifier(fitted_estimator):
|
|
metrics = _get_classifier_metrics(
|
|
fitted_estimator, prefix, X, y_true, sample_weight, pos_label
|
|
)
|
|
elif sklearn.base.is_regressor(fitted_estimator):
|
|
metrics = _get_regressor_metrics(fitted_estimator, prefix, X, y_true, sample_weight)
|
|
except Exception as err:
|
|
msg = (
|
|
"Failed to autolog metrics for "
|
|
+ fitted_estimator.__class__.__name__
|
|
+ ". Logging error: "
|
|
+ str(err)
|
|
)
|
|
_logger.warning(msg)
|
|
else:
|
|
autologging_client.log_metrics(
|
|
run_id=run_id,
|
|
metrics=metrics,
|
|
model_id=model_id,
|
|
dataset=dataset,
|
|
)
|
|
|
|
if sklearn.base.is_classifier(fitted_estimator):
|
|
try:
|
|
artifacts = _get_classifier_artifacts(
|
|
fitted_estimator, prefix, X, y_true, sample_weight
|
|
)
|
|
except Exception as e:
|
|
msg = (
|
|
"Failed to autolog artifacts for "
|
|
+ fitted_estimator.__class__.__name__
|
|
+ ". Logging error: "
|
|
+ str(e)
|
|
)
|
|
_logger.warning(msg)
|
|
return metrics
|
|
|
|
try:
|
|
import matplotlib
|
|
import matplotlib.pyplot as plt
|
|
except ImportError as ie:
|
|
_logger.warning(
|
|
f"Failed to import matplotlib (error: {ie!r}). Skipping artifact logging."
|
|
)
|
|
return metrics
|
|
|
|
_matplotlib_config = {"savefig.dpi": 175, "figure.autolayout": True, "font.size": 8}
|
|
with TempDir() as tmp_dir:
|
|
for artifact in artifacts:
|
|
try:
|
|
with matplotlib.rc_context(_matplotlib_config):
|
|
display = artifact.function(**artifact.arguments)
|
|
display.ax_.set_title(artifact.title)
|
|
artifact_path = f"{artifact.name}.png"
|
|
filepath = tmp_dir.path(artifact_path)
|
|
display.figure_.savefig(fname=filepath, format="png")
|
|
plt.close(display.figure_)
|
|
except Exception as e:
|
|
_log_warning_for_artifacts(artifact.name, artifact.function, e)
|
|
|
|
MlflowClient().log_artifacts(run_id, tmp_dir.path())
|
|
|
|
return metrics
|
|
|
|
|
|
def _is_estimator_html_repr_supported():
|
|
import sklearn
|
|
|
|
# Only scikit-learn >= 0.23 supports `estimator_html_repr`
|
|
return Version(sklearn.__version__) >= Version("0.23.0")
|
|
|
|
|
|
def _log_estimator_html(run_id, estimator):
|
|
if not _is_estimator_html_repr_supported():
|
|
return
|
|
|
|
from sklearn.utils import estimator_html_repr
|
|
|
|
# Specifies charset so triangle toggle buttons are not garbled
|
|
estimator_html_string = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
</head>
|
|
<body>
|
|
{estimator_html_repr(estimator)}
|
|
</body>
|
|
</html>
|
|
"""
|
|
MlflowClient().log_text(run_id, estimator_html_string, artifact_file="estimator.html")
|
|
|
|
|
|
def _log_estimator_content(
|
|
autologging_client,
|
|
estimator,
|
|
run_id,
|
|
prefix,
|
|
X,
|
|
y_true=None,
|
|
sample_weight=None,
|
|
pos_label=None,
|
|
model_id=None,
|
|
dataset=None,
|
|
):
|
|
"""
|
|
Logs content for the given estimator, which includes metrics and artifacts that might be
|
|
tailored to the estimator's type (e.g., regression vs classification). Training labels
|
|
are required for metric computation; metrics will be omitted if labels are not available.
|
|
|
|
Args:
|
|
autologging_client: An instance of `MlflowAutologgingQueueingClient` used for
|
|
efficiently logging run data to MLflow Tracking.
|
|
estimator: The estimator used to compute metrics and artifacts.
|
|
run_id: The run under which the content is logged.
|
|
prefix: A prefix used to name the logged content. Typically it's 'training_' for
|
|
training-time content and user-controlled for evaluation-time content.
|
|
X: The data samples.
|
|
y_true: Labels.
|
|
sample_weight: Per-sample weights used in the computation of metrics and artifacts.
|
|
pos_label: The positive label used to compute binary classification metrics such as
|
|
precision, recall, f1, etc. This parameter is only used for classification metrics.
|
|
If set to `None`, the function will calculate metrics for each label and find their
|
|
average weighted by support (number of true instances for each label).
|
|
model_id: Model ID.
|
|
dataset: The dataset used to evaluate the model.
|
|
|
|
Returns:
|
|
A dict of the computed metrics.
|
|
"""
|
|
metrics = _log_specialized_estimator_content(
|
|
autologging_client=autologging_client,
|
|
fitted_estimator=estimator,
|
|
run_id=run_id,
|
|
prefix=prefix,
|
|
X=X,
|
|
y_true=y_true,
|
|
sample_weight=sample_weight,
|
|
pos_label=pos_label,
|
|
model_id=model_id,
|
|
dataset=dataset,
|
|
)
|
|
|
|
if hasattr(estimator, "score") and y_true is not None:
|
|
try:
|
|
# Use the sample weight only if it is present in the score args
|
|
score_arg_names = _get_arg_names(estimator.score)
|
|
score_args = (
|
|
(X, y_true, sample_weight) if _SAMPLE_WEIGHT in score_arg_names else (X, y_true)
|
|
)
|
|
score = estimator.score(*score_args)
|
|
except Exception as e:
|
|
msg = (
|
|
estimator.score.__qualname__
|
|
+ " failed. The 'training_score' metric will not be recorded. Scoring error: "
|
|
+ str(e)
|
|
)
|
|
_logger.warning(msg)
|
|
else:
|
|
score_key = prefix + "score"
|
|
autologging_client.log_metrics(
|
|
run_id=run_id,
|
|
metrics={score_key: score},
|
|
model_id=model_id,
|
|
dataset=dataset,
|
|
)
|
|
metrics[score_key] = score
|
|
_log_estimator_html(run_id, estimator)
|
|
return metrics
|
|
|
|
|
|
def _get_meta_estimators_for_autologging():
|
|
"""
|
|
Returns:
|
|
A list of meta estimator class definitions
|
|
(e.g., `sklearn.model_selection.GridSearchCV`) that should be included
|
|
when patching training functions for autologging
|
|
"""
|
|
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
|
|
from sklearn.pipeline import Pipeline
|
|
|
|
return [
|
|
GridSearchCV,
|
|
RandomizedSearchCV,
|
|
Pipeline,
|
|
]
|
|
|
|
|
|
def _is_parameter_search_estimator(estimator):
|
|
"""
|
|
Returns:
|
|
`True` if the specified scikit-learn estimator is a parameter search estimator,
|
|
such as `GridSearchCV`. `False` otherwise.
|
|
"""
|
|
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
|
|
|
|
parameter_search_estimators = [
|
|
GridSearchCV,
|
|
RandomizedSearchCV,
|
|
]
|
|
|
|
return any(
|
|
isinstance(estimator, param_search_estimator)
|
|
for param_search_estimator in parameter_search_estimators
|
|
)
|
|
|
|
|
|
def _log_parameter_search_results_as_artifact(cv_results_df, run_id):
|
|
"""
|
|
Records a collection of parameter search results as an MLflow artifact
|
|
for the specified run.
|
|
|
|
Args:
|
|
cv_results_df: A Pandas DataFrame containing the results of a parameter search
|
|
training session, which may be obtained by parsing the `cv_results_`
|
|
attribute of a trained parameter search estimator such as
|
|
`GridSearchCV`.
|
|
run_id: The ID of the MLflow Run to which the artifact should be recorded.
|
|
"""
|
|
with TempDir() as t:
|
|
results_path = t.path("cv_results.csv")
|
|
cv_results_df.to_csv(results_path, index=False)
|
|
MlflowClient().log_artifact(run_id, results_path)
|
|
|
|
|
|
# Log how many child runs will be created vs omitted based on `max_tuning_runs`.
|
|
def _log_child_runs_info(max_tuning_runs, total_runs):
|
|
rest = total_runs - max_tuning_runs
|
|
|
|
# Set logging statement for runs to be logged.
|
|
if max_tuning_runs == 0:
|
|
logging_phrase = "no runs"
|
|
elif max_tuning_runs == 1:
|
|
logging_phrase = "the best run"
|
|
else:
|
|
logging_phrase = f"the {max_tuning_runs} best runs"
|
|
|
|
# Set logging statement for runs to be omitted.
|
|
if rest <= 0:
|
|
omitting_phrase = "no runs"
|
|
elif rest == 1:
|
|
omitting_phrase = "one run"
|
|
else:
|
|
omitting_phrase = f"{rest} runs"
|
|
|
|
_logger.info("Logging %s, %s will be omitted.", logging_phrase, omitting_phrase)
|
|
|
|
|
|
def _create_child_runs_for_parameter_search(
|
|
autologging_client,
|
|
cv_estimator,
|
|
parent_run,
|
|
max_tuning_runs,
|
|
child_tags=None,
|
|
dataset=None,
|
|
best_estimator_params=None,
|
|
best_estimator_model_id=None,
|
|
):
|
|
"""
|
|
Creates a collection of child runs for a parameter search training session.
|
|
Runs are reconstructed from the `cv_results_` attribute of the specified trained
|
|
parameter search estimator - `cv_estimator`, which provides relevant performance
|
|
metrics for each point in the parameter search space. One child run is created
|
|
for each point in the parameter search space. For additional information, see
|
|
`https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.GridSearchCV.html`_.
|
|
|
|
Args:
|
|
autologging_client: An instance of `MlflowAutologgingQueueingClient` used for
|
|
efficiently logging run data to MLflow Tracking.
|
|
cv_estimator: The trained parameter search estimator for which to create
|
|
child runs.
|
|
parent_run: A py:class:`mlflow.entities.Run` object referring to the parent
|
|
parameter search run for which child runs should be created.
|
|
child_tags: An optional dictionary of MLflow tag keys and values to log
|
|
for each child run.
|
|
dataset: The dataset used to evaluate the model.
|
|
best_estimator_params: The parameters of the best estimator.
|
|
best_estimator_model_id: The model ID of the logged best estimator.
|
|
"""
|
|
import pandas as pd
|
|
|
|
def first_custom_rank_column(df):
|
|
column_names = df.columns.values
|
|
for col_name in column_names:
|
|
if "rank_test_" in col_name:
|
|
return col_name
|
|
|
|
# Use the start time of the parent parameter search run as a rough estimate for the
|
|
# start time of child runs, since we cannot precisely determine when each point
|
|
# in the parameter search space was explored
|
|
child_run_start_time = parent_run.info.start_time
|
|
child_run_end_time = get_current_time_millis()
|
|
|
|
seed_estimator = cv_estimator.estimator
|
|
# In the unlikely case that a seed of a parameter search estimator is,
|
|
# itself, a parameter search estimator, we should avoid logging the untuned
|
|
# parameters of the seeds's seed estimator
|
|
should_log_params_deeply = not _is_parameter_search_estimator(seed_estimator)
|
|
# Each row of `cv_results_` only provides parameters that vary across
|
|
# the user-specified parameter grid. In order to log the complete set
|
|
# of parameters for each child run, we fetch the parameters defined by
|
|
# the seed estimator and update them with parameter subset specified
|
|
# in the result row
|
|
base_params = seed_estimator.get_params(deep=should_log_params_deeply)
|
|
cv_results_df = pd.DataFrame.from_dict(cv_estimator.cv_results_)
|
|
|
|
if max_tuning_runs is None:
|
|
cv_results_best_n_df = cv_results_df
|
|
else:
|
|
rank_column_name = "rank_test_score"
|
|
if rank_column_name not in cv_results_df.columns.values:
|
|
rank_column_name = first_custom_rank_column(cv_results_df)
|
|
warnings.warn(
|
|
f"Top {max_tuning_runs} child runs will be created based on ordering in "
|
|
f"{rank_column_name} column. You can choose not to limit the number of "
|
|
"child runs created by setting `max_tuning_runs=None`."
|
|
)
|
|
cv_results_best_n_df = cv_results_df.nsmallest(max_tuning_runs, rank_column_name)
|
|
# Log how many child runs will be created vs omitted.
|
|
_log_child_runs_info(max_tuning_runs, len(cv_results_df))
|
|
|
|
datasets = [
|
|
DatasetInput(
|
|
dataset._to_mlflow_entity(), tags=[InputTag(key=MLFLOW_DATASET_CONTEXT, value="train")]
|
|
)
|
|
]
|
|
for _, result_row in cv_results_best_n_df.iterrows():
|
|
tags_to_log = dict(child_tags) if child_tags else {}
|
|
tags_to_log.update({MLFLOW_PARENT_RUN_ID: parent_run.info.run_id})
|
|
tags_to_log.update(_get_estimator_info_tags(seed_estimator))
|
|
pending_child_run_id = autologging_client.create_run(
|
|
experiment_id=parent_run.info.experiment_id,
|
|
start_time=child_run_start_time,
|
|
tags=tags_to_log,
|
|
)
|
|
|
|
params_to_log = dict(base_params)
|
|
params_to_log.update(result_row.get("params", {}))
|
|
autologging_client.log_params(run_id=pending_child_run_id, params=params_to_log)
|
|
|
|
# Parameters values are recorded twice in the set of search `cv_results_`:
|
|
# once within a `params` column with dictionary values and once within
|
|
# a separate dataframe column that is created for each parameter. To prevent
|
|
# duplication of parameters, we log the consolidated values from the parameter
|
|
# dictionary column and filter out the other parameter-specific columns with
|
|
# names of the form `param_{param_name}`. Additionally, `cv_results_` produces
|
|
# metrics for each training split, which is fairly verbose; accordingly, we filter
|
|
# out per-split metrics in favor of aggregate metrics (mean, std, etc.)
|
|
excluded_metric_prefixes = ["param", "split"]
|
|
metrics_to_log = {
|
|
key: value
|
|
for key, value in result_row.items()
|
|
if not any(key.startswith(prefix) for prefix in excluded_metric_prefixes)
|
|
and isinstance(value, Number)
|
|
}
|
|
# Only log metrics to the best_estimator_model when the child run's
|
|
# parameters match the best_estimator's parameters.
|
|
model_id = (
|
|
best_estimator_model_id
|
|
if best_estimator_params
|
|
and result_row.get("params", {}).items() <= best_estimator_params.items()
|
|
else None
|
|
)
|
|
autologging_client.log_metrics(
|
|
run_id=pending_child_run_id,
|
|
metrics=metrics_to_log,
|
|
dataset=dataset,
|
|
model_id=model_id,
|
|
)
|
|
autologging_client.log_inputs(run_id=pending_child_run_id, datasets=datasets)
|
|
autologging_client.set_terminated(run_id=pending_child_run_id, end_time=child_run_end_time)
|
|
|
|
|
|
# Util function to check whether a metric is able to be computed in given sklearn version
|
|
def _is_metric_supported(metric_name):
|
|
import sklearn
|
|
|
|
# This dict can be extended to store special metrics' specific supported versions
|
|
_metric_supported_version = {"roc_auc_score": "0.22.2"}
|
|
|
|
return Version(sklearn.__version__) >= Version(_metric_supported_version[metric_name])
|
|
|
|
|
|
# Util function to check whether artifact plotting functions are able to be computed
|
|
# in given sklearn version (should >= 0.22.0)
|
|
def _is_plotting_supported():
|
|
import sklearn
|
|
|
|
return Version(sklearn.__version__) >= Version("0.22.0")
|
|
|
|
|
|
def _all_estimators():
|
|
try:
|
|
from sklearn.utils import all_estimators
|
|
|
|
return all_estimators()
|
|
except ImportError:
|
|
return _backported_all_estimators()
|
|
|
|
|
|
def _backported_all_estimators(type_filter=None):
|
|
"""
|
|
Backported from scikit-learn 0.23.2:
|
|
https://github.com/scikit-learn/scikit-learn/blob/0.23.2/sklearn/utils/__init__.py#L1146
|
|
|
|
Use this backported `all_estimators` in old versions of sklearn because:
|
|
1. An inferior version of `all_estimators` that old versions of sklearn use for testing,
|
|
might function differently from a newer version.
|
|
2. This backported `all_estimators` works on old versions of sklearn that don't even define
|
|
the testing utility variant of `all_estimators`.
|
|
|
|
========== original docstring ==========
|
|
Get a list of all estimators from sklearn.
|
|
This function crawls the module and gets all classes that inherit
|
|
from BaseEstimator. Classes that are defined in test-modules are not
|
|
included.
|
|
By default meta_estimators such as GridSearchCV are also not included.
|
|
Parameters
|
|
----------
|
|
type_filter : string, list of string, or None, default=None
|
|
Which kind of estimators should be returned. If None, no filter is
|
|
applied and all estimators are returned. Possible values are
|
|
'classifier', 'regressor', 'cluster' and 'transformer' to get
|
|
estimators only of these specific types, or a list of these to
|
|
get the estimators that fit at least one of the types.
|
|
|
|
Returns
|
|
-------
|
|
estimators : list of tuples
|
|
List of (name, class), where ``name`` is the class name as string
|
|
and ``class`` is the actual type of the class.
|
|
"""
|
|
# lazy import to avoid circular imports from sklearn.base
|
|
import sklearn
|
|
from sklearn.base import (
|
|
BaseEstimator,
|
|
ClassifierMixin,
|
|
ClusterMixin,
|
|
RegressorMixin,
|
|
TransformerMixin,
|
|
)
|
|
from sklearn.utils._testing import ignore_warnings
|
|
|
|
IS_PYPY = platform.python_implementation() == "PyPy"
|
|
|
|
def is_abstract(c):
|
|
if not hasattr(c, "__abstractmethods__"):
|
|
return False
|
|
if not len(c.__abstractmethods__):
|
|
return False
|
|
return True
|
|
|
|
all_classes = []
|
|
modules_to_ignore = {"tests", "externals", "setup", "conftest"}
|
|
root = sklearn.__path__[0] # sklearn package
|
|
# Ignore deprecation warnings triggered at import time and from walking
|
|
# packages
|
|
with ignore_warnings(category=FutureWarning):
|
|
for _, modname, _ in pkgutil.walk_packages(path=[root], prefix="sklearn."):
|
|
mod_parts = modname.split(".")
|
|
if any(part in modules_to_ignore for part in mod_parts) or "._" in modname:
|
|
continue
|
|
module = import_module(modname)
|
|
classes = inspect.getmembers(module, inspect.isclass)
|
|
classes = [(name, est_cls) for name, est_cls in classes if not name.startswith("_")]
|
|
|
|
# TODO: Remove when FeatureHasher is implemented in PYPY
|
|
# Skips FeatureHasher for PYPY
|
|
if IS_PYPY and "feature_extraction" in modname:
|
|
classes = [(name, est_cls) for name, est_cls in classes if name == "FeatureHasher"]
|
|
|
|
all_classes.extend(classes)
|
|
|
|
all_classes = set(all_classes)
|
|
|
|
estimators = [
|
|
c for c in all_classes if (issubclass(c[1], BaseEstimator) and c[0] != "BaseEstimator")
|
|
]
|
|
# get rid of abstract base classes
|
|
estimators = [c for c in estimators if not is_abstract(c[1])]
|
|
|
|
if type_filter is not None:
|
|
# copy the object if type_filter is a list
|
|
type_filter = list(type_filter) if isinstance(type_filter, list) else [type_filter]
|
|
filtered_estimators = []
|
|
filters = {
|
|
"classifier": ClassifierMixin,
|
|
"regressor": RegressorMixin,
|
|
"transformer": TransformerMixin,
|
|
"cluster": ClusterMixin,
|
|
}
|
|
for name, mixin in filters.items():
|
|
if name in type_filter:
|
|
type_filter.remove(name)
|
|
filtered_estimators.extend([est for est in estimators if issubclass(est[1], mixin)])
|
|
estimators = filtered_estimators
|
|
if type_filter:
|
|
raise ValueError(
|
|
"Parameter type_filter must be 'classifier', "
|
|
"'regressor', 'transformer', 'cluster' or "
|
|
"None, got"
|
|
f" {type_filter!r}"
|
|
)
|
|
|
|
# drop duplicates, sort for reproducibility
|
|
# itemgetter is used to ensure the sort does not extend to the 2nd item of
|
|
# the tuple
|
|
return sorted(set(estimators), key=itemgetter(0))
|