跳到主要内容

了解 MLflow 中的父运行和子运行

简介

机器学习项目通常涉及复杂的关联关系。这些联系可能出现在各个阶段,无论是项目的构思阶段、数据预处理阶段、模型架构中,还是模型调优过程中。MLflow 提供了工具来有效地捕获和表示这些关系。

MLflow 的核心概念:标签、实验和运行

在我们基础的 MLflow 教程中,我们强调了一个基本的关联关系:**标签**、**实验** 和 **运行** 之间的关联。当处理复杂的 ML 项目时,例如在我们的示例中呈现的超市中单个产品的预测模型,这种关联至关重要。下图提供了可视化的表示。

Tags, experiments, and runs relationships

一个模型分组层级结构

关键方面

  • 标签:这些对于定义业务级别的过滤键至关重要。它们有助于检索相关的实验及其运行。

  • 实验:它们设置了边界,无论是从业务角度还是数据角度。例如,未经事先验证,胡萝卜的销售数据将不会用于预测苹果的销售额。

  • 运行:每次运行都捕获特定的假设或训练迭代,嵌套在实验的上下文中。

现实世界的挑战:超参数调优

虽然上面的模型足以满足入门目的,但现实世界场景会引入复杂性。其中一种复杂性出现在调整模型时。

模型调优至关重要。方法范围从网格搜索(尽管通常不推荐,因为它效率低)到随机搜索,以及更高级的方法,如自动超参数调优。目标保持不变:以最佳方式遍历模型的参数空间。

超参数调优的益处

  • 损失指标关系:通过分析超参数和优化损失指标之间的关系,我们可以辨别出潜在的无关参数。

  • 参数空间分析:监控测试值的范围可以表明我们是否需要约束或扩展我们的搜索空间。

  • 模型敏感度分析:估计模型对特定参数的反应可以查明潜在的特征集问题。

但问题在于:我们如何系统地存储在超参数调优期间产生的大量数据?

Challenges with hyperparameter data storage

存储超参数数据的难题

在接下来的章节中,我们将深入探讨,探索 MLflow 解决这一挑战的能力,重点关注父运行和子运行的概念。

什么是父运行和子运行?

MLflow 的核心是允许用户跟踪实验,实验本质上是运行的命名组。在这种上下文中,“运行”是指模型训练事件的单次执行,您可以在其中记录与训练过程关联的参数、指标、标签和工件。父运行和子运行的概念为这些运行引入了分层结构。

想象一个场景,您正在测试具有不同架构的深度学习模型。每种架构都可以被视为父运行,并且该架构的每次超参数调优迭代都成为嵌套在其各自父运行下的子运行。

益处

  1. 组织清晰度:通过使用父运行和子运行,您可以轻松地将相关运行分组在一起。例如,如果您正在对特定模型架构使用贝叶斯方法进行超参数搜索,则每次迭代都可以记录为子运行,而总体的贝叶斯优化过程可以是父运行。

  2. 增强的可追溯性:在处理具有广泛产品层级结构的大型项目时,子运行可以表示单个产品或变体,从而可以轻松地将结果、指标或工件追溯到其特定的运行。

  3. 可扩展性:随着您的实验数量和复杂性的增长,拥有嵌套结构可确保您的跟踪保持可扩展性。与包含数百或数千个运行的平面列表相比,浏览结构化层级结构要容易得多。

  4. 改进的协作:对于团队而言,此方法可确保成员可以轻松地了解其同事进行的实验的结构和流程,从而促进协作和知识共享。

实验、父运行和子运行之间的关系

  • 实验:将实验视为最顶层。它们是所有相关运行所在的命名实体。例如,名为 “深度学习架构” 的实验可能包含与您正在测试的各种架构相关的运行。

  • 父运行:在实验中,父运行表示您工作流程的重要部分或阶段。以前面的示例为例,每个特定架构(如 CNN、RNN 或 Transformer)都可以是一个父运行。

  • 子运行:嵌套在父运行中的是子运行。这些是其父范围内的迭代或变体。对于 CNN 父运行,不同的超参数集或略微的架构调整都可以是子运行。

