This commit is contained in:
Christian Mantha
2026-03-02 19:10:52 -05:00
commit 2ca0b9ef7c
28907 changed files with 5233713 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
"""
.. warning::
MLflow Recipes is deprecated and will be removed in a future release.
MLflow Recipes is a framework that enables you to quickly develop high-quality models and deploy
them to production. Compared to ad-hoc ML workflows, MLflow Recipes offers several major benefits:
- **Recipe templates**: `Predefined templates <../../recipes/index.html#recipe-templates>`_ for
common ML tasks, such as `regression modeling <../../recipes/index.html#regression-template>`_,
enable you to get started quickly and focus on building great models, eliminating the large amount
of boilerplate code that is traditionally required to curate datasets, engineer features, train &
tune models, and package models for production deployment.
- **Recipe engine**: The intelligent recipe execution engine accelerates model development by
caching results from each step of the process and re-running the minimal set of steps as changes
are made.
- **Production-ready structure**: The modular, git-integrated `recipe structure
<../../recipes/index.html#recipe-templates-key-concept>`_ dramatically simplifies the handoff from
development to production by ensuring that all model code, data, and configurations are easily
reviewable and deployable by ML engineers.
For more information, see the `MLflow Recipes overview <../../recipes/index.html>`_.
"""
from mlflow.recipes.recipe import Recipe
__all__ = ["Recipe"]

View File

@@ -0,0 +1,202 @@
import json
import logging
import os
from abc import ABC, abstractmethod
import mlflow
from mlflow.recipes.utils.execution import get_step_output_path
from mlflow.tracking import MlflowClient
from mlflow.tracking._tracking_service.utils import _use_tracking_uri
from mlflow.utils.file_utils import chdir
_logger = logging.getLogger(__name__)
class Artifact(ABC):
@abstractmethod
def name(self):
pass
@abstractmethod
def path(self):
pass
@abstractmethod
def load(self):
pass
class DataframeArtifact(Artifact):
def __init__(self, name, recipe_root, step_name, rel_path=""):
self._name = name
self._path = get_step_output_path(recipe_root, step_name, rel_path)
self._step_name = step_name
def name(self):
return self._name
def path(self):
return self._path
def load(self):
import pandas as pd
if os.path.exists(self._path):
return pd.read_parquet(self._path)
log_artifact_not_found_warning(self._name, self._step_name)
return None
class ModelArtifact(Artifact):
def __init__(self, name, recipe_root, step_name, tracking_uri):
self._name = name
self._path = get_step_output_path(recipe_root, step_name, "model/model.pkl")
self._recipe_root = recipe_root
self._step_name = step_name
self._tracking_uri = tracking_uri
def name(self):
return self._name
def path(self):
return self._path
def load(self):
run_id = read_run_id(self._recipe_root)
if run_id:
with _use_tracking_uri(self._tracking_uri), chdir(self._recipe_root):
return mlflow.pyfunc.load_model(f"runs:/{run_id}/{self._step_name}/model")
log_artifact_not_found_warning(self._name, self._step_name)
return None
class TransformerArtifact(Artifact):
def __init__(self, name, recipe_root, step_name, tracking_uri):
self._name = name
self._path = get_step_output_path(recipe_root, step_name, "transformer.pkl")
self._recipe_root = recipe_root
self._step_name = step_name
self._tracking_uri = tracking_uri
def name(self):
return self._name
def path(self):
return self._path
def load(self):
run_id = read_run_id(self._recipe_root)
if run_id:
with _use_tracking_uri(self._tracking_uri), chdir(self._recipe_root):
return mlflow.sklearn.load_model(f"runs:/{run_id}/{self._step_name}/transformer")
log_artifact_not_found_warning(self._name, self._step_name)
return None
class RunArtifact(Artifact):
def __init__(self, name, recipe_root, step_name, tracking_uri):
self._name = name
self._path = get_step_output_path(recipe_root, step_name, "run_id")
self._recipe_root = recipe_root
self._step_name = step_name
self._tracking_uri = tracking_uri
def name(self):
return self._name
def path(self):
return self._path
def load(self):
run_id = read_run_id(self._recipe_root)
if run_id:
with _use_tracking_uri(self._tracking_uri), chdir(self._recipe_root):
return MlflowClient().get_run(run_id)
log_artifact_not_found_warning(self._name, self._step_name)
return None
class ModelVersionArtifact(Artifact):
def __init__(self, name, recipe_root, step_name, tracking_uri):
self._name = name
self._path = get_step_output_path(recipe_root, step_name, "registered_model_version.json")
self._recipe_root = recipe_root
self._step_name = step_name
self._tracking_uri = tracking_uri
def name(self):
return self._name
def path(self):
return self._path
def load(self):
if os.path.exists(self._path):
registered_model_info = RegisteredModelVersionInfo.from_json(path=self._path)
with _use_tracking_uri(self._tracking_uri), chdir(self._recipe_root):
return MlflowClient().get_model_version(
name=registered_model_info.name, version=registered_model_info.version
)
log_artifact_not_found_warning(self._name, self._step_name)
return None
class HyperParametersArtifact(Artifact):
def __init__(self, name, recipe_root, step_name):
self._name = name
self._path = get_step_output_path(recipe_root, step_name, "best_parameters.yaml")
def name(self):
return self._name
def path(self):
return self._path
def load(self):
if os.path.exists(self._path):
with open(self._path) as f:
return f.read()
def log_artifact_not_found_warning(artifact_name, step_name):
_logger.warning(
f"The artifact with name '{artifact_name}' was not found."
f" Re-run the '{step_name}' step to generate it."
)
def read_run_id(recipe_root):
run_id_file_path = get_step_output_path(recipe_root, "train", "run_id")
if os.path.exists(run_id_file_path):
with open(run_id_file_path) as f:
return f.read().strip()
return None
class RegisteredModelVersionInfo:
_KEY_REGISTERED_MODEL_NAME = "registered_model_name"
_KEY_REGISTERED_MODEL_VERSION = "registered_model_version"
def __init__(self, name: str, version: int):
self.name = name
self.version = version
def to_json(self, path):
registered_model_info_dict = {
RegisteredModelVersionInfo._KEY_REGISTERED_MODEL_NAME: self.name,
RegisteredModelVersionInfo._KEY_REGISTERED_MODEL_VERSION: self.version,
}
with open(path, "w") as f:
json.dump(registered_model_info_dict, f)
@classmethod
def from_json(cls, path):
with open(path) as f:
registered_model_info_dict = json.load(f)
return cls(
name=registered_model_info_dict[RegisteredModelVersionInfo._KEY_REGISTERED_MODEL_NAME],
version=registered_model_info_dict[
RegisteredModelVersionInfo._KEY_REGISTERED_MODEL_VERSION
],
)

View File

@@ -0,0 +1,343 @@
from __future__ import annotations
import base64
import html
import logging
import os
import pathlib
import pickle
import random
import re
import string
from io import StringIO
from typing import Optional, Union
from packaging.version import Version
from mlflow.exceptions import INVALID_PARAMETER_VALUE, MlflowException
CARD_PICKLE_NAME = "card.pkl"
CARD_HTML_NAME = "card.html"
_PP_VARIABLE_LINK_REGEX = re.compile(r'<a\s+href="?(?P<href>#pp_var_[-0-9]+)"?\s*>')
_logger = logging.getLogger(__name__)
class CardTab:
def __init__(self, name: str, template: str) -> None:
"""
Construct a step card tab with supported HTML template.
Args:
name: A string representing the name of the tab.
template: A string representing the HTML template for the card content.
"""
import jinja2
from jinja2 import meta as jinja2_meta
self.name = name
self.template = template
j2_env = jinja2.Environment()
self._variables = jinja2_meta.find_undeclared_variables(j2_env.parse(template))
self._context = {}
def add_html(self, name: str, html_content: str) -> CardTab:
"""
Adds html to the CardTab.
Args:
name: Name of the variable in the Jinja2 template.
html_content: The html to replace the named template variable.
Returns:
The updated card instance.
"""
if name not in self._variables:
raise MlflowException(
f"{name} is not a valid template variable defined in template: '{self.template}'",
error_code=INVALID_PARAMETER_VALUE,
)
self._context[name] = html_content
return self
def add_markdown(self, name: str, markdown: str) -> CardTab:
"""
Adds markdown to the card replacing the variable name in the CardTab template.
Args:
name: Name of the variable in the CardTab Jinja2 template.
markdown: The markdown content.
Returns:
The updated card tab instance.
"""
from markdown import markdown as md_to_html
self.add_html(name, md_to_html(markdown))
return self
def add_image(
self,
name: str,
image_file_path: str,
width: Optional[int] = None,
height: Optional[int] = None,
) -> None:
if not os.path.exists(image_file_path):
self.add_html(name, "Image Unavailable")
_logger.warning(f"Unable to locate image file {image_file_path} to render {name}.")
return
with open(image_file_path, "rb") as f:
base64_str = base64.b64encode(f.read()).decode("utf-8")
image_type = pathlib.Path(image_file_path).suffix[1:]
width_style = f'width="{width}"' if width else ""
height_style = f'height="{width}"' if height else ""
img_html = (
f'<img src="data:image/{image_type};base64, {base64_str}" '
f"{width_style} {height_style} />"
)
self.add_html(name, img_html)
def add_pandas_profile(self, name: str, profile: str) -> CardTab:
"""
Add a new tab representing the provided pandas profile to the card.
Args:
name: Name of the variable in the Jinja2 template.
profile: HTML string to render profile in the step card.
Returns:
The updated card instance.
"""
try:
profile_iframe = (
"<iframe srcdoc='{src}' width='100%' height='500' frameborder='0'></iframe>"
).format(src=html.escape(profile))
except Exception as e:
profile_iframe = f"Unable to create data profile. Error found:\n{e}"
self.add_html(name, profile_iframe)
return self
def to_html(self) -> str:
"""
Returns a rendered HTML representing the content of the tab.
Returns:
a HTML string
"""
from jinja2 import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
j2_env = SandboxedEnvironment(loader=BaseLoader()).from_string(self.template)
return j2_env.render({**self._context})
class BaseCard:
def __init__(self, recipe_name: str, step_name: str) -> None:
"""
BaseCard Constructor
Args:
recipe_name: A string representing name of the recipe.
step_name: A string representing the name of the step.
"""
self._recipe_name = recipe_name
self._step_name = step_name
self._template_name = "base.html"
self._string_builder = StringIO()
self._tabs = []
def add_tab(self, name, html_template) -> CardTab:
"""
Add a new tab with arbitrary content.
Args:
name: A string representing the name of the tab.
html_template: A string representing the HTML template for the card content.
"""
tab = CardTab(name, html_template)
self._tabs.append((name, tab))
return tab
def get_tab(self, name) -> Union[CardTab, None]:
"""
Returns an existing tab with the specified name. Returns None if not found.
Args:
name: A string representing the name of the tab.
Returns:
An existing tab with the specified name. If not found, returns None.
"""
for key, tab in self._tabs:
if key == name:
return tab
return None
def add_text(self, text: str) -> BaseCard:
"""
Add text to the textual representation of this card.
Args:
text: A string text.
Returns:
The updated card instance.
"""
self._string_builder.write(text)
return self
def to_html(self) -> str:
"""
This function renders the Jinja2 template based on the provided context so far.
Returns:
A HTML string.
"""
import jinja2
def get_random_id(length=6):
return "".join(
random.choice(string.ascii_lowercase + string.digits) for _ in range(length)
)
base_template_path = os.path.join(os.path.dirname(__file__), "templates")
j2_env = jinja2.Environment(loader=jinja2.FileSystemLoader(base_template_path))
tab_list = [(name, tab.to_html()) for name, tab in self._tabs]
page_id = get_random_id()
return j2_env.get_template(self._template_name).render(
{
"HEADER_TITLE": f"{self._step_name.capitalize()}@{self._recipe_name}",
"TABLINK": f"tablink-{page_id}",
"CONTENT": f"content-{page_id}",
"BUTTON_CONTAINER": f"button-container-{page_id}",
"tab_list": tab_list,
}
)
def to_text(self) -> str:
"""
Returns:
The textual representation of the card.
"""
return self._string_builder.getvalue()
def save_as_html(self, path) -> None:
if os.path.isdir(path):
path = os.path.join(path, CARD_HTML_NAME)
with open(path, "w", encoding="utf-8") as f:
f.write(self.to_html())
def save(self, path: str) -> None:
if os.path.isdir(path):
path = os.path.join(path, CARD_PICKLE_NAME)
with open(path, "wb") as out:
pickle.dump(self, out)
@staticmethod
def load(path):
if os.path.isdir(path):
path = os.path.join(path, CARD_PICKLE_NAME)
with open(path, "rb") as f:
return pickle.load(f)
@staticmethod
def render_table(table, columns=None, hide_index=True):
"""
Renders a table-like object as an HTML table.
Args:
table: Table-like object (e.g. pandas DataFrame, 2D numpy array, list of tuples).
columns: Column names to use. If `table` doesn't have column names, this argument
provides names for the columns. Otherwise, only the specified columns will be
included in the output HTML table.
hide_index: Hide index column when rendering.
"""
import pandas as pd
from pandas.io.formats.style import Styler
pandas_version = Version(pd.__version__)
if not isinstance(table, Styler):
table = pd.DataFrame(table, columns=columns)
# Escape specific characters in HTML to prevent
# javascript code injection
# Note that `pandas_df.style.to_html(escape=True) does not work
# So that we have to manually escape values in dataframe cells.
def escape_value(x):
return html.escape(str(x))
if hasattr(table, "map"):
table = table.map(escape_value)
else:
if pandas_version >= Version("2.1.0"):
table = table.map(escape_value)
else:
table = table.applymap(escape_value)
table = table.style
styler = table.set_table_attributes('style="border-collapse:collapse"').set_table_styles(
[
{
"selector": "table, th, td",
"props": [
("border", "1px solid grey"),
("text-align", "left"),
("padding", "5px"),
],
},
]
)
if hide_index:
rendered_table = (
styler.hide(axis="index").to_html()
if pandas_version >= Version("1.4.0")
else styler.hide_index().render()
)
else:
rendered_table = (
styler.to_html() if pandas_version >= Version("1.4.0") else styler.render()
)
return f'<div style="max-height: 500px; overflow: scroll;">{rendered_table}</div>'
class FailureCard(BaseCard):
"""
Step card providing information about a failed step execution, including a stacktrace.
TODO: Migrate the failure card to a tab-based card, removing this class and its associated
HTML template in the process.
"""
def __init__(
self, recipe_name: str, step_name: str, failure_traceback: str, output_directory: str
):
super().__init__(
recipe_name=recipe_name,
step_name=step_name,
)
self.add_tab("Step Status", "{{ STEP_STATUS }}").add_html(
"STEP_STATUS",
'<p><strong>Step status: <span style="color:red">Failed</span></strong></p>',
)
self.add_tab(
"Stacktrace",
(
"<div class='stacktrace-container'><p style='margin-top:0px'><code>"
"{{ STACKTRACE|e }}</code></p></div>"
),
).add_html("STACKTRACE", str(failure_traceback))
warning_output_path = os.path.join(output_directory, "warning_logs.txt")
if os.path.exists(warning_output_path):
with open(warning_output_path) as f:
self.add_tab("Warning Logs", "{{ STEP_WARNINGS }}").add_html(
"STEP_WARNINGS", f"<pre>{f.read()}</pre>"
)

View File

@@ -0,0 +1,132 @@
"""
Generates facets_overview histogram message for numeric features.
"""
from mlflow.protos.facet_feature_statistics_pb2 import Histogram
def generate_equal_height_histogram(quantiles, num_buckets: int) -> Histogram:
"""
Generates the equal height histogram from the input quantiles. The quantiles are assumed to
be ordered and corresponding to equal distant percentiles.
Args:
quantiles: The quantiles that capture the frequency distribution.
num_buckets: The number of buckets in the generated equal height histogram.
Returns:
An equal height histogram or None if inputs are invalid.
"""
if (len(quantiles) < 3) or ((len(quantiles) - 1) % num_buckets != 0):
return None
histogram = Histogram()
histogram.type = Histogram.HistogramType.QUANTILES
step = (len(quantiles) - 1) // num_buckets
for low_index in range(0, len(quantiles) - step, step):
high_index = low_index + step
histogram.buckets.append(
Histogram.Bucket(low_value=quantiles[low_index], high_value=quantiles[high_index])
)
return histogram
def generate_equal_width_histogram(quantiles, num_buckets: int, total_freq: float) -> Histogram:
"""
Generates the equal width histogram from the input quantiles and total frequency. The
quantiles are assumed to be ordered and corresponding to equal distant percentiles.
Args:
quantiles: The quantiles that capture the frequency distribution.
num_buckets: The number of buckets in the generated histogram.
total_freq: The total frequency (=count of rows).
Returns:
Equal width histogram or None if inputs are invalid.
"""
if len(quantiles) < 2 or num_buckets <= 0 or total_freq <= 0:
return None
min_val = quantiles[0]
max_val = quantiles[-1]
# If all values are the same, the width of all buckets will be 1 as fixed,
# except the bucket that contains the real value. The width of that will be 0.
histogram = Histogram()
histogram.type = Histogram.HistogramType.STANDARD
if min_val == max_val:
half_buckets = num_buckets // 2
bucket_left = min_val - half_buckets
for i in range(num_buckets):
if i == half_buckets:
histogram.buckets.append(
Histogram.Bucket(
low_value=bucket_left, high_value=bucket_left, sample_count=total_freq
)
)
else:
histogram.buckets.append(
Histogram.Bucket(
low_value=bucket_left, high_value=bucket_left + 1, sample_count=0
)
)
bucket_left += 1
else:
bucket_width = (max_val - min_val) / num_buckets
for i in range(num_buckets):
bucket_left = min_val + i * bucket_width
bucket_right = bucket_left + bucket_width
histogram.buckets.append(
_generate_equal_width_histogram_internal(
bucket_left=bucket_left,
bucket_right=bucket_right,
quantiles=quantiles,
total_freq=total_freq,
)
)
return histogram
def _generate_equal_width_histogram_internal(
bucket_left: float,
bucket_right: float,
quantiles,
total_freq: float,
) -> Histogram.Bucket:
"""
Generates a histogram bucket given the bucket range, the quantiles and the total frequency.
Args:
bucket_left: Bucket left boundary.
bucket_right: Bucket right boundary.
quantiles: The quantiles that capture the frequency distribution.
total_freq: The total frequency (=count of rows).
Returns:
The histogram bucket corresponding to the inputs.
"""
max_val = quantiles[-1]
bucket_freq = 0.0
quantile_freq = total_freq / (len(quantiles) - 1)
for i in range(len(quantiles) - 1):
quantile_low = quantiles[i]
quantile_high = quantiles[i + 1]
overlap_low = max(quantile_low, bucket_left)
overlap_high = min(quantile_high, bucket_right)
overlap_length = overlap_high - overlap_low
quantile_contribution_ratio = 0.0
if quantile_low == quantile_high:
if (bucket_left <= quantile_low < bucket_right) or (
quantile_low == bucket_right == max_val
):
quantile_contribution_ratio = 1.0
elif overlap_length > 0:
quantile_contribution_ratio = overlap_length / (quantile_high - quantile_low)
bucket_freq += quantile_freq * quantile_contribution_ratio
return Histogram.Bucket(
low_value=bucket_left, high_value=bucket_right, sample_count=bucket_freq
)

View File

@@ -0,0 +1,325 @@
"""
Renders the statistics of logged data in a HTML format.
"""
import base64
import sys
from typing import Iterable, Union
import numpy as np
import pandas as pd
from packaging.version import Version
from mlflow.exceptions import MlflowException
from mlflow.protos import facet_feature_statistics_pb2
from mlflow.recipes.cards import histogram_generator
# Number of categorical strings values to be rendered as part of the histogram
HISTOGRAM_CATEGORICAL_LEVELS_COUNT = 100
def get_facet_type_from_numpy_type(dtype):
"""Converts a Numpy dtype to the FeatureNameStatistics.Type proto enum."""
fs_proto = facet_feature_statistics_pb2.FeatureNameStatistics
if dtype.char in np.typecodes["Complex"]:
raise MlflowException(
"Found type complex, but expected one of: int, long, float, string, bool"
)
elif dtype.char in np.typecodes["AllFloat"]:
return fs_proto.FLOAT
elif (
dtype.char in np.typecodes["AllInteger"]
or np.issubdtype(dtype, np.datetime64)
or np.issubdtype(dtype, np.timedelta64)
):
return fs_proto.INT
else:
return fs_proto.STRING
def datetime_and_timedelta_converter(dtype):
"""
Converts a Numpy dtype to a converter method if applicable.
The converter method takes in a numpy array of objects of the provided
dtype and returns a numpy array of the numbers backing that object for
statistical analysis. Returns None if no converter is necessary.
Args:
dtype: The numpy dtype to make a converter for.
Returns:
The converter method or None.
"""
if np.issubdtype(dtype, np.datetime64):
def datetime_converter(dt_list):
return np.array([pd.Timestamp(dt).value for dt in dt_list])
return datetime_converter
elif np.issubdtype(dtype, np.timedelta64):
def timedelta_converter(td_list):
return np.array([pd.Timedelta(td).value for td in td_list])
return timedelta_converter
else:
return None
def compute_common_stats(column) -> facet_feature_statistics_pb2.CommonStatistics:
"""
Computes common statistics for a given column in the DataFrame.
Args:
column: A column from a DataFrame.
Returns:
A CommonStatistics proto.
"""
common_stats = facet_feature_statistics_pb2.CommonStatistics()
common_stats.num_missing = column.isnull().sum()
common_stats.num_non_missing = len(column) - common_stats.num_missing
# TODO: Add support to multi dimensional columns similar to
# https://github.com/PAIR-code/facets/blob/4742b8b93c2dacf22fc8ace2cee42dd06382c48e/facets_overview/facets_overview/base_generic_feature_statistics_generator.py#L106-L117
common_stats.min_num_values = 1
common_stats.max_num_values = 1
common_stats.avg_num_values = 1.0
return common_stats
def convert_to_dataset_feature_statistics(
df: pd.DataFrame,
) -> facet_feature_statistics_pb2.DatasetFeatureStatistics:
"""
Converts the data statistics from DataFrame format to DatasetFeatureStatistics proto.
Args:
df: The DataFrame for which feature statistics need to be computed.
Returns:
A DatasetFeatureStatistics proto.
"""
fs_proto = facet_feature_statistics_pb2.FeatureNameStatistics
feature_stats = facet_feature_statistics_pb2.DatasetFeatureStatistics()
data_type_custom_stat = facet_feature_statistics_pb2.CustomStatistic()
kwargs = {} if Version(pd.__version__) >= Version("2.0.0rc0") else {"datetime_is_numeric": True}
pandas_describe = df.describe(include="all", **kwargs)
feature_stats.num_examples = len(df)
quantiles_to_get = [x * 10 / 100 for x in range(10 + 1)]
try:
quantiles = df.select_dtypes(include="number").quantile(quantiles_to_get)
except Exception:
raise MlflowException("Error in generating quantiles")
for key in df:
pandas_describe_key = pandas_describe[key]
current_column_value = df[key]
data_type = current_column_value.dtype
data_type_custom_stat.name = "data type"
data_type_custom_stat.str = str(data_type)
feat = feature_stats.features.add(
type=get_facet_type_from_numpy_type(data_type),
name=key.encode("utf-8"),
custom_stats=[data_type_custom_stat],
)
if feat.type in (fs_proto.INT, fs_proto.FLOAT):
feat_stats = feat.num_stats
converter = datetime_and_timedelta_converter(current_column_value.dtype)
if converter:
date_time_converted = converter(current_column_value)
current_column_value = pd.DataFrame(date_time_converted)[0]
kwargs = (
{}
if Version(pd.__version__) >= Version("2.0.0rc0")
else {"datetime_is_numeric": True}
)
pandas_describe_key = current_column_value.describe(include="all", **kwargs)
quantiles[key] = current_column_value.quantile(quantiles_to_get)
default_value = 0
feat_stats.std_dev = pandas_describe_key.get("std", default_value)
feat_stats.mean = pandas_describe_key.get("mean", default_value)
feat_stats.min = pandas_describe_key.get("min", default_value)
feat_stats.max = pandas_describe_key.get("max", default_value)
feat_stats.median = current_column_value.median()
feat_stats.num_zeros = (current_column_value == 0).sum()
feat_stats.common_stats.CopyFrom(compute_common_stats(current_column_value))
if key in quantiles:
equal_width_hist = histogram_generator.generate_equal_width_histogram(
quantiles=quantiles[key].to_numpy(),
num_buckets=10,
total_freq=feat_stats.common_stats.num_non_missing,
)
if equal_width_hist:
feat_stats.histograms.append(equal_width_hist)
equal_height_hist = histogram_generator.generate_equal_height_histogram(
quantiles=quantiles[key].to_numpy(), num_buckets=10
)
if equal_height_hist:
feat_stats.histograms.append(equal_height_hist)
elif feat.type == fs_proto.STRING:
is_current_column_boolean_type = False
if current_column_value.dtype == bool:
current_column_value = current_column_value.replace({True: "True", False: "False"})
is_current_column_boolean_type = True
feat_stats = feat.string_stats
strs = current_column_value.dropna()
feat_stats.avg_length = (
np.mean(np.vectorize(len)(strs))
if not is_current_column_boolean_type and not current_column_value.isnull().all()
else 0
)
vals, counts = np.unique(strs, return_counts=True)
feat_stats.unique = pandas_describe_key.get("unique", len(vals))
sorted_vals = sorted(zip(counts, vals), reverse=True)
sorted_vals = sorted_vals[:HISTOGRAM_CATEGORICAL_LEVELS_COUNT]
for val_index, val in enumerate(sorted_vals):
try:
if sys.version_info.major < 3 or isinstance(val[1], (bytes, bytearray)):
printable_val = val[1].decode("UTF-8", "strict")
else:
printable_val = val[1]
except (UnicodeDecodeError, UnicodeEncodeError):
printable_val = "__BYTES_VALUE__"
bucket = feat_stats.rank_histogram.buckets.add(
low_rank=val_index,
high_rank=val_index,
sample_count=val[0].item(),
label=printable_val,
)
if val_index < 2:
feat_stats.top_values.add(value=bucket.label, frequency=bucket.sample_count)
feat_stats.common_stats.CopyFrom(compute_common_stats(current_column_value))
return feature_stats
def convert_to_proto(df: pd.DataFrame) -> facet_feature_statistics_pb2.DatasetFeatureStatisticsList:
"""
Converts the data from DataFrame format to DatasetFeatureStatisticsList proto.
Args:
df: The DataFrame for which feature statistics need to be computed.
Returns:
A DatasetFeatureStatisticsList proto.
"""
feature_stats = convert_to_dataset_feature_statistics(df)
feature_stats_list = facet_feature_statistics_pb2.DatasetFeatureStatisticsList()
feature_stats_list.datasets.append(feature_stats)
return feature_stats_list
def convert_to_comparison_proto(
dfs: Iterable[tuple[str, pd.DataFrame]],
) -> facet_feature_statistics_pb2.DatasetFeatureStatisticsList:
"""
Converts a collection of named stats DataFrames to a single DatasetFeatureStatisticsList proto.
Args:
dfs: The named "glimpses" that contain the DataFrame. Each "glimpse"
DataFrame has the same properties as the input to `convert_to_proto()`.
Returns:
A DatasetFeatureStatisticsList proto which contains a translation
of the glimpses with the given names.
"""
feature_stats_list = facet_feature_statistics_pb2.DatasetFeatureStatisticsList()
for name, df in dfs:
if not df.empty:
proto = convert_to_dataset_feature_statistics(df)
proto.name = name
feature_stats_list.datasets.append(proto)
return feature_stats_list
def get_facets_polyfills() -> str:
"""
A JS polyfill/monkey-patching function that fixes issue where objectURL passed as a
"base" argument to the URL constructor ends up in a "invalid URL" exception.
Polymer is using parent's URL in its internal asset URL resolution system, while MLFLow
artifact rendering engine uses object URLs to display iframed artifacts code. This ends up
in object URL being used in `new URL()` constructor which needs to be patched.
Original function code:
(function patchURLConstructor() {
const _originalURLConstructor = window.URL;
window.URL = function (url, base) {
if (typeof base === "string" && base.startsWith("blob:")) {
return new URL(base);
}
return new _originalURLConstructor(url, base);
};
})();
"""
return """
!function() {
let t = window.URL;
window.URL = function(n, e) {
if (typeof e === "string" && e.startsWith("blob:")) {
return new URL(e);
} else {
return new t(n, e);
}
}
}();
"""
def construct_facets_html(
proto: facet_feature_statistics_pb2.DatasetFeatureStatisticsList, compare: bool = False
) -> str:
"""
Constructs the facets HTML to visualize the serialized FeatureStatisticsList proto.
Args:
proto: A DatasetFeatureStatisticsList proto which contains the statistics for a DataFrame.
compare: If True, then the returned visualization switches on the comparison
mode for several stats.
Returns:
The HTML for Facets visualization.
"""
# facets_html_bundle = _get_facets_html_bundle()
protostr = base64.b64encode(proto.SerializeToString()).decode("utf-8")
polyfills_code = get_facets_polyfills()
return f"""
<div style="background-color: white">
<script>{polyfills_code}</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.3.3/webcomponents-lite.js"></script>
<link rel="import" href="https://raw.githubusercontent.com/PAIR-code/facets/1.0.0/facets-dist/facets-jupyter.html" >
<facets-overview id="facets" proto-input="{protostr}" compare-mode="{compare}"></facets-overview>
</div>
""" # noqa: E501
def get_html(inputs: Union[pd.DataFrame, Iterable[tuple[str, pd.DataFrame]]]) -> str:
"""
Rendering the data statistics in a HTML format.
Args:
inputs: Either a single "glimpse" DataFrame that contains the statistics, or a
collection of (name, DataFrame) pairs where each pair names a separate "glimpse"
and they are all visualized in comparison mode.
Returns:
None
"""
if isinstance(inputs, pd.DataFrame):
if not inputs.empty:
proto = convert_to_proto(inputs)
compare = False
else:
proto = convert_to_comparison_proto(inputs)
compare = True
return construct_facets_html(proto, compare=compare)

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{HEADER_TITLE}}</title>
<style>
h1.title {
color: #4287f5;
}
h2.step-title {
color : #4287f5;
}
.tab {
overflow: hidden;
border: 1px solid #ccc;
background-color: #f1f1f1;
}
.tab button {
background-color: inherit;
float: left;
border: none;
outline: none;
cursor: pointer;
padding: 14px 16px;
transition: 0.3s;
}
.tab button:hover {
background-color: #ddd;
}
.tab button.active {
background-color: #ccc;
}
.tabcontent {
display: block;
padding: 6px 12px;
border: 1px solid #ccc;
border-top: none;
}
.content-hide {
display: none;
}
.content-active {
display: block;
}
h3.section-title {
color: #57a8de;
}
.dataset-container {
width: max;
overflow-x: auto;
white-space: nowrap;
}
.stacktrace-container {
max-height: 300px;
overflow: auto;
display: flex;
flex-direction: column-reverse;
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="tab">
<div class="{{BUTTON_CONTAINER}}">
{% for tab in tab_list %}
<button id={{loop.index - 1}} class="{{TABLINK}}">
{{tab.0}}
</button>
{% endfor %}
</div>
</div>
<div class="tabcontent">
{% for tab in tab_list %}
<div class="{{CONTENT}} content-hide">{{ tab.1 }}</div>
{% endfor %}
</div>
<script>
function onclickTab(event) {
const tablinks = document.getElementsByClassName("{{TABLINK}}");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace("active", "");
}
event.target.classList.add("active");
const tabId = event.target.id;
const tabContents = document.getElementsByClassName("{{CONTENT}}");
for (i = 0; i < tabContents.length; i++) {
tabContents[i].className = tabContents[i].className.replace("content-active", "content-hide");
}
tabContents[tabId].classList.add("content-active");
}
(function() {
const buttonsContainer = document.getElementsByClassName("{{BUTTON_CONTAINER}}")[0]
buttonsContainer.addEventListener("click", onclickTab);
const tablinks = document.getElementsByClassName("{{TABLINK}}");
tablinks[0].classList.add("active");
const tabContents = document.getElementsByClassName("{{CONTENT}}");
tabContents[0].classList.add("content-active");
})()
</script>
</body>
</html>

