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,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