实际示例

在此示例中,让我们想象一下,我们正在为特定的建模解决方案进行微调练习。我们正在经历粗略调整的调优阶段,最初试图确定我们可能希望考虑哪些参数范围和分类选择值,以便使用更高的迭代计数进行完整的超参数调优运行。

没有子运行的简单方法

在第一阶段,我们将尝试相对较小批量的不同参数组合,并在 MLflow UI 中对其进行评估,以确定我们是否应该根据迭代试验中的相对性能来包括或排除某些值。

如果我们使用每次迭代作为其自身的 MLflow 运行,我们的代码可能如下所示

import random
import mlflow
from functools import partial
from itertools import starmap
from more_itertools import consume


# Define a function to log parameters and metrics
def log_run(run_name, test_no):
with mlflow.start_run(run_name=run_name):
mlflow.log_param("param1", random.choice(["a", "b", "c"]))
mlflow.log_param("param2", random.choice(["d", "e", "f"]))
mlflow.log_metric("metric1", random.uniform(0, 1))
mlflow.log_metric("metric2", abs(random.gauss(5, 2.5)))


# Generate run names
def generate_run_names(test_no, num_runs=5):
return (f"run_{i}_test_{test_no}" for i in range(num_runs))


# Execute tuning function
def execute_tuning(test_no):
# Partial application of the log_run function
log_current_run = partial(log_run, test_no=test_no)
# Generate run names and apply log_current_run function to each run name
runs = starmap(
log_current_run, ((run_name,) for run_name in generate_run_names(test_no))
)
# Consume the iterator to execute the runs
consume(runs)


# Set the tracking uri and experiment
mlflow.set_tracking_uri("https://:8080")
mlflow.set_experiment("No Child Runs")

# Execute 5 hyperparameter tuning runs
consume(starmap(execute_tuning, ((x,) for x in range(5))))

执行此操作后,我们可以导航到 MLflow UI 以查看迭代结果,并将每次运行的错误指标与选择的参数进行比较。

Hyperparameter tuning no child runs

初始超参数调优执行

当我们需要再次运行它并进行一些细微修改时会发生什么?

我们的代码可能会就地更改,并测试这些值

def log_run(run_name, test_no):
with mlflow.start_run(run_name=run_name):
mlflow.log_param("param1", random.choice(["a", "c"])) # remove 'b'
# remainder of code ...

当我们执行此操作并返回 UI 时,现在更难以确定哪些运行结果与特定的参数分组相关联。对于此示例,由于特征相同且参数搜索空间是原始超参数测试的子集,因此它不是特别有问题。

如果我们出现以下情况,这可能会成为分析的严重问题

  • 将术语添加到原始超参数搜索空间

  • 修改特征数据(添加或删除特征)

  • 更改底层模型架构(测试 1 是随机森林模型,而测试 2 是梯度提升树模型)

让我们看一下 UI,看看是否清楚特定运行是哪个迭代的成员。

Adding more runs

没有子运行封装的迭代调优的挑战

如果此实验中有数千个运行,很容易想象这会变得多么复杂。

但是,有一个解决方案。我们可以设置完全相同的测试方案,只需进行一些小的修改,即可轻松找到相关运行,清理 UI,并大大简化调优过程中评估超参数范围和参数包含的整个过程。只需要进行一些修改

  • 通过在父运行的上下文中添加嵌套的 start_run() 上下文来使用子运行。

  • 通过修改父运行的 run_name,将消除歧义的信息添加到运行中

  • 将标签信息添加到父运行和子运行,以便能够搜索标识一系列运行的键

调整为父运行和子运行

下面的代码演示了对原始超参数调优示例的这些修改。

import random
import mlflow
from functools import partial
from itertools import starmap
from more_itertools import consume