View File

@@ -0,0 +1,5 @@
from mlflow.recipes.classification.v1.recipe import (
ClassificationRecipe as RecipeImpl,
)
__all__ = ["RecipeImpl"]

View File

@@ -0,0 +1,380 @@
"""
.. _mlflow-classification-recipe:
The MLflow Classification Recipe is an MLflow Recipe for developing binary classification
models. Multiclass classifiers are currently not supported.
The classification recipe is designed for developing models using scikit-learn and
frameworks that integrate with scikit-learn, such as the ``XGBClassifier`` API from XGBoost.
The `ClassificationRecipe API Documentation <https://github.com/mlflow/recipes-classification-template/blob/main/README.md>`
provides instructions for executing the recipe and inspecting its results.
The training recipe contains the following sequential steps:
**ingest** -> **split** -> **transform** -> **train** -> **evaluate** -> **register**
The batch scoring recipe contains the following sequential steps:
**ingest_scoring** -> **predict**
The recipe steps are defined as follows:
- **ingest**
- The **ingest** step resolves the dataset specified by
|'ingest' step definition in recipe.yaml| and converts it to parquet format, leveraging
the custom dataset parsing code defined in |steps/ingest.py| if necessary. Subsequent steps
convert this dataset into training, validation, & test sets and use them to develop a model.
.. note::
If you make changes to the dataset referenced by the **ingest** step (e.g. by adding
new records or columns), you must manually re-run the **ingest** step in order to
use the updated dataset in the recipe. The **ingest** step does *not* automatically
detect changes in the dataset.
.. note::
`target_col` must have a cardinality of two and `positive_class` must be specified.
.. _mlflow-classification-recipe-split-step:
- **split**
- The **split** step splits the ingested dataset produced by the **ingest** step into
a training dataset for model training, a validation dataset for model performance
evaluation & tuning, and a test dataset for model performance evaluation. The fraction
of records allocated to each dataset is defined by the ``split_ratios`` attribute of the
|'split' step definition in recipe.yaml|. The **split** step also preprocesses the
datasets using logic defined in |steps/split.py|. Subsequent steps use these datasets
to develop a model and measure its performance.
- **transform**
- The **transform** step uses the training dataset created by **split** to fit
a transformer that performs the transformations defined in |steps/transform.py|. The
transformer is then applied to the training dataset and the validation dataset, creating
transformed datasets that are used by subsequent steps for estimator training and model
performance evaluation.
.. _mlflow-classification-recipe-train-step:
- **train**
- The **train** step uses the transformed training dataset output from the **transform**
step to fit an estimator with the type and parameters defined in |steps/train.py|. The
estimator is then joined with the fitted transformer output from the **transform** step
to create a model recipe. Finally, this model recipe is evaluated against the
transformed training and validation datasets to compute performance metrics; custom
metrics are computed according to definitions in |steps/custom_metrics.py| and the
|'custom_metrics' section of recipe.yaml|. The model recipe and its associated parameters,
performance metrics, and lineage information are logged to MLflow Tracking, producing
an MLflow Run.
.. note::
The **train** step supports hyperparameter tuning with hyperopt by adding
configurations in the
|'tuning' section of the 'train' step definition in recipe.yaml|.
- **evaluate**
- The **evaluate** step evaluates the model recipe created by the **train** step on
the test dataset output from the **split** step, computing performance metrics and
model explanations. Performance metrics are compared against configured thresholds to
compute a ``model_validation_status``, which indicates whether or not a model is good
enough to be registered to the MLflow Model Registry by the subsequent **register**
step. Custom performance metrics are computed according to definitions in
|steps/custom_metrics.py| and the |'custom_metrics' section of recipe.yaml|. Model
performance thresholds are defined in the
|'validation_criteria' section of the 'evaluate' step definition in recipe.yaml|. Model
performance metrics and explanations are logged to the same MLflow Tracking Run used by
the **train** step.
- **register**
- The **register** step checks the ``model_validation_status`` output of the preceding
**evaluate** step and, if model validation was successful
(as indicated by the ``'VALIDATED'`` status), registers the model recipe created by
the **train** step to the MLflow Model Registry. If the ``model_validation_status`` does
not indicate that the model passed validation checks (i.e. its value is ``'REJECTED'``),
the model recipe is not registered to the MLflow Model Registry.
If the model recipe is registered to the MLflow Model Registry, a
``registered_model_version`` is produced containing the model name and the model version.
.. note::
The model validation status check can be disabled by specifying
``allow_non_validated_model: true`` in the
|'register' step definition of recipe.yaml|, in which case the model recipe is
always registered with the MLflow Model Registry when the **register** step is
executed.
- **ingest_scoring**
- The **ingest_scoring** step resolves the dataset specified by the
|'ingest_scoring' section in recipe.yaml| and converts it to parquet format, leveraging
the custom dataset parsing code defined in |steps/ingest.py| if necessary.
.. note::
If you make changes to the dataset referenced by the **ingest_scoring** step
(e.g. by adding new records or columns), you must manually re-run the
**ingest_scoring** step in order to use the updated dataset in the recipe.
The **ingest_scoring** step does *not* automatically detect changes in the dataset.
- **predict**
- The **predict** step uses the ingested dataset for scoring created by the
**ingest_scoring** step and applies the specified model to the dataset.
.. note::
In Databricks, the **predict** step writes the output parquet/delta files to
DBFS.
"""
import logging
from typing import Any, Optional
from mlflow.recipes.recipe import BaseRecipe
from mlflow.recipes.step import BaseStep
from mlflow.recipes.steps.evaluate import EvaluateStep
from mlflow.recipes.steps.ingest import IngestScoringStep, IngestStep
from mlflow.recipes.steps.predict import PredictStep
from mlflow.recipes.steps.register import RegisterStep
from mlflow.recipes.steps.split import SplitStep
from mlflow.recipes.steps.train import TrainStep
from mlflow.recipes.steps.transform import TransformStep
_logger = logging.getLogger(__name__)
class ClassificationRecipe(BaseRecipe):
"""
A recipe for developing high-quality classification models. The recipe is designed for
developing models using scikit-learn and frameworks that integrate with scikit-learn,
such as the ``XGBClassifier`` API from XGBoost.
The training recipe contains the following sequential steps:
**ingest** -> **split** -> **transform** -> **train** -> **evaluate** -> **register**
while the batch scoring recipe contains this set of sequential steps:
**ingest_scoring** -> **predict**
.. code-block:: python
:caption: Example
import os
from mlflow.recipes import Recipe
os.chdir("~/mlp-classification-template")
classification_recipe = Recipe(profile="local")
# Display a visual overview of the recipe graph
classification_recipe.inspect()
# Run the full recipe
classification_recipe.run()
# Display a summary of results from the 'train' step, including the trained model
# and associated performance metrics computed from the training & validation datasets
classification_recipe.inspect(step="train")
# Display a summary of results from the 'evaluate' step, including model explanations
# computed from the validation dataset and metrics computed from the test dataset
classification_recipe.inspect(step="evaluate")
"""
_RECIPE_STEPS = (
# Training data ingestion DAG
IngestStep,
# Model training DAG
SplitStep,
TransformStep,
TrainStep,
EvaluateStep,
RegisterStep,
# Batch scoring DAG
IngestScoringStep,
PredictStep,
)
_DEFAULT_STEP_INDEX = _RECIPE_STEPS.index(RegisterStep)
def _get_step_classes(self):
return self._RECIPE_STEPS
def _get_default_step(self) -> BaseStep:
return self._steps[self._DEFAULT_STEP_INDEX]
def run(self, step: Optional[str] = None) -> None:
"""
Runs the full recipe or a particular recipe step, producing outputs and displaying a
summary of results upon completion. Step outputs are cached from previous executions, and
steps are only re-executed if configuration or code changes have been made to the step or
to any of its dependent steps (e.g. changes to the recipe's ``recipe.yaml`` file or
``steps/ingest.py`` file) since the previous execution.
Args:
step: String name of the step to run within the classification recipe. The step and
its dependencies are executed sequentially. If a step is not specified, the
entire recipe is executed. Supported steps, in their order of execution, are:
- ``"ingest"``: resolves the dataset specified by the ``data/training`` section
in the recipe's configuration file (``recipe.yaml``) and converts it to
parquet format.
- ``"ingest_scoring"``: resolves the dataset specified by the
``ingest_scoring`` section in the recipe's configuration file
(``recipe.yaml``) and converts it to parquet format.
- ``"split"``: splits the ingested dataset produced by the **ingest** step into
a training dataset for model training, a validation dataset for model
performance evaluation & tuning, and a test dataset for model performance
evaluation.
- ``"transform"``: uses the training dataset created by the **split** step to
fit a transformer that performs the transformations defined in the
recipe's ``steps/transform.py`` file. Then, applies the transformer to the
training dataset and the validation dataset, creating transformed datasets
that are used by subsequent steps for estimator training and model
performance evaluation.
- ``"train"``: uses the transformed training dataset output from the
**transform** step to fit an estimator with the type and parameters defined
in in the recipe's ``steps/train.py`` file. Then, joins the estimator with
the fitted transformer output from the **transform** step to create a model
recipe. Finally, evaluates the model recipe against the transformed
training and validation datasets to compute performance metrics.
- ``"evaluate"``: evaluates the model recipe created by the **train** step
on the validation and test dataset outputs from the **split** step, computing
performance metrics and model explanations. Then, compares performance
metrics against thresholds configured in the recipe's ``recipe.yaml``
configuration file to compute a ``model_validation_status``, which indicates
whether or not the model is good enough to be registered to the MLflow Model
Registry by the subsequent **register** step.
- ``"register"``: checks the ``model_validation_status`` output of the
preceding **evaluate** step and, if model validation was successful (as
indicated by the ``'VALIDATED'`` status), registers the model recipe
created by the **train** step to the MLflow Model Registry.
- ``"predict"``: uses the ingested dataset for scoring created by the
**ingest_scoring** step and applies the specified model to the dataset.
.. code-block:: python
:caption: Example
import os
from mlflow.recipes import Recipe
os.chdir("~/mlp-classification-template")
classification_recipe = Recipe(profile="local")
# Run the 'train' step and preceding steps
classification_recipe.run(step="train")
# Run the 'register' step and preceding steps; the 'train' step and all steps
# prior to 'train' are not re-executed because their outputs are already cached
classification_recipe.run(step="register")
# Run all recipe steps; equivalent to running 'register'; no steps are re-executed
# because the outputs of all steps are already cached
classification_recipe.run()
"""
return super().run(step=step)
def get_artifact(self, artifact_name: str) -> Optional[Any]:
"""
Reads an artifact from the recipe's outputs. Supported artifact names can be obtained by
examining the recipe graph visualization displayed by
:py:func:`ClassificationRecipe.inspect()`.
Args:
artifact_name: The string name of the artifact. Supported artifact values are:
- ``"ingested_data"``: returns the ingested dataset created in the
**ingest** step as a pandas DataFrame.
- ``"training_data"``: returns the training dataset created in the
**split** step as a pandas DataFrame.
- ``"validation_data"``: returns the validation dataset created in the
**split** step as a pandas DataFrame.
- ``"test_data"``: returns the test dataset created in the **split** step
as a pandas DataFrame.
- ``"ingested_scoring_data"``: returns the scoring dataset created in the
**ingest_scoring** step as a pandas DataFrame.
- ``"transformed_training_data"``: returns the transformed training dataset
created in the **transform** step as a pandas DataFrame.
- ``"transformed_validation_data"``: returns the transformed validation
dataset created in the **transform** step as a pandas DataFrame.
- ``"model"``: returns the MLflow Model recipe created in the **train**
step as a :py:class:`PyFuncModel <mlflow.pyfunc.PyFuncModel>` instance.
- ``"transformer"``: returns the scikit-learn transformer created in the
**transform** step.
- ``"run"``: returns the
:py:class:`MLflow Tracking Run <mlflow.entities.Run>` containing the
model recipe created in the **train** step and its associated
parameters, as well as performance metrics and model explanations created
during the **train** and **evaluate** steps.
- ``"registered_model_version``": returns the MLflow Model Registry
:py:class:`ModelVersion <mlflow.entities.model_registry.ModelVersion>`
created by the **register** step.
- ``"scored_data"``: returns the scored dataset created in the
**predict** step as a pandas DataFrame.
Returns:
An object representation of the artifact corresponding to the specified name,
as described in the ``artifact_name`` parameter docstring. If the artifact is
not present because its corresponding step has not been executed or its output
cache has been cleaned, ``None`` is returned.
"""
return super().get_artifact(artifact_name=artifact_name)
def clean(self, step: Optional[str] = None) -> None:
"""
Removes all recipe outputs from the cache, or removes the cached outputs of a particular
recipe step if specified. After cached outputs are cleaned for a particular step, the
step will be re-executed in its entirety the next time it is run.
Args:
step: String name of the step to clean within the recipe. If not specified,
cached outputs are removed for all recipe steps.
.. code-block:: python
import os
from mlflow.recipes import Recipe
os.chdir("~/mlp-classification-template")
classification_recipe = Recipe(profile="local")
# Run the 'train' step and preceding steps
classification_recipe.run(step="train")
# Clean the cache of the 'transform' step
classification_recipe.clean(step="transform")
# Run the 'split' step; outputs are still cached because 'split' precedes
# 'transform' & 'train'
classification_recipe.run(step="split")
# Run the 'train' step again; the 'transform' and 'train' steps are re-executed because:
# 1. the cache of the preceding 'transform' step was cleaned and 2. 'train' occurs after
# 'transform'. The 'ingest' and 'split' steps are not re-executed because their outputs
# are still cached
classification_recipe.run(step="train")
"""
super().clean(step=step)
def inspect(self, step: Optional[str] = None) -> None:
"""
Displays a visual overview of the recipe graph, or displays a summary of results from
a particular recipe step if specified. If the specified step has not been executed,
nothing is displayed.
Args:
step: String name of the recipe step for which to display a results summary. If
unspecified, a visual overview of the recipe graph is displayed.
.. code-block:: python
import os
from mlflow.recipes import Recipe
os.chdir("~/mlp-classification-template")
classification_recipe = Recipe(profile="local")
# Display a visual overview of the recipe graph.
classification_recipe.inspect()
# Run the 'train' recipe step
classification_recipe.run(step="train")
# Display a summary of results from the preceding 'transform' step
classification_recipe.inspect(step="transform")
"""
super().inspect(step=step)

View File

@@ -0,0 +1,110 @@
import click
from mlflow.environment_variables import MLFLOW_RECIPES_PROFILE
from mlflow.recipes import Recipe
_CLI_ARG_RECIPE_PROFILE = click.option(
"--profile",
"-p",
envvar=MLFLOW_RECIPES_PROFILE.name,
type=click.STRING,
default=None,
required=True,
help=(
"The name of the recipe profile to use. Profiles customize the configuration of"
" one or more recipe steps, and recipe executions with different profiles often"
" produce different results."
),
)
@click.group("recipes")
def commands():
"""
MLflow Recipes is deprecated and will be removed in MLflow 3.0.
Run MLflow Recipes and inspect recipe results.
"""
@commands.command(short_help="Run the full recipe or a particular recipe step.")
@click.option(
"--step",
"-s",
type=click.STRING,
default=None,
required=False,
help="The name of the recipe step to run.",
)
@_CLI_ARG_RECIPE_PROFILE
def run(step, profile):
"""
Run the full recipe, or run a particular recipe step if specified, producing
outputs and displaying a summary of results upon completion.
"""
Recipe(profile=profile).run(step)
@commands.command(
short_help=(
"Remove all recipe outputs from the cache, or remove the cached outputs of"
" a particular recipe step."
)
)
@click.option(
"--step",
"-s",
type=click.STRING,
default=None,
required=False,
help="The name of the recipe step for which to remove cached outputs.",
)
@_CLI_ARG_RECIPE_PROFILE
def clean(step, profile):
"""
Remove all recipe outputs from the cache, or remove the cached outputs of a particular
recipe step if specified. After cached outputs are cleaned for a particular step, the step
will be re-executed in its entirety the next time it is run.
"""
Recipe(profile=profile).clean(step)
@commands.command(
short_help=(
"Display an overview of the recipe graph or a summary of results from a particular step."
)
)
@click.option(
"--step",
"-s",
type=click.STRING,
default=None,
required=False,
help="The name of the recipe step to inspect.",
)
@_CLI_ARG_RECIPE_PROFILE
def inspect(step, profile):
"""
Display a visual overview of the recipe graph, or display a summary of results from a
particular recipe step if specified. If the specified step has not been executed,
nothing is displayed.
"""
Recipe(profile=profile).inspect(step)
@commands.command(short_help=("Get the location of an artifact output from the recipe."))
@click.option(
"--artifact",
"-a",
type=click.STRING,
default=None,
required=True,
help="The name of the artifact to retrieve.",
)
@_CLI_ARG_RECIPE_PROFILE
def get_artifact(profile, artifact):
"""
Get the location of an artifact output from the recipe.
"""
artifact_location = Recipe(profile=profile)._get_artifact(artifact).path()
click.echo(artifact_location)

View File

@@ -0,0 +1,287 @@
# ruff: noqa: E501
def format_help_string(help_string):
"""
Formats the specified ``help_string`` to obtain a Mermaid-compatible help string. For example,
this method replaces quotation marks with their HTML representation.
Args:
help_string: The raw help string.
Returns:
A Mermaid-compatible help string.
"""
return help_string.replace('"', "&bsol;#quot;").replace("'", "&bsol;&#39;")
RECIPE_YAML = format_help_string(
"""# recipe.yaml is the main configuration file for the recipe. It defines attributes for each step of the recipe, such as the dataset to use (defined in the the 'ingest' step definition) and the metrics to compute during model training & evaluation (defined in the 'custom_metrics' section, which is used by the 'train' and 'evaluate' steps). recipe.yaml files also support value overrides from profiles (located in the 'profiles' subdirectory of the recipe) using Jinja2 templating syntax. An example recipe.yaml file is displayed below.\n
recipe: "regression/v1"
target_col: "fare_amount"
primary_metric: "root_mean_squared_error"
steps:
ingest: {{INGEST_CONFIG}}
split:
split_ratios: {{SPLIT_RATIOS|default([0.75, 0.125, 0.125])}}
post_split_filter_method: create_dataset_filter
transform:
using: custom
transformer_method: transformer_fn
train:
using: custom
estimator_method: estimator_fn
evaluate:
validation_criteria:
- metric: root_mean_squared_error
threshold: 10
- metric: mean_absolute_error
threshold: 50
- metric: weighted_mean_squared_error
threshold: 50
register:
allow_non_validated_model: false
ingest_scoring: {{INGEST_SCORING_CONFIG}}
predict:
output: {{PREDICT_OUTPUT_CONFIG}}
custom_metrics:
- name: weighted_mean_squared_error
function: weighted_mean_squared_error
greater_is_better: False
"""
)
INGEST_STEP_BASE = """The '{0}' step resolves the dataset specified by the '{1}' section in recipe.yaml and converts it to parquet format, leveraging the custom dataset parsing code defined in `steps/ingest.py` (and referred to by the 'loader_method' attribute of the '{1}' section in recipe.yaml) if necessary. {2} An example recipe.yaml '{1}' configuration is shown below.
{1}:
location: https://nyc-tlc.s3.amazonaws.com/trip+data/yellow_tripdata_2022-01.parquet
using: {{{{INGEST_DATA_FORMAT|default('parquet')}}}}
loader_method: load_file_as_dataframe
"""
INGEST_STEP = format_help_string(
INGEST_STEP_BASE.format(
"ingest",
"steps.ingest",
"Subsequent steps convert this dataset into training, validation, & test sets and use them to develop a model.",
)
)
INGEST_USER_CODE = format_help_string(
"""\"\"\"\nsteps/ingest.py defines customizable logic for parsing arbitrary dataset formats (i.e. formats that are not natively parsed by MLflow Recipes) via the `load_file_as_dataframe` function. Note that the Parquet, Delta, and Spark SQL dataset formats are natively parsed by MLflow Recipes, and you do not need to define custom logic for parsing them. An example `load_file_as_dataframe` implementation is displayed below (note that a different function name or module can be specified via the 'loader_method' attribute of the 'data' section in recipe.yaml).\n\"\"\"\n
def load_file_as_dataframe(
file_path: str,
file_format: str,
) -> pandas.DataFrame:
\"\"\"
Load content from the specified dataset file as a Pandas DataFrame.
This method is used to load dataset types that are not natively managed by MLflow Recipes (datasets that are not in Parquet, Delta Table, or Spark SQL Table format). This method is called once for each file in the dataset, and MLflow Recipes automatically combines the resulting DataFrames together.
:param file_path: The path to the dataset file.
:param file_format: The file format string, such as "csv".
:return: A Pandas DataFrame representing the content of the specified file.
\"\"\"
"""
)
INGESTED_DATA = format_help_string(
"The ingested parquet representation of the dataset defined in the 'steps.ingest' section of recipe.yaml. Subsequent steps convert this dataset into training, validation, & test sets and use them to develop a model."
)
SPLIT_STEP = format_help_string(
"""The 'split' step splits the ingested dataset produced by the 'ingest' step into a training dataset for model training, a validation dataset for model performance evaluation & tuning, and a test dataset for model performance evaluation. The fraction of records allocated to each dataset is defined by the 'split_ratios' attribute of the 'split' step definition in recipe.yaml. The split step also preprocesses the datasets using logic defined in `steps/split.py` (and referred to by the 'post_split_method' attribute of the 'split' step definition in recipe.yaml). Subsequent steps use these datasets to develop a model and measure its performance. An example recipe.yaml 'split' step definition is shown below.
steps:
split:
split_ratios: {{SPLIT_RATIOS|default([0.75, 0.125, 0.125])}}
post_split_filter_method: create_dataset_filter
"""
)
SPLIT_USER_CODE = format_help_string(
"""\"\"\"\nsteps/split.py defines customizable logic for postprocessing the training, validation, and test datasets prior to model creation via the `create_dataset_filter` function, an example of which is displayed below (note that a different function name or module can be specified via the 'post_split_filter_method' attribute of the 'split' step definition in recipe.yaml).\n\"\"\"\n
This module defines the following routines used by the 'split' step of the recipe:
- ``create_dataset_filter``: Defines customizable logic for filtering the training, validation,
and test datasets produced by the data splitting procedure. Note that arbitrary transformations
should go into the transform step.
def create_dataset_filter(dataset: DataFrame) -> Series(bool):
Mark rows of the split datasets to be additionally filtered. This function will be called on
the training, validation, and test datasets.
:param dataset: The {train,validation,test} dataset produced by the data splitting procedure.
:return: A Series indicating whether each row should be filtered
"""
)
TRAINING_DATA = format_help_string(
"The training dataset used to train the model. Subsequent steps fit a transformer using this training data, create transformed features, and use the transformed features to fit an estimator, producing a model pipeline consisting of the fitted transformer and the fitted estimator."
)
VALIDATION_DATA = format_help_string(
"The validation dataset used to evaluate model performance and tune the model pipeline in the train step. It is also used in evaluate step to compute model explanations such as feature importances."
)
TEST_DATA = format_help_string(
"The test dataset used to evaluate the performance of the model. The 'evaluate' step uses the test dataset to compute a variety of performance metrics."
)
TRANSFORM_STEP = format_help_string(
"""The 'transform' step uses the training dataset produced by 'split' to fit a transformer with the transformation operations defined in `steps/transform.py` (and referred to by the 'transformer_method' attribute of the 'transform' step definition in recipe.yaml). The transformer is then applied to the training dataset and the validation dataset, producing transformed datasets that are used by subsequent steps for estimator training and model performance evaluation. An example recipe.yaml 'transform' step definition is shown below.
steps:
transform:
using: custom
transformer_method: transformer_fn
"""
)
TRANSFORM_USER_CODE = format_help_string(
"""\"\"\"\nsteps/transform.py defines customizable logic for transforming input data during model inference. Transformations are specified via the via the `transformer_fn` function, an example of which is displayed below (note that a different function name or module can be specified via the 'transformer_method' attribute of the 'transform' step definition in recipe.yaml).\n\"\"\"\n
def transformer_fn():
\"\"\"
Returns an *unfitted* transformer that defines ``fit()`` and ``transform()`` methods. The transformer's input and output signatures should be compatible with scikit-learn transformers.
\"\"\"
"""
)
FITTED_TRANSFORMER = format_help_string(
"The fitted transformer produced by fitting the transformer defined in `steps/transform.py` on the training dataset output from the 'split' step. The fitted transformer is the first component of the model pipeline. The subsequent 'train' step fits an estimator and creates a model pipeline consisting of the fitted transformer and the fitted estimator."
)
TRANSFORMED_TRAINING_AND_VALIDATION_DATA = format_help_string(
"1. The transformed training dataset used to fit the estimator component of the model pipeline. Note that training produces a model pipeline consisting of a fitted transformer and a fitted estimator.\n\n2. The validation dataset used to evaluate estimator performance and tune the estimator."
)
TRAIN_STEP = format_help_string(
"""The 'train' step uses the transformed training dataset produced by 'transform' to fit an estimator with the type and parameters defined in `steps/train.py` (and referred to by the 'estimator_method' attribute of the 'train' step definition in recipe.yaml). The estimator is then joined with the fitted transformer output from the 'transform' step to create a model pipeline. Finally, this model pipeline is evaluated against the transformed training and validation datasets to produce performance metrics; custom metrics are computed according to definitions in `steps/custom_metrics.py` and the 'function' attributes of entries in the 'custom' subsection of the 'metrics' section in recipe.yaml. The model pipeline and its associated parameters, performance metrics, and lineage information are logged to MLflow Tracking, producing an MLflow Run. An example recipe.yaml 'train' step definition is shown below, as well as an example custom metric definition.
steps:
train:
using: custom
estimator_method: estimator_fn
custom_metrics:
- name: weighted_mean_squared_error
function: weighted_mean_squared_error
greater_is_better: False
"""
)
TRAIN_USER_CODE = format_help_string(
"""\"\"\"\nsteps/train.py defines customizable logic for specifying your estimator's type and parameters that will be used during training. The estimator type and its parameters are specified via the `estimator_fn` function, an example of which is displayed below (note that a different function name or module can be specified via the 'estimator_method' attribute of the 'train' step definition in recipe.yaml).\n\"\"\"\n
def estimator_fn():
\"\"\"
Returns an *unfitted* estimator that defines ``fit()`` and ``predict()`` methods. The estimator's input and output signatures should be compatible with scikit-learn estimators.
\"\"\"
"""
)
FITTED_MODEL = format_help_string(
"The model pipeline produced by fitting the estimator defined in `steps/train.py` on the training dataset and preceding it with the fitted transformer output by the 'transform' step."
)
MLFLOW_RUN = format_help_string(
"The MLflow Tracking Run containing the model pipeline & its parameters, model performance metrics on the training & validation datasets, and lineage information about the current recipe execution. The downstream 'evaluate' step logs performance metrics and model explanations from the test dataset to this MLflow Run."
)
PREDICTED_TRAINING_DATA = format_help_string(
"The predicted training dataset that is obtained by predicted training data using the fitted model."
)
CUSTOM_METRICS_USER_CODE = format_help_string(
"""\"\"\"\nsteps/custom_metrics.py defines customizable logic for specifying custom metrics to compute during model training and evaluation. Custom metric functions defined in `steps/custom_metrics.py` are referenced by the 'function' attributes of entries in the 'custom' subsection of the 'metrics' section in recipe.yaml. For example:
custom_metrics:
- name: weighted_mean_squared_error
function: weighted_mean_squared_error
greater_is_better: False
An example custom_metrics.py file is displayed below.
\"\"\"\
def weighted_mean_squared_error(
eval_df: pandas.DataFrame,
builtin_metrics: Dict[str, int],
) -> Dict[str, int]:
\"\"\"
Computes the weighted mean squared error (MSE) metric.
:param eval_df: A Pandas DataFrame containing the following columns:
- ``"prediction"``: Predictions produced by submitting input data to the model.
- ``"target"``: Ground truth values corresponding to the input data.
:param builtin_metrics: A dictionary containing the built-in metrics that are calculated automatically during model evaluation. The keys are the names of the metrics and the values are the scalar values of the metrics. For more information, see https://mlflow.org/docs/latest/python_api/mlflow.html#mlflow.evaluate.
:return: A single-entry dictionary containing the MSE metric. The key is the metric names and the value is the scalar metric value. Note that custom metric functions can return dictionaries with multiple metric entries as well.
\"\"\"
"""
)
EVALUATE_STEP = format_help_string(
"""The 'evaluate' step evaluates the model pipeline produced by the 'train' step on the test dataset output from the 'split' step, producing performance metrics and model explanations. Performance metrics are compared against configured thresholds to compute a 'model_validation_status', which indicates whether or not a model is good enough to be registered to the MLflow Model Registry by the subsequent 'register' step. Custom performance metrics are computed according to definitions in `steps/custom_metrics.py` and the 'function' attributes of entries in the 'custom' subsection of the 'metrics' section in recipe.yaml. Model performance thresholds are defined in the 'validation_criteria' section of the 'evaluate' step definition in recipe.yaml. Model performance metrics and explanations are logged to MLflow Tracking using the same MLflow Run produced by the 'train' step. An example recipe.yaml 'evaluate' step definition is shown below, as well as an example custom metric definition.
evaluate:
validation_criteria:
- metric: root_mean_squared_error
threshold: 10
- metric: weighted_mean_squared_error
threshold: 20
custom_metrics:
- name: weighted_mean_squared_error
function: weighted_mean_squared_error
greater_is_better: False
"""
)
MODEL_VALIDATION_STATUS = format_help_string(
"""Boolean status indicating whether or not the model meets the performance criteria for registration to the MLflow Model Registry. Performance criteria are defined in the 'validation_criteria' section of the 'evaluate' step definition in recipe.yaml, as shown in the example below. The subsequent 'register' step checks the model validation status, and, if it is 'VALIDATED', creates a new model version in the Model Registry corresponding to the trained model pipeline.
evaluate:
validation_criteria:
- metric: root_mean_squared_error
threshold: 10
- metric: mean_absolute_error
threshold: 50
- metric: weighted_mean_squared_error
threshold: 20
"""
)
REGISTER_STEP = format_help_string(
"""The 'register' step checks the 'model_validation_status' output of the preceding 'evaluate' step and, if model validation was successful (as indicated by the 'VALIDATED' status), registers the model pipeline produced by the 'train' step to the MLflow Model Registry. If the 'model_validation_status' does not indicate that the model passed validation checks (i.e. its value is 'REJECTED'), the model pipeline is not registered to the MLflow Model Registry. This validation status check can be disabled by specifying 'allow_non_validated_model: true' in the 'register' step definition of recipe.yaml, in which case the model pipeline is always registered with the MLflow Model Registry when the 'register' step is executed. If the model pipeline is registered to the MLflow Model Registry, a 'registered_model_version' is produced containing the model name (as configured by the 'model_name' attribute of the 'register' step definition in recipe.yaml) and the model version. An example recipe.yaml 'register' step definition is shown below.
register:
allow_non_validated_model: true
"""
)
INGEST_SCORING_STEP = format_help_string(
INGEST_STEP_BASE.format(
"ingest_scoring", "data_scoring", "Subsequent steps score this dataset for batch scoring."
)
)
INGESTED_SCORING_DATA = format_help_string(
"The ingested parquet representation of the dataset defined in the 'data_scoring' section of recipe.yaml. Subsequent steps score this dataset for batch scoring."
)
PREDICT_STEP = format_help_string(
"""The 'predict' step uses the model registered by the 'register' step to score the ingested dataset produced by the 'ingest_scoring' step and writes the resulting dataset to the specified output format and location. To get model for scoring, it reads the register step model version artifact. If the register step was cleared, it uses the latest version of the registered model specified by the `model_name` attribute of the recipe.yaml 'register' step definition. To fix a specific model for use in the 'predict' step, provide its model URI as the 'model_uri' attribute of the recipe.yaml 'predict' step definition. An example recipe.yaml 'predict' step definition is shown below.
steps:
predict:
model_uri: "models:/taxi_fare_regressor/Production" # optional
using: {{OUTPUT_DATA_FORMAT|default('parquet')}}
location: "{{OUTPUT_DATA_LOCATION}}"
"""
)
SCORED_DATA = format_help_string(
"The dataset produced by scoring the ingested dataset generated by the 'ingest_scoring' step with the model specified by the the 'predict' step."
)
REGISTERED_MODEL_VERSION = format_help_string(
"The Model Version in the MLflow Model Registry corresponding to the trained model. A Model Version is produced if the trained model meets the defined performance criteria for model registration or if `allow_non_validated_model: true` is specified in the 'register' step definition of recipe.yaml"
)

