跳到主要内容

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

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

结论

父子运行关联的使用可以大大简化迭代模型开发。对于超参数调优等重复性高、数据量大的任务,封装训练运行的参数搜索空间或特征工程评估运行,可以帮助确保您比较的是您打算比较的内容,而且 effort 极小。