# Define a function to log parameters and metrics and add tag
# logging for search_runs functionality
def log_run(run_name, test_no, param1_choices, param2_choices, tag_ident):
with mlflow.start_run(run_name=run_name, nested=True):
mlflow.log_param("param1", random.choice(param1_choices))
mlflow.log_param("param2", random.choice(param2_choices))
mlflow.log_metric("metric1", random.uniform(0, 1))
mlflow.log_metric("metric2", abs(random.gauss(5, 2.5)))
mlflow.set_tag("test_identifier", tag_ident)


# Generate run names
def generate_run_names(test_no, num_runs=5):
return (f"run_{i}_test_{test_no}" for i in range(num_runs))


# Execute tuning function, allowing for param overrides,
# run_name disambiguation, and tagging support
def execute_tuning(
test_no,
param1_choices=["a", "b", "c"],
param2_choices=["d", "e", "f"],
test_identifier="",
):
ident = "default" if not test_identifier else test_identifier
# Use a parent run to encapsulate the child runs
with mlflow.start_run(run_name=f"parent_run_test_{ident}_{test_no}"):
# Partial application of the log_run function
log_current_run = partial(
log_run,
test_no=test_no,
param1_choices=param1_choices,
param2_choices=param2_choices,
tag_ident=ident,
)
mlflow.set_tag("test_identifier", ident)
# Generate run names and apply log_current_run function to each run name
runs = starmap(
log_current_run, ((run_name,) for run_name in generate_run_names(test_no))
)
# Consume the iterator to execute the runs
consume(runs)


# Set the tracking uri and experiment
mlflow.set_tracking_uri("https://:8080")
mlflow.set_experiment("Nested Child Association")

# Define custom parameters
param_1_values = ["x", "y", "z"]
param_2_values = ["u", "v", "w"]

# Execute hyperparameter tuning runs with custom parameter choices
consume(
starmap(execute_tuning, ((x, param_1_values, param_2_values) for x in range(5)))
)

我们可以在 UI 中查看执行此操作的结果

当我们添加具有不同超参数选择标准的其他运行时,此嵌套架构的真正好处变得更加明显。

# Execute modified hyperparameter tuning runs with custom parameter choices
param_1_values = ["a", "b"]
param_2_values = ["u", "v", "w"]
ident = "params_test_2"
consume(
starmap(
execute_tuning, ((x, param_1_values, param_2_values, ident) for x in range(5))
)
)

... 甚至更多运行 ...

param_1_values = ["b", "c"]
param_2_values = ["d", "f"]
ident = "params_test_3"
consume(
starmap(
execute_tuning, ((x, param_1_values, param_2_values, ident) for x in range(5))
)
)

一旦我们执行了这三个调优运行测试,我们就可以在 UI 中查看结果

Using child runs

使用子运行封装测试

在上面的视频中,您可以看到我们有目的地避免将父运行包含在运行比较中。这是因为实际上没有指标或参数被写入这些父运行;相反,它们纯粹用于组织目的,以限制 UI 中可见的运行数量。

在实践中,最好将超参数执行的子运行中找到的最佳条件存储在父运行的数据中。

挑战

作为一项练习,如果您有兴趣,可以下载包含这两个示例的笔记本,并修改其中的代码以实现此目的。

下载笔记本

笔记本包含此示例的实现,但建议您开发自己的实现,以满足以下要求

  • 在父运行的信息中记录子运行中 metric1 的最低值以及与该子运行关联的参数。

  • 添加指定迭代计数的能力,以指定从调用入口点创建的子项的数量。

下面显示了 UI 中此挑战的结果。

Challenge

将最佳子运行数据添加到父运行

结论

父运行和子运行关联的使用可以大大简化迭代模型开发。对于超参数调优等重复性和高数据量的任务,封装训练运行的参数搜索空间或特征工程评估运行可以帮助确保您比较的是您想要比较的内容,只需付出最小的努力。