View File

@@ -0,0 +1,447 @@
import abc
import logging
import os
import warnings
from typing import Optional
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import BAD_REQUEST, INTERNAL_ERROR, INVALID_PARAMETER_VALUE
from mlflow.recipes import dag_help_strings
from mlflow.recipes.artifacts import Artifact
from mlflow.recipes.step import BaseStep, StepClass, StepStatus
from mlflow.recipes.utils import (
get_recipe_config,
get_recipe_name,
get_recipe_root_path,
)
from mlflow.recipes.utils.execution import (
clean_execution_state,
get_or_create_base_execution_directory,
get_step_output_path,
run_recipe_step,
)
from mlflow.recipes.utils.step import display_html
from mlflow.utils.class_utils import _get_class_from_string
_logger = logging.getLogger(__name__)
class BaseRecipe:
"""
Base Recipe
"""
def __init__(self, recipe_root_path: str, profile: str) -> None:
"""
Recipe base class.
Args:
recipe_root_path: String path to the directory under which the recipe template
such as recipe.yaml, profiles/{profile}.yaml and steps/{step_name}.py are defined.
profile: String specifying the profile name, with which
{recipe_root_path}/profiles/{profile}.yaml is read and merged with
recipe.yaml to generate the configuration to run the recipe.
"""
self._recipe_root_path = recipe_root_path
self._run_args = {}
self._profile = profile
self._name = get_recipe_name(recipe_root_path)
# self._steps contains concatenated ordered lists of step objects representing multiple
# disjoint DAGs. To keep it in sync with the underlying config file, it should be reloaded
# from config files using self._resolve_recipe_steps() at the beginning of __init__(),
# run(), and inspect(), and should not reload it elsewhere.
self._steps = self._resolve_recipe_steps()
self._recipe = get_recipe_config(self._recipe_root_path, self._profile).get("recipe")
@property
def name(self) -> str:
"""Returns the name of the recipe."""
return self._name
@property
def profile(self) -> str:
"""
Returns the profile under which the recipe and its steps will execute.
"""
return self._profile
def run(self, step: Optional[str] = None) -> None:
"""
Runs a step in the recipe, or the entire recipe if a step is not specified.
Args:
step: String name to run a step within the recipe. The step and its dependencies
will be run sequentially. If a step is not specified, the entire recipe is
executed.
Returns:
None
"""
# TODO Record performance here.
self._steps = self._resolve_recipe_steps()
target_step = self._get_step(step) if step else self._get_default_step()
last_executed_step = run_recipe_step(
self._recipe_root_path,
self._get_subgraph_for_target_step(target_step),
target_step,
self._recipe,
)
self.inspect(last_executed_step.name)
# Verify that the step execution succeeded and throw if it didn't.
last_executed_step_output_directory = get_step_output_path(
self._recipe_root_path, last_executed_step.name, ""
)
last_executed_step_state = last_executed_step.get_execution_state(
last_executed_step_output_directory
)
if last_executed_step_state.status != StepStatus.SUCCEEDED:
last_step_error_mesg = (
f"The following error occurred while running step '{last_executed_step}':\n"
f"{last_executed_step_state.stack_trace}\n"
f"Last step status: '{last_executed_step_state.status}'\n"
)
if step is not None:
raise MlflowException(
f"Failed to run step '{step}' of recipe '{self.name}':\n{last_step_error_mesg}",
error_code=BAD_REQUEST,
)
else:
raise MlflowException(
f"Failed to run recipe '{self.name}':\n{last_step_error_mesg}",
error_code=BAD_REQUEST,
)
def inspect(self, step: Optional[str] = None) -> None:
"""
Displays main output from a step, or a recipe DAG if no step is specified.
Args:
step: String name to display a step output within the recipe. If a step is not
specified, the DAG of the recipe is shown instead.
Returns:
None
"""
self._steps = self._resolve_recipe_steps()
if not step:
display_html(html_file_path=self._get_recipe_dag_file())
else:
output_directory = get_step_output_path(self._recipe_root_path, step, "")
self._get_step(step).inspect(output_directory)
def clean(self, step: Optional[str] = None) -> None:
"""
Removes the outputs of the specified step from the cache, or removes the cached outputs
of all steps if no particular step is specified. After cached outputs are cleaned
for a particular step, the step will be re-executed in its entirety the next time it is
invoked via ``BaseRecipe.run()``.
Args:
step: String name of the step to clean within the recipe. If not specified,
cached outputs are removed for all recipe steps.
"""
to_clean = self._steps if not step else [self._get_step(step)]
clean_execution_state(self._recipe_root_path, to_clean)
def _get_step(self, step_name) -> BaseStep:
"""Returns a step class object from the recipe."""
steps = self._steps
step_names = [s.name for s in steps]
if step_name not in step_names:
raise MlflowException(
f"Step {step_name} not found in recipe. Available steps are {step_names}"
)
return self._steps[step_names.index(step_name)]
def _get_subgraph_for_target_step(self, target_step: BaseStep) -> list[BaseStep]:
"""
Return a list of step objects representing a connected DAG containing the target_step.
The returned list should be a sublist of self._steps.
"""
subgraph = []
if target_step.step_class == StepClass.UNKNOWN:
return subgraph
for step in self._steps:
if target_step.step_class() == step.step_class():
subgraph.append(step)
return subgraph
@abc.abstractmethod
def _get_default_step(self) -> BaseStep:
"""
Defines which step to run if no step is specified.
Concrete recipe class should implement this method.
"""
@abc.abstractmethod
def _get_step_classes(self):
"""
Returns a list of step classes defined in the recipe.
Concrete recipe class should implement this method.
"""
def _get_recipe_dag_file(self) -> str:
"""
Returns absolute path to the recipe DAG representation HTML file.
"""
import jinja2
j2_env = jinja2.Environment(loader=jinja2.FileSystemLoader(os.path.dirname(__file__)))
recipe_dag_template = j2_env.get_template("resources/recipe_dag_template.html").render(
{
"recipe_yaml_help": {
"help_string_type": "yaml",
"help_string": dag_help_strings.RECIPE_YAML,
},
"ingest_step_help": {
"help_string": dag_help_strings.INGEST_STEP,
"help_string_type": "text",
},
"ingest_user_code_help": {
"help_string": dag_help_strings.INGEST_USER_CODE,
"help_string_type": "python",
},
"ingested_data_help": {
"help_string": dag_help_strings.INGESTED_DATA,
"help_string_type": "text",
},
"split_step_help": {
"help_string": dag_help_strings.SPLIT_STEP,
"help_string_type": "text",
},
"split_user_code_help": {
"help_string": dag_help_strings.SPLIT_USER_CODE,
"help_string_type": "python",
},
"training_data_help": {
"help_string": dag_help_strings.TRAINING_DATA,
"help_string_type": "text",
},
"validation_data_help": {
"help_string": dag_help_strings.VALIDATION_DATA,
"help_string_type": "text",
},
"test_data_help": {
"help_string": dag_help_strings.TEST_DATA,
"help_string_type": "text",
},
"transform_step_help": {
"help_string": dag_help_strings.TRANSFORM_STEP,
"help_string_type": "text",
},
"transform_user_code_help": {
"help_string": dag_help_strings.TRANSFORM_USER_CODE,
"help_string_type": "python",
},
"fitted_transformer_help": {
"help_string": dag_help_strings.FITTED_TRANSFORMER,
"help_string_type": "text",
},
"transformed_training_and_validation_data_help": {
"help_string": dag_help_strings.TRANSFORMED_TRAINING_AND_VALIDATION_DATA,
"help_string_type": "text",
},
"train_step_help": {
"help_string": dag_help_strings.TRAIN_STEP,
"help_string_type": "text",
},
"train_user_code_help": {
"help_string": dag_help_strings.TRAIN_USER_CODE,
"help_string_type": "python",
},
"fitted_model_help": {
"help_string": dag_help_strings.FITTED_MODEL,
"help_string_type": "text",
},
"mlflow_run_help": {
"help_string": dag_help_strings.MLFLOW_RUN,
"help_string_type": "text",
},
"predicted_training_data_help": {
"help_string": dag_help_strings.PREDICTED_TRAINING_DATA,
"help_string_type": "text",
},
"custom_metrics_user_code_help": {
"help_string": dag_help_strings.CUSTOM_METRICS_USER_CODE,
"help_string_type": "python",
},
"evaluate_step_help": {
"help_string": dag_help_strings.EVALUATE_STEP,
"help_string_type": "text",
},
"model_validation_status_help": {
"help_string": dag_help_strings.MODEL_VALIDATION_STATUS,
"help_string_type": "text",
},
"register_step_help": {
"help_string": dag_help_strings.REGISTER_STEP,
"help_string_type": "text",
},
"registered_model_version_help": {
"help_string": dag_help_strings.REGISTERED_MODEL_VERSION,
"help_string_type": "text",
},
"ingest_scoring_step_help": {
"help_string": dag_help_strings.INGEST_SCORING_STEP,
"help_string_type": "text",
},
"ingested_scoring_data_help": {
"help_string": dag_help_strings.INGESTED_SCORING_DATA,
"help_string_type": "text",
},
"predict_step_help": {
"help_string": dag_help_strings.PREDICT_STEP,
"help_string_type": "text",
},
"scored_data_help": {
"help_string": dag_help_strings.SCORED_DATA,
"help_string_type": "text",
},
}
)
recipe_dag_file = os.path.join(
get_or_create_base_execution_directory(self._recipe_root_path), "recipe_dag.html"
)
with open(recipe_dag_file, "w") as f:
f.write(recipe_dag_template)
return recipe_dag_file
def _resolve_recipe_steps(self) -> list[BaseStep]:
"""
Constructs and returns all recipe step objects from the recipe configuration.
"""
recipe_config = get_recipe_config(self._recipe_root_path, self._profile)
recipe_config["profile"] = self.profile
return [
s.from_recipe_config(recipe_config, self._recipe_root_path)
for s in self._get_step_classes()
]
def get_artifact(self, artifact_name: str):
"""
Read an artifact from recipe output. artifact names can be obtained from
`Recipe.inspect()` or `Recipe.run()` output.
Returns None if the specified artifact is not found.
Raise an error if the artifact is not supported.
"""
return self._get_artifact(artifact_name).load()
def _get_artifact(self, artifact_name: str) -> Artifact:
"""
Read an Artifact object from recipe output. artifact names can be obtained
from `Recipe.inspect()` or `Recipe.run()` output.
Returns None if the specified artifact is not found.
Raise an error if the artifact is not supported.
"""
for step in self._steps:
for artifact in step.get_artifacts():
if artifact.name() == artifact_name:
return artifact
raise MlflowException(
f"The artifact with name '{artifact_name}' is not supported.",
error_code=INVALID_PARAMETER_VALUE,
)
class Recipe:
"""
A factory class that creates an instance of a recipe for a particular ML problem
(e.g. regression, classification) or MLOps task (e.g. batch scoring) based on the current
working directory and supplied configuration.
.. code-block:: python
:caption: Example
import os
from mlflow.recipes import Recipe
os.chdir("~/recipes-regression-template")
regression_recipe = Recipe(profile="local")
regression_recipe.run(step="train")
"""
def __new__(cls, profile: str):
"""
Creates an instance of an MLflow Recipe for a particular ML problem or MLOps task based
on the current working directory and supplied configuration. The current working directory
must be the root directory of an MLflow Recipe repository or a subdirectory of an
MLflow Recipe repository.
Args:
profile: The name of the profile to use for configuring the problem-specific or
task-specific recipe. Profiles customize the configuration of
one or more recipe steps, and recipe executions with different profiles
often produce different results.
Returns:
A recipe for a particular ML problem or MLOps task. For example, an instance of
`RegressionRecipe <https://github.com/mlflow/recipes-regression-template>`_
for regression problems.
.. code-block:: python
import os
from mlflow.recipes import Recipe
os.chdir("~/recipes-regression-template")
regression_recipe = Recipe(profile="local")
regression_recipe.run(step="train")
"""
warnings.warn(
"MLflow Recipes is deprecated and will be removed in MLflow 3.0.",
FutureWarning,
)
if not profile:
raise MlflowException(
"A profile name must be provided to construct a valid Recipe object.",
error_code=INVALID_PARAMETER_VALUE,
) from None
recipe_root_path = get_recipe_root_path()
if " " in recipe_root_path:
raise MlflowException(
message=(
"Recipe directory path cannot contain spaces. Please move or rename your "
f"recipe directory. Current path: {recipe_root_path}"
),
error_code=INVALID_PARAMETER_VALUE,
) from None
recipe_config = get_recipe_config(recipe_root_path=recipe_root_path, profile=profile)
recipe = recipe_config.get("recipe")
if recipe is None:
raise MlflowException(
"The `recipe` property needs to be defined in the `recipe.yaml` file. "
"For example: `recipe: regression/v1`",
error_code=INVALID_PARAMETER_VALUE,
) from None
recipe_path = recipe.replace("/", ".").replace("@", ".")
class_name = f"mlflow.recipes.{recipe_path}.RecipeImpl"
try:
recipe_class_module = _get_class_from_string(class_name)
except Exception as e:
if isinstance(e, ModuleNotFoundError):
raise MlflowException(
f"Failed to find Recipe {class_name}."
f"Please check the correctness of the recipe template setting: {recipe}",
error_code=INVALID_PARAMETER_VALUE,
) from None
else:
raise MlflowException(
f"Failed to construct Recipe {class_name}",
error_code=INTERNAL_ERROR,
) from e
recipe_name = get_recipe_name(recipe_root_path)
_logger.info(f"Creating MLflow Recipe '{recipe_name}' with profile: '{profile}'")
return recipe_class_module(recipe_root_path, profile)

View File

@@ -0,0 +1,5 @@
from mlflow.recipes.regression.v1.recipe import (
RegressionRecipe as RecipeImpl,
)
__all__ = ["RecipeImpl"]

View File

@@ -0,0 +1,399 @@
"""
.. _mlflow-regression-recipe:
The MLflow Regression Recipe is an MLflow Recipe for developing high-quality regression models.
It is designed for developing models using scikit-learn and frameworks that integrate with
scikit-learn, such as the ``XGBRegressor`` API from XGBoost. The corresponding recipe
template repository is available at https://github.com/mlflow/recipes-regression-template, and the
`RegressionRecipe API Documentation <https://github.com/mlflow/recipes-regression-template/blob/main/README.md>_`
provides instructions for executing the recipe and inspecting its results.
The training recipe contains the following sequential steps:
**ingest** -> **split** -> **transform** -> **train** -> **evaluate** -> **register**
The batch scoring recipe contains the following sequential steps:
**ingest_scoring** -> **predict**
The recipe steps are defined as follows:
- **ingest**
- The **ingest** step resolves the dataset specified by
|'ingest' step definition in recipe.yaml| and converts it to parquet format, leveraging
the custom dataset parsing code defined in |steps/ingest.py| if necessary. Subsequent steps
convert this dataset into training, validation, & test sets and use them to develop a model.
.. note::
If you make changes to the dataset referenced by the **ingest** step (e.g. by adding
new records or columns), you must manually re-run the **ingest** step in order to
use the updated dataset in the recipe. The **ingest** step does *not* automatically
detect changes in the dataset.
.. _mlflow-regression-recipe-split-step:
- **split**
- The **split** step splits the ingested dataset produced by the **ingest** step into
a training dataset for model training, a validation dataset for model performance
evaluation & tuning, and a test dataset for model performance evaluation. The fraction
of records allocated to each dataset is defined by the ``split_ratios`` attribute of the
|'split' step definition in recipe.yaml|. The **split** step also preprocesses the
datasets using logic defined in |steps/split.py|. Subsequent steps use these datasets
to develop a model and measure its performance.
- **transform**
- The **transform** step uses the training dataset created by **split** to fit
a transformer that performs the transformations defined in |steps/transform.py|. The
transformer is then applied to the training dataset and the validation dataset, creating
transformed datasets that are used by subsequent steps for estimator training and model
performance evaluation.
.. _mlflow-regression-recipe-train-step:
- **train**
- The **train** step uses the transformed training dataset output from the **transform**
step to fit an estimator with the type and parameters defined in |steps/train.py|. The
estimator is then joined with the fitted transformer output from the **transform** step
to create a model recipe. Finally, this model recipe is evaluated against the
transformed training and validation datasets to compute performance metrics; custom
metrics are computed according to definitions in |steps/custom_metrics.py| and the
|'custom_metrics' section of recipe.yaml|. The model recipe and its associated parameters,
performance metrics, and lineage information are logged to MLflow Tracking, producing
an MLflow Run.
.. note::
The **train** step supports hyperparameter tuning with hyperopt by adding
configurations in the
|'tuning' section of the 'train' step definition in recipe.yaml|.
- **evaluate**
- The **evaluate** step evaluates the model recipe created by the **train** step on
the test dataset output from the **split** step, computing performance metrics and
model explanations. Performance metrics are compared against configured thresholds to
compute a ``model_validation_status``, which indicates whether or not a model is good
enough to be registered to the MLflow Model Registry by the subsequent **register**
step. Custom performance metrics are computed according to definitions in
|steps/custom_metrics.py| and the |'custom_metrics' section of recipe.yaml|. Model
performance thresholds are defined in the
|'validation_criteria' section of the 'evaluate' step definition in recipe.yaml|. Model
performance metrics and explanations are logged to the same MLflow Tracking Run used by
the **train** step.
- **register**
- The **register** step checks the ``model_validation_status`` output of the preceding
**evaluate** step and, if model validation was successful
(as indicated by the ``'VALIDATED'`` status), registers the model recipe created by
the **train** step to the MLflow Model Registry. If the ``model_validation_status`` does
not indicate that the model passed validation checks (i.e. its value is ``'REJECTED'``),
the model recipe is not registered to the MLflow Model Registry.
If the model recipe is registered to the MLflow Model Registry, a
``registered_model_version`` is produced containing the model name and the model version.
.. note::
The model validation status check can be disabled by specifying
``allow_non_validated_model: true`` in the
|'register' step definition of recipe.yaml|, in which case the model recipe is
always registered with the MLflow Model Registry when the **register** step is
executed.
- **ingest_scoring**
- The **ingest_scoring** step resolves the dataset specified by the
|'ingest_scoring' section in recipe.yaml| and converts it to parquet format, leveraging
the custom dataset parsing code defined in |steps/ingest.py| if necessary.
.. note::
If you make changes to the dataset referenced by the **ingest_scoring** step
(e.g. by adding new records or columns), you must manually re-run the
**ingest_scoring** step in order to use the updated dataset in the recipe.
The **ingest_scoring** step does *not* automatically detect changes in the dataset.
- **predict**
- The **predict** step uses the ingested dataset for scoring created by the
**ingest_scoring** step and applies the specified model to the dataset.
.. note::
In Databricks, the **predict** step writes the output parquet/delta files to
DBFS.
.. |'ingest' step definition in recipe.yaml|
replace:: `'ingest' step definition in recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L30>`__
.. |'split' step definition in recipe.yaml|
replace:: `'split' step definition in recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L31-L39>`__
.. |'register' step definition of recipe.yaml|
replace:: `'register' step definition of recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L57-L62>`__
.. |'ingest_scoring' section in recipe.yaml|
replace:: `'ingest_scoring' step definition in recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L63>`__
.. |'custom_metrics' section of recipe.yaml|
replace:: `'custom_metrics' section of recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L69-L73>`__
.. |'validation_criteria' section of the 'evaluate' step definition in recipe.yaml|
replace:: `'validation_criteria' section of the 'evaluate' step definition in recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L54-L56>`__
.. |'tuning' section of the 'train' step definition in recipe.yaml|
replace:: `'tuning' section of the 'train' step definition in recipe.yaml <https://github.com/mlflow/recipes-regression-template/blob/main/recipe.yaml#L45>`__
.. |steps/ingest.py| replace:: `steps/ingest.py <https://github.com/mlflow/recipes-regression-template/blob/main/steps/ingest.py>`__
.. |steps/split.py| replace:: `steps/split.py <https://github.com/mlflow/recipes-regression-template/blob/main/steps/split.py>`__
.. |steps/train.py| replace:: `steps/train.py <https://github.com/mlflow/recipes-regression-template/blob/main/steps/train.py>`__
.. |steps/transform.py| replace:: `steps/transform.py <https://github.com/mlflow/recipes-regression-template/blob/main/steps/transform.py>`__
.. |steps/custom_metrics.py| replace:: `steps/custom_metrics.py <https://github.com/mlflow/recipes-regression-template/blob/main/steps/custom_metrics.py>`__
"""
import logging
from typing import Any, Optional
from mlflow.recipes.recipe import BaseRecipe
from mlflow.recipes.step import BaseStep
from mlflow.recipes.steps.evaluate import EvaluateStep
from mlflow.recipes.steps.ingest import IngestScoringStep, IngestStep
from mlflow.recipes.steps.predict import PredictStep
from mlflow.recipes.steps.register import RegisterStep
from mlflow.recipes.steps.split import SplitStep
from mlflow.recipes.steps.train import TrainStep
from mlflow.recipes.steps.transform import TransformStep
_logger = logging.getLogger(__name__)
class RegressionRecipe(BaseRecipe):
"""
A recipe for developing high-quality regression models. The recipe is designed for
developing models using scikit-learn and frameworks that integrate with scikit-learn,
such as the ``XGBRegressor`` API from XGBoost. The corresponding recipe
template repository is available at https://github.com/mlflow/recipes-regression-template.
The training recipe contains the following sequential steps:
**ingest** -> **split** -> **transform** -> **train** -> **evaluate** -> **register**
while the batch scoring recipe contains this set of sequential steps:
**ingest_scoring** -> **predict**
.. code-block:: python
:caption: Example
import os
from mlflow.recipes import Recipe
os.chdir("~/recipes-regression-template")
regression_recipe = Recipe(profile="local")
# Display a visual overview of the recipe graph
regression_recipe.inspect()
# Run the full recipe
regression_recipe.run()
# Display a summary of results from the 'train' step, including the trained model
# and associated performance metrics computed from the training & validation datasets
regression_recipe.inspect(step="train")
# Display a summary of results from the 'evaluate' step, including model explanations
# computed from the validation dataset and metrics computed from the test dataset
regression_recipe.inspect(step="evaluate")
"""
_RECIPE_STEPS = (
# Training data ingestion DAG
IngestStep,
# Model training DAG
SplitStep,
TransformStep,
TrainStep,
EvaluateStep,
RegisterStep,
# Batch scoring DAG
IngestScoringStep,
PredictStep,
)
_DEFAULT_STEP_INDEX = _RECIPE_STEPS.index(RegisterStep)
def _get_step_classes(self):
return self._RECIPE_STEPS
def _get_default_step(self) -> BaseStep:
return self._steps[self._DEFAULT_STEP_INDEX]
def run(self, step: Optional[str] = None) -> None:
"""
Runs the full recipe or a particular recipe step, producing outputs and displaying a
summary of results upon completion. Step outputs are cached from previous executions, and
steps are only re-executed if configuration or code changes have been made to the step or
to any of its dependent steps (e.g. changes to the recipe's ``recipe.yaml`` file or
``steps/ingest.py`` file) since the previous execution.
Args:
step: String name of the step to run within the regression recipe. The step and
its dependencies are executed sequentially. If a step is not specified, the
entire recipe is executed. Supported steps, in their order of execution, are:
- ``"ingest"``: resolves the dataset specified by the ``data/training`` section
in the recipe's configuration file (``recipe.yaml``) and converts it to
parquet format.
- ``"ingest_scoring"``: resolves the dataset specified by the
``ingest_scoring`` section in the recipe's configuration file
(``recipe.yaml``)and converts it to parquet format.
- ``"split"``: splits the ingested dataset produced by the **ingest** step into
a training dataset for model training, a validation dataset for model
performance evaluation & tuning, and a test dataset for model performance
evaluation.
- ``"transform"``: uses the training dataset created by the **split** step to
fit a transformer that performs the transformations defined in the
recipe's ``steps/transform.py`` file. Then, applies the transformer to the
training dataset and the validation dataset, creating transformed datasets
that are used by subsequent steps for estimator training and model
performance evaluation.
- ``"train"``: uses the transformed training dataset output from the
**transform** step to fit an estimator with the type and parameters defined
in in the recipe's ``steps/train.py`` file. Then, joins the estimator with
the fitted transformer output from the **transform** step to create a model
recipe. Finally, evaluates the model recipe against the transformed
training and validation datasets to compute performance metrics.
- ``"evaluate"``: evaluates the model recipe created by the **train** step
on the validation and test dataset outputs from the **split** step, computing
performance metrics and model explanations. Then, compares performance
metrics against thresholds configured in the recipe's ``recipe.yaml``
configuration file to compute a ``model_validation_status``, which indicates
whether or not the model is good enough to be registered to the MLflow Model
Registry by the subsequent **register** step.
- ``"register"``: checks the ``model_validation_status`` output of the
preceding **evaluate** step and, if model validation was successful (as
indicated by the ``'VALIDATED'`` status), registers the model recipe
created by the **train** step to the MLflow Model Registry.
- ``"predict"``: uses the ingested dataset for scoring created by the
**ingest_scoring** step and applies the specified model to the dataset.
.. code-block:: python
:caption: Example
import os
from mlflow.recipes import Recipe
os.chdir("~/recipes-regression-template")
regression_recipe = Recipe(profile="local")
# Run the 'train' step and preceding steps
regression_recipe.run(step="train")
# Run the 'register' step and preceding steps; the 'train' step and all steps
# prior to 'train' are not re-executed because their outputs are already cached
regression_recipe.run(step="register")
# Run all recipe steps; equivalent to running 'register'; no steps are re-executed
# because the outputs of all steps are already cached
regression_recipe.run()
"""
return super().run(step=step)
def get_artifact(self, artifact_name: str) -> Optional[Any]:
"""
Reads an artifact from the recipe's outputs. Supported artifact names can be obtained by
examining the recipe graph visualization displayed by
:py:func:`RegressionRecipe.inspect()`.
Args:
artifact_name: The string name of the artifact. Supported artifact values are:
- ``"ingested_data"``: returns the ingested dataset created in the
**ingest** step as a pandas DataFrame.
- ``"training_data"``: returns the training dataset created in the
**split** step as a pandas DataFrame.
- ``"validation_data"``: returns the validation dataset created in the
**split** step as a pandas DataFrame.
- ``"test_data"``: returns the test dataset created in the **split** step
as a pandas DataFrame.
- ``"ingested_scoring_data"``: returns the scoring dataset created in the
**ingest_scoring** step as a pandas DataFrame.
- ``"transformed_training_data"``: returns the transformed training dataset
created in the **transform** step as a pandas DataFrame.
- ``"transformed_validation_data"``: returns the transformed validation
dataset created in the **transform** step as a pandas DataFrame.
- ``"model"``: returns the MLflow Model recipe created in the **train**
step as a :py:class:`PyFuncModel <mlflow.pyfunc.PyFuncModel>` instance.
- ``"transformer"``: returns the scikit-learn transformer created in the
**transform** step.
- ``"run"``: returns the
:py:class:`MLflow Tracking Run <mlflow.entities.Run>` containing the
model recipe created in the **train** step and its associated
parameters, as well as performance metrics and model explanations created
during the **train** and **evaluate** steps.
- ``"registered_model_version``": returns the MLflow Model Registry
:py:class:`ModelVersion <mlflow.entities.model_registry.ModelVersion>`
created by the **register** step.
- ``"scored_data"``: returns the scored dataset created in the
**predict** step as a pandas DataFrame.
Returns:
An object representation of the artifact corresponding to the specified name,
as described in the ``artifact_name`` parameter docstring. If the artifact is
not present because its corresponding step has not been executed or its output
cache has been cleaned, ``None`` is returned.
"""
return super().get_artifact(artifact_name=artifact_name)
def clean(self, step: Optional[str] = None) -> None:
"""
Removes all recipe outputs from the cache, or removes the cached outputs of a particular
recipe step if specified. After cached outputs are cleaned for a particular step, the
step will be re-executed in its entirety the next time it is run.
Args:
step: String name of the step to clean within the recipe. If not specified,
cached outputs are removed for all recipe steps.
.. code-block:: python
import os
from mlflow.recipes import Recipe
os.chdir("~/recipes-regression-template")
regression_recipe = Recipe(profile="local")
# Run the 'train' step and preceding steps
regression_recipe.run(step="train")
# Clean the cache of the 'transform' step
regression_recipe.clean(step="transform")
# Run the 'split' step; outputs are still cached because 'split' precedes
# 'transform' & 'train'
regression_recipe.run(step="split")
# Run the 'train' step again; the 'transform' and 'train' steps are re-executed because:
# 1. the cache of the preceding 'transform' step was cleaned and 2. 'train' occurs after
# 'transform'. The 'ingest' and 'split' steps are not re-executed because their outputs
# are still cached
regression_recipe.run(step="train")
"""
super().clean(step=step)
def inspect(self, step: Optional[str] = None) -> None:
"""
Displays a visual overview of the recipe graph, or displays a summary of results from
a particular recipe step if specified. If the specified step has not been executed,
nothing is displayed.
Args:
step: String name of the recipe step for which to display a results summary. If
unspecified, a visual overview of the recipe graph is displayed.
.. code-block:: python
import os
from mlflow.recipes import Recipe
os.chdir("~/recipes-regression-template")
regression_recipe = Recipe(profile="local")
# Display a visual overview of the recipe graph.
regression_recipe.inspect()
# Run the 'train' recipe step
regression_recipe.run(step="train")
# Display a summary of results from the preceding 'transform' step
regression_recipe.inspect(step="transform")
"""
super().inspect(step=step)

View File

