理解 MLflow 中的父级运行与子级运行
简介
机器学习项目通常涉及错综复杂的关系。这些连接可能出现在项目的构想、数据预处理、模型架构,甚至模型调整过程中的各个阶段。MLflow 提供了工具来高效捕获和表示这些关系。
MLflow 的核心概念:标签、实验和运行
在我们 MLflow 的基础教程中,我们强调了一个基本关系:标签、实验和运行之间的关联。这种关联在处理复杂的机器学习项目时至关重要,例如我们示例中展示的超市单个产品预测模型。下图提供了可视化表示。
主要方面
-
标签:它们有助于定义业务级别的过滤键。它们有助于检索相关的实验及其运行。
-
实验:它们从业务和数据角度设定了界限。例如,在未经事先验证的情况下,胡萝卜的销售数据不会用于预测苹果的销售。
-
运行:每次运行都捕获了在实验上下文中进行的特定假设或训练迭代。
现实世界的挑战:超参数调优
尽管上述模型足以满足入门需求,但现实世界场景会引入复杂性。其中一个复杂性出现在模型调优时。
模型调优至关重要。方法包括网格搜索(尽管通常不推荐,因为它效率低下)、随机搜索以及更高级的方法,如自动化超参数调优。目标始终相同:以最佳方式遍历模型的参数空间。
超参数调优的优势
-
损失指标关系:通过分析超参数与优化损失指标之间的关系,我们可以识别潜在的不相关参数。
-
参数空间分析:监控测试值的范围可以指示我们是否需要缩小或扩大搜索空间。
-
模型敏感性分析:估计模型对特定参数的反应可以找出潜在的特征集问题。
但挑战在于:我们如何系统地存储超参数调优期间产生的大量数据?
在接下来的部分中,我们将深入探讨 MLflow 解决此挑战的能力,重点关注父级运行和子级运行的概念。
什么是父级运行和子级运行?
本质上,MLflow 允许用户跟踪实验,这些实验基本上是命名的一组运行。在这种情况下,“运行”指的是模型训练事件的单次执行,您可以在其中记录与训练过程相关的参数、指标、标签和工件。父级运行和子级运行的概念为这些运行引入了分层结构。
设想一个场景,您正在测试一个具有不同深度学习架构的模型。每个架构都可以被视为一个父级运行,而该架构的每次超参数调优迭代都成为其各自父级下的嵌套子级运行。
优势
-
组织清晰度:通过使用父级运行和子级运行,您可以轻松地将相关的运行分组在一起。例如,如果您正在使用贝叶斯方法对特定模型架构进行超参数搜索,则每次迭代都可以记录为子级运行,而整个贝叶斯优化过程可以是父级运行。
-
增强可追溯性:在处理具有广泛产品层次结构的大型项目时,子级运行可以表示单个产品或变体,从而可以直接追溯结果、指标或工件到其特定的运行。
-
可伸缩性:随着实验数量和复杂性的增长,拥有嵌套结构可确保您的跟踪保持可伸缩性。导航结构化层次结构比导航数百或数千个运行的平面列表要容易得多。
-
改进协作:对于团队而言,这种方法确保团队成员可以轻松理解同行进行的实验的结构和流程,从而促进协作和知识共享。
实验、父级运行和子级运行之间的关系
-
实验:将实验视为最顶层。它们是所有相关运行所属的命名实体。例如,名为“深度学习架构”的实验可能包含与您正在测试的各种架构相关的运行。
-
父级运行:在一个实验中,父级运行表示工作流的重要部分或阶段。以前面为例,每个特定的架构(如 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 以查看迭代结果,并将每次运行的错误指标与所选参数进行比较。
当我们需要对一些微小修改再次运行时会发生什么?
我们的代码可能会就地更改测试值
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,看看是否清楚特定运行属于哪个迭代。
不难想象,如果这个实验中有数千次运行,这会变得多么复杂。
然而,有一个解决方案。我们可以通过少量修改来设置完全相同的测试场景,以便轻松找到相关运行、简化 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 中查看结果
在上面的视频中,您可以看到我们特意避免在运行比较中包含父级运行。这是因为这些父级运行实际上没有写入任何指标或参数;相反,它们纯粹用于组织目的,以限制在 UI 中可见的运行数量。
实际上,最好将超参数执行的子级运行中找到的最佳条件存储在父级运行数据中。
挑战
作为练习,如果您有兴趣,可以下载包含这两个示例的笔记本,并修改其中的代码以实现此目的。
该笔记本包含此功能的示例实现,但建议您开发自己的实现,以满足以下要求:
-
在父级运行的信息中记录子级中最低的 metric1 值以及与该子级运行相关的参数。
-
添加从调用入口点指定创建子级数量的迭代计数的能力。
UI 中此挑战的结果如下所示。
结论
父级运行和子级运行关联的使用可以极大地简化迭代模型开发。对于超参数调优等重复且数据量大的任务,封装训练运行的参数搜索空间或特征工程评估运行有助于确保您正在比较的正是您想要比较的内容,而且所需的工作量极小。