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

介绍

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

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

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

标签、实验和运行关系

模型分组层次结构

关键方面

  • 标签:这些在定义业务级过滤键时起着重要作用。它们有助于检索相关的实验及其运行。

  • 实验:它们设定了界限,无论是从商业角度还是数据角度。例如,在没有事先验证的情况下,不会使用胡萝卜的销售数据来预测苹果的销售。

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

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

虽然上述模型足以用于入门目的,但现实场景引入了复杂性。其中一个复杂性出现在调整模型时。

模型调优至关重要。方法从网格搜索(尽管由于效率问题通常不推荐)到随机搜索,以及更高级的方法如自动超参数调优。目标始终如一:最优地遍历模型的参数空间。

超参数调优的好处

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

  • 参数空间分析: 监控测试值的范围可以指示我们是否需要缩小或扩大搜索空间。

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

但这里有一个挑战:我们如何系统地存储在超参数调优过程中产生的大量数据?

超参数数据存储的挑战

存储超参数数据的困境

在接下来的章节中,我们将深入探讨,探索 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("http://localhost: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是梯度提升树模型)

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

增加更多运行

在没有子运行封装的情况下进行迭代调优的挑战

如果这个实验有成千上万次运行,不难想象这会变得多么复杂。

不过,对此有一个解决方案。我们可以通过进行一些小的修改来设置完全相同的测试场景,以便于查找相关运行、清理用户界面,并极大地简化在调优过程中评估超参数范围和参数包含性的整体过程。只需要进行少量修改:

  • 通过在父运行的上下文中添加嵌套的 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("http://localhost: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)))
)

我们可以在用户界面中查看执行此操作的结果:

当我们添加具有不同超参数选择条件的额外运行时,这种嵌套架构的真正优势变得更加明显。

# 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中可见的运行量。

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

挑战

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

Download the notebook

笔记本包含了这个的一个示例实现,但建议开发自己的实现,以满足以下要求:

  • 在父运行的信息中记录子运行中的最低 metric1 值及其相关参数。

  • 为从调用入口点创建的子项数量添加指定迭代次数的功能。

以下是此挑战在用户界面中的结果。

挑战

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

结论

父运行和子运行关联的使用可以极大地简化迭代模型开发。对于超参数调优等重复且数据量大的任务,封装训练运行的参数搜索空间或特征工程评估运行可以帮助确保你正在比较你打算比较的内容,而且这一切都可以以最小的努力完成。