@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="en">
<body>
<style>
@keyframes spin {
to { transform: translate(calc(-50%), calc(50vh - 50%)) rotate(360deg); }
}
.mermaidTooltip {
display: none;
}
.wrapper {
display: flex;
}
.mermaid {
width: 60%;
}
.pane-loading {
font-size: 0;
color: transparent;
}
.pane-loading::after {
content: "";
position: absolute;
inset: 0 50%;
width: 32px;
height: 32px;
transform: translate(calc(-50%), calc(50vh - 50%));
border: 0.25rem solid #9370DB;
border-bottom: 0.25rem solid rgba(0,0,0,0);
border-radius: 50%;
animation: spin 1s linear infinite;
}
#editor {
font-size: 13px;
width: 40%;
position: relative;
}
font {
font-size: 24px;
padding: 24px;
}
.node:hover {
stroke: #2272B4 !important;
color: #0E538B !important;
background-color: #bbdaf4;
}
</style>
<div class="wrapper">
<div class="mermaid pane-loading">
flowchart TD
subgraph " "
recipe([recipe.yaml])
ingestUserCode([steps/ingest.py]) --> ingestMLPStep[["<font>ingest</font>"]]
ingestMLPStep --> dataParquet[(ingested_data)]
ingestScoringUserCode([steps/ingest.py]) --> ingestScoringMLPStep[["<font>ingest_scoring</font>"]]
ingestScoringMLPStep --> dataScoringParquet[(ingested_scoring_data)]
dataScoring[(ingested_scoring_data)] --> predictMLPStep[["<font>predict</font>"]]
predictMLPStep --> dataScored[(scored_data)]
end
data[(ingested_data)] --> splitStep[["<font>split</font>"]]
transformUserCode([steps/transform.py]) --> transformMLPStep
splitUserCode([steps/split.py]) --> splitStep
splitStep --> splitData0[(training_data)]
splitStep --> splitData1[(validation_data)]
splitStep --> splitData2[(test_data)]
splitData0 --> transformMLPStep[["<font>transform</font>"]]
splitData1 --> transformMLPStep[["<font>transform</font>"]]
transformMLPStep --> transformedParquet[(transformed_training_data, <br/> transformed_validation_data)]
transformMLPStep --> transformer{{transformer}}
transformedParquet --> trainMLPStep[["<font>train</font>"]]
trainUserCode([steps/train.py]) --> trainMLPStep
customMetricsUserCode([steps/custom_metrics.py]) --> trainMLPStep
transformer --> trainMLPStep
trainMLPStep --> run{{run}}
trainMLPStep --> model{{model}}
trainMLPStep --> predictedTrainingData[(predicted_training_data)]
model --> evaluateMLPStep[["<font>evaluate</font>"]]
splitData1 --> evaluateMLPStep
splitData2 --> evaluateMLPStep
customMetricsUserCode --> evaluateMLPStep
evaluateMLPStep --> model_validation_status{{model_validation_status}}
run --> registerMLPStep[["<font>register</font>"]]
run --> evaluateMLPStep
model_validation_status --> registerMLPStep
registerMLPStep --> registered_model_version{{registered_model_version}}
click ingestMLPStep renderMoreInformation "{{ingest_step_help}}"
click ingestUserCode renderMoreInformation "{{ingest_user_code_help}}"
click splitStep renderMoreInformation "{{split_step_help}}"
click transformMLPStep renderMoreInformation "{{transform_step_help}}"
click transformUserCode renderMoreInformation "{{transform_user_code_help}}"
click trainMLPStep renderMoreInformation "{{train_step_help}}"
click trainUserCode renderMoreInformation "{{train_user_code_help}}"
click evaluateMLPStep renderMoreInformation "{{evaluate_step_help}}"
click registerMLPStep renderMoreInformation "{{register_step_help}}"
click customMetricsUserCode renderMoreInformation "{{custom_metrics_user_code_help}}"
click splitUserCode renderMoreInformation "{{split_user_code_help}}"
click ingestScoringUserCode renderMoreInformation "{{ingest_user_code_help}}"
click ingestScoringMLPStep renderMoreInformation "{{ingest_scoring_step_help}}"
click predictMLPStep renderMoreInformation "{{predict_step_help}}"
click recipe renderMoreInformation "{{recipe_yaml_help}}"
click dataParquet renderMoreInformation "{{ingested_data_help}}"
click data renderMoreInformation "{{ingested_data_help}}"
click splitData0 renderMoreInformation "{{training_data_help}}"
click splitData1 renderMoreInformation "{{validation_data_help}}"
click splitData2 renderMoreInformation "{{test_data_help}}"
click transformedParquet renderMoreInformation "{{transformed_training_and_validation_data_help}}"
click run renderMoreInformation "{{mlflow_run_help}}"
click model renderMoreInformation "{{fitted_model_help}}"
click predictedTrainingData renderMoreInformation "{{predicted_training_data_help}}"
click transformer renderMoreInformation "{{fitted_transformer_help}}"
click model_validation_status renderMoreInformation "{{model_validation_status_help}}"
click registered_model_version renderMoreInformation "{{registered_model_version_help}}"
click dataScoringParquet renderMoreInformation "{{ingested_scoring_data_help}}"
click dataScoring renderMoreInformation "{{ingested_scoring_data_help}}"
click dataScored renderMoreInformation "{{scored_data_help}}"
</div>
<div id="editor"></div>
</div>
<script src="https://requirejs.org/docs/release/2.1.5/comments/require.js"></script>
<script type="text/javascript">
require.config({
paths: {
"mermaid": "https://cdn.jsdelivr.net/npm/mermaid@9.3.0/dist/mermaid.min",
"ace": "https://cdnjs.cloudflare.com/ajax/libs/ace/1.5.1/",
"python": "https://cdnjs.cloudflare.com/ajax/libs/ace/1.5.1/mode-python.min",
"yaml": "https://cdnjs.cloudflare.com/ajax/libs/ace/1.5.1/mode-yaml.min",
"idle_fingers": "https://cdnjs.cloudflare.com/ajax/libs/ace/1.5.1/theme-idle_fingers.min",
},
});
require(["mermaid"],
function (mermaid) {
const config = {
startOnLoad:true,
securityLevel:'loose',
theme: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "default",
themeCSS: ".cluster rect { fill: none; stroke: none; }",
flowchart:{
useMaxWidth:true,
htmlLabels:true,
}
};
document.querySelector('.mermaid.pane-loading').classList.remove('pane-loading');
mermaid.initialize(config);
mermaid.init();
const nodes = document.querySelectorAll(".node");
[...nodes].forEach((node) => {
var nodeInfo = node.getAttribute('title');
// In order to parse the node text as JSON with `help_string` and `help_string_type`
// attributes, single quotes must be replaced with double quotes. However, we also want
// to convert backslashed single quotes to single quotes in the parsed JSON and preserve
// backslashed double quotes in the parsed JSON. Accordingly, we first replace instances
// of the string \' with \\" . Then, we replace all instances of ' with " . Finally, we
// replace instances of \\" with '
nodeInfo = nodeInfo && nodeInfo.replace(/\\'/g, '\\\\"');
nodeInfo = nodeInfo && nodeInfo.replace(/'/g, '"');
nodeInfo = nodeInfo && nodeInfo.replace(/\\\\"/g, "'");
if (nodeInfo) {
const nodeLabel = node.querySelector(".nodeLabel");
nodeLabel.setAttribute("title", JSON.parse(nodeInfo).help_string)
}
})
resetStyles()
window.editor = null;
}
);
var resetStyles = function() {
const allNodes = document.querySelector('.nodes').querySelectorAll('[id^="flowchart-"]');
[...allNodes].forEach(node => {
const rect = node.firstChild
if (rect) {
rect.style.fill = "inherit";
rect.style.stroke = "inherit";
}
node.style.fill = "#F2F5F7";
node.style.stroke = "#CDDAE5";
node.style.color = "#20272E";
const span = node.querySelector('span')
const label = node.querySelector('.label')
span.style.color = "inherit";
label.style.color = "inherit";
});
}
var noOp = function() {}
var renderMoreInformation = function(nodeId) {
resetStyles()
regex = `[id^="flowchart-${nodeId}-"]`
const node = document.querySelector(regex);
var nodeInfo = node.getAttribute('title');
// In order to parse the node text as JSON with `help_string` and `help_string_type`
// attributes, single quotes must be replaced with double quotes. However, we also want
// to convert backslashed single quotes to single quotes in the parsed JSON and preserve
// backslashed double quotes in the parsed JSON. Accordingly, we first replace instances
// of the string \' with \\" . Then, we replace all instances of ' with " . Finally, we
// replace instances of \\" with '
nodeInfo = nodeInfo && nodeInfo.replace(/\\'/g, '\\\\"');
nodeInfo = nodeInfo && nodeInfo.replace(/'/g, '"');
nodeInfo = nodeInfo && nodeInfo.replace(/\\\\"/g, "'");
nodeInfo = JSON.parse(nodeInfo)
const rect = node.firstChild;
rect.style.stroke = "#04355D";
rect.style.fill = "#bbdaf4";
rect.style.color = "#04355D";
document.querySelector("#editor").classList.add('pane-loading');
const hideSpinner = () => document.querySelector("#editor").classList.remove('pane-loading');
require(["ace/ace"],
function (ace) {
require(["python", "yaml", "idle_fingers"],
function () {
hideSpinner();
if (!window.editor) {
const editor = ace.edit("editor");
editor.setTheme("ace/theme/idle_fingers");
// Wrap text lines for readability
editor.session.setUseWrapMode(true);
// Minimize indentation on wrapped lines by setting tab size to 1 character
// (note that 0 characters does not appear to work. TODO: Find a better approach)
editor.setOption("tabSize", 1);
editor.setReadOnly(true);
// Disable active line highlighting
editor.setHighlightActiveLine(false);
// Hide the cursor, the presence of which creates the misconception
// that the text is editable
editor.renderer.$cursorLayer.element.style.display = "none"
// Hide the editor gutter, since the window isn't really an editor and instead
// is intended for displaying text
editor.renderer.setShowGutter(false);
// Disable the line length ruler
editor.setShowPrintMargin(false);
window.editor = editor;
}
// Set the editor value and the editor session value to the text associated with
// the recipe node; setting the session is important in order to avoid
// obtrusive highlighting
window.editor.setValue(nodeInfo.help_string)
window.editor.session.setValue(nodeInfo.help_string)
window.editor.session.setMode(`ace/mode/${nodeInfo.help_string_type}`);
}, hideSpinner
);
}, hideSpinner
);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,390 @@
import abc
import json
import logging
import os
import time
import traceback
from enum import Enum
from typing import Any, Optional
import yaml
from mlflow.recipes.cards import CARD_HTML_NAME, CARD_PICKLE_NAME, BaseCard, FailureCard
from mlflow.recipes.utils import get_recipe_name
from mlflow.recipes.utils.step import display_html
from mlflow.tracking import MlflowClient
from mlflow.utils.databricks_utils import is_in_databricks_runtime
_logger = logging.getLogger(__name__)
class StepStatus(Enum):
"""
Represents the execution status of a step.
"""
# Indicates that no execution status information is available for the step,
# which may occur if the step has never been run or its outputs have been cleared
UNKNOWN = "UNKNOWN"
# Indicates that the step is currently running
RUNNING = "RUNNING"
# Indicates that the step completed successfully
SUCCEEDED = "SUCCEEDED"
# Indicates that the step completed with one or more failures
FAILED = "FAILED"
class StepClass(Enum):
"""
Represents the class of a step.
"""
# Indicates that the step class is unknown.
UNKNOWN = "UNKNOWN"
# Indicates that the step runs at training time.
TRAINING = "TRAINING"
# Indicates that the step runs at inference time.
PREDICTION = "PREDICTION"
class StepExecutionState:
"""
Represents execution state for a step, including the current status and
the time of the last status update.
"""
_KEY_STATUS = "recipe_step_execution_status"
_KEY_LAST_UPDATED_TIMESTAMP = "recipe_step_execution_last_updated_timestamp"
_KEY_STACK_TRACE = "recipe_step_stack_trace"
def __init__(self, status: StepStatus, last_updated_timestamp: int, stack_trace: str):
"""
Args:
status: The execution status of the step.
last_updated_timestamp: The timestamp of the last execution status update, measured
in seconds since the UNIX epoch.
stack_trace: The stack trace of the last execution. None if the step execution
succeeds.
"""
self.status = status
self.last_updated_timestamp = last_updated_timestamp
self.stack_trace = stack_trace
def to_dict(self) -> dict[str, Any]:
"""
Creates a dictionary representation of the step execution state.
"""
return {
StepExecutionState._KEY_STATUS: self.status.value,
StepExecutionState._KEY_LAST_UPDATED_TIMESTAMP: self.last_updated_timestamp,
StepExecutionState._KEY_STACK_TRACE: self.stack_trace,
}
@classmethod
def from_dict(cls, state_dict) -> "StepExecutionState":
"""
Creates a ``StepExecutionState`` instance from the specified execution state dictionary.
"""
return cls(
status=StepStatus[state_dict[StepExecutionState._KEY_STATUS]],
last_updated_timestamp=state_dict[StepExecutionState._KEY_LAST_UPDATED_TIMESTAMP],
stack_trace=state_dict[StepExecutionState._KEY_STACK_TRACE],
)
class BaseStep(metaclass=abc.ABCMeta):
"""
Base class representing a step in an MLflow Recipe
"""
_EXECUTION_STATE_FILE_NAME = "execution_state.json"
def __init__(self, step_config: dict[str, Any], recipe_root: str):
"""
Args:
step_config: Dictionary of the config needed to run/implement the step.
recipe_root: String file path to the directory where step are defined.
"""
self.step_config = step_config
self.recipe_root = recipe_root
self.recipe_name = get_recipe_name(recipe_root_path=recipe_root)
self.task = self.step_config.get("recipe", "regression/v1").rsplit("/", 1)[0]
self.step_card = None
def __str__(self):
return f"Step:{self.name}"
def run(self, output_directory: str):
"""
Executes the step by running common setup operations and invoking
step-specific code (as defined in ``_run()``).
Args:
output_directory: String file path to the directory where step
outputs should be stored.
"""
_logger.info(f"Running step {self.name}...")
start_timestamp = time.time()
self._initialize_databricks_spark_connection_and_hooks_if_applicable()
try:
self._update_status(status=StepStatus.RUNNING, output_directory=output_directory)
self._validate_and_apply_step_config()
self.step_card = self._run(output_directory=output_directory)
self._update_status(status=StepStatus.SUCCEEDED, output_directory=output_directory)
except Exception:
stack_trace = traceback.format_exc()
self._update_status(
status=StepStatus.FAILED, output_directory=output_directory, stack_trace=stack_trace
)
self.step_card = FailureCard(
recipe_name=self.recipe_name,
step_name=self.name,
failure_traceback=stack_trace,
output_directory=output_directory,
)
raise
finally:
self._serialize_card(start_timestamp, output_directory)
def inspect(self, output_directory: str):
"""
Inspect the step output state by running the generic inspect information here and
running the step specific inspection code in the step's _inspect() method.
Args:
output_directory: String file path where to the directory where step
outputs are located.
"""
card_path = os.path.join(output_directory, CARD_PICKLE_NAME)
if not os.path.exists(card_path):
_logger.info(
"Unable to locate runtime info for step '%s'. Re-run the step before inspect.",
self.name,
)
return None
card = BaseCard.load(card_path)
card_html_path = os.path.join(output_directory, CARD_HTML_NAME)
display_html(html_data=card.to_html(), html_file_path=card_html_path)
@abc.abstractmethod
def _run(self, output_directory: str) -> BaseCard:
"""
This function is responsible for executing the step, writing outputs
to the specified directory, and returning results to the user. It
is invoked by the internal step runner.
Args:
output_directory: String file path to the directory where step outputs
should be stored.
Returns:
A BaseCard containing step execution information.
"""
@abc.abstractmethod
def _validate_and_apply_step_config(self) -> None:
"""
This function is responsible for validating and loading the step config for
a particular step. It is invoked by the internal step runner.
"""
@classmethod
@abc.abstractmethod
def from_recipe_config(cls, recipe_config: dict[str, Any], recipe_root: str) -> "BaseStep":
"""
Constructs a step class instance by creating a step config using the recipe
config.
Subclasses must implement this method to produce the config required to correctly
run the corresponding step.
Args:
recipe_config: Dictionary representation of the full recipe config.
recipe_root: String file path to the recipe root directory.
Returns:
class instance of the step.
"""
@classmethod
def from_step_config_path(cls, step_config_path: str, recipe_root: str) -> "BaseStep":
"""
Constructs a step class instance using the config specified in the
configuration file.
Args:
step_config_path: String path to the step-specific configuration
on the local filesystem.
recipe_root: String path to the recipe root directory on
the local filesystem.
Returns:
class instance of the step.
"""
with open(step_config_path) as f:
step_config = yaml.safe_load(f)
return cls(step_config, recipe_root)
@property
@abc.abstractmethod
def name(self) -> str:
"""
Returns back the name of the step for the current class instance. This is used
downstream by the execution engine to create step-specific directory structures.
"""
@property
def environment(self) -> dict[str, str]:
"""
Returns environment variables associated with step that should be set when the
step is executed.
"""
return {}
def get_artifacts(self) -> list[Any]:
"""
Returns the named artifacts produced by the step for the current class instance.
"""
return {}
@abc.abstractmethod
def step_class(self) -> StepClass:
"""
Returns the step class.
"""
def get_execution_state(self, output_directory: str) -> StepExecutionState:
"""
Returns the execution state of the step, which provides information about its
status (succeeded, failed, unknown), last update time, and, if applicable, encountered
stacktraces.
Args:
output_directory: String file path to the directory where step
outputs are stored.
Returns:
A ``StepExecutionState`` instance containing the step execution state.
"""
execution_state_file_path = os.path.join(
output_directory, BaseStep._EXECUTION_STATE_FILE_NAME
)
if os.path.exists(execution_state_file_path):
with open(execution_state_file_path) as f:
return StepExecutionState.from_dict(json.load(f))
else:
return StepExecutionState(StepStatus.UNKNOWN, 0, None)
def _serialize_card(self, start_timestamp: float, output_directory: str) -> None:
if self.step_card is None:
return
execution_duration = time.time() - start_timestamp
tab = self.step_card.get_tab("Run Summary")
if tab is not None:
tab.add_markdown("EXE_DURATION", f"**Run duration (s)**: {execution_duration:.3g}")
tab.add_markdown(
"LAST_UPDATE_TIME",
f"**Last updated:** {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}",
)
self.step_card.save(path=output_directory)
self.step_card.save_as_html(path=output_directory)
def _update_status(
self, status: StepStatus, output_directory: str, stack_trace: Optional[str] = None
) -> None:
execution_state = StepExecutionState(
status=status, last_updated_timestamp=time.time(), stack_trace=stack_trace
)
with open(os.path.join(output_directory, BaseStep._EXECUTION_STATE_FILE_NAME), "w") as f:
json.dump(execution_state.to_dict(), f)
def _initialize_databricks_spark_connection_and_hooks_if_applicable(self) -> None:
"""
Initializes a connection to the Databricks Spark Gateway and sets up associated hooks
(e.g. MLflow Run creation notification hooks) if MLflow Recipes is running in the
Databricks Runtime.
"""
if is_in_databricks_runtime():
try:
from dbruntime.spark_connection import (
initialize_spark_connection,
is_pinn_mode_enabled,
)
from IPython.utils.io import capture_output
with capture_output():
spark_handles, entry_point = initialize_spark_connection(is_pinn_mode_enabled())
except Exception as e:
_logger.warning(
"Encountered unexpected failure while initializing Spark connection. Spark"
" operations may not succeed. Exception: %s",
e,
)
else:
try:
from dbruntime.MlflowCreateRunHook import get_mlflow_create_run_hook
# `get_mlflow_create_run_hook` sets up a patch to trigger a Databricks command
# notification every time an MLflow Run is created. This notification is
# visible to users in notebook environments
get_mlflow_create_run_hook(spark_handles["sc"], entry_point)
except Exception as e:
_logger.warning(
"Encountered unexpected failure while setting up Databricks MLflow Run"
" creation hooks. Exception: %s",
e,
)
def _log_step_card(self, run_id: str, step_name: str) -> None:
"""
Logs a step card as an artifact (destination: <step_name>/card.html) in a specified run.
If the step card does not exist, logging is skipped.
Args:
run_id: Run ID to which the step card is logged.
step_name: Step name.
"""
from mlflow.recipes.utils.execution import get_step_output_path
local_card_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name=step_name,
relative_path=CARD_HTML_NAME,
)
if os.path.exists(local_card_path):
MlflowClient().log_artifact(run_id, local_card_path, artifact_path=step_name)
else:
_logger.warning(
"Failed to log step card for step %s. Run ID: %s. Card local path: %s",
step_name,
run_id,
local_card_path,
)
@staticmethod
def _generate_worst_examples_dataframe(
dataframe,
predictions,
error,
target_col,
worst_k=10,
):
"""
Generate dataframe containing worst k examples with largest prediction error.
Dataframe contains columns of all features, prediction, error, and target_col column.
The prediction error is defined as absolute error between target value and
prediction value.
"""
import numpy as np
predictions = np.array(predictions)
abs_error = np.absolute(error)
worst_k_indexes = np.argsort(abs_error)[::-1][:worst_k]
result_df = dataframe.iloc[worst_k_indexes].assign(
prediction=predictions[worst_k_indexes],
absolute_error=abs_error[worst_k_indexes],
)
front_columns = ["absolute_error", "prediction", target_col]
reordered_columns = front_columns + result_df.columns.drop(front_columns).tolist()
return result_df[reordered_columns].reset_index(drop=True)

View File

@@ -0,0 +1,177 @@
import importlib
import logging
from typing import TYPE_CHECKING, Any, Callable
import pandas as pd
if TYPE_CHECKING:
from sklearn.base import BaseEstimator
import mlflow
from mlflow import MlflowException
from mlflow.models import EvaluationMetric
from mlflow.models.evaluation.evaluators.classifier import _get_binary_classifier_metrics
from mlflow.models.evaluation.evaluators.regressor import _get_regressor_metrics
from mlflow.recipes.utils.metrics import RecipeMetric, _load_custom_metrics
_logger = logging.getLogger(__name__)
_AUTOML_DEFAULT_TIME_BUDGET = 600
_MLFLOW_TO_FLAML_METRICS = {
"mean_absolute_error": "mae",
"mean_squared_error": "mse",
"root_mean_squared_error": "rmse",
"r2_score": "r2",
"mean_absolute_percentage_error": "mape",
"f1_score": "f1",
"f1_score_micro": "micro_f1",
"f1_score_macro": "macro_f1",
"accuracy_score": "accuracy",
"roc_auc": "roc_auc",
"roc_auc_ovr": "roc_auc_ovr",
"roc_auc_ovo": "roc_auc_ovo",
"log_loss": "log_loss",
}
# metrics that are not supported natively in FLAML
_SKLEARN_METRICS = ["recall_score", "precision_score"]
def get_estimator_and_best_params(
X,
y,
task: str,
extended_task: str,
step_config: dict[str, Any],
recipe_root: str,
evaluation_metrics: dict[str, RecipeMetric],
primary_metric: str,
) -> tuple["BaseEstimator", dict[str, Any]]:
return _create_model_automl(
X, y, task, extended_task, step_config, recipe_root, evaluation_metrics, primary_metric
)
def _create_custom_metric_flaml(
task: str, metric_name: str, coeff: int, eval_metric: EvaluationMetric
) -> Callable:
def calc_metric(X, y, estimator) -> dict[str, float]:
y_pred = estimator.predict(X)
builtin_metrics = (
_get_regressor_metrics(y, y_pred, sample_weights=None)
if task == "regression"
else _get_binary_classifier_metrics(y_true=y, y_pred=y_pred)
)
res_df = pd.DataFrame()
res_df["prediction"] = y_pred
res_df["target"] = y if task == "classification" else y.values
return eval_metric.eval_fn(res_df, builtin_metrics)
def custom_metric(
X_val,
y_val,
estimator,
labels,
X_train,
y_train,
weight_val=None,
weight_train=None,
*args,
):
val_metric = coeff * calc_metric(X_val, y_val, estimator)
train_metric = calc_metric(X_train, y_train, estimator)
main_metric = coeff * val_metric
return main_metric, {
f"{metric_name}_train": train_metric,
f"{metric_name}_val": val_metric,
}
return custom_metric
def _create_sklearn_metric_flaml(metric_name: str, coeff: int, avg: str = "binary") -> Callable:
def sklearn_metric(
X_val,
y_val,
estimator,
labels,
X_train,
y_train,
weight_val=None,
weight_train=None,
*args,
):
custom_metrics_mod = importlib.import_module("sklearn.metrics")
eval_fn = getattr(custom_metrics_mod, metric_name)
val_metric = coeff * eval_fn(y_val, estimator.predict(X_val), average=avg)
train_metric = coeff * eval_fn(y_train, estimator.predict(X_train), average=avg)
return val_metric, {
f"{metric_name}_train": train_metric,
f"{metric_name}_val": val_metric,
}
return sklearn_metric
def _create_model_automl(
X,
y,
task: str,
extended_task: str,
step_config: dict[str, Any],
recipe_root: str,
evaluation_metrics: dict[str, RecipeMetric],
primary_metric: str,
) -> tuple["BaseEstimator", dict[str, Any]]:
try:
from flaml import AutoML
except ImportError:
raise MlflowException("Please install FLAML to use AutoML!")
try:
if primary_metric in _MLFLOW_TO_FLAML_METRICS and primary_metric in evaluation_metrics:
metric = _MLFLOW_TO_FLAML_METRICS[primary_metric]
if primary_metric == "roc_auc" and extended_task == "classification/multiclass":
metric = "roc_auc_ovr"
elif primary_metric in _SKLEARN_METRICS and primary_metric in evaluation_metrics:
metric = _create_sklearn_metric_flaml(
primary_metric,
-1 if evaluation_metrics[primary_metric].greater_is_better else 1,
"macro" if extended_task in ["classification/multiclass"] else "binary",
)
elif primary_metric in evaluation_metrics:
metric = _create_custom_metric_flaml(
task,
primary_metric,
-1 if evaluation_metrics[primary_metric].greater_is_better else 1,
_load_custom_metrics(recipe_root, [evaluation_metrics[primary_metric]])[0],
)
else:
raise MlflowException(
f"There is no FLAML alternative or custom metric for {primary_metric} metric."
)
automl_settings = step_config.get("flaml_params", {})
automl_settings["time_budget"] = step_config.get(
"time_budget_secs", _AUTOML_DEFAULT_TIME_BUDGET
)
automl_settings["metric"] = metric
automl_settings["task"] = task
# Disabled Autologging, because during the hyperparameter search
# it tries to log the same parameters multiple times.
mlflow.autolog(disable=True)
automl = AutoML()
automl.fit(X, y, **automl_settings)
mlflow.autolog(disable=False, log_models=False)
if automl.model is None:
raise MlflowException(
"AutoML (FLAML) could not train a suitable algorithm. "
"Maybe you should increase `time_budget_secs`parameter "
"to give AutoML process more time."
)
return automl.model.estimator, automl.best_config
except Exception as e:
_logger.warning(e, exc_info=e, stack_info=True)
raise MlflowException(
f"Error has occurred during training of AutoML model using FLAML: {e!r}"
)

View File

@@ -0,0 +1,502 @@
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)",
"<h3 class='section-title'>Summary Metrics</h3>"
"<b>NOTE</b>: Use evaluation metrics over test dataset with care. "
"Fine-tuning model over the test dataset is not advised."
"{{ METRICS }} ",
).add_html("METRICS", metric_table_html)
# Tab 1: model validation results, if exists.
if validation_results is not None:
def get_icon(validated):
return (
# check mark button emoji
"\u2705"
if validated
# cross mark emoji
else "\u274c"
)
result_df = pd.DataFrame(validation_results).assign(
validated=lambda df: df["validated"].map(get_icon)
)
criteria_html = BaseCard.render_table(
result_df.style.format({"value": "{:.6g}", "threshold": "{:.6g}"})
)
card.add_tab("Model Validation", "{{ METRIC_VALIDATION_RESULTS }}").add_html(
"METRIC_VALIDATION_RESULTS",
"<h3 class='section-title'>Model Validation Results (Test Dataset)</h3> "
+ criteria_html,
)
# Tab 2: Classifier plots.
if self.recipe == "classification/v1":
classifiers_plot_tab = card.add_tab(
"Model Performance Plots",
"{{ CONFUSION_MATRIX }} {{CONFUSION_MATRIX_PLOT}}"
+ "{{ LIFT_CURVE }} {{LIFT_CURVE_PLOT}}"
+ "{{ PR_CURVE }} {{PR_CURVE_PLOT}}"
+ "{{ ROC_CURVE }} {{ROC_CURVE_PLOT}}",
)
confusion_matrix_path = os.path.join(
output_directory,
"eval_validation/artifacts",
f"{_VALIDATION_METRIC_PREFIX}confusion_matrix.png",
)
if os.path.exists(confusion_matrix_path):
classifiers_plot_tab.add_html(
"CONFUSION_MATRIX",
'<h3 class="section-title">Confusion Matrix Plot</h3>',
)
classifiers_plot_tab.add_image(
"CONFUSION_MATRIX_PLOT", confusion_matrix_path, width=400
)
lift_curve_path = os.path.join(
output_directory,
"eval_validation/artifacts",
f"{_VALIDATION_METRIC_PREFIX}lift_curve_plot.png",
)
if os.path.exists(lift_curve_path):
classifiers_plot_tab.add_html(
"LIFT_CURVE",
'<h3 class="section-title">Lift Curve Plot</h3>',
)
classifiers_plot_tab.add_image("LIFT_CURVE_PLOT", lift_curve_path, width=400)
pr_curve_path = os.path.join(
output_directory,
"eval_validation/artifacts",
f"{_VALIDATION_METRIC_PREFIX}precision_recall_curve_plot.png",
)
if os.path.exists(pr_curve_path):
classifiers_plot_tab.add_html(
"PR_CURVE",
'<h3 class="section-title">Precision Recall Curve Plot</h3>',
)
classifiers_plot_tab.add_image("PR_CURVE_PLOT", pr_curve_path, width=400)
roc_curve_path = os.path.join(
output_directory,
"eval_validation/artifacts",
f"{_VALIDATION_METRIC_PREFIX}roc_curve_plot.png",
)
if os.path.exists(roc_curve_path):
classifiers_plot_tab.add_html(
"ROC_CURVE",
'<h3 class="section-title">ROC Curve Plot</h3>',
)
classifiers_plot_tab.add_image("ROC_CURVE_PLOT", roc_curve_path, width=400)
# Tab 3: SHAP plots.
def _add_shap_plots(card):
"""Contingent on shap being installed."""
shap_plot_tab = card.add_tab(
"Feature Importance",
'<h3 class="section-title">Feature Importance on Validation Dataset</h3>'
'<h3 class="section-title">SHAP Bar Plot</h3>{{SHAP_BAR_PLOT}}'
'<h3 class="section-title">SHAP Beeswarm Plot</h3>{{SHAP_BEESWARM_PLOT}}',
)
shap_bar_plot_path = os.path.join(
output_directory,
"eval_validation/artifacts",
f"{_VALIDATION_METRIC_PREFIX}shap_feature_importance_plot.png",
)
shap_beeswarm_plot_path = os.path.join(
output_directory,
"eval_validation/artifacts",
f"{_VALIDATION_METRIC_PREFIX}shap_beeswarm_plot.png",
)
shap_plot_tab.add_image("SHAP_BAR_PLOT", shap_bar_plot_path, width=800)
shap_plot_tab.add_image("SHAP_BEESWARM_PLOT", shap_beeswarm_plot_path, width=800)
try:
import shap # noqa: F401
from matplotlib import pyplot # noqa: F401
_add_shap_plots(card)
except ImportError:
_logger.warning(
"SHAP or matplotlib package is not installed, so shap plots will not be added."
)
# Tab 3: Warning log outputs.
warning_output_path = os.path.join(output_directory, "warning_logs.txt")
if os.path.exists(warning_output_path):
warnings_output_tab = card.add_tab("Warning Logs", "{{ STEP_WARNINGS }}")
with open(warning_output_path) as f:
warnings_output_tab.add_html("STEP_WARNINGS", f"<pre>{f.read()}</pre>")
# 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"<b>MLflow Run ID:</b> <a href={run_url}>{run_id}</a><br><br>"
)
else:
run_summary_card_tab.add_markdown("RUN_ID", f"**MLflow Run ID:** `{run_id}`")
if model_url is not None:
run_summary_card_tab.add_html(
"MODEL_URI", f"<b>MLflow Model URI:</b> <a href={model_url}>{model_uri}</a>"
)
else:
run_summary_card_tab.add_markdown("MODEL_URI", f"**MLflow Model URI:** `{model_uri}`")
return card
@classmethod
def from_recipe_config(cls, recipe_config, recipe_root):
step_config = {}
if recipe_config.get("steps", {}).get("evaluate", {}) is not None:
step_config.update(recipe_config.get("steps", {}).get("evaluate", {}))
step_config["target_col"] = recipe_config.get("target_col")
if "positive_class" in recipe_config:
step_config["positive_class"] = recipe_config.get("positive_class")
if recipe_config.get("custom_metrics") is not None:
step_config["custom_metrics"] = recipe_config["custom_metrics"]
if recipe_config.get("primary_metric") is not None:
step_config["primary_metric"] = recipe_config["primary_metric"]
step_config["recipe"] = recipe_config.get("recipe")
step_config.update(
get_recipe_tracking_config(
recipe_root_path=recipe_root,
recipe_config=recipe_config,
).to_dict()
)
return cls(step_config, recipe_root)
@property
def name(self):
return "evaluate"
@property
def environment(self):
environ = get_databricks_env_vars(tracking_uri=self.tracking_config.tracking_uri)
environ.update(get_run_tags_env_vars(recipe_root_path=self.recipe_root))
return environ
def step_class(self):
return StepClass.TRAINING

View File

@@ -0,0 +1,275 @@
import abc
import logging
import os
from pathlib import Path
from typing import Any, Optional
import pandas as pd
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
from mlflow.recipes.artifacts import DataframeArtifact
from mlflow.recipes.cards import BaseCard
from mlflow.recipes.step import BaseStep, StepClass
from mlflow.recipes.steps.ingest.datasets import (
CustomDataset,
DeltaTableDataset,
ParquetDataset,
SparkSqlDataset,
)
from mlflow.recipes.utils.step import get_pandas_data_profiles, validate_classification_config
from mlflow.utils.file_utils import read_parquet_as_pandas_df
_logger = logging.getLogger(__name__)
class BaseIngestStep(BaseStep, metaclass=abc.ABCMeta):
_DATASET_FORMAT_SPARK_TABLE = "spark_table"
_DATASET_FORMAT_DELTA = "delta"
_DATASET_FORMAT_PARQUET = "parquet"
_DATASET_PROFILE_OUTPUT_NAME = "dataset_profile.html"
_STEP_CARD_OUTPUT_NAME = "card.pkl"
_SUPPORTED_DATASETS = [
ParquetDataset,
DeltaTableDataset,
SparkSqlDataset,
# NB: The custom dataset is deliberately listed last as a catch-all for any
# format not matched by the datasets above. When mapping a format to a dataset,
# datasets are explored in the listed order
CustomDataset,
]
def _validate_and_apply_step_config(self):
dataset_format = self.step_config.get("using")
if not dataset_format:
raise MlflowException(
message=(
"Dataset format must be specified via the `using` key within the `ingest`"
" section of recipe.yaml"
),
error_code=INVALID_PARAMETER_VALUE,
)
if self.step_class() == StepClass.TRAINING:
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.positive_class = self.step_config.get("positive_class")
for dataset_class in BaseIngestStep._SUPPORTED_DATASETS:
if dataset_class.handles_format(dataset_format):
self.dataset = dataset_class.from_config(
dataset_config=self.step_config,
recipe_root=self.recipe_root,
)
break
else:
raise MlflowException(
message=f"Unrecognized dataset format: {dataset_format}",
error_code=INVALID_PARAMETER_VALUE,
)
self.skip_data_profiling = self.step_config.get("skip_data_profiling", False)
def _run(self, output_directory: str) -> BaseCard:
dataset_dst_path = os.path.abspath(os.path.join(output_directory, self.dataset_output_name))
self.dataset.resolve_to_parquet(
dst_path=dataset_dst_path,
)
_logger.debug("Successfully stored data in parquet format at '%s'", dataset_dst_path)
ingested_df = read_parquet_as_pandas_df(data_parquet_path=dataset_dst_path)
if self.step_class() == StepClass.TRAINING:
if self.target_col not in ingested_df.columns:
raise MlflowException(
f"Target column '{self.target_col}' not found in ingested dataset.",
error_code=INVALID_PARAMETER_VALUE,
)
if self.task == "classification":
validate_classification_config(
self.task, self.positive_class, ingested_df, self.target_col
)
cardinality = ingested_df[self.target_col].nunique()
if cardinality > 2 and self.positive_class is not None:
raise MlflowException(
f"Target column '{self.target_col}' must have a cardinality of 2,"
f"found '{cardinality}'.",
error_code=INVALID_PARAMETER_VALUE,
)
if self.positive_class is not None and cardinality != 2:
raise MlflowException(
f"`For binary classification problems, "
f"target column '{self.target_col}' must have a cardinality of 2,"
f"found '{cardinality}'. `positive_class` was set, "
f"so we treat this problem as a binary classification problem. ",
error_code=INVALID_PARAMETER_VALUE,
)
ingested_dataset_profile = None
if not self.skip_data_profiling:
_logger.debug("Profiling ingested dataset")
ingested_dataset_profile = get_pandas_data_profiles(
[["Profile of Ingested Dataset", ingested_df]]
)
dataset_profile_path = Path(
str(os.path.join(output_directory, BaseIngestStep._DATASET_PROFILE_OUTPUT_NAME))
)
dataset_profile_path.write_text(ingested_dataset_profile, encoding="utf-8")
_logger.debug(f"Wrote dataset profile to '{dataset_profile_path}'")
schema = pd.io.json.build_table_schema(ingested_df, index=False)
return self._build_step_card(
ingested_dataset_profile=ingested_dataset_profile,
ingested_rows=len(ingested_df),
schema=schema,
data_preview=ingested_df.head(),
dataset_src_location=getattr(self.dataset, "location", None),
dataset_sql=getattr(self.dataset, "sql", None),
)
def _build_step_card( # noqa: D417
self,
ingested_dataset_profile: str,
ingested_rows: int,
schema: dict,
data_preview: pd.DataFrame = None,
dataset_src_location: Optional[str] = None,
dataset_sql: Optional[str] = None,
) -> BaseCard:
"""
Constructs a step card instance corresponding to the current ingest step state.
Args:
ingested_dataset_path: The local filesystem path to the ingested parquet dataset file.
dataset_src_location: The source location of the dataset (e.g. '/tmp/myfile.parquet',
's3://mybucket/mypath', ...), if the dataset is a location-based dataset. Either
``dataset_src_location`` or ``dataset_sql`` must be specified.
dataset_sql: The Spark SQL query string that defines the dataset
(e.g. 'SELECT * FROM my_spark_table'), if the dataset is a Spark SQL dataset. Either
``dataset_src_location`` or ``dataset_sql`` must be specified.
Returns:
An BaseCard instance corresponding to the current ingest step state.
"""
if dataset_src_location is None and dataset_sql is None:
raise MlflowException(
message=(
"Failed to build step card because neither a dataset location nor a"
" dataset Spark SQL query were specified"
),
error_code=INVALID_PARAMETER_VALUE,
)
card = BaseCard(self.recipe_name, self.name)
if not self.skip_data_profiling:
( # Tab #1 -- Ingested dataset profile.
card.add_tab("Data Profile", "{{PROFILE}}").add_pandas_profile(
"PROFILE", ingested_dataset_profile
)
)
# Tab #2 -- Ingested dataset schema.
schema_html = BaseCard.render_table(schema["fields"])
card.add_tab("Data Schema", "{{SCHEMA}}").add_html("SCHEMA", schema_html)
if data_preview is not None:
# Tab #3 -- Ingested dataset preview.
card.add_tab("Data Preview", "{{DATA_PREVIEW}}").add_html(
"DATA_PREVIEW", BaseCard.render_table(data_preview)
)
( # Tab #4 -- Step run summary.
card.add_tab(
"Run Summary",
"{{ INGESTED_ROWS }}"
+ "{{ DATA_SOURCE }}"
+ "{{ EXE_DURATION }}"
+ "{{ LAST_UPDATE_TIME }}",
)
.add_markdown(
name="INGESTED_ROWS",
markdown=f"**Number of rows ingested:** `{ingested_rows}`",
)
.add_markdown(
name="DATA_SOURCE",
markdown=(
f"**Dataset source location:** `{dataset_src_location}`"
if dataset_src_location is not None
else f"**Dataset SQL:** `{dataset_sql}`"
),
)
)
return card
class IngestStep(BaseIngestStep):
_DATASET_OUTPUT_NAME = "dataset.parquet"
def __init__(self, step_config: dict[str, Any], recipe_root: str):
super().__init__(step_config, recipe_root)
self.dataset_output_name = IngestStep._DATASET_OUTPUT_NAME
@classmethod
def from_recipe_config(cls, recipe_config: dict[str, Any], recipe_root: str):
ingest_config = recipe_config.get("steps", {}).get("ingest", {})
target_config = {"target_col": recipe_config.get("target_col")}
if "positive_class" in recipe_config:
target_config["positive_class"] = recipe_config.get("positive_class")
return cls(
step_config={
**ingest_config,
**target_config,
**{"recipe": recipe_config.get("recipe", "regression/v1")},
},
recipe_root=recipe_root,
)
@property
def name(self) -> str:
return "ingest"
def get_artifacts(self):
return [
DataframeArtifact(
"ingested_data", self.recipe_root, self.name, IngestStep._DATASET_OUTPUT_NAME
)
]
def step_class(self):
return StepClass.TRAINING
class IngestScoringStep(BaseIngestStep):
_DATASET_OUTPUT_NAME = "scoring-dataset.parquet"
def __init__(self, step_config: dict[str, Any], recipe_root: str):
super().__init__(step_config, recipe_root)
self.dataset_output_name = IngestScoringStep._DATASET_OUTPUT_NAME
@classmethod
def from_recipe_config(cls, recipe_config: dict[str, Any], recipe_root: str):
step_config = recipe_config.get("steps", {}).get("ingest_scoring", {})
step_config["recipe"] = recipe_config.get("recipe")
return cls(
step_config=step_config,
recipe_root=recipe_root,
)
@property
def name(self) -> str:
return "ingest_scoring"
def get_artifacts(self):
return [
DataframeArtifact(
"ingested_scoring_data",
self.recipe_root,
self.name,
IngestScoringStep._DATASET_OUTPUT_NAME,
)
]
def step_class(self):
return StepClass.PREDICTION

View File

@@ -0,0 +1,675 @@
import importlib
import logging
import os
import pathlib
import posixpath
import sys
from abc import abstractmethod
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Optional, Union
from urllib.parse import urlparse
from mlflow.artifacts import download_artifacts
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import BAD_REQUEST, INVALID_PARAMETER_VALUE
from mlflow.store.artifact.artifact_repo import (
_NUM_DEFAULT_CPUS,
_NUM_MAX_THREADS,
_NUM_MAX_THREADS_PER_CPU,
)
from mlflow.utils._spark_utils import (
_create_local_spark_session_for_recipes,
_get_active_spark_session,
)
from mlflow.utils.file_utils import (
TempDir,
download_file_using_http_uri,
get_local_path_or_none,
local_file_uri_to_path,
read_parquet_as_pandas_df,
write_pandas_df_as_parquet,
)
_logger = logging.getLogger(__name__)
_USER_DEFINED_INGEST_STEP_MODULE = "steps.ingest"
class _Dataset:
"""
Base class representing an ingestable dataset.
"""
def __init__(self, dataset_format: str):
"""
Args:
dataset_format: The format of the dataset (e.g. 'csv', 'parquet', ...).
"""
self.dataset_format = dataset_format
@abstractmethod
def resolve_to_parquet(self, dst_path: str):
"""
Fetches the dataset, converts it to parquet, and stores it at the specified `dst_path`.
Args:
dst_path: The local filesystem path at which to store the resolved parquet dataset
(e.g. `<execution_directory_path>/steps/ingest/outputs/dataset.parquet`).
"""
@classmethod
def from_config(cls, dataset_config: dict[str, Any], recipe_root: str) -> "_Dataset":
"""
Constructs a dataset instance from the specified dataset configuration
and recipe root path.
Args:
dataset_config: Dictionary representation of the recipe dataset configuration
(i.e. the `data` section of recipe.yaml).
recipe_root: The absolute path of the associated recipe root directory on the
local filesystem.
Returns:
A `_Dataset` instance representing the configured dataset.
"""
if not cls.handles_format(dataset_config.get("using")):
raise MlflowException(
f"Invalid format {dataset_config.get('using')} for dataset {cls}",
error_code=INVALID_PARAMETER_VALUE,
)
return cls._from_config(dataset_config, recipe_root)
@classmethod
@abstractmethod
def _from_config(cls, dataset_config, recipe_root) -> "_Dataset":
"""
Constructs a dataset instance from the specified dataset configuration
and recipe root path.
Args:
dataset_config: Dictionary representation of the recipe dataset configuration
(i.e. the `data` section of recipe.yaml).
recipe_root: The absolute path of the associated recipe root directory on the
local filesystem.
Returns:
A `_Dataset` instance representing the configured dataset.
"""
@staticmethod
@abstractmethod
def handles_format(dataset_format: str) -> bool:
"""
Determines whether or not the dataset class is a compatible representation of the
specified dataset format.
Args:
dataset_format: The format of the dataset (e.g. 'csv', 'parquet', ...).
Returns:
`True` if the dataset class is a compatible representation of the specified
dataset format, `False` otherwise.
"""
@classmethod
def _get_required_config(cls, dataset_config: dict[str, Any], key: str) -> Any:
"""
Obtains the value associated with the specified dataset configuration key, first verifying
that the key is present in the config and throwing if it is not.
Args:
dataset_config: Dictionary representation of the recipe dataset configuration
(i.e. the `data` section of recipe.yaml).
key: The key within the dataset configuration for which to fetch the associated
value.
Returns:
The value associated with the specified configuration key.
"""
try:
return dataset_config[key]
except KeyError:
raise MlflowException(
f"The `{key}` configuration key must be specified for dataset with"
f" using '{dataset_config.get('using')}' format"
) from None
class _LocationBasedDataset(_Dataset):
"""
Base class representing an ingestable dataset with a configurable `location` attribute.
"""
def __init__(
self,
location: Union[str, list[str]],
dataset_format: str,
recipe_root: str,
):
"""
Args:
location: The location of the dataset (one dataset as a string or list of multiple
datasets)
(e.g. '/tmp/myfile.parquet', './mypath', 's3://mybucket/mypath', or YAML list:
location:
- http://www.myserver.com/dataset/df1.csv
- http://www.myserver.com/dataset/df1.csv
)
dataset_format: The format of the dataset (e.g. 'csv', 'parquet', ...).
recipe_root: The absolute path of the associated recipe root directory on the local
filesystem.
"""
super().__init__(dataset_format=dataset_format)
self.location = (
_LocationBasedDataset._sanitize_local_dataset_multiple_locations_if_necessary(
dataset_location=location,
recipe_root=recipe_root,
)
)
@abstractmethod
def resolve_to_parquet(self, dst_path: str):
pass
@classmethod
def _from_config(cls, dataset_config: dict[str, Any], recipe_root: str) -> "_Dataset":
return cls(
location=cls._get_required_config(dataset_config=dataset_config, key="location"),
recipe_root=recipe_root,
dataset_format=cls._get_required_config(dataset_config=dataset_config, key="using"),
)
@staticmethod
def _sanitize_local_dataset_multiple_locations_if_necessary(
dataset_location: Union[str, list[str]], recipe_root: str
) -> list[str]:
if isinstance(dataset_location, str):
return [
_LocationBasedDataset._sanitize_local_dataset_location_if_necessary(
dataset_location, recipe_root
)
]
elif isinstance(dataset_location, list):
return [
_LocationBasedDataset._sanitize_local_dataset_location_if_necessary(
locaton, recipe_root
)
for locaton in dataset_location
]
else:
raise MlflowException(f"Unsupported location type: {type(dataset_location)}")
@staticmethod
def _sanitize_local_dataset_location_if_necessary(
dataset_location: str, recipe_root: str
) -> str:
"""
Checks whether or not the specified `dataset_location` is a local filesystem location and,
if it is, converts it to an absolute path if it is not already absolute.
Args:
dataset_location: The dataset location from the recipe dataset configuration.
recipe_root: The absolute path of the recipe root directory on the local
filesystem.
Returns:
The sanitized dataset location.
"""
local_dataset_path_or_none = get_local_path_or_none(path_or_uri=dataset_location)
if local_dataset_path_or_none is None:
return dataset_location
# If the local dataset path is a file: URI, convert it to a filesystem path
local_dataset_path = local_file_uri_to_path(uri=local_dataset_path_or_none)
local_dataset_path = pathlib.Path(local_dataset_path)
if local_dataset_path.is_absolute():
return str(local_dataset_path)
else:
# Use pathlib to join the local dataset relative path with the recipe root
# directory to correctly handle the case where the root path is Windows-formatted
# and the local dataset relative path is POSIX-formatted
return str(pathlib.Path(recipe_root) / local_dataset_path)
@staticmethod
@abstractmethod
def handles_format(dataset_format: str) -> bool:
pass
class _DownloadThenConvertDataset(_LocationBasedDataset):
"""
Base class representing a location-based ingestible dataset that is resolved in two distinct
phases: 1. Download the dataset files to the local filesystem. 2. Convert the dataset files to
parquet format, aggregating them together as a single parquet file.
`_DownloadThenConvertDataset` implements phase (1) and provides an abstract method
for phase (2).
"""
_FILE_DOWNLOAD_CHUNK_SIZE_BYTES = 10**7 # 10MB
def resolve_to_parquet(self, dst_path: str):
with TempDir(chdr=True) as tmpdir:
_logger.debug("Resolving input data from '%s'", self.location)
local_dataset_path = _DownloadThenConvertDataset._download_dataset(
dataset_location=self.location,
dst_path=tmpdir.path(),
)
if os.path.isdir(local_dataset_path):
# NB: Sort the file names alphanumerically to ensure a consistent
# ordering across invocations
if self.dataset_format == "custom":
dataset_file_paths = sorted(pathlib.Path(local_dataset_path).glob("*"))
else:
dataset_file_paths = sorted(
pathlib.Path(local_dataset_path).glob(f"*.{self.dataset_format}")
)
if len(dataset_file_paths) == 0:
raise MlflowException(
message=(
"Did not find any data files with the specified format"
f" '{self.dataset_format}' in the resolved data directory with path"
f" '{local_dataset_path}'. Directory contents:"
f" {os.listdir(local_dataset_path)}."
),
error_code=INVALID_PARAMETER_VALUE,
)
else:
if self.dataset_format != "custom" and not local_dataset_path.endswith(
f".{self.dataset_format}"
):
raise MlflowException(
message=(
f"Resolved data file with path '{local_dataset_path}' does not have the"
f" expected format '{self.dataset_format}'."
),
error_code=INVALID_PARAMETER_VALUE,
)
dataset_file_paths = [local_dataset_path]
_logger.debug("Resolved input data to '%s'", local_dataset_path)
_logger.debug("Converting dataset to parquet format, if necessary")
return self._convert_to_parquet(
dataset_file_paths=dataset_file_paths,
dst_path=dst_path,
)
@staticmethod
def _download_dataset(dataset_location: list[str], dst_path: str):
dest_locations = _DownloadThenConvertDataset._download_all_datasets_in_parallel(
dataset_location, dst_path
)
if len(dest_locations) == 1:
return dest_locations[0]
else:
res_path = pathlib.Path(dest_locations[0])
if res_path.is_dir():
return str(res_path)
else:
return str(res_path.parent)
@staticmethod
def _download_all_datasets_in_parallel(dataset_location, dst_path):
num_cpus = os.cpu_count() or _NUM_DEFAULT_CPUS
with ThreadPoolExecutor(
max_workers=min(num_cpus * _NUM_MAX_THREADS_PER_CPU, _NUM_MAX_THREADS)
) as executor:
futures = []
for location in dataset_location:
future = executor.submit(
_DownloadThenConvertDataset._download_one_dataset,
dataset_location=location,
dst_path=dst_path,
)
futures.append(future)
dest_locations = []
failed_downloads = []
for future in as_completed(futures):
try:
dest_locations.append(future.result())
except Exception as e:
failed_downloads.append(repr(e))
if len(failed_downloads) > 0:
raise MlflowException(
"During downloading of the datasets a number "
+ f"of errors have occurred: {failed_downloads}"
)
return dest_locations
@staticmethod
def _download_one_dataset(dataset_location: str, dst_path: str):
parsed_location_uri = urlparse(dataset_location)
if parsed_location_uri.scheme in ["http", "https"]:
dst_file_name = posixpath.basename(parsed_location_uri.path)
dst_file_path = os.path.join(dst_path, dst_file_name)
download_file_using_http_uri(
http_uri=dataset_location,
download_path=dst_file_path,
chunk_size=_DownloadThenConvertDataset._FILE_DOWNLOAD_CHUNK_SIZE_BYTES,
)
return dst_file_path
else:
return download_artifacts(artifact_uri=dataset_location, dst_path=dst_path)
@abstractmethod
def _convert_to_parquet(self, dataset_file_paths: list[str], dst_path: str):
"""
Converts the specified dataset files to parquet format and aggregates them together,
writing the consolidated parquet file to the specified destination path.
Args:
dataset_file_paths: A list of local filesystem of dataset files to convert to
parquet format.
dst_path: The local filesystem path at which to store the resolved parquet dataset
(e.g. `<execution_directory_path>/steps/ingest/outputs/dataset.parquet`).
"""
class _PandasConvertibleDataset(_DownloadThenConvertDataset):
"""
Base class representing a location-based ingestable dataset that can be parsed and converted to
parquet using a series of Pandas DataFrame ``read_*`` and ``concat`` operations.
"""
def _convert_to_parquet(self, dataset_file_paths: list[str], dst_path: str):
import pandas as pd
aggregated_dataframe = None
for data_file_path in dataset_file_paths:
_path = pathlib.Path(data_file_path)
data_file_as_dataframe = self._load_file_as_pandas_dataframe(
local_data_file_path=data_file_path,
)
aggregated_dataframe = (
pd.concat([aggregated_dataframe, data_file_as_dataframe])
if aggregated_dataframe is not None
else data_file_as_dataframe
)
write_pandas_df_as_parquet(df=aggregated_dataframe, data_parquet_path=dst_path)
@abstractmethod
def _load_file_as_pandas_dataframe(self, local_data_file_path: str):
"""
Loads the specified file as a Pandas DataFrame.
Args:
local_data_file_path: The local filesystem path of the file to load.
Returns:
A Pandas DataFrame representation of the specified file.
"""
@staticmethod
@abstractmethod
def handles_format(dataset_format: str) -> bool:
pass
class ParquetDataset(_PandasConvertibleDataset):
"""
Representation of a dataset in parquet format with files having the `.parquet` extension.
"""
def _load_file_as_pandas_dataframe(self, local_data_file_path: str):
return read_parquet_as_pandas_df(data_parquet_path=local_data_file_path)
@staticmethod
def handles_format(dataset_format: str) -> bool:
return dataset_format == "parquet"
class CustomDataset(_PandasConvertibleDataset):
"""
Representation of a location-based dataset with files containing a consistent, custom
extension (e.g. 'csv', 'csv.gz', 'json', ...), as well as a custom function used to load
and convert the dataset to parquet format.
"""
def __init__(
self,
location: str,
dataset_format: str,
loader_method: str,
recipe_root: str,
):
"""
Args:
location: The location of the dataset
(e.g. '/tmp/myfile.parquet', './mypath', 's3://mybucket/mypath', ...).
dataset_format: The format of the dataset (e.g. 'csv', 'parquet', ...).
loader_method: The custom loader method used to load and convert the dataset
to parquet format, e.g.`load_file_as_dataframe`.
recipe_root: The absolute path of the associated recipe root directory on the
local filesystem.
"""
super().__init__(
location=location,
dataset_format=dataset_format,
recipe_root=recipe_root,
)
self.recipe_root = recipe_root
self.loader_method = loader_method
def _validate_user_code_output(self, func, *args):
import pandas as pd
ingested_df = func(*args)
if not isinstance(ingested_df, pd.DataFrame):
raise MlflowException(
message=(
"The `ingested_data` is not a DataFrame, please make sure "
f"'{_USER_DEFINED_INGEST_STEP_MODULE}.{self.loader_method}' "
"returns a Pandas DataFrame object."
),
error_code=INVALID_PARAMETER_VALUE,
) from None
return ingested_df
def _load_file_as_pandas_dataframe(self, local_data_file_path: str):
try:
sys.path.append(self.recipe_root)
loader_method = getattr(
importlib.import_module(_USER_DEFINED_INGEST_STEP_MODULE),
self.loader_method,
)
except Exception as e:
raise MlflowException(
message=(
"Failed to import custom dataset loader function"
f" '{_USER_DEFINED_INGEST_STEP_MODULE}.{self.loader_method}' for"
f" ingesting dataset with format '{self.dataset_format}'.",
),
error_code=BAD_REQUEST,
) from e
try:
return self._validate_user_code_output(
loader_method, local_data_file_path, self.dataset_format
)
except MlflowException as e:
raise e
except NotImplementedError:
raise MlflowException(
message=(
f"Unable to load data file at path '{local_data_file_path}' with format"
f" '{self.dataset_format}' using custom loader method"
f" '{loader_method.__name__}' because it is not"
" supported. Please update the custom loader method to support this"
" format."
),
error_code=INVALID_PARAMETER_VALUE,
) from None
except Exception as e:
raise MlflowException(
message=(
f"Unable to load data file at path '{local_data_file_path}' with format"
f" '{self.dataset_format}' using custom loader method"
f" '{loader_method.__name__}'."
),
error_code=BAD_REQUEST,
) from e
@classmethod
def _from_config(cls, dataset_config: dict[str, Any], recipe_root: str) -> "_Dataset":
return cls(
location=cls._get_required_config(dataset_config=dataset_config, key="location"),
dataset_format=cls._get_required_config(dataset_config=dataset_config, key="using"),
loader_method=cls._get_required_config(
dataset_config=dataset_config, key="loader_method"
),
recipe_root=recipe_root,
)
@staticmethod
def handles_format(dataset_format: str) -> bool:
return dataset_format is not None
class _SparkDatasetMixin:
"""
Mixin class providing Spark-related utilities for Datasets that use Spark for resolution
and conversion to parquet format.
"""
def _convert_spark_df_to_pandas(self, spark_df):
import pandas as pd
datetime_cols = [
field.name for field in spark_df.schema.fields if str(field.dataType) == "DateType"
]
pandas_df = spark_df.toPandas()
pandas_df[datetime_cols] = pandas_df[datetime_cols].apply(pd.to_datetime, errors="coerce")
return pandas_df
def _get_or_create_spark_session(self):
"""
Obtains the active Spark session, throwing if a session does not exist.
Returns:
The active Spark session.
"""
try:
spark_session = _get_active_spark_session()
if spark_session:
_logger.debug("Found active spark session")
else:
spark_session = _create_local_spark_session_for_recipes()
_logger.debug("Creating new spark session")
return spark_session
except Exception as e:
raise MlflowException(
message=(
f"Encountered an error while searching for an active Spark session to"
f" load the dataset with format '{self.dataset_format}'. Please create a"
f" Spark session and try again."
),
error_code=BAD_REQUEST,
) from e
class DeltaTableDataset(_SparkDatasetMixin, _LocationBasedDataset):
"""
Representation of a dataset in delta format with files having the `.delta` extension.
"""
def __init__(
self,
location: str,
dataset_format: str,
recipe_root: str,
version: Optional[int] = None,
timestamp: Optional[str] = None,
):
"""
Args:
location: The location of the dataset (e.g. '/tmp/myfile.parquet', './mypath',
's3://mybucket/mypath', ...).
dataset_format: The format of the dataset (e.g. 'csv', 'parquet', ...).
recipe_root: The absolute path of the associated recipe root directory on the
local filesystem.
version: The version of the Delta table to read.
timestamp: The timestamp at which to read the Delta table.
"""
super().__init__(location=location, dataset_format=dataset_format, recipe_root=recipe_root)
self.version = version
self.timestamp = timestamp
def resolve_to_parquet(self, dst_path: str):
spark_session = self._get_or_create_spark_session()
spark_read_op = spark_session.read.format("delta")
if self.version is not None:
spark_read_op = spark_read_op.option("versionAsOf", self.version)
if self.timestamp is not None:
spark_read_op = spark_read_op.option("timestampAsOf", self.timestamp)
spark_df = spark_read_op.load(self.location)
pandas_df = self._convert_spark_df_to_pandas(spark_df)
write_pandas_df_as_parquet(df=pandas_df, data_parquet_path=dst_path)
@staticmethod
def handles_format(dataset_format: str) -> bool:
return dataset_format == "delta"
@classmethod
def _from_config(cls, dataset_config: dict[str, Any], recipe_root: str) -> "_Dataset":
return cls(
location=cls._get_required_config(dataset_config=dataset_config, key="location"),
recipe_root=recipe_root,
dataset_format=cls._get_required_config(dataset_config=dataset_config, key="using"),
version=dataset_config.get("version"),
timestamp=dataset_config.get("timestamp"),
)
class SparkSqlDataset(_SparkDatasetMixin, _Dataset):
"""
Representation of a Spark SQL dataset defined by a Spark SQL query string
(e.g. `SELECT * FROM my_spark_table`).
"""
def __init__(self, sql: str, location: str, dataset_format: str):
"""
Args:
sql: The Spark SQL query string that defines the dataset
(e.g. 'SELECT * FROM my_spark_table').
location: The location of the dataset
(e.g. 'catalog.schema.table', 'schema.table', 'table').
dataset_format: The format of the dataset (e.g. 'csv', 'parquet', ...).
"""
super().__init__(dataset_format=dataset_format)
self.sql = sql
self.location = location
def resolve_to_parquet(self, dst_path: str):
if self.location is None and self.sql is None:
raise MlflowException(
"Either location or sql configuration key must be specified for "
"dataset with format spark_sql"
) from None
spark_session = self._get_or_create_spark_session()
spark_df = None
if self.sql is not None:
spark_df = spark_session.sql(self.sql)
elif self.location is not None:
spark_df = spark_session.table(self.location)
pandas_df = self._convert_spark_df_to_pandas(spark_df)
write_pandas_df_as_parquet(df=pandas_df, data_parquet_path=dst_path)
@classmethod
def _from_config(cls, dataset_config: dict[str, Any], recipe_root: str) -> "_Dataset":
return cls(
sql=dataset_config.get("sql"),
location=dataset_config.get("location"),
dataset_format=cls._get_required_config(dataset_config=dataset_config, key="using"),
)
@staticmethod
def handles_format(dataset_format: str) -> bool:
return dataset_format == "spark_sql"

View File

@@ -0,0 +1,288 @@
import logging
import os
import time
from typing import Any
import mlflow
from mlflow.exceptions import BAD_REQUEST, INVALID_PARAMETER_VALUE, MlflowException
from mlflow.recipes.artifacts import DataframeArtifact, RegisteredModelVersionInfo
from mlflow.recipes.cards import BaseCard
from mlflow.recipes.step import BaseStep, StepClass
from mlflow.recipes.steps.register import _REGISTERED_MV_INFO_FILE
from mlflow.recipes.utils.execution import get_step_output_path
from mlflow.recipes.utils.step import get_pandas_data_profiles
from mlflow.recipes.utils.tracking import (
TrackingConfig,
apply_recipe_tracking_config,
get_recipe_tracking_config,
)
from mlflow.utils._spark_utils import (
_create_local_spark_session_for_recipes,
_get_active_spark_session,
)
from mlflow.utils.databricks_utils import get_databricks_env_vars
from mlflow.utils.file_utils import write_spark_dataframe_to_parquet_on_local_disk
_logger = logging.getLogger(__name__)
# This should maybe imported from the ingest scoring step for consistency
_INPUT_FILE_NAME = "scoring-dataset.parquet"
_SCORED_OUTPUT_FILE_NAME = "scored.parquet"
_PREDICTION_COLUMN_NAME = "prediction"
# Max dataframe size for profiling after scoring
_MAX_PROFILE_SIZE = 10000
# Environment manager for Spark UDF model restoration
_ENV_MANAGER = "virtualenv"
class PredictStep(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):
required_configuration_keys = ["using", "location"]
for key in required_configuration_keys:
if key not in self.step_config:
raise MlflowException(
f"The `{key}` configuration key must be specified for the predict step.",
error_code=INVALID_PARAMETER_VALUE,
)
if self.step_config["using"] not in {"parquet", "delta", "table"}:
raise MlflowException(
"Invalid `using` in predict step configuration.",
error_code=INVALID_PARAMETER_VALUE,
)
if "model_uri" not in self.step_config:
try:
register_config = self.step_config["model_registry"]
model_name = register_config["model_name"]
except KeyError:
raise MlflowException(
"No model specified for batch scoring: model_registry does not have "
"`model_uri` and does not have `model_name` configuration key.",
error_code=INVALID_PARAMETER_VALUE,
)
else:
self.step_config["model_uri"] = f"models:/{model_name}/latest"
self.registry_uri = self.step_config.get("registry_uri", None)
self.skip_data_profiling = self.step_config.get("skip_data_profiling", False)
self.save_mode = self.step_config.get("save_mode", "overwrite")
self.run_end_time = None
self.execution_duration = None
def _build_profiles_and_card(self, scored_sdf) -> BaseCard:
# Build profiles for scored dataset
card = BaseCard(self.recipe_name, self.name)
scored_size = scored_sdf.count()
if not self.skip_data_profiling:
_logger.info("Profiling scored dataset")
if scored_size > _MAX_PROFILE_SIZE:
_logger.info("Sampling scored dataset for profiling because dataset size is large.")
sample_percentage = _MAX_PROFILE_SIZE / scored_size
scored_sdf = scored_sdf.sample(sample_percentage)
scored_df = scored_sdf.toPandas()
scored_dataset_profile = get_pandas_data_profiles(
[["Profile of Scored Dataset", scored_df]]
)
# Optional tab : data profile for scored data:
card.add_tab("Scored Data Profile", "{{PROFILE}}").add_pandas_profile(
"PROFILE", scored_dataset_profile
)
# Tab #1/2: run summary.
(
card.add_tab(
"Run Summary",
"""
{{ SCORED_DATA_NUM_ROWS }}
{{ EXE_DURATION }}
{{ LAST_UPDATE_TIME }}
""",
).add_markdown(
"SCORED_DATA_NUM_ROWS",
f"**Number of scored dataset rows:** `{scored_size}`",
)
)
return card
def _run(self, output_directory):
import pandas as pd
from pyspark.sql.functions import struct
run_start_time = time.time()
apply_recipe_tracking_config(self.tracking_config)
if self.registry_uri:
mlflow.set_registry_uri(self.registry_uri)
# Get or create spark session
try:
spark = _get_active_spark_session()
if spark:
_logger.info("Found active spark session")
else:
_logger.info("Creating new spark session")
spark = _create_local_spark_session_for_recipes()
except Exception as e:
raise MlflowException(
message=(
"Encountered an error while getting or creating an active Spark session to"
" score dataset with spark UDF."
),
error_code=BAD_REQUEST,
) from e
# read cleaned dataset
ingested_data_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name="ingest_scoring",
relative_path=_INPUT_FILE_NAME,
)
# Because the cached parquet file is not on DBFS, we have to first load it as a pandas df
input_pdf = pd.read_parquet(ingested_data_path)
input_sdf = spark.createDataFrame(input_pdf)
if _PREDICTION_COLUMN_NAME in input_sdf.columns:
_logger.warning(
f"Input scoring dataframe already contains a column '{_PREDICTION_COLUMN_NAME}'. "
f"This column will be dropped in favor of the predict output column name."
)
# get model uri
model_uri = self.step_config["model_uri"]
registered_model_file_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name="register",
relative_path=_REGISTERED_MV_INFO_FILE,
)
if os.path.exists(registered_model_file_path):
rmi = RegisteredModelVersionInfo.from_json(path=registered_model_file_path)
model_uri = f"models:/{rmi.name}/{rmi.version}"
# scored dataset
result_type = self.step_config.get("result_type", "double")
predict = mlflow.pyfunc.spark_udf(
spark, model_uri, result_type=result_type, env_manager=_ENV_MANAGER
)
scored_sdf = input_sdf.withColumn(
_PREDICTION_COLUMN_NAME, predict(struct(*input_sdf.columns))
)
# check if output location is already populated for non-delta output formats
output_format = self.step_config["using"]
output_location = self.step_config["location"]
output_populated = False
if self.save_mode in ["default", "error", "errorifexists"]:
if output_format == "parquet" or output_format == "delta":
output_populated = os.path.exists(output_location)
else:
try:
output_populated = spark._jsparkSession.catalog().tableExists(output_location)
except Exception:
# swallow spark failures
pass
if output_populated:
raise MlflowException(
message=(
f"Output location `{output_location}` using format `{output_format}` is "
"already populated. To overwrite, please change the spark `save_mode` in "
"the predict step configuration."
),
error_code=BAD_REQUEST,
)
if output_format == "table":
try:
from delta.tables import DeltaTable
output_populated = DeltaTable.forName(spark, output_location)
except Exception:
# swallow spark failures
pass
if output_populated:
_logger.info(f"Table already exists at {output_location}")
# If the table already exists, we are just setting up the table properties to
# ensure that the table can be written with column names with spaces.
spark.sql(
f"ALTER TABLE {output_location} SET TBLPROPERTIES "
"('delta.columnMapping.mode'='name','delta.minReaderVersion'='2',"
"'delta.minWriterVersion'='5')"
)
else:
_logger.info(f"Creating a new table at {output_location}")
from delta.tables import DeltaTable
# If the table location specified doesn't exist, we are creating a new table
# with properties required to ensure that column names can have spaces.
DeltaTable.create().addColumns(scored_sdf.schema).property(
"delta.minReaderVersion", "2"
).property("delta.minWriterVersion", "5").property(
"delta.columnMapping.mode", "name"
).tableName(output_location).execute()
# We are overriding the save_mode to append for the create case, since the table
# is already created above, so adding any record to the empty table can be
# appended to the table
self.save_mode = "append"
# save predictions
if output_format in ["parquet", "delta"]:
scored_sdf.coalesce(1).write.format(output_format).mode(self.save_mode).save(
output_location
)
else:
scored_sdf.write.format("delta").mode(self.save_mode).saveAsTable(output_location)
# predict step artifacts
write_spark_dataframe_to_parquet_on_local_disk(
scored_sdf, os.path.join(output_directory, _SCORED_OUTPUT_FILE_NAME)
)
self.run_end_time = time.time()
self.execution_duration = self.run_end_time - run_start_time
return self._build_profiles_and_card(scored_sdf)
@classmethod
def from_recipe_config(cls, recipe_config, recipe_root):
step_config = {}
if recipe_config.get("steps", {}).get("predict", {}) is not None:
step_config.update(recipe_config.get("steps", {}).get("predict", {}))
if recipe_config.get("steps", {}).get("predict", {}).get("output", {}) is not None:
step_config.update(recipe_config.get("steps", {}).get("predict", {}).get("output", {}))
step_config["register"] = recipe_config.get("steps", {}).get("register", {})
step_config["model_registry"] = recipe_config.get("model_registry", {})
step_config["recipe"] = recipe_config.get("recipe", "regression/v1")
if recipe_config.get("model_registry", {}).get("registry_uri") is not None:
step_config["registry_uri"] = recipe_config.get("model_registry", {}).get(
"registry_uri"
)
step_config.update(
get_recipe_tracking_config(
recipe_root_path=recipe_root,
recipe_config=recipe_config,
).to_dict()
)
return cls(step_config, recipe_root)
@property
def name(self):
return "predict"
@property
def environment(self):
return get_databricks_env_vars(tracking_uri=self.tracking_config.tracking_uri)
def get_artifacts(self):
return [
DataframeArtifact("scored_data", self.recipe_root, self.name, _SCORED_OUTPUT_FILE_NAME)
]
def step_class(self):
return StepClass.PREDICTION

View File

@@ -0,0 +1,205 @@
import logging
from pathlib import Path
from typing import Any
import mlflow
from mlflow.entities import SourceType
from mlflow.exceptions import INVALID_PARAMETER_VALUE, MlflowException
from mlflow.recipes.artifacts import ModelVersionArtifact, RegisteredModelVersionInfo
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.tracking import (
TrackingConfig,
apply_recipe_tracking_config,
get_recipe_tracking_config,
)
from mlflow.tracking._model_registry import DEFAULT_AWAIT_MAX_SLEEP_SECONDS
from mlflow.utils.databricks_utils import (
get_databricks_env_vars,
get_databricks_model_version_url,
get_databricks_run_url,
)
from mlflow.utils.mlflow_tags import MLFLOW_RECIPE_TEMPLATE_NAME, MLFLOW_SOURCE_TYPE
_logger = logging.getLogger(__name__)
_REGISTERED_MV_INFO_FILE = "registered_model_version.json"
class RegisterStep(BaseStep):
def __init__(self, step_config: dict[str, Any], recipe_root: str):
super().__init__(step_config, recipe_root)
self.tracking_config = TrackingConfig.from_dict(self.step_config)
def _validate_and_apply_step_config(self):
self.num_dropped_rows = None
self.model_uri = None
self.model_details = None
self.version = None
self.register_model_name = self.step_config.get("model_name")
if self.register_model_name is None:
raise MlflowException(
"Missing 'model_name' config in register step config.",
error_code=INVALID_PARAMETER_VALUE,
)
self.allow_non_validated_model = self.step_config.get("allow_non_validated_model", False)
self.registry_uri = self.step_config.get("registry_uri", None)
def _run(self, output_directory):
apply_recipe_tracking_config(self.tracking_config)
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_validation_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name="evaluate",
relative_path="model_validation_status",
)
model_validation = Path(model_validation_path).read_text()
artifact_path = "train/model"
tags = {
MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.RECIPE),
MLFLOW_RECIPE_TEMPLATE_NAME: self.step_config["recipe"],
}
self.model_uri = f"runs:/{run_id}/{artifact_path}"
if model_validation == "VALIDATED" or (
model_validation == "UNKNOWN" and self.allow_non_validated_model
):
if self.registry_uri:
mlflow.set_registry_uri(self.registry_uri)
self.model_details = mlflow.register_model(
model_uri=self.model_uri,
name=self.register_model_name,
tags=tags,
await_registration_for=DEFAULT_AWAIT_MAX_SLEEP_SECONDS,
)
self.version = self.model_details.version
registered_model_info = RegisteredModelVersionInfo(
name=self.register_model_name, version=self.version
)
registered_model_info.to_json(
path=str(Path(output_directory) / _REGISTERED_MV_INFO_FILE)
)
else:
raise MlflowException(
f"Model registration on {self.model_uri} failed because it "
"is not validated. Bypass by setting allow_non_validated_model to True. "
)
card = self._build_card(run_id)
card.save_as_html(output_directory)
self._log_step_card(run_id, self.name)
return card
def _build_card(self, run_id: str) -> BaseCard:
card = BaseCard(self.recipe_name, self.name)
card_tab = card.add_tab(
"Run Summary",
"{{ MODEL_NAME }}"
+ "{{ MODEL_VERSION }}"
+ "{{ MODEL_SOURCE_URI }}"
+ "{{ ALERTS }}"
+ "{{ EXE_DURATION }}"
+ "{{ LAST_UPDATE_TIME }}",
)
if self.version is not None:
model_version_url = get_databricks_model_version_url(
registry_uri=mlflow.get_registry_uri(),
name=self.register_model_name,
version=self.version,
)
if model_version_url is not None:
card_tab.add_html(
"MODEL_NAME",
(
f"<b>Model Name:</b> <a href={model_version_url}>"
f"{self.register_model_name}</a><br><br>"
),
)
card_tab.add_html(
"MODEL_VERSION",
(
f"<b>Model Version</b> <a href={model_version_url}>"
f"{self.version}</a><br><br>"
),
)
else:
card_tab.add_markdown(
"MODEL_NAME",
f"**Model Name:** `{self.register_model_name}`",
)
card_tab.add_markdown(
"MODEL_VERSION",
f"**Model Version:** `{self.version}`",
)
model_source_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 self.model_uri is not None and model_source_url is not None:
card_tab.add_html(
"MODEL_SOURCE_URI",
f"<b>Model Source URI</b> <a href={model_source_url}>{self.model_uri}</a>",
)
elif self.model_uri is not None:
card_tab.add_markdown(
"MODEL_SOURCE_URI",
f"**Model Source URI:** `{self.model_uri}`",
)
return card
@classmethod
def from_recipe_config(cls, recipe_config, recipe_root):
step_config = {}
if recipe_config.get("steps", {}).get("register") is not None:
step_config.update(recipe_config.get("steps", {}).get("register"))
step_config["recipe"] = recipe_config.get("recipe")
if recipe_config.get("model_registry", {}).get("registry_uri") is not None:
step_config["registry_uri"] = recipe_config.get("model_registry", {}).get(
"registry_uri"
)
if recipe_config.get("model_registry", {}).get("model_name") is not None:
step_config["model_name"] = recipe_config.get("model_registry", {}).get("model_name")
step_config.update(
get_recipe_tracking_config(
recipe_root_path=recipe_root,
recipe_config=recipe_config,
).to_dict()
)
return cls(step_config, recipe_root)
@property
def name(self):
return "register"
@property
def environment(self):
return get_databricks_env_vars(tracking_uri=self.tracking_config.tracking_uri)
def get_artifacts(self):
return [
ModelVersionArtifact(
"registered_model_version",
self.recipe_root,
self.name,
self.tracking_config.tracking_uri,
)
]
def step_class(self):
return StepClass.TRAINING

View File

@@ -0,0 +1,482 @@
import importlib
import logging
import os
import sys
import time
from enum import Enum
from functools import partial
from multiprocessing.pool import Pool, ThreadPool
import numpy as np
import pandas as pd
from packaging.version import Version
from mlflow.exceptions import BAD_REQUEST, INVALID_PARAMETER_VALUE, MlflowException
from mlflow.recipes.artifacts import DataframeArtifact
from mlflow.recipes.cards import BaseCard
from mlflow.recipes.step import BaseStep, StepClass
from mlflow.recipes.utils.execution import get_step_output_path
from mlflow.recipes.utils.step import get_pandas_data_profiles, validate_classification_config
from mlflow.store.artifact.artifact_repo import _NUM_DEFAULT_CPUS
from mlflow.utils.time import Timer
_logger = logging.getLogger(__name__)
_SPLIT_HASH_BUCKET_NUM = 1000
_INPUT_FILE_NAME = "dataset.parquet"
_OUTPUT_TRAIN_FILE_NAME = "train.parquet"
_OUTPUT_VALIDATION_FILE_NAME = "validation.parquet"
_OUTPUT_TEST_FILE_NAME = "test.parquet"
_USER_DEFINED_SPLIT_STEP_MODULE = "steps.split"
_MAX_CLASSES_TO_PROFILE = 5
class SplitValues(Enum):
"""
Represents the custom split return values.
"""
# Indicates that the row is part of test split
TEST = "TEST"
# Indicates that the row is part of train split
TRAINING = "TRAINING"
# Indicates that the row is part of validation split
VALIDATION = "VALIDATION"
def _make_elem_hashable(elem):
if isinstance(elem, list):
return tuple(_make_elem_hashable(e) for e in elem)
elif isinstance(elem, dict):
return tuple((_make_elem_hashable(k), _make_elem_hashable(v)) for k, v in elem.items())
elif isinstance(elem, np.ndarray):
return elem.shape, tuple(elem.flatten(order="C"))
else:
return elem
def _run_split(task, input_df, split_ratios, target_col):
if task == "classification":
return _perform_stratified_split_per_class(input_df, split_ratios, target_col)
elif task == "regression":
return _perform_split(input_df, split_ratios)
def _perform_stratified_split_per_class(input_df, split_ratios, target_col):
classes = np.unique(input_df[target_col])
partial_func = partial(
_perform_split_for_one_class,
input_df=input_df,
split_ratios=split_ratios,
target_col=target_col,
)
with ThreadPool(os.cpu_count() or _NUM_DEFAULT_CPUS) as p:
zipped_dfs = p.map(partial_func, classes)
train_df, validation_df, test_df = [pd.concat(x) for x in list(zip(*zipped_dfs))]
return train_df, validation_df, test_df
def _perform_split_for_one_class(
class_value,
input_df,
split_ratios,
target_col,
):
filtered_df = input_df[input_df[target_col] == class_value]
return _perform_split(filtered_df, split_ratios, n_jobs=2)
def _perform_split(input_df, split_ratios, n_jobs=-1):
hash_buckets = _create_hash_buckets(input_df, n_jobs=n_jobs)
train_df, validation_df, test_df = _get_split_df(input_df, hash_buckets, split_ratios)
return train_df, validation_df, test_df
def _get_split_df(input_df, hash_buckets, split_ratios):
# split dataset into train / validation / test splits
train_ratio, validation_ratio, test_ratio = split_ratios
ratio_sum = train_ratio + validation_ratio + test_ratio
train_bucket_end = train_ratio / ratio_sum
validation_bucket_end = (train_ratio + validation_ratio) / ratio_sum
train_df = input_df[hash_buckets.map(lambda x: x < train_bucket_end)]
validation_df = input_df[
hash_buckets.map(lambda x: train_bucket_end <= x < validation_bucket_end)
]
test_df = input_df[hash_buckets.map(lambda x: x >= validation_bucket_end)]
empty_splits = [
split_name
for split_name, split_df in [
("train split", train_df),
("validation split", validation_df),
("test split", test_df),
]
if len(split_df) == 0
]
if len(empty_splits) > 0:
_logger.warning(f"The following input dataset splits are empty: {','.join(empty_splits)}.")
return train_df, validation_df, test_df
def _parallelize(data, func, n_jobs=-1):
n_jobs = n_jobs if 0 < n_jobs <= _NUM_DEFAULT_CPUS else _NUM_DEFAULT_CPUS
data_split = np.array_split(data, n_jobs)
pool = Pool(n_jobs)
data = pd.concat(pool.map(func, data_split))
pool.close()
pool.join()
return data
def _run_on_subset(func, data_subset):
if Version(pd.__version__) >= Version("2.1.0"):
return data_subset.map(func)
return data_subset.applymap(func)
def _parallelize_on_rows(data, func, n_jobs=-1):
return _parallelize(data, partial(_run_on_subset, func), n_jobs=n_jobs)
def _hash_pandas_dataframe(input_df, n_jobs=-1):
from pandas.util import hash_pandas_object
hashed_input_df = _parallelize_on_rows(input_df, _make_elem_hashable, n_jobs=n_jobs)
return hash_pandas_object(hashed_input_df)
def _create_hash_buckets(input_df, n_jobs=-1):
# Create hash bucket used for splitting dataset
# Note: use `hash_pandas_object` instead of python builtin hash because it is stable
# across different process runs / different python versions
with Timer() as t:
hash_buckets = _hash_pandas_dataframe(input_df, n_jobs=n_jobs).map(
lambda x: (x % _SPLIT_HASH_BUCKET_NUM) / _SPLIT_HASH_BUCKET_NUM
)
_logger.debug(
f"Creating hash buckets on input dataset containing {len(input_df)} "
f"rows consumes {t:.3f} seconds."
)
return hash_buckets
def _validate_user_code_output(post_split, train_df, validation_df, test_df):
try:
(
post_filter_train_df,
post_filter_validation_df,
post_filter_test_df,
) = post_split(train_df, validation_df, test_df)
except Exception:
raise MlflowException(
message="Error in cleaning up the data frame post split step."
" Expected output is a tuple with (train_df, validation_df, test_df)"
) from None
for post_split_df, pre_split_df, split_type in [
[post_filter_train_df, train_df, "train"],
[post_filter_validation_df, validation_df, "validation"],
[post_filter_test_df, test_df, "test"],
]:
if not isinstance(post_split_df, pd.DataFrame):
raise MlflowException(
message="The split data is not a DataFrame, please return the correct data."
) from None
if list(pre_split_df.columns) != list(post_split_df.columns):
raise MlflowException(
message="The number of columns post split step are different."
f" Column list for {split_type} dataset pre-slit is {list(pre_split_df.columns)}"
f" and post split is {list(post_split_df.columns)}. "
"Split filter function should be used to filter rows rather than filtering columns."
) from None
return (
post_filter_train_df,
post_filter_validation_df,
post_filter_test_df,
)
class SplitStep(BaseStep):
def _validate_and_apply_step_config(self):
self.run_end_time = None
self.execution_duration = None
self.num_dropped_rows = None
self.target_col = self.step_config.get("target_col")
self.positive_class = self.step_config.get("positive_class")
self.skip_data_profiling = self.step_config.get("skip_data_profiling", False)
if self.target_col is None:
raise MlflowException(
"Missing target_col config in recipe config.",
error_code=INVALID_PARAMETER_VALUE,
)
self.skip_data_profiling = self.step_config.get("skip_data_profiling", False)
if "using" in self.step_config:
if self.step_config["using"] not in ["custom", "split_ratios"]:
raise MlflowException(
f"Invalid split step configuration value {self.step_config['using']} for "
f"key 'using'. Supported values are: ['custom', 'split_ratios']",
error_code=INVALID_PARAMETER_VALUE,
)
else:
self.step_config["using"] = "split_ratios"
if self.step_config["using"] == "split_ratios":
self.split_ratios = self.step_config.get("split_ratios", [0.75, 0.125, 0.125])
if not (
isinstance(self.split_ratios, list)
and len(self.split_ratios) == 3
and all(isinstance(x, (int, float)) and x > 0 for x in self.split_ratios)
):
raise MlflowException(
"Config split_ratios must be a list containing 3 positive numbers."
)
if "split_method" not in self.step_config and self.step_config["using"] == "custom":
raise MlflowException(
"Missing 'split_method' configuration in the split step, which is using 'custom'.",
error_code=INVALID_PARAMETER_VALUE,
)
def _build_profiles_and_card(self, train_df, validation_df, test_df) -> BaseCard:
from sklearn.utils import compute_class_weight
def _set_target_col_as_first(df, target_col):
columns = list(df.columns)
col = columns.pop(columns.index(target_col))
return df[[col] + columns]
# Build card
card = BaseCard(self.recipe_name, self.name)
if not self.skip_data_profiling:
# Build profiles for input dataset, and train / validation / test splits
train_df = _set_target_col_as_first(train_df, self.target_col)
validation_df = _set_target_col_as_first(validation_df, self.target_col)
test_df = _set_target_col_as_first(test_df, self.target_col)
data_profile = get_pandas_data_profiles(
[
["Train", train_df.reset_index(drop=True)],
["Validation", validation_df.reset_index(drop=True)],
["Test", test_df.reset_index(drop=True)],
]
)
# Tab #1 - #3: data profiles for train/validation and test.
card.add_tab("Compare Splits", "{{PROFILE}}").add_pandas_profile(
"PROFILE", data_profile
)
if self.task == "classification":
if self.positive_class is not None:
mask = train_df[self.target_col] == self.positive_class
dfs_for_profiles = [
("Positive", train_df[mask]),
("Negative", train_df[~mask]),
]
sub_title = "Positive vs Negative"
else:
classes = np.unique(train_df[self.target_col])
class_weights = compute_class_weight(
class_weight="balanced",
classes=classes,
y=train_df[self.target_col],
)
class_weights = list(zip(classes, class_weights))
class_weights = sorted(class_weights, key=lambda x: x[1], reverse=True)
if len(class_weights) > _MAX_CLASSES_TO_PROFILE:
class_weights = class_weights[:_MAX_CLASSES_TO_PROFILE]
dfs_for_profiles = [
(name, train_df[(train_df[self.target_col] == name)])
for name, _ in class_weights
]
sub_title = f"Top {min(5, len(class_weights))} Classes"
profiles = [
[
str(p[0]),
p[1].drop(columns=[self.target_col]).reset_index(drop=True),
]
for p in dfs_for_profiles
]
generated_profile = get_pandas_data_profiles(profiles)
# Tab #4: data profiles positive negative training split.
card.add_tab(
f"Compare Training Data ({sub_title})", "{{PROFILE}}"
).add_pandas_profile("PROFILE", generated_profile)
# Tab #5: run summary.
(
card.add_tab(
"Run Summary",
"""
{{ SCHEMA_LOCATION }}
{{ TRAIN_SPLIT_NUM_ROWS }}
{{ VALIDATION_SPLIT_NUM_ROWS }}
{{ TEST_SPLIT_NUM_ROWS }}
{{ NUM_DROPPED_ROWS }}
{{ EXE_DURATION}}
{{ LAST_UPDATE_TIME }}
""",
)
.add_markdown(
"NUM_DROPPED_ROWS", f"**Number of dropped rows:** `{self.num_dropped_rows}`"
)
.add_markdown(
"TRAIN_SPLIT_NUM_ROWS", f"**Number of train dataset rows:** `{len(train_df)}`"
)
.add_markdown(
"VALIDATION_SPLIT_NUM_ROWS",
f"**Number of validation dataset rows:** `{len(validation_df)}`",
)
.add_markdown(
"TEST_SPLIT_NUM_ROWS", f"**Number of test dataset rows:** `{len(test_df)}`"
)
)
return card
def _validate_and_execute_custom_split(self, split_fn, input_df):
custom_split_mapping_series = split_fn(input_df)
if not isinstance(custom_split_mapping_series, pd.Series):
raise MlflowException(
"Return type of the custom split function should be a pandas series",
error_code=INVALID_PARAMETER_VALUE,
)
copy_df = input_df.copy()
copy_df["split"] = custom_split_mapping_series
train_df = input_df[copy_df["split"] == SplitValues.TRAINING.value].reset_index(drop=True)
validation_df = input_df[copy_df["split"] == SplitValues.VALIDATION.value].reset_index(
drop=True
)
test_df = input_df[copy_df["split"] == SplitValues.TEST.value].reset_index(drop=True)
if train_df.size + validation_df.size + test_df.size != input_df.size:
incorrect_args = custom_split_mapping_series[
~custom_split_mapping_series.isin(
[
SplitValues.TRAINING.value,
SplitValues.VALIDATION.value,
SplitValues.TEST.value,
]
)
].unique()
raise MlflowException(
f"Returned pandas series from custom split step should only contain "
f"{SplitValues.TRAINING.value}, {SplitValues.VALIDATION.value} or "
f"{SplitValues.TEST.value} as values. Value returned back: {incorrect_args}",
error_code=INVALID_PARAMETER_VALUE,
)
return train_df, validation_df, test_df
def _run_custom_split(self, input_df):
split_fn = getattr(
importlib.import_module(_USER_DEFINED_SPLIT_STEP_MODULE),
self.step_config["split_method"],
)
return self._validate_and_execute_custom_split(split_fn, input_df)
def _run(self, output_directory):
run_start_time = time.time()
# read ingested dataset
ingested_data_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name="ingest",
relative_path=_INPUT_FILE_NAME,
)
input_df = pd.read_parquet(ingested_data_path)
validate_classification_config(self.task, self.positive_class, input_df, self.target_col)
# drop rows which target value is missing
raw_input_num_rows = len(input_df)
# Make sure the target column is actually present in the input DF.
if self.target_col not in input_df.columns:
raise MlflowException(
f"Target column '{self.target_col}' not found in ingested dataset.",
error_code=INVALID_PARAMETER_VALUE,
)
input_df = input_df.dropna(how="any", subset=[self.target_col])
self.num_dropped_rows = raw_input_num_rows - len(input_df)
# split dataset
if self.step_config["using"] == "custom":
train_df, validation_df, test_df = self._run_custom_split(input_df)
else:
train_df, validation_df, test_df = _run_split(
self.task, input_df, self.split_ratios, self.target_col
)
# Import from user function module to process dataframes
post_split_config = self.step_config.get("post_split_method", None)
post_split_filter_config = self.step_config.get("post_split_filter_method", None)
if post_split_config is not None:
sys.path.append(self.recipe_root)
post_split = getattr(
importlib.import_module(_USER_DEFINED_SPLIT_STEP_MODULE), post_split_config
)
_logger.debug(f"Running {post_split_config} on train, validation and test datasets.")
(
train_df,
validation_df,
test_df,
) = _validate_user_code_output(post_split, train_df, validation_df, test_df)
elif post_split_filter_config is not None:
sys.path.append(self.recipe_root)
post_split_filter = getattr(
importlib.import_module(_USER_DEFINED_SPLIT_STEP_MODULE), post_split_filter_config
)
_logger.debug(
f"Running {post_split_filter_config} on train, validation and test datasets."
)
train_df = train_df[post_split_filter(train_df)]
if min(len(train_df), len(validation_df), len(test_df)) < 4:
raise MlflowException(
f"Train, validation, and testing datasets cannot be less than 4 rows. Train has "
f"{len(train_df)} rows, validation has {len(validation_df)} rows, and test has "
f"{len(test_df)} rows.",
error_code=BAD_REQUEST,
)
# Output train / validation / test splits
train_df.to_parquet(os.path.join(output_directory, _OUTPUT_TRAIN_FILE_NAME))
validation_df.to_parquet(os.path.join(output_directory, _OUTPUT_VALIDATION_FILE_NAME))
test_df.to_parquet(os.path.join(output_directory, _OUTPUT_TEST_FILE_NAME))
self.run_end_time = time.time()
self.execution_duration = self.run_end_time - run_start_time
return self._build_profiles_and_card(train_df, validation_df, test_df)
@classmethod
def from_recipe_config(cls, recipe_config, recipe_root):
step_config = {}
if recipe_config.get("steps", {}).get("split", {}) is not None:
step_config.update(recipe_config.get("steps", {}).get("split", {}))
step_config["target_col"] = recipe_config.get("target_col")
step_config["positive_class"] = recipe_config.get("positive_class")
step_config["recipe"] = recipe_config.get("recipe", "regression/v1")
return cls(step_config, recipe_root)
@property
def name(self):
return "split"
def get_artifacts(self):
return [
DataframeArtifact(
"training_data", self.recipe_root, self.name, _OUTPUT_TRAIN_FILE_NAME
),
DataframeArtifact(
"validation_data", self.recipe_root, self.name, _OUTPUT_VALIDATION_FILE_NAME
),
DataframeArtifact("test_data", self.recipe_root, self.name, _OUTPUT_TEST_FILE_NAME),
]
def step_class(self):
return StepClass.TRAINING

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
import importlib
import logging
import os
import sys
import time
import cloudpickle
from packaging.version import Version
from mlflow.exceptions import INVALID_PARAMETER_VALUE, MlflowException
from mlflow.recipes.artifacts import DataframeArtifact, TransformerArtifact
from mlflow.recipes.cards import BaseCard
from mlflow.recipes.step import BaseStep, StepClass
from mlflow.recipes.utils.execution import get_step_output_path
from mlflow.recipes.utils.step import get_pandas_data_profiles, validate_classification_config
from mlflow.recipes.utils.tracking import TrackingConfig, get_recipe_tracking_config
_logger = logging.getLogger(__name__)
_USER_DEFINED_TRANSFORM_STEP_MODULE = "steps.transform"
def _generate_feature_names(num_features):
max_length = len(str(num_features))
return ["f_" + str(i).zfill(max_length) for i in range(num_features)]
def _get_output_feature_names(transformer, num_features, input_features):
import sklearn
# `get_feature_names_out` was introduced in scikit-learn 1.0.0.
if Version(sklearn.__version__) < Version("1.0.0"):
return _generate_feature_names(num_features)
try:
# `get_feature_names_out` fails if `transformer` contains a transformer that doesn't
# implement `get_feature_names_out`. For example, `FunctionTransformer` only implements
# `get_feature_names_out` when it's instantiated with `feature_names_out`.
# In scikit-learn >= 1.1.0, all transformers implement `get_feature_names_out`.
# In scikit-learn == 1.0.*, some transformers implement `get_feature_names_out`.
return transformer.get_feature_names_out(input_features)
except Exception as e:
_logger.warning(
f"Failed to get output feature names with `get_feature_names_out`: {e}. "
"Falling back to using auto-generated feature names."
)
return _generate_feature_names(num_features)
def _validate_user_code_output(transformer_fn):
transformer = transformer_fn()
if transformer is not None and not (hasattr(transformer, "fit") and callable(transformer.fit)):
raise MlflowException(
message="The transformer provided doesn't have a fit method."
) from None
if transformer is not None and not (
hasattr(transformer, "transform") and callable(transformer.transform)
):
raise MlflowException(
message="The transformer provided doesn't have a transform method."
) from None
return transformer
class TransformStep(BaseStep):
def __init__(self, step_config, recipe_root):
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")
self.positive_class = self.step_config.get("positive_class")
if self.target_col is None:
raise MlflowException(
"Missing target_col config in recipe config.",
error_code=INVALID_PARAMETER_VALUE,
)
if "using" in self.step_config:
if self.step_config["using"] not in ["custom"]:
raise MlflowException(
f"Invalid transform step configuration value {self.step_config['using']} for "
f"key 'using'. Supported values are: ['custom']",
error_code=INVALID_PARAMETER_VALUE,
)
else:
self.step_config["using"] = "custom"
self.run_end_time = None
self.execution_duration = None
self.skip_data_profiling = self.step_config.get("skip_data_profiling", False)
def _run(self, output_directory):
import pandas as pd
run_start_time = time.time()
train_data_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name="split",
relative_path="train.parquet",
)
train_df = pd.read_parquet(train_data_path)
validate_classification_config(self.task, self.positive_class, train_df, self.target_col)
validation_data_path = get_step_output_path(
recipe_root_path=self.recipe_root,
step_name="split",
relative_path="validation.parquet",
)
validation_df = pd.read_parquet(validation_data_path)
sys.path.append(self.recipe_root)
def get_identity_transformer():
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
return Pipeline(steps=[("identity", FunctionTransformer())])
if "transformer_method" not in self.step_config and self.step_config["using"] == "custom":
raise MlflowException(
"Missing 'transformer_method' configuration in the transform step, "
"which is using 'custom'.",
error_code=INVALID_PARAMETER_VALUE,
)
method_config = self.step_config.get("transformer_method")
transformer = None
if method_config and self.step_config["using"] == "custom":
transformer_fn = getattr(
importlib.import_module(_USER_DEFINED_TRANSFORM_STEP_MODULE), method_config
)
transformer = _validate_user_code_output(transformer_fn)
transformer = transformer if transformer else get_identity_transformer()
transformer.fit(train_df.drop(columns=[self.target_col]), train_df[self.target_col])
def transform_dataset(dataset):
features = dataset.drop(columns=[self.target_col])
transformed_features = transformer.transform(features)
if not isinstance(transformed_features, pd.DataFrame):
num_features = transformed_features.shape[1]
columns = _get_output_feature_names(transformer, num_features, features.columns)
transformed_features = pd.DataFrame(transformed_features, columns=columns)
transformed_features[self.target_col] = dataset[self.target_col].values
return transformed_features
train_transformed = transform_dataset(train_df)
validation_transformed = transform_dataset(validation_df)
with open(os.path.join(output_directory, "transformer.pkl"), "wb") as f:
cloudpickle.dump(transformer, f)
train_transformed.to_parquet(
os.path.join(output_directory, "transformed_training_data.parquet")
)
validation_transformed.to_parquet(
os.path.join(output_directory, "transformed_validation_data.parquet")
)
self.run_end_time = time.time()
self.execution_duration = self.run_end_time - run_start_time
return self._build_profiles_and_card(train_df, train_transformed, transformer)
def _build_profiles_and_card(self, train_df, train_transformed, transformer) -> BaseCard:
# Build card
card = BaseCard(self.recipe_name, self.name)
if not self.skip_data_profiling:
# Tab 1: build profiles for train_transformed
train_transformed_profile = get_pandas_data_profiles(
[["Profile of Train Transformed Dataset", train_transformed]]
)
card.add_tab("Data Profile (Train Transformed)", "{{PROFILE}}").add_pandas_profile(
"PROFILE", train_transformed_profile
)
# Tab 3: transformer diagram
from sklearn import set_config
from sklearn.utils import estimator_html_repr
set_config(display="diagram")
transformer_repr = estimator_html_repr(transformer)
card.add_tab("Transformer", "{{TRANSFORMER}}").add_html("TRANSFORMER", transformer_repr)
# Tab 4: transformer input schema
card.add_tab("Input Schema", "{{INPUT_SCHEMA}}").add_html(
"INPUT_SCHEMA",
BaseCard.render_table({"Name": n, "Type": t} for n, t in train_df.dtypes.items()),
)
# Tab 5: transformer output schema
try:
card.add_tab("Output Schema", "{{OUTPUT_SCHEMA}}").add_html(
"OUTPUT_SCHEMA",
BaseCard.render_table(
{"Name": n, "Type": t} for n, t in train_transformed.dtypes.items()
),
)
except Exception as e:
card.add_tab("Output Schema", "{{OUTPUT_SCHEMA}}").add_html(
"OUTPUT_SCHEMA", f"Failed to extract transformer schema. Error: {e}"
)
# Tab 6: transformer output data preview
card.add_tab("Data Preview", "{{DATA_PREVIEW}}").add_html(
"DATA_PREVIEW", BaseCard.render_table(train_transformed.head())
)
# Tab 7: run summary
(
card.add_tab(
"Run Summary",
"""
{{ EXE_DURATION }}
{{ LAST_UPDATE_TIME }}
""",
)
)
return card
@classmethod
def from_recipe_config(cls, recipe_config, recipe_root):
step_config = {}
if recipe_config.get("steps", {}).get("transform", {}) is not None:
step_config.update(recipe_config.get("steps", {}).get("transform", {}))
step_config["target_col"] = recipe_config.get("target_col")
step_config["recipe"] = recipe_config.get("recipe", "regression/v1")
if "positive_class" in recipe_config:
step_config["positive_class"] = recipe_config.get("positive_class")
step_config.update(
get_recipe_tracking_config(
recipe_root_path=recipe_root,
recipe_config=recipe_config,
).to_dict()
)
return cls(step_config, recipe_root)
@property
def name(self):
return "transform"
def get_artifacts(self):
return [
DataframeArtifact(
"transformed_training_data",
self.recipe_root,
self.name,
"transformed_training_data.parquet",
),
DataframeArtifact(
"transformed_validation_data",
self.recipe_root,
self.name,
"transformed_validation_data.parquet",
),
TransformerArtifact(
"transformer", self.recipe_root, self.name, self.tracking_config.tracking_uri
),
]
def step_class(self):
return StepClass.TRAINING

View File

@@ -0,0 +1,149 @@
import logging
import os
import pathlib
import posixpath
from typing import Any, Optional
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
from mlflow.utils.databricks_utils import is_in_databricks_runtime
from mlflow.utils.file_utils import read_yaml, render_and_merge_yaml
_RECIPE_CONFIG_FILE_NAME = "recipe.yaml"
_RECIPE_PROFILE_DIR = "profiles"
_logger = logging.getLogger(__name__)
def get_recipe_name(recipe_root_path: Optional[str] = None) -> str:
"""
Obtains the name of the specified recipe or of the recipe corresponding to the current
working directory.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem. If unspecified, the recipe root directory is resolved from the current
working directory.
Raises:
MlflowException: If the specified ``recipe_root_path`` is not a recipe root
directory or if ``recipe_root_path`` is ``None`` and the current working directory
does not correspond to a recipe.
Returns:
The name of the specified recipe.
"""
recipe_root_path = recipe_root_path or get_recipe_root_path()
_verify_is_recipe_root_directory(recipe_root_path=recipe_root_path)
return os.path.basename(recipe_root_path)
def get_recipe_config(
recipe_root_path: Optional[str] = None, profile: Optional[str] = None
) -> dict[str, Any]:
"""
Obtains a dictionary representation of the configuration for the specified recipe.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem. If unspecified, the recipe root directory is resolved from the current
working directory.
profile: The name of the profile under the `profiles` directory to use, e.g. "dev" to
use configs from "profiles/dev.yaml".
Raises:
MlflowException: If the specified ``recipe_root_path`` is not a recipe root directory
or if ``recipe_root_path`` is ``None`` and the current working directory does not
correspond to a recipe.
Returns:
The configuration of the specified recipe.
"""
recipe_root_path = recipe_root_path or get_recipe_root_path()
_verify_is_recipe_root_directory(recipe_root_path=recipe_root_path)
try:
if profile:
# Jinja expects template names in posixpath format relative to environment root,
# so use posixpath to construct the relative path here.
profile_relpath = posixpath.join(_RECIPE_PROFILE_DIR, f"{profile}.yaml")
profile_file_path = os.path.join(
recipe_root_path, _RECIPE_PROFILE_DIR, f"{profile}.yaml"
)
if not os.path.exists(profile_file_path):
raise MlflowException(
"Did not find the YAML configuration file for the specified profile"
f" '{profile}' at expected path '{profile_file_path}'.",
error_code=INVALID_PARAMETER_VALUE,
)
return render_and_merge_yaml(
recipe_root_path, _RECIPE_CONFIG_FILE_NAME, profile_relpath
)
else:
return read_yaml(recipe_root_path, _RECIPE_CONFIG_FILE_NAME)
except MlflowException:
raise
except Exception as e:
raise MlflowException(
"Failed to read recipe configuration. Please verify that the `recipe.yaml`"
" configuration file and the YAML configuration file for the selected profile are"
" syntactically correct and that the specified profile provides all required values"
" for template substitutions defined in `recipe.yaml`.",
error_code=INVALID_PARAMETER_VALUE,
) from e
def get_recipe_root_path() -> str:
"""
Obtains the path of the recipe corresponding to the current working directory, throwing an
``MlflowException`` if the current working directory does not reside within a recipe
directory.
Returns:
The absolute path of the recipe root directory on the local filesystem.
"""
# In the release version of MLflow Recipes, each recipe will be its own git repository.
# To improve developer velocity for now, we choose to treat a recipe as a directory, which
# may be a subdirectory of a git repo. The logic for resolving the repository root for
# development purposes finds the first `recipe.yaml` file by traversing up the directory
# tree, while the release version will find the recipe repository root (commented out below)
curr_dir_path = pathlib.Path.cwd()
while True:
recipe_yaml_path_to_check = curr_dir_path / _RECIPE_CONFIG_FILE_NAME
if recipe_yaml_path_to_check.exists():
return str(curr_dir_path.resolve())
elif curr_dir_path != curr_dir_path.parent:
curr_dir_path = curr_dir_path.parent
else:
# If curr_dir_path == curr_dir_path.parent,
# we have reached the root directory without finding
# the desired recipe.yaml file
raise MlflowException(f"Failed to find {_RECIPE_CONFIG_FILE_NAME}!")
def get_default_profile() -> str:
"""
Returns the default profile name under which a recipe is executed. The default
profile may change depending on runtime environment.
Returns:
The default profile name string.
"""
return "databricks" if is_in_databricks_runtime() else "local"
def _verify_is_recipe_root_directory(recipe_root_path: str) -> str:
"""
Verifies that the specified local filesystem path is the path of a recipe root directory.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem to validate.
Raises:
MlflowException: If the specified ``recipe_root_path`` is not a recipe root
directory.
"""
recipe_yaml_path = os.path.join(recipe_root_path, _RECIPE_CONFIG_FILE_NAME)
if not os.path.exists(recipe_yaml_path):
raise MlflowException(f"Failed to find {_RECIPE_CONFIG_FILE_NAME} in {recipe_yaml_path}!")

View File

@@ -0,0 +1,618 @@
import hashlib
import logging
import os
import pathlib
import re
import shutil
from mlflow.environment_variables import (
MLFLOW_RECIPES_EXECUTION_DIRECTORY,
MLFLOW_RECIPES_EXECUTION_TARGET_STEP_NAME,
)
from mlflow.recipes.step import BaseStep, StepStatus
from mlflow.utils.file_utils import read_yaml, write_yaml
from mlflow.utils.process import _exec_cmd
_logger = logging.getLogger(__name__)
_STEPS_SUBDIRECTORY_NAME = "steps"
_STEP_OUTPUTS_SUBDIRECTORY_NAME = "outputs"
_STEP_CONF_YAML_NAME = "conf.yaml"
def run_recipe_step(
recipe_root_path: str,
recipe_steps: list[BaseStep],
target_step: BaseStep,
template: str,
) -> BaseStep:
"""
Runs the specified step in the specified recipe, as well as all dependent steps.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
recipe_steps: A list of all the steps contained in the subgraph of the specified
recipe that contains the target_step. Recipe steps must be provided in the order
that they are intended to be executed.
target_step: The step to run.
template: The template to use when selecting a Makefile to load. If the template is
invalid, an exception is thrown.
Returns:
The last step that successfully completed during the recipe execution. If execution
was successful, this always corresponds to the supplied target step. If execution was
unsuccessful, this corresponds to the step that failed.
"""
target_step_index = recipe_steps.index(target_step)
execution_dir_path = _get_or_create_execution_directory(
recipe_root_path, recipe_steps, template
)
def get_execution_state(step):
return step.get_execution_state(
output_directory=_get_step_output_directory_path(
execution_directory_path=execution_dir_path,
step_name=step.name,
)
)
# Check the previous execution state of the target step and all of its
# dependencies. If any of these steps previously failed, clear its execution
# state to ensure that the step is run again during the upcoming execution
clean_execution_state(
recipe_root_path=recipe_root_path,
recipe_steps=[
step
for step in recipe_steps[: target_step_index + 1]
if get_execution_state(step).status != StepStatus.SUCCEEDED
],
)
_write_updated_step_confs(
recipe_steps=recipe_steps,
execution_directory_path=execution_dir_path,
)
# Aggregate step-specific environment variables into a single environment dictionary
# that is passed to the Make subprocess. In the future, steps with different environments
# should be isolated in different subprocesses
make_env = {
# Include target step name in the environment variable set
MLFLOW_RECIPES_EXECUTION_TARGET_STEP_NAME.name: target_step.name,
}
for step in recipe_steps:
make_env.update(step.environment)
# Use Make to run the target step and all of its dependencies
_run_make(
execution_directory_path=execution_dir_path,
rule_name=target_step.name,
extra_env=make_env,
recipe_steps=recipe_steps,
)
# Identify the last step that was executed, excluding steps that are downstream of the
# specified target step
last_executed_step = recipe_steps[0]
last_executed_step_state = get_execution_state(last_executed_step)
for step in recipe_steps[1 : target_step_index + 1]:
step_state = get_execution_state(step)
if step_state.last_updated_timestamp >= last_executed_step_state.last_updated_timestamp:
last_executed_step = step
last_executed_step_state = step_state
# Check the previous execution state of all recipe steps downstream of the last executed step.
# If any of these steps was last executed before the target step or another step upstream of the
# target step, this indicates that downstream steps are out of date and need to be cleared
clean_execution_state(
recipe_root_path=recipe_root_path,
recipe_steps=[
step
for step in recipe_steps[recipe_steps.index(last_executed_step) :]
if get_execution_state(step).last_updated_timestamp
< last_executed_step_state.last_updated_timestamp
],
)
return last_executed_step
def clean_execution_state(recipe_root_path: str, recipe_steps: list[BaseStep]) -> None:
"""
Removes all execution state for the specified recipe steps from the associated execution
directory on the local filesystem. This method does *not* remove other execution results, such
as content logged to MLflow Tracking.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
recipe_steps: The recipe steps for which to remove execution state.
"""
execution_dir_path = get_or_create_base_execution_directory(recipe_root_path=recipe_root_path)
for step in recipe_steps:
step_outputs_path = _get_step_output_directory_path(
execution_directory_path=execution_dir_path,
step_name=step.name,
)
if os.path.exists(step_outputs_path):
shutil.rmtree(step_outputs_path)
os.makedirs(step_outputs_path)
def get_step_output_path(recipe_root_path: str, step_name: str, relative_path: str) -> str:
"""
Obtains the absolute path of the specified step output on the local filesystem. Does
not check the existence of the output.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
step_name: The name of the recipe step containing the specified output.
relative_path: The relative path of the output within the output directory
of the specified recipe step.
Returns:
The absolute path of the step output on the local filesystem, which may or may
not exist.
"""
execution_dir_path = get_or_create_base_execution_directory(recipe_root_path=recipe_root_path)
step_outputs_path = _get_step_output_directory_path(
execution_directory_path=execution_dir_path,
step_name=step_name,
)
return os.path.abspath(os.path.join(step_outputs_path, relative_path))
def _get_or_create_execution_directory(
recipe_root_path: str, recipe_steps: list[BaseStep], template: str
) -> str:
"""
Obtains the path of the execution directory on the local filesystem corresponding to the
specified recipe, creating the execution directory and its required contents if they do
not already exist.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
recipe_steps: A list of all the steps contained in the specified recipe.
template: The template to use to generate the makefile.
Returns:
The absolute path of the execution directory on the local filesystem for the specified
recipe.
"""
execution_dir_path = get_or_create_base_execution_directory(recipe_root_path=recipe_root_path)
_create_makefile(recipe_root_path, execution_dir_path, template)
for step in recipe_steps:
step_output_subdir_path = _get_step_output_directory_path(execution_dir_path, step.name)
os.makedirs(step_output_subdir_path, exist_ok=True)
return execution_dir_path
def _write_updated_step_confs(recipe_steps: list[BaseStep], execution_directory_path: str) -> None:
"""
Compares the in-memory configuration state of the specified recipe steps with step-specific
internal configuration files written by prior executions. If updates are found, writes updated
state to the corresponding files. If no updates are found, configuration state is not
rewritten.
Args:
recipe_steps: A list of all the steps contained in the specified recipe.
execution_directory_path: The absolute path of the execution directory on the local
filesystem for the specified recipe. Configuration files are written to step-specific
subdirectories of this execution directory.
"""
for step in recipe_steps:
step_subdir_path = os.path.join(
execution_directory_path, _STEPS_SUBDIRECTORY_NAME, step.name
)
step_conf_path = os.path.join(step_subdir_path, _STEP_CONF_YAML_NAME)
if os.path.exists(step_conf_path):
prev_step_conf = read_yaml(root=step_subdir_path, file_name=_STEP_CONF_YAML_NAME)
else:
prev_step_conf = None
if prev_step_conf != step.step_config:
write_yaml(
root=step_subdir_path,
file_name=_STEP_CONF_YAML_NAME,
data=step.step_config,
overwrite=True,
sort_keys=True,
)
def get_or_create_base_execution_directory(recipe_root_path: str) -> str:
"""
Obtains the path of the execution directory on the local filesystem corresponding to the
specified recipe. The directory is created if it does not exist.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
Returns:
The path of the execution directory on the local filesystem corresponding to the
specified recipe.
"""
execution_directory_basename = _get_execution_directory_basename(
recipe_root_path=recipe_root_path
)
execution_dir_path = os.path.abspath(
MLFLOW_RECIPES_EXECUTION_DIRECTORY.get()
or os.path.join(os.path.expanduser("~"), ".mlflow", "recipes", execution_directory_basename)
)
os.makedirs(execution_dir_path, exist_ok=True)
return execution_dir_path
def _get_execution_directory_basename(recipe_root_path):
"""
Obtains the basename of the execution directory corresponding to the specified recipe.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
Returns:
The basename of the execution directory corresponding to the specified recipe.
"""
return hashlib.sha256(os.path.abspath(recipe_root_path).encode("utf-8")).hexdigest()
def _get_step_output_directory_path(execution_directory_path: str, step_name: str) -> str:
"""
Obtains the path of the local filesystem directory containing outputs for the specified step,
which may or may not exist.
Args:
execution_directory_path: The absolute path of the execution directory on the local
filesystem for the relevant recipe. The Makefile is created in this directory.
step_name: The name of the recipe step for which to obtain the output directory path.
Returns:
The absolute path of the local filesystem directory containing outputs for the specified
step.
"""
return os.path.abspath(
os.path.join(
execution_directory_path,
_STEPS_SUBDIRECTORY_NAME,
step_name,
_STEP_OUTPUTS_SUBDIRECTORY_NAME,
)
)
class _ExecutionPlan:
_MSG_REGEX = r'^echo "Run MLflow Recipe step: (\w+)"\n$'
_FORMAT_STEPS_CACHED = "%s: No changes. Skipping."
def __init__(self, rule_name, output_lines_of_make: list[str], recipe_step_names: list[str]):
steps_to_run = self._parse_output_lines(output_lines_of_make)
self.steps_cached = self._infer_cached_steps(rule_name, steps_to_run, recipe_step_names)
@staticmethod
def _parse_output_lines(output_lines_of_make: list[str]) -> list[str]:
"""
Parse the output lines of Make to get steps to run.
"""
def get_step_to_run(output_line: str):
m = re.search(_ExecutionPlan._MSG_REGEX, output_line)
return m.group(1) if m else None
def steps_to_run():
for output_line in output_lines_of_make:
step = get_step_to_run(output_line)
if step is not None:
yield step
return list(steps_to_run())
@staticmethod
def _infer_cached_steps(rule_name, steps_to_run, recipe_step_names) -> list[str]:
"""
Infer cached steps.
Args:
rule_name: The name of the Make rule to run.
steps_to_run: The step names obtained by parsing the Make output showing
which steps will be executed.
recipe_step_names: A list of all the step names contained in the specified
recipe sorted by the execution order.
"""
index = recipe_step_names.index(rule_name)
if index == 0:
# If the rule_name is ingest, it should always be executed
return []
if len(steps_to_run) == 0:
# All steps are cached
return recipe_step_names[: index + 1]
first_step_index = min([recipe_step_names.index(step) for step in steps_to_run])
return recipe_step_names[:first_step_index]
def print(self) -> None:
if len(self.steps_cached) > 0:
steps_cached_str = ", ".join(self.steps_cached)
_logger.info(self._FORMAT_STEPS_CACHED, steps_cached_str)
def _run_make(
execution_directory_path,
rule_name: str,
extra_env: dict[str, str],
recipe_steps: list[BaseStep],
) -> None:
"""
Runs the specified recipe rule with Make. This method assumes that a Makefile named `Makefile`
exists in the specified execution directory.
Args:
execution_directory_path: The absolute path of the execution directory on the local
filesystem for the relevant recipe. The Makefile is created in this directory.
rule_name: The name of the Make rule to run.
extra_env: Extra environment variables to be defined when running the Make child process.
recipe_steps: A list of step instances that is a subgraph containing the step specified
by `rule_name`.
"""
# Dry-run Make and collect the outputs
process = _exec_cmd(
["make", "-n", "-f", "Makefile", rule_name],
capture_output=False,
stream_output=True,
synchronous=False,
throw_on_error=False,
cwd=execution_directory_path,
extra_env=extra_env,
)
output_lines = list(iter(process.stdout.readline, ""))
process.communicate()
return_code = process.poll()
if return_code == 0:
# Only try to print cached steps message when `make -n` completes with no error.
# Note that runtime errors from shell cannot be detected by Make dry-run, so the
# return code will be 0 in this case. As long as `make -n` has no error, cached
# steps inference logic can work correctly even when shell runtime error occurs.
recipe_step_names = [step.name for step in recipe_steps]
_ExecutionPlan(rule_name, output_lines, recipe_step_names).print()
_exec_cmd(
["make", "-s", "-f", "Makefile", rule_name],
capture_output=False,
stream_output=True,
synchronous=True,
throw_on_error=False,
cwd=execution_directory_path,
extra_env=extra_env,
)
def _create_makefile(recipe_root_path, execution_directory_path, template) -> None:
"""
Creates a Makefile with a set of relevant MLflow Recipes targets for the specified recipe,
overwriting the preexisting Makefile if one exists. The Makefile is created in the specified
execution directory.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
execution_directory_path: The absolute path of the execution directory on the local
filesystem for the specified recipe. The Makefile is created in this directory.
template: The template to use to generate the makefile.
"""
makefile_path = os.path.join(execution_directory_path, "Makefile")
if template == "regression/v1" or template == "classification/v1":
makefile_to_use = _MAKEFILE_FORMAT_STRING
steps_folder_path = os.path.join(recipe_root_path, "steps")
if not os.path.exists(steps_folder_path):
os.mkdir(steps_folder_path)
for required_file in [
"ingest.py",
"split.py",
"train.py",
"transform.py",
"custom_metrics.py",
]:
required_file_path = os.path.join(steps_folder_path, required_file)
if not os.path.exists(required_file_path):
try:
with open(required_file_path, "w") as f:
f.write("# Created by MLflow Pipelines\n")
except OSError:
pass
if not os.path.exists(required_file_path):
raise ValueError(
f"Can not find required file {required_file_path} from steps folder. "
"Please create empty python file if the step is not used."
)
else:
raise ValueError(f"Invalid template: {template}")
makefile_contents = makefile_to_use.format(
path=_MakefilePathFormat(
os.path.abspath(recipe_root_path),
execution_directory_path=os.path.abspath(execution_directory_path),
),
)
with open(makefile_path, "w") as f:
f.write(makefile_contents)
class _MakefilePathFormat:
r"""
Provides platform-agnostic path substitution for execution Makefiles, ensuring that POSIX-style
relative paths are joined correctly with POSIX-style or Windows-style recipe root paths.
For example, given a format string `s = "{path:prp/my/subpath.txt}"`, invoking
`s.format(path=_MakefilePathFormat(recipe_root_path="/my/recipe/root/path", ...))` on
Unix systems or
`s.format(path=_MakefilePathFormat(recipe_root_path="C:\my\recipe\root\path", ...))`` on
Windows systems will yield "/my/recipe/root/path/my/subpath.txt" or
"C:/my/recipe/root/path/my/subpath.txt", respectively.
Additionally, given a format string `s = "{path:exe/my/subpath.txt}"`, invoking
`s.format(path=_MakefilePathFormat(execution_directory_path="/my/exe/dir/path", ...))` on
Unix systems or
`s.format(path=_MakefilePathFormat(execution_directory_path="/my/exe/dir/path", ...))`` on
Windows systems will yield "/my/exe/dir/path/my/subpath.txt" or
"C:/my/exe/dir/path/my/subpath.txt", respectively.
"""
_RECIPE_ROOT_PATH_PREFIX_PLACEHOLDER = "prp/"
_EXECUTION_DIRECTORY_PATH_PREFIX_PLACEHOLDER = "exe/"
def __init__(self, recipe_root_path: str, execution_directory_path: str):
"""
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
execution_directory_path: The absolute path of the execution directory on the local
filesystem for the recipe.
"""
self.recipe_root_path = recipe_root_path
self.execution_directory_path = execution_directory_path
def _get_formatted_path(
self, path_spec: str, prefix_placeholder: str, replacement_path: str
) -> str:
"""
Args:
path_spec: A substitution path spec of the form `<placeholder>/<subpath>`. This
method substitutes `<placeholder>` with `<recipe_root_path>`, if
`<placeholder>` is `prp`, or `<execution_directory_path>`, if
`<placeholder>` is `exe`.
prefix_placeholder: The prefix placeholder, which is present at the beginning of
`path_spec`. Either `prp` or `exe`.
replacement_path: The path to use to replace the specified `prefix_placeholder`
in the specified `path_spec`.
Returns:
The formatted path obtained by replacing the ``prefix placeholder`` in the
specified ``path_spec`` with the specified ``replacement_path``.
"""
subpath = pathlib.PurePosixPath(path_spec.split(prefix_placeholder)[1])
recipe_root_posix_path = pathlib.PurePosixPath(pathlib.Path(replacement_path).as_posix())
full_formatted_path = recipe_root_posix_path / subpath
return str(full_formatted_path)
def __format__(self, path_spec: str) -> str:
"""
Args:
path_spec: A substitution path spec of the form `<placeholder>/<subpath>`. This
method substitutes `<placeholder>` with `<recipe_root_path>`, if
`<placeholder>` is `prp`, or `<execution_directory_path>`, if
`<placeholder>` is `exe`.
"""
if path_spec.startswith(_MakefilePathFormat._RECIPE_ROOT_PATH_PREFIX_PLACEHOLDER):
return self._get_formatted_path(
path_spec=path_spec,
prefix_placeholder=_MakefilePathFormat._RECIPE_ROOT_PATH_PREFIX_PLACEHOLDER,
replacement_path=self.recipe_root_path,
)
elif path_spec.startswith(_MakefilePathFormat._EXECUTION_DIRECTORY_PATH_PREFIX_PLACEHOLDER):
return self._get_formatted_path(
path_spec=path_spec,
prefix_placeholder=_MakefilePathFormat._EXECUTION_DIRECTORY_PATH_PREFIX_PLACEHOLDER,
replacement_path=self.execution_directory_path,
)
else:
raise ValueError(f"Invalid Makefile string format path spec: {path_spec}")
# Makefile contents for cache-aware recipe execution. These contents include variable placeholders
# that need to be formatted (substituted) with the recipe root directory in order to produce a
# valid Makefile
_MAKEFILE_FORMAT_STRING = r"""
# Define `ingest` as a target with no dependencies to ensure that it runs whenever a user explicitly
# invokes the MLflow Recipes ingest step, allowing them to reingest data on-demand
ingest:
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.ingest import IngestStep; IngestStep.from_step_config_path(step_config_path='{path:exe/steps/ingest/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/ingest/outputs}')"
# Define a separate target for the ingested dataset that recursively invokes make with the `ingest`
# target. Downstream steps depend on the ingested dataset target, rather than the `ingest` target,
# ensuring that data is only ingested for downstream steps if it is not already present on the
# local filesystem
steps/ingest/outputs/dataset.parquet: steps/ingest/conf.yaml {path:prp/steps/ingest.py}
echo "Run MLflow Recipe step: ingest"
$(MAKE) ingest
split_objects = steps/split/outputs/train.parquet steps/split/outputs/validation.parquet steps/split/outputs/test.parquet
split: $(split_objects)
steps/%/outputs/train.parquet steps/%/outputs/validation.parquet steps/%/outputs/test.parquet: {path:prp/steps/split.py} steps/ingest/outputs/dataset.parquet steps/split/conf.yaml
echo "Run MLflow Recipe step: split"
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.split import SplitStep; SplitStep.from_step_config_path(step_config_path='{path:exe/steps/split/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/split/outputs}')"
transform_objects = steps/transform/outputs/transformer.pkl steps/transform/outputs/transformed_training_data.parquet steps/transform/outputs/transformed_validation_data.parquet
transform: $(transform_objects)
steps/%/outputs/transformer.pkl steps/%/outputs/transformed_training_data.parquet steps/%/outputs/transformed_validation_data.parquet: {path:prp/steps/transform.py} steps/split/outputs/train.parquet steps/split/outputs/validation.parquet steps/transform/conf.yaml
echo "Run MLflow Recipe step: transform"
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.transform import TransformStep; TransformStep.from_step_config_path(step_config_path='{path:exe/steps/transform/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/transform/outputs}')"
train_objects = steps/train/outputs/model steps/train/outputs/run_id
train: $(train_objects)
steps/%/outputs/model steps/%/outputs/run_id: {path:prp/steps/train.py} {path:prp/steps/custom_metrics.py} steps/transform/outputs/transformed_training_data.parquet steps/transform/outputs/transformed_validation_data.parquet steps/split/outputs/train.parquet steps/split/outputs/validation.parquet steps/transform/outputs/transformer.pkl steps/train/conf.yaml
echo "Run MLflow Recipe step: train"
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.train import TrainStep; TrainStep.from_step_config_path(step_config_path='{path:exe/steps/train/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/train/outputs}')"
evaluate_objects = steps/evaluate/outputs/model_validation_status
evaluate: $(evaluate_objects)
steps/%/outputs/model_validation_status: {path:prp/steps/custom_metrics.py} steps/train/outputs/model steps/split/outputs/validation.parquet steps/split/outputs/test.parquet steps/train/outputs/run_id steps/evaluate/conf.yaml
echo "Run MLflow Recipe step: evaluate"
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.evaluate import EvaluateStep; EvaluateStep.from_step_config_path(step_config_path='{path:exe/steps/evaluate/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/evaluate/outputs}')"
register_objects = steps/register/outputs/registered_model_version.json
register: $(register_objects)
steps/%/outputs/registered_model_version.json: steps/train/outputs/run_id steps/register/conf.yaml steps/evaluate/outputs/model_validation_status
echo "Run MLflow Recipe step: register"
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.register import RegisterStep; RegisterStep.from_step_config_path(step_config_path='{path:exe/steps/register/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/register/outputs}')"
# Define `ingest_scoring` as a target with no dependencies to ensure that it runs whenever a user explicitly
# invokes the MLflow Recipes ingest_scoring step, allowing them to reingest data on-demand
ingest_scoring:
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.ingest import IngestScoringStep; IngestScoringStep.from_step_config_path(step_config_path='{path:exe/steps/ingest_scoring/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/ingest_scoring/outputs}')"
# Define a separate target for the ingested dataset that recursively invokes make with the
# `ingest_scoring` target. Downstream steps depend on the ingested dataset target, rather than the
# `ingest_scoring` target, ensuring that data is only ingested for downstream steps if it is not
# already present on the local filesystem
steps/ingest_scoring/outputs/scoring-dataset.parquet: steps/ingest_scoring/conf.yaml {path:prp/steps/ingest.py}
echo "Run MLflow Recipe step: ingest_scoring"
$(MAKE) ingest_scoring
predict_objects = steps/predict/outputs/scored.parquet
predict: $(predict_objects)
steps/predict/outputs/scored.parquet: steps/ingest_scoring/outputs/scoring-dataset.parquet steps/predict/conf.yaml
echo "Run MLflow Recipe step: predict"
cd {path:prp/} && \
python -c "from mlflow.recipes.steps.predict import PredictStep; PredictStep.from_step_config_path(step_config_path='{path:exe/steps/predict/conf.yaml}', recipe_root='{path:prp/}').run(output_directory='{path:exe/steps/predict/outputs}')"
clean:
rm -rf $(split_objects) $(transform_objects) $(train_objects) $(evaluate_objects) $(predict_objects)
""" # noqa: E501

View File

@@ -0,0 +1,238 @@
import importlib
import logging
import sys
from typing import Any, Optional
from mlflow.exceptions import BAD_REQUEST, MlflowException
from mlflow.models import EvaluationMetric, make_metric
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
_logger = logging.getLogger(__name__)
class RecipeMetric:
_KEY_METRIC_NAME = "name"
_KEY_METRIC_GREATER_IS_BETTER = "greater_is_better"
_KEY_CUSTOM_FUNCTION = "function"
def __init__(self, name: str, greater_is_better: bool, custom_function: Optional[str] = None):
self.name = name
self.greater_is_better = greater_is_better
self.custom_function = custom_function
@classmethod
def from_custom_metric_dict(cls, custom_metric_dict):
metric_name = custom_metric_dict.get(RecipeMetric._KEY_METRIC_NAME)
greater_is_better = custom_metric_dict.get(RecipeMetric._KEY_METRIC_GREATER_IS_BETTER)
custom_function = custom_metric_dict.get(RecipeMetric._KEY_CUSTOM_FUNCTION)
if (metric_name, greater_is_better, custom_function).count(None) > 0:
raise MlflowException(
f"Invalid custom metric definition: {custom_metric_dict}",
error_code=INVALID_PARAMETER_VALUE,
)
return cls(
name=metric_name, greater_is_better=greater_is_better, custom_function=custom_function
)
BUILTIN_BINARY_CLASSIFICATION_RECIPE_METRICS = [
RecipeMetric(name="true_negatives", greater_is_better=True),
RecipeMetric(name="false_positives", greater_is_better=False),
RecipeMetric(name="false_negatives", greater_is_better=False),
RecipeMetric(name="true_positives", greater_is_better=True),
RecipeMetric(name="recall_score", greater_is_better=True),
RecipeMetric(name="precision_score", greater_is_better=True),
RecipeMetric(name="f1_score", greater_is_better=True),
RecipeMetric(name="accuracy_score", greater_is_better=True),
RecipeMetric(name="roc_auc", greater_is_better=True),
RecipeMetric(name="log_loss", greater_is_better=False),
]
BUILTIN_MULTICLASS_CLASSIFICATION_RECIPE_METRICS = [
RecipeMetric(name="recall_score", greater_is_better=True),
RecipeMetric(name="precision_score", greater_is_better=True),
RecipeMetric(name="f1_score_macro", greater_is_better=True),
RecipeMetric(name="f1_score_micro", greater_is_better=True),
RecipeMetric(name="accuracy_score", greater_is_better=True),
RecipeMetric(name="roc_auc", greater_is_better=True),
RecipeMetric(name="log_loss", greater_is_better=False),
]
BUILTIN_REGRESSION_RECIPE_METRICS = [
RecipeMetric(name="mean_absolute_error", greater_is_better=False),
RecipeMetric(name="mean_squared_error", greater_is_better=False),
RecipeMetric(name="root_mean_squared_error", greater_is_better=False),
RecipeMetric(name="max_error", greater_is_better=False),
RecipeMetric(name="mean_absolute_percentage_error", greater_is_better=False),
]
DEFAULT_METRICS = {
"regression": "root_mean_squared_error",
"classification/binary": "f1_score",
"classification/multiclass": "f1_score_macro",
}
def _get_error_fn(tmpl: str, use_probability: bool = False, positive_class: Optional[str] = None): # noqa: D417
"""
Args:
tmpl: The template kind, e.g. `regression/v1`.
Returns:
The error function for the provided template.
"""
if tmpl == "regression/v1":
return lambda predictions, targets: predictions - targets
if tmpl == "classification/v1":
if use_probability:
# It computes error rate for binary classification since
# positive class only exists in binary classification.
def error_rate(true_label, predicted_positive_class_proba):
if true_label == positive_class:
# if true_label == positive_class then the probability is
# predicted_positive_class_proba but the error rate is
# 1 - predicted_positive_class_proba
return 1 - predicted_positive_class_proba
else:
# if true_label != positive_class then the probability is
# 1 - predicted_positive_class_proba but the error rate is
# predicted_positive_class_proba
return predicted_positive_class_proba
return lambda predictions, targets: [
error_rate(x, y) for (x, y) in zip(targets, predictions)
]
else:
return lambda predictions, targets: predictions != targets
raise MlflowException(
f"No error function for template kind {tmpl}",
error_code=INVALID_PARAMETER_VALUE,
)
def _get_extended_task(recipe: str, positive_class: str) -> str: # noqa: D417
"""
Args:
step_config: Step config
Returns:
Extended type string. Currently supported types are: "regression",
"binary_classification", "multiclass_classification"
"""
if "regression" in recipe:
return "regression"
elif "classification" in recipe:
if positive_class is not None:
return "classification/binary"
else:
return "classification/multiclass"
raise MlflowException(
f"No model type for template kind {recipe}",
error_code=INVALID_PARAMETER_VALUE,
)
def _get_model_type_from_template(tmpl: str) -> str:
"""
Args:
tmpl: The template kind, e.g. `regression/v1`.
Returns:
A model type literal compatible with the mlflow evaluation service, e.g. regressor.
"""
if tmpl == "regression/v1":
return "regressor"
if tmpl == "classification/v1":
return "classifier"
raise MlflowException(
f"No model type for template kind {tmpl}",
error_code=INVALID_PARAMETER_VALUE,
)
def _get_builtin_metrics(ext_task: str) -> dict[str, str]: # noqa: D417
"""
Args:
tmpl: The template kind, e.g. `regression/v1`.
Returns:
The builtin metrics for the mlflow evaluation service for the model type for
this template.
"""
if ext_task == "regression":
return BUILTIN_REGRESSION_RECIPE_METRICS
elif ext_task == "classification/binary":
return BUILTIN_BINARY_CLASSIFICATION_RECIPE_METRICS
elif ext_task == "classification/multiclass":
return BUILTIN_MULTICLASS_CLASSIFICATION_RECIPE_METRICS
raise MlflowException(
f"No builtin metrics for template kind {ext_task}",
error_code=INVALID_PARAMETER_VALUE,
)
def transform_multiclass_metric(metric_name: str, ext_task: str) -> str:
if ext_task == "classification/multiclass":
for m in BUILTIN_MULTICLASS_CLASSIFICATION_RECIPE_METRICS:
if metric_name in m.name:
return m.name
return metric_name
def transform_multiclass_metrics_dict(eval_metrics: dict[str, Any], ext_task) -> dict[str, Any]:
return {transform_multiclass_metric(k, ext_task): v for k, v in eval_metrics.items()}
def _get_custom_metrics(step_config: dict, ext_task: str) -> list[dict]: # noqa: D417
"""
Args:
Configuration dictionary: For the train or evaluate step.
Returns:
A list of custom metrics defined in the specified configuration dictionary,
or an empty list if the configuration dictionary does not define any custom metrics.
"""
custom_metric_dicts = step_config.get("custom_metrics", [])
custom_metrics = [
RecipeMetric.from_custom_metric_dict(metric_dict) for metric_dict in custom_metric_dicts
]
custom_metric_names = {metric.name for metric in custom_metrics}
builtin_metric_names = {metric.name for metric in _get_builtin_metrics(ext_task)}
overridden_builtin_metrics = custom_metric_names.intersection(builtin_metric_names)
if overridden_builtin_metrics:
_logger.warning(
"Custom metrics override the following built-in metrics: %s",
sorted(overridden_builtin_metrics),
)
return custom_metrics
def _load_custom_metrics(recipe_root: str, metrics: list[RecipeMetric]) -> list[EvaluationMetric]:
custom_metrics = [metric for metric in metrics if metric.custom_function is not None]
if not custom_metrics:
return None
try:
sys.path.append(recipe_root)
custom_metrics_mod = importlib.import_module("steps.custom_metrics")
return [
make_metric(
eval_fn=getattr(custom_metrics_mod, custom_metric.custom_function),
name=custom_metric.name,
greater_is_better=custom_metric.greater_is_better,
)
for custom_metric in custom_metrics
]
except Exception as e:
raise MlflowException(
message="Failed to load custom metric functions",
error_code=BAD_REQUEST,
) from e
def _get_primary_metric(configured_metric: str, ext_task: str):
if configured_metric is not None:
return configured_metric
else:
return DEFAULT_METRICS[ext_task]

View File

@@ -0,0 +1,206 @@
import logging
import os
import shutil
import subprocess
from typing import Iterable, Optional
import numpy as np
import pandas as pd
from mlflow.exceptions import BAD_REQUEST, INVALID_PARAMETER_VALUE, MlflowException
from mlflow.recipes.cards import pandas_renderer
from mlflow.utils.databricks_utils import (
get_databricks_runtime_version,
is_in_databricks_runtime,
is_running_in_ipython_environment,
)
from mlflow.utils.os import is_windows
_logger = logging.getLogger(__name__)
_MAX_PROFILE_CELL_SIZE = 10000000 # 10M Cells
_MAX_PROFILE_ROW_SIZE = 1000000 # 1M Rows
_MAX_PROFILE_COL_SIZE = 10000 # 10k Cols
def get_merged_eval_metrics(
eval_metrics: dict[str, dict], ordered_metric_names: Optional[list[str]] = None
):
"""
Returns a merged Pandas DataFrame from a map of dataset to evaluation metrics.
Optionally, the rows in the DataFrame are ordered by input ordered metric names.
Args:
eval_metrics: Dict maps from dataset name to a Dict of evaluation metrics, which itself
is a map from metric name to metric value.
ordered_metric_names: List containing metric names. The ordering of the output is
determined by this list, if provided.
Returns:
Pandas DataFrame containing evaluation metrics. The DataFrame is indexed by metric
name. Columns are dataset names.
"""
from pandas import DataFrame
merged_metrics = {}
for src, metrics in eval_metrics.items():
if src not in merged_metrics:
merged_metrics[src] = {}
merged_metrics[src].update(metrics)
if ordered_metric_names is None:
ordered_metric_names = []
metric_names = set()
for val in merged_metrics.values():
metric_names.update(val.keys())
missing_metrics = set(ordered_metric_names) - metric_names
if len(missing_metrics) > 0:
_logger.warning(
"Input metric names %s not found in eval metrics: %s", missing_metrics, metric_names
)
ordered_metric_names = [
name for name in ordered_metric_names if name not in missing_metrics
]
ordered_metric_names.extend(sorted(metric_names - set(ordered_metric_names)))
return DataFrame.from_dict(merged_metrics).reindex(ordered_metric_names)
def display_html(html_data: Optional[str] = None, html_file_path: Optional[str] = None) -> None:
if html_file_path is None and html_data is None:
raise MlflowException(
"At least one HTML source must be provided. html_data and html_file_path are empty.",
error_code=INVALID_PARAMETER_VALUE,
)
if is_running_in_ipython_environment():
from IPython.display import HTML
from IPython.display import display as ip_display
html_file_path = html_file_path if html_data is None else None
if is_in_databricks_runtime():
dbr_version = get_databricks_runtime_version()
if int(dbr_version.split(".")[0]) < 11:
raise MlflowException(
f"Use Databricks Runtime 11 or newer with MLflow Recipes. "
f"Current version is {dbr_version} ",
error_code=BAD_REQUEST,
)
# Patch IPython display with Databricks display before showing the HTML.
import IPython.core.display as icd
orig_display = icd.display
icd.display = display # noqa: F821
ip_display(HTML(data=html_data, filename=html_file_path))
icd.display = orig_display
else:
ip_display(HTML(data=html_data, filename=html_file_path))
else:
# Use xdg-open in Linux environment
if shutil.which("xdg-open") is not None:
open_tool = shutil.which("xdg-open")
elif shutil.which("open") is not None:
open_tool = shutil.which("open")
else:
open_tool = None
if (
os.path.exists(html_file_path)
and open_tool is not None
# On Windows, attempting to clean up the card while it's being accessed by
# the process running `open_tool` results in a PermissionError. To avoid this,
# skip displaying the card.
and "GITHUB_ACTIONS" not in os.environ
):
_logger.info(f"Opening HTML file at: '{html_file_path}'")
try:
subprocess.run([open_tool, html_file_path], check=True)
except Exception as e:
_logger.warning(
f"Encountered unexpected error opening the html page."
f" The file may be manually accessed at {html_file_path}. Exception: {e}"
)
# Prevent pandas_profiling from using multiprocessing on Windows while running tests.
# multiprocessing and pytest don't play well together on Windows.
# Relevant code: https://github.com/ydataai/pandas-profiling/blob/f8bad5dde27e3f87f11ac74fb8966c034bc22db8/src/pandas_profiling/model/pandas/summary_pandas.py#L76-L97
def _get_pool_size():
return 1 if "PYTEST_CURRENT_TEST" in os.environ and is_windows() else 0
def get_pandas_data_profiles(inputs: Iterable[tuple[str, pd.DataFrame]]) -> str:
"""
Returns a data profiling string over input data frame.
Args:
inputs: Either a single "glimpse" DataFrame that contains the statistics, or a
collection of (title, DataFrame) pairs where each pair names a separate "glimpse"
and they are all visualized in comparison mode.
Returns:
a data profiling string such as Pandas profiling ProfileReport.
"""
truncated_input = [truncate_pandas_data_profile(*input) for input in inputs]
return pandas_renderer.get_html(truncated_input)
def truncate_pandas_data_profile(title: str, data_frame) -> str:
"""
Returns a data profiling string over input data frame.
Args:
title: The title of the data profile.
data_frame: Contains data to be profiled.
Returns:
A data profiling string such as Pandas profiling ProfileReport.
"""
if len(data_frame) == 0:
return (title, data_frame)
max_cells = min(data_frame.size, _MAX_PROFILE_CELL_SIZE)
max_cols = min(data_frame.columns.size, _MAX_PROFILE_COL_SIZE)
max_rows = min(max(max_cells // max_cols, 1), len(data_frame), _MAX_PROFILE_ROW_SIZE)
truncated_df = data_frame.drop(columns=data_frame.columns[max_cols:]).sample(
n=max_rows, ignore_index=True, random_state=42
)
if (
max_cells == _MAX_PROFILE_CELL_SIZE
or max_cols == _MAX_PROFILE_COL_SIZE
or max_rows == _MAX_PROFILE_ROW_SIZE
):
_logger.info(
"Truncating the data frame for %s to %d cells, %d columns and %d rows",
title,
max_cells,
max_cols,
max_rows,
)
return (title, truncated_df)
def validate_classification_config( # noqa: D417
task: str, positive_class: str, input_df: pd.DataFrame, target_col: str
):
"""
Args:
task:
positive_class:
input_df:
target_col:
"""
if task == "classification":
classes = np.unique(input_df[target_col])
num_classes = len(classes)
if num_classes <= 1:
raise MlflowException(
f"Classification tasks require at least two tasks. "
f"Your dataset contains {num_classes}."
)
elif positive_class is None and num_classes == 2:
raise MlflowException(
"`positive_class` must be specified for classification/v1 recipes.",
error_code=INVALID_PARAMETER_VALUE,
)

View File

@@ -0,0 +1,310 @@
import json
import logging
import pathlib
import shutil
import tempfile
import uuid
from typing import Any, Optional
import mlflow
from mlflow.environment_variables import MLFLOW_RUN_CONTEXT
from mlflow.exceptions import MlflowException, RestException
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
from mlflow.recipes.utils import get_recipe_name
from mlflow.tracking.client import MlflowClient
from mlflow.tracking.context.registry import resolve_tags
from mlflow.tracking.default_experiment import DEFAULT_EXPERIMENT_ID
from mlflow.tracking.fluent import _get_experiment_id
from mlflow.tracking.fluent import set_experiment as fluent_set_experiment
from mlflow.utils.databricks_utils import is_in_databricks_runtime
from mlflow.utils.file_utils import path_to_local_file_uri, path_to_local_sqlite_uri
from mlflow.utils.git_utils import get_git_branch, get_git_commit, get_git_repo_url
from mlflow.utils.mlflow_tags import (
LEGACY_MLFLOW_GIT_REPO_URL,
MLFLOW_GIT_BRANCH,
MLFLOW_GIT_COMMIT,
MLFLOW_GIT_REPO_URL,
MLFLOW_SOURCE_NAME,
)
_logger = logging.getLogger(__name__)
def _get_run_name(run_name_prefix):
if run_name_prefix is None:
return None
sep = "-"
num = uuid.uuid4().hex[:8]
return f"{run_name_prefix}{sep}{num}"
class TrackingConfig:
"""
The MLflow Tracking configuration associated with an MLflow Recipe, including the
Tracking URI and information about the destination Experiment for writing results.
"""
_KEY_TRACKING_URI = "mlflow_tracking_uri"
_KEY_EXPERIMENT_NAME = "mlflow_experiment_name"
_KEY_EXPERIMENT_ID = "mlflow_experiment_id"
_KEY_RUN_NAME = "mlflow_run_name"
_KEY_ARTIFACT_LOCATION = "mlflow_experiment_artifact_location"
def __init__(
self,
tracking_uri: str,
experiment_name: Optional[str] = None,
experiment_id: Optional[str] = None,
run_name: Optional[str] = None,
artifact_location: Optional[str] = None,
):
"""
Args:
tracking_uri: The MLflow Tracking URI.
experiment_name: The MLflow Experiment name. At least one of ``experiment_name`` or
``experiment_id`` must be specified. If both are specified, they must be consistent
with Tracking server state. Note that this Experiment may not exist prior to recipe
execution.
experiment_id: The MLflow Experiment ID. At least one of ``experiment_name`` or
``experiment_id`` must be specified. If both are specified, they must be consistent
with Tracking server state. Note that this Experiment may not exist prior to recipe
execution.
run_name: The MLflow Run Name. If the run name is not specified, then a random name is
set for the run.
artifact_location: The artifact location to use for the Experiment, if the Experiment
does not already exist. If the Experiment already exists, this location is ignored.
"""
if tracking_uri is None:
raise MlflowException(
message="`tracking_uri` must be specified",
error_code=INVALID_PARAMETER_VALUE,
)
if (experiment_name, experiment_id).count(None) != 1:
raise MlflowException(
message="Exactly one of `experiment_name` or `experiment_id` must be specified",
error_code=INVALID_PARAMETER_VALUE,
)
self.tracking_uri = tracking_uri
self.experiment_name = experiment_name
self.experiment_id = experiment_id
self.run_name = run_name
self.artifact_location = artifact_location
def to_dict(self) -> dict[str, str]:
"""
Obtains a dictionary representation of the MLflow Tracking configuration.
Returns:
A dictionary representation of the MLflow Tracking configuration.
"""
config_dict = {
TrackingConfig._KEY_TRACKING_URI: self.tracking_uri,
}
if self.experiment_name:
config_dict[TrackingConfig._KEY_EXPERIMENT_NAME] = self.experiment_name
elif self.experiment_id:
config_dict[TrackingConfig._KEY_EXPERIMENT_ID] = self.experiment_id
if self.artifact_location:
config_dict[TrackingConfig._KEY_ARTIFACT_LOCATION] = self.artifact_location
if self.run_name:
config_dict[TrackingConfig._KEY_RUN_NAME] = self.run_name
return config_dict
@classmethod
def from_dict(cls, config_dict: dict[str, str]) -> "TrackingConfig":
"""
Creates a ``TrackingConfig`` instance from a dictionary representation.
Args:
config_dict: A dictionary representation of the MLflow Tracking configuration.
Returns:
A ``TrackingConfig`` instance.
"""
return TrackingConfig(
tracking_uri=config_dict.get(TrackingConfig._KEY_TRACKING_URI),
experiment_name=config_dict.get(TrackingConfig._KEY_EXPERIMENT_NAME),
experiment_id=config_dict.get(TrackingConfig._KEY_EXPERIMENT_ID),
run_name=config_dict.get(TrackingConfig._KEY_RUN_NAME),
artifact_location=config_dict.get(TrackingConfig._KEY_ARTIFACT_LOCATION),
)
def get_recipe_tracking_config(
recipe_root_path: str, recipe_config: dict[str, Any]
) -> TrackingConfig:
"""
Obtains the MLflow Tracking configuration for the specified recipe.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
recipe_config: The configuration of the specified recipe.
Returns:
A ``TrackingConfig`` instance containing MLflow Tracking information for the
specified recipe, including Tracking URI, Experiment name, and more.
"""
if is_in_databricks_runtime():
default_tracking_uri = "databricks"
default_artifact_location = None
else:
mlflow_metadata_base_path = pathlib.Path(recipe_root_path) / "metadata" / "mlflow"
mlflow_metadata_base_path.mkdir(exist_ok=True, parents=True)
default_tracking_uri = path_to_local_sqlite_uri(
path=str((mlflow_metadata_base_path / "mlruns.db").resolve())
)
default_artifact_location = path_to_local_file_uri(
path=str((mlflow_metadata_base_path / "mlartifacts").resolve())
)
tracking_config = recipe_config.get("experiment", {})
config_obj_kwargs = {
"run_name": _get_run_name(tracking_config.get("run_name_prefix")),
"tracking_uri": tracking_config.get("tracking_uri", default_tracking_uri),
"artifact_location": tracking_config.get("artifact_location", default_artifact_location),
}
experiment_name = tracking_config.get("name")
if experiment_name is not None:
return TrackingConfig(
experiment_name=experiment_name,
**config_obj_kwargs,
)
experiment_id = tracking_config.get("id")
if experiment_id is not None:
return TrackingConfig(
experiment_id=experiment_id,
**config_obj_kwargs,
)
experiment_id = _get_experiment_id()
if experiment_id != DEFAULT_EXPERIMENT_ID:
return TrackingConfig(
experiment_id=experiment_id,
**config_obj_kwargs,
)
return TrackingConfig(
experiment_name=get_recipe_name(recipe_root_path=recipe_root_path),
**config_obj_kwargs,
)
def apply_recipe_tracking_config(tracking_config: TrackingConfig):
"""
Applies the specified ``TrackingConfig`` in the current context by setting the associated
MLflow Tracking URI (via ``mlflow.set_tracking_uri()``) and setting the associated MLflow
Experiment (via ``mlflow.set_experiment()``), creating it if necessary.
Args:
tracking_config: The MLflow Recipe ``TrackingConfig`` to apply.
"""
mlflow.set_tracking_uri(uri=tracking_config.tracking_uri)
client = MlflowClient()
if tracking_config.experiment_name is not None:
experiment = client.get_experiment_by_name(name=tracking_config.experiment_name)
if not experiment:
_logger.info(
"Experiment with name '%s' does not exist. Creating a new experiment.",
tracking_config.experiment_name,
)
try:
client.create_experiment(
name=tracking_config.experiment_name,
artifact_location=tracking_config.artifact_location,
)
except RestException:
# Inform user they should create an experiment and specify it in the recipe
# config if an experiment with the recipe name can't be created.
raise MlflowException(
f"Could not create an MLflow Experiment with "
f"name {tracking_config.experiment_name}. Please create an "
f"MLflow Experiment for this recipe and specify its name in the "
f'"name" field of the "experiment" section in your profile configuration.'
)
fluent_set_experiment(
experiment_id=tracking_config.experiment_id, experiment_name=tracking_config.experiment_name
)
def get_run_tags_env_vars(recipe_root_path: str) -> dict[str, str]:
"""
Returns environment variables that should be set during step execution to ensure that MLflow
Run Tags from the current context are applied to any MLflow Runs that are created during
recipe execution.
Args:
recipe_root_path: The absolute path of the recipe root directory on the local
filesystem.
Returns:
A dictionary of environment variable names and values.
"""
run_context_tags = resolve_tags()
git_tags = {}
git_repo_url = get_git_repo_url(path=recipe_root_path)
if git_repo_url:
git_tags[MLFLOW_SOURCE_NAME] = git_repo_url
git_tags[MLFLOW_GIT_REPO_URL] = git_repo_url
git_tags[LEGACY_MLFLOW_GIT_REPO_URL] = git_repo_url
git_commit = get_git_commit(path=recipe_root_path)
if git_commit:
git_tags[MLFLOW_GIT_COMMIT] = git_commit
git_branch = get_git_branch(path=recipe_root_path)
if git_branch:
git_tags[MLFLOW_GIT_BRANCH] = git_branch
return {MLFLOW_RUN_CONTEXT.name: json.dumps({**run_context_tags, **git_tags})}
def log_code_snapshot(
recipe_root: str,
run_id: str,
artifact_path: str = "recipe_snapshot",
recipe_config: Optional[dict[str, Any]] = None,
) -> None:
"""
Logs a recipe code snapshot as mlflow artifacts.
Args:
recipe_root: String file path to the directory where the recipe is defined.
run_id: Run ID to which the code snapshot is logged.
artifact_path: Directory within the run's artifact director (default: "snapshots").
recipe_config: Dict containing the full recipe configuration at runtime.
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = pathlib.Path(tmpdir)
recipe_root = pathlib.Path(recipe_root)
for file_path in (
# TODO: Log a filled recipe.yaml created in `Recipe._resolve_recipe_steps`
# instead of a raw recipe.yaml.
recipe_root.joinpath("recipe.yaml"),
recipe_root.joinpath("requirements.txt"),
*recipe_root.glob("profiles/*.yaml"),
*recipe_root.glob("steps/*.py"),
):
if file_path.exists():
tmp_path = tmpdir.joinpath(file_path.relative_to(recipe_root))
tmp_path.parent.mkdir(exist_ok=True, parents=True)
shutil.copy2(file_path, tmp_path)
if recipe_config is not None:
import yaml
tmp_path = tmpdir.joinpath("runtime/recipe.yaml")
tmp_path.parent.mkdir(exist_ok=True, parents=True)
with open(tmp_path, mode="w", encoding="utf-8") as config_file:
yaml.dump(recipe_config, config_file)
MlflowClient().log_artifacts(run_id, str(tmpdir), artifact_path=artifact_path)

View File

@@ -0,0 +1,60 @@
from typing import Any, Optional
import numpy as np
import pandas as pd
import mlflow
from mlflow.pyfunc import PythonModel
class WrappedRecipeModel(PythonModel):
def __init__(
self, predict_scores_for_all_classes, predict_prefix, target_column_class_labels=None
):
super().__init__()
self.predict_scores_for_all_classes = predict_scores_for_all_classes
self.predict_prefix = predict_prefix
self.target_column_class_labels = target_column_class_labels
def load_context(self, context):
self._classifier = mlflow.sklearn.load_model(context.artifacts["model_path"])
def predict(
self,
context,
model_input,
params: Optional[dict[str, Any]] = None,
):
"""
Args:
context: A :class:`~PythonModelContext` instance containing artifacts that the model
can use to perform inference.
model_input: A pyfunc-compatible input for the model to evaluate.
params: Additional parameters to pass to the model for inference.
Returns:
Model predictions.
"""
predicted_label = self._classifier.predict(model_input)
# Only classification recipe would be have multiple classes in the target column
# So if it doesn't have multiple classes, return back the predicted_label
# or else we try to commute the predict_proba if the algorithm supports it.
if (
not hasattr(self._classifier, "classes_")
or not hasattr(self._classifier, "predict_proba")
or not self.predict_scores_for_all_classes
):
return predicted_label
classes = (
self.target_column_class_labels
if self.target_column_class_labels is not None
else self._classifier.classes_
)
score_cols = [f"{self.predict_prefix}score_" + str(c) for c in classes]
probabilities = self._classifier.predict_proba(model_input)
output = pd.DataFrame(columns=score_cols, data=probabilities)
output[f"{self.predict_prefix}score"] = np.max(probabilities, axis=1)
output[f"{self.predict_prefix}label"] = predicted_label
return output