跳到主要内容

MLflow 与 Optuna:超参数优化和跟踪

下载此笔记本

构建生产级模型的一个关键部分是确保给定模型的参数选择能够产生尽可能最佳的推理数据集。然而,手动跟踪大量的组合及其产生的指标可能会让人不知所措。这时 MLflow 和 Optuna 等工具就派上用场了。

目标:

在本笔记本中,您将学习如何将 MLflow 与 Optuna 集成以进行超参数优化。我们将引导您完成以下过程:

  • 设置 MLflow 跟踪环境。
  • 生成我们的训练和评估数据集。
  • 定义一个用于拟合机器学习模型的偏函数。
  • 使用 Optuna 进行超参数调优。
  • 利用 MLflow 中的子运行来跟踪超参数调优过程中的每次迭代。

为什么选择 Optuna?

Optuna 是一个开源的 Python 超参数优化框架。它提供了一种高效的超参数搜索方法,融合了最新的研究和技术。通过集成到 MLflow 中,每次试验都可以被系统地记录下来。

MLflow 中的子运行:

我们将重点介绍 MLflow 中的“子运行”概念。在进行超参数调优时,Optuna 中的每次迭代(或试验)都可以被视为一个“子运行”。这使我们能够将所有运行分组到一个主“父运行”下,从而确保 MLflow UI 保持整洁和易于理解。每个子运行将跟踪使用的特定超参数以及产生的指标,从而提供对整个优化过程的整合视图。

接下来看什么?

数据准备:我们将从加载和预处理数据集开始。

模型定义:定义我们要优化的机器学习模型。

Optuna Study:设置一个 Optuna Study 以查找模型的最佳超参数。

MLflow 集成:将每个 Optuna 试验跟踪为 MLflow 中的子运行。

分析:在 MLflow UI 中查看跟踪结果。

通过本笔记本的学习,您将获得设置高级超参数调优工作流的实践经验,重点关注使用 MLflow 和 Optuna 实现最佳实践和清晰的组织。

让我们开始吧!

import math
from datetime import datetime, timedelta

import numpy as np
import optuna
import pandas as pd
import xgboost as xgb
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split

import mlflow

配置跟踪服务器 URI

根据您运行此笔记本的位置,初始化 MLflow 跟踪服务器接口的配置可能会有所不同。

在此示例中,我们使用的是本地运行的跟踪服务器,但也有其他选项(最简单的方法是使用 Databricks 免费试用中的免费托管服务)。

请参阅 此处的运行笔记本指南,了解有关设置跟踪服务器 URI 和配置对托管或自托管 MLflow 跟踪服务器的访问的更多信息。

# NOTE: review the links mentioned above for guidance on connecting to a managed tracking server, such as the Databricks managed MLflow

mlflow.set_tracking_uri("https://:8080")

生成我们的合成训练数据

如果您已按照“使用 MLflow 记录第一个模型”的入门教程进行操作,那么您应该熟悉我们为该教程创建的苹果销售数据生成器。

在这里,我们扩展了数据,创建了一个稍微复杂的数据集,该数据集应该在特征和目标变量(需求)之间具有更好的相关性。

def generate_apple_sales_data_with_promo_adjustment(
base_demand: int = 1000,
n_rows: int = 5000,
competitor_price_effect: float = -50.0,
):
"""
Generates a synthetic dataset for predicting apple sales demand with multiple
influencing factors.

This function creates a pandas DataFrame with features relevant to apple sales.
The features include date, average_temperature, rainfall, weekend flag, holiday flag,
promotional flag, price_per_kg, competitor's price, marketing intensity, stock availability,
and the previous day's demand. The target variable, 'demand', is generated based on a
combination of these features with some added noise.

Args:
base_demand (int, optional): Base demand for apples. Defaults to 1000.
n_rows (int, optional): Number of rows (days) of data to generate. Defaults to 5000.
competitor_price_effect (float, optional): Effect of competitor's price being lower
on our sales. Defaults to -50.

Returns:
pd.DataFrame: DataFrame with features and target variable for apple sales prediction.

Example:
>>> df = generate_apple_sales_data_with_promo_adjustment(base_demand=1200, n_rows=6000)
>>> df.head()
"""

# Set seed for reproducibility
np.random.seed(9999)

# Create date range
dates = [datetime.now() - timedelta(days=i) for i in range(n_rows)]
dates.reverse()

# Generate features
df = pd.DataFrame(
{
"date": dates,
"average_temperature": np.random.uniform(10, 35, n_rows),
"rainfall": np.random.exponential(5, n_rows),
"weekend": [(date.weekday() >= 5) * 1 for date in dates],
"holiday": np.random.choice([0, 1], n_rows, p=[0.97, 0.03]),
"price_per_kg": np.random.uniform(0.5, 3, n_rows),
"month": [date.month for date in dates],
}
)

# Introduce inflation over time (years)
df["inflation_multiplier"] = 1 + (df["date"].dt.year - df["date"].dt.year.min()) * 0.03

# Incorporate seasonality due to apple harvests
df["harvest_effect"] = np.sin(2 * np.pi * (df["month"] - 3) / 12) + np.sin(
2 * np.pi * (df["month"] - 9) / 12
)

# Modify the price_per_kg based on harvest effect
df["price_per_kg"] = df["price_per_kg"] - df["harvest_effect"] * 0.5

# Adjust promo periods to coincide with periods lagging peak harvest by 1 month
peak_months = [4, 10] # months following the peak availability
df["promo"] = np.where(
df["month"].isin(peak_months),
1,
np.random.choice([0, 1], n_rows, p=[0.85, 0.15]),
)

# Generate target variable based on features
base_price_effect = -df["price_per_kg"] * 50
seasonality_effect = df["harvest_effect"] * 50
promo_effect = df["promo"] * 200

df["demand"] = (
base_demand
+ base_price_effect
+ seasonality_effect
+ promo_effect
+ df["weekend"] * 300
+ np.random.normal(0, 50, n_rows)
) * df["inflation_multiplier"] # adding random noise

# Add previous day's demand
df["previous_days_demand"] = df["demand"].shift(1)
df["previous_days_demand"].fillna(method="bfill", inplace=True) # fill the first row

# Introduce competitor pricing
df["competitor_price_per_kg"] = np.random.uniform(0.5, 3, n_rows)
df["competitor_price_effect"] = (
df["competitor_price_per_kg"] < df["price_per_kg"]
) * competitor_price_effect

# Stock availability based on past sales price (3 days lag with logarithmic decay)
log_decay = -np.log(df["price_per_kg"].shift(3) + 1) + 2
df["stock_available"] = np.clip(log_decay, 0.7, 1)

# Marketing intensity based on stock availability
# Identify where stock is above threshold
high_stock_indices = df[df["stock_available"] > 0.95].index

# For each high stock day, increase marketing intensity for the next week
for idx in high_stock_indices:
df.loc[idx : min(idx + 7, n_rows - 1), "marketing_intensity"] = np.random.uniform(0.7, 1)

# If the marketing_intensity column already has values, this will preserve them;
# if not, it sets default values
fill_values = pd.Series(np.random.uniform(0, 0.5, n_rows), index=df.index)
df["marketing_intensity"].fillna(fill_values, inplace=True)

# Adjust demand with new factors
df["demand"] = df["demand"] + df["competitor_price_effect"] + df["marketing_intensity"]

# Drop temporary columns
df.drop(
columns=[
"inflation_multiplier",
"harvest_effect",
"month",
"competitor_price_effect",
"stock_available",
],
inplace=True,
)

return df
df = generate_apple_sales_data_with_promo_adjustment(base_demand=1_000, n_rows=5000)
df
日期 平均温度 降雨量 周末 假期 每公斤价格 促销 需求 前几天的需求 竞争对手每千克价格 营销强度
0 2010-01-14 11:52:20.662955 30.584727 1.199291 0 0 1.726258 0 851.375336 851.276659 1.935346 0.098677
1 2010-01-15 11:52:20.662954 15.465069 1.037626 0 0 0.576471 0 906.855943 851.276659 2.344720 0.019318
2 2010-01-16 11:52:20.662954 10.786525 5.656089 1 0 2.513328 0 1108.304909 906.836626 0.998803 0.409485
3 2010-01-17 11:52:20.662953 23.648154 12.030937 1 0 1.839225 0 1099.833810 1157.895424 0.761740 0.872803
4 2010-01-18 11:52:20.662952 13.861391 4.303812 0 0 1.531772 0 983.949061 1148.961007 2.123436 0.820779
... ... ... ... ... ... ... ... ... ... ... ...
4995 2023-09-18 11:52:20.659592 21.643051 3.821656 0 0 2.391010 0 1140.210762 1563.064082 1.504432 0.756489
4996 2023-09-19 11:52:20.659591 13.808813 1.080603 0 1 0.898693 0 1285.149505 1189.454273 1.343586 0.742145
4997 2023-09-20 11:52:20.659590 11.698227 1.911000 0 0 2.839860 0 965.171368 1284.407359 2.771896 0.742145
4998 2023-09-21 11:52:20.659589 18.052081 1.000521 0 0 1.188440 0 1368.369501 1014.429223 2.564075 0.742145
4999 2023-09-22 11:52:20.659584 17.017294 0.650213 0 0 2.131694 0 1261.301286 1367.627356 0.785727 0.833140

5000 行 × 11 列

检查特征-目标相关性

在深入模型构建过程之前,了解特征与目标变量之间的关系至关重要。接下来的函数将显示一个图表,其中包含每个特征与目标相关的相关系数。此步骤至关重要,原因如下:

  1. 避免数据泄露:我们必须确保没有特征与目标完美相关(相关系数为 1.0)。如果存在这种相关性,则表明我们的数据集可能正在“泄露”有关目标的信息。使用此类数据进行超参数调优会误导模型,因为它可以在不真正学习潜在模式的情况下轻松获得完美分数。

  2. 确保有意义的关系:理想情况下,我们的特征应与目标具有一定程度的相关性。如果所有特征的相关系数都接近零,则表明线性关系较弱。虽然这并不自动使特征无用,但确实会带来挑战:

    • 预测能力:模型可能难以做出准确预测。
    • 过拟合风险:在相关性较弱的情况下,模型将特征拟合到噪声而不是真实模式的风险会增加,从而导致过拟合。
    • 复杂性:展示非线性关系或特征之间的交互作用将需要更复杂的可视化和评估。
  3. 审计和可追溯性:将此相关性可视化与我们的主 MLflow 运行一起记录可确保可追溯性。它提供了模型训练时数据特征的快照,这对于审计和可重复性目的非常宝贵。

在继续进行时,请记住,虽然理解相关性是一个强大的工具,但它只是拼图的一部分。让我们可视化这些关系以获得更多见解!

import matplotlib.pyplot as plt
import seaborn as sns


def plot_correlation_with_demand(df, save_path=None):
"""
Plots the correlation of each variable in the dataframe with the 'demand' column.

Args:
- df (pd.DataFrame): DataFrame containing the data, including a 'demand' column.
- save_path (str, optional): Path to save the generated plot. If not specified, plot won't be saved.

Returns:
- None (Displays the plot on a Jupyter window)
"""

# Compute correlations between all variables and 'demand'
correlations = df.corr()["demand"].drop("demand").sort_values()

# Generate a color palette from red to green
colors = sns.diverging_palette(10, 130, as_cmap=True)
color_mapped = correlations.map(colors)

# Set Seaborn style
sns.set_style(
"whitegrid", {"axes.facecolor": "#c2c4c2", "grid.linewidth": 1.5}
) # Light grey background and thicker grid lines

# Create bar plot
fig = plt.figure(figsize=(12, 8))
plt.barh(correlations.index, correlations.values, color=color_mapped)

# Set labels and title with increased font size
plt.title("Correlation with Demand", fontsize=18)
plt.xlabel("Correlation Coefficient", fontsize=16)
plt.ylabel("Variable", fontsize=16)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
plt.grid(axis="x")

plt.tight_layout()

# Save the plot if save_path is specified
if save_path:
plt.savefig(save_path, format="png", dpi=600)

# prevent matplotlib from displaying the chart every time we call this function
plt.close(fig)

return fig


# Test the function
correlation_plot = plot_correlation_with_demand(df, save_path="correlation_plot.png")
<Figure size 432x288 with 0 Axes>

调查特征与目标变量的相关性

在上面的代码中,我们已故意禁用 Matplotlib 生成的图表的自动显示。在机器学习实验中,直接在笔记本中显示图形通常没有用,原因有很多。相反,我们的目标是将此图与迭代实验运行的结果相关联。为了实现这一点,我们将图表保存到 MLflow 跟踪系统中。这为我们提供了详细的记录,将数据集的状态与已记录的模型、其参数和性能指标关联起来。

为什么不在笔记本中直接显示图表?

选择不在笔记本中显示图表是一个有意识的决定,原因有多种。一些要点包括:

  • 笔记本的短暂性:笔记本本质上是短暂的;它们并非设计用于永久记录您的工作。

  • 过时风险:如果您重新运行笔记本的部分内容,显示的图表可能会过时或产生误导,从而在解释结果时存在风险。

  • 丢失先前状态:如果您重新运行整个笔记本,图表将丢失。虽然某些插件可以恢复先前的单元格状态,但设置起来可能既麻烦又耗时。

相反,将图表记录到 MLflow 可确保我们拥有一个永久的、易于访问的记录,该记录直接与其他实验数据相关联。这对于保持机器学习实验的完整性和可重复性非常宝贵。

为本指南显示图表

为了本指南的目的,我们将花一些时间检查图表。我们可以通过显式调用从函数返回的图形对象来完成此操作。

correlation_plot

可视化模型残差以获得诊断洞察

plot_residuals 函数用于可视化残差,即模型预测值与验证集中实际值之间的差异。残差图是机器学习中的关键诊断工具,因为它们可以揭示表明模型未能捕获数据的某些方面或模型本身存在系统性问题的模式。

为什么使用残差图?

残差图具有多种优势:

  • 识别偏差:如果残差显示趋势(不以零为中心),则可能表明您的模型系统地高估或低估了目标变量。

  • 异方差性:在预测值范围内残差的分布变化可能表明“异方差性”,这会违反某些建模技术的假设。

  • 异常值:远离零线的点可视为异常值,可能需要进一步调查。

自动保存图表

与相关性图表一样,此函数允许您将残差图保存到特定路径。此功能符合我们将重要图形记录到 MLflow 以实现更有效的模型跟踪和审计的总体策略。

图表结构

在散点图中,每个点代表验证集中特定观测值的残差。零处的红色水平线用作参考,表示如果模型的预测是完美的,残差将位于何处。

为了本指南的目的,我们将生成此图,但不会在稍后 MLflow UI 中看到它之前进行检查。

def plot_residuals(model, dvalid, valid_y, save_path=None):
"""
Plots the residuals of the model predictions against the true values.

Args:
- model: The trained XGBoost model.
- dvalid (xgb.DMatrix): The validation data in XGBoost DMatrix format.
- valid_y (pd.Series): The true values for the validation set.
- save_path (str, optional): Path to save the generated plot. If not specified, plot won't be saved.

Returns:
- None (Displays the residuals plot on a Jupyter window)
"""

# Predict using the model
preds = model.predict(dvalid)

# Calculate residuals
residuals = valid_y - preds

# Set Seaborn style
sns.set_style("whitegrid", {"axes.facecolor": "#c2c4c2", "grid.linewidth": 1.5})

# Create scatter plot
fig = plt.figure(figsize=(12, 8))
plt.scatter(valid_y, residuals, color="blue", alpha=0.5)
plt.axhline(y=0, color="r", linestyle="-")

# Set labels, title and other plot properties
plt.title("Residuals vs True Values", fontsize=18)
plt.xlabel("True Values", fontsize=16)
plt.ylabel("Residuals", fontsize=16)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
plt.grid(axis="y")

plt.tight_layout()

# Save the plot if save_path is specified
if save_path:
plt.savefig(save_path, format="png", dpi=600)

# Show the plot
plt.close(fig)

return fig

使用 XGBoost 理解特征重要性

plot_feature_importance 函数旨在可视化 XGBoost 模型中使用的每个特征的重要性。理解特征重要性可以为模型的决策过程提供关键见解,并有助于特征选择、工程和解释。

特征重要性类型

XGBoost 提供了多种解释特征重要性的方法。此函数支持:

  • 权重:在整个树集合中,特征在树中出现的次数(对于 gblinear booster)。
  • 增益:特征在树中使用时对模型的平均增益(或改进)(对于其他 booster 类型)。

我们根据 XGBoost 模型中使用的 booster 自动选择适当的重要性类型。

特征重要性为何重要

理解特征重要性具有多种优势:

  • 可解释性:了解哪些特征影响最大有助于我们更好地理解模型。
  • 特征选择:可以删除不重要的特征以简化模型。
  • 领域理解:将模型的重要性尺度与领域特定知识或直觉对齐。
保存和访问图表

此函数返回一个 Matplotlib 图形对象,您可以对其进行进一步操作或保存。与之前的图表一样,建议将此图记录到 MLflow 中,以获得模型解释特征的不可变记录。

在生成的图表中,每个条形代表模型中使用的特征。条形的长度对应于根据所选重要性类型计算的特征的重要性。

我们需要一个已训练的模型才能生成此图表。因此,我们在训练模型时会生成但不显示图表。生成的图形将被记录到 MLflow 中,并在 UI 中可见。

def plot_feature_importance(model, booster):
"""
Plots feature importance for an XGBoost model.

Args:
- model: A trained XGBoost model

Returns:
- fig: The matplotlib figure object
"""
fig, ax = plt.subplots(figsize=(10, 8))
importance_type = "weight" if booster == "gblinear" else "gain"
xgb.plot_importance(
model,
importance_type=importance_type,
ax=ax,
title=f"Feature Importance based on {importance_type}",
)
plt.tight_layout()
plt.close(fig)

return fig

设置 MLflow 实验

在开始超参数调优过程之前,我们需要在 MLflow 中指定一个特定的“实验”来跟踪和记录我们的结果。MLflow 中的实验本质上是一组命名的运行。实验中的每个运行都跟踪其自己的参数、指标、标签和构件。

为什么创建新实验?

  1. 组织:它有助于将我们的运行组织在一个特定的任务或项目下,从而更容易比较和分析结果。
  2. 隔离:通过将不同的任务或项目隔离到单独的实验中,我们可以防止意外覆盖或误解结果。

我们定义的 get_or_create_experiment 函数有助于此过程。它会检查具有指定名称的实验是否已存在。如果存在,则检索其 ID。如果不存在,则创建新实验并返回其 ID。

如何使用 experiment_id

检索到的或创建的 experiment_id 在我们启动超参数调优时变得至关重要。当我们开始父运行进行调优时,experiment_id 可确保运行及其嵌套的子运行被记录在正确的实验下。它提供了一种在 MLflow UI 中导航、比较和分析我们的调优结果的结构化方法。

当我们想要尝试额外的参数范围、不同的参数或稍微修改的数据集时,我们可以使用此实验将所有父运行记录下来,以保持我们的 MLflow 跟踪 UI 整洁且易于导航。

让我们继续设置我们的实验!

def get_or_create_experiment(experiment_name):
"""
Retrieve the ID of an existing MLflow experiment or create a new one if it doesn't exist.

This function checks if an experiment with the given name exists within MLflow.
If it does, the function returns its ID. If not, it creates a new experiment
with the provided name and returns its ID.

Parameters:
- experiment_name (str): Name of the MLflow experiment.

Returns:
- str: ID of the existing or newly created MLflow experiment.
"""

if experiment := mlflow.get_experiment_by_name(experiment_name):
return experiment.experiment_id
else:
return mlflow.create_experiment(experiment_name)

为我们的超参数调优运行创建实验

experiment_id = get_or_create_experiment("Apples Demand")

我们可以查看生成或获取的 experiment_id,以了解此唯一引用键的外观。此处生成的值也显示在 MLflow UI 中。

experiment_id
'908436739760555869'

设置 MLflow 和数据预处理以进行模型训练

代码的这一部分完成了两项主要任务:初始化一个 MLflow 实验用于运行跟踪,并准备数据集用于模型训练和验证。

MLflow 初始化

我们首先使用 set_experiment 函数设置 MLflow 实验。experiment_id 作为实验的唯一标识符,使我们能够有效地分割和管理不同的运行及其关联数据。

数据预处理

接下来的步骤包括准备数据集以进行模型训练:

  1. 特征选择:我们从 DataFrame 中删除“date”和“demand”列,仅保留 X 中的特征列。

  2. 目标变量:“demand”列被指定为我们的目标变量 y

  3. 数据拆分:我们使用 75-25 的比例将数据集拆分为训练集(train_x, train_y)和验证集(valid_x, valid_y)。

  4. XGBoost 数据格式:最后,我们将训练集和验证集转换为 XGBoost 的 DMatrix 格式。这种优化的数据结构可以加快训练过程,并且是使用 XGBoost 高级功能的必需。

这些步骤为何重要
  • MLflow 跟踪:初始化 MLflow 实验可确保所有后续模型运行、指标和构件都记录在同一实验下,从而更轻松地比较和分析不同的模型。虽然我们在这里使用 fluent API 来执行此操作,但您也可以在 start_run() 上下文中指定 experiment_id

  • 数据准备:正确准备数据可确保模型训练过程顺利进行,并且结果尽可能准确。

在接下来的步骤中,我们将继续进行模型训练和评估,所有这些准备步骤都将发挥作用。

# Set the current active MLflow experiment
mlflow.set_experiment(experiment_id=experiment_id)

# Preprocess the dataset
X = df.drop(columns=["date", "demand"])
y = df["demand"]
train_x, valid_x, train_y, valid_y = train_test_split(X, y, test_size=0.25)
dtrain = xgb.DMatrix(train_x, label=train_y)
dvalid = xgb.DMatrix(valid_x, label=valid_y)

使用 Optuna 和 MLflow 进行超参数调优和模型训练

objective 函数是我们在 Optuna 中进行超参数调优过程的核心。此外,它还使用选定的超参数训练 XGBoost 模型,并将指标和参数记录到 MLflow。

MLflow 嵌套运行

该函数在 MLflow 中启动一个新的嵌套运行。嵌套运行对于组织超参数调优实验很有用,因为它们允许我们将单个运行分组到一个父运行下。

定义超参数

Optuna 的 trial.suggest_* 方法用于定义超参数的可能值范围。以下是每个超参数的作用:

  • objectiveeval_metric:定义损失函数和评估指标。
  • booster:要使用的提升类型(gbtreegblineardart)。
  • lambdaalpha:正则化参数。
  • max_depthetagamma 等附加参数特定于基于树的模型(gbtreedart)。
模型训练

使用选择的超参数和预处理后的训练数据集(dtrain)训练 XGBoost 模型。在验证集(dvalid)上进行预测,并计算均方误差(mse)。

使用 MLflow 进行日志记录

所有选定的超参数和指标(msermse)都记录到 MLflow 以供以后分析和比较。

  • mlflow.log_params:记录超参数。
  • mlflow.log_metric:记录指标。
此函数为何重要
  • 自动化调优:Optuna 自动化了查找最佳超参数的过程。
  • 实验跟踪:MLflow 使我们能够跟踪每次运行的超参数和性能指标,从而更容易在以后分析、比较和重现实验。

在下一步中,Optuna 将使用此目标函数来查找 XGBoost 模型的最佳超参数集。

杂项:精简 Optuna 试验的日志记录

当我们开始使用 Optuna 进行超参数调优时,需要了解该过程会产生大量的运行。事实上,数量非常多,以至于默认日志记录器的标准输出 (stdout) 会很快被淹没,产生大量的日志报告。

虽然默认日志记录配置的详细程度在代码开发阶段无疑很有价值,但启动全面试用可能会导致信息量过大。考虑到这一点,将每个细节都记录到 stdout 变得不那么实用,特别是当我们有 MLflow 等专用工具来仔细跟踪我们的实验时。

为了取得平衡,我们将使用回调函数来定制我们的日志记录行为。

实现日志记录回调:

我们将要介绍的回调函数将修改默认的报告行为。我们不会记录每次试验,而只会收到当新的超参数组合产生优于迄今为止记录的最佳指标值时才进行的更新。

这种方法提供了两个显著的好处:

  1. 提高可读性:通过过滤掉大量的日志细节,只关注那些有改进的试验,我们可以衡量超参数搜索的有效性。例如,如果我们发现“最佳结果”报告的频率早期就开始减少,这可能表明只需要较少的迭代就可以找到最佳超参数集。另一方面,持续的改进率可能表明我们的特征集需要进一步完善。

  2. 进度指示器:对于持续数小时甚至数天的扩展试验尤其重要,定期收到更新可确保过程仍在进行中。这些“心跳”通知肯定了我们的系统正在努力工作,即使它不会将每个细微之处都充斥 stdout。

此外,MLflow 的用户界面 (UI) 补充了此策略。每次试验完成后,MLflow 都会记录子运行,使其在父运行的保护伞下可用。

在接下来的代码中,我们:

  1. 将 Optuna 的日志记录级别调整为仅报告错误,以确保 stdout 清洁。
  2. 定义一个 champion_callback 函数,该函数仅在试验超过先前记录的最佳指标时进行日志记录。

让我们深入实现!

# override Optuna's default logging to ERROR only
optuna.logging.set_verbosity(optuna.logging.ERROR)

# define a logging callback that will report on only new challenger parameter configurations if a
# trial has usurped the state of 'best conditions'


def champion_callback(study, frozen_trial):
"""
Logging callback that will report when a new trial iteration improves upon existing
best trial values.

Note: This callback is not intended for use in distributed computing systems such as Spark
or Ray due to the micro-batch iterative implementation for distributing trials to a cluster's
workers or agents.
The race conditions with file system state management for distributed trials will render
inconsistent values with this callback.
"""

winner = study.user_attrs.get("winner", None)

if study.best_value and winner != study.best_value:
study.set_user_attr("winner", study.best_value)
if winner:
improvement_percent = (abs(winner - study.best_value) / study.best_value) * 100
print(
f"Trial {frozen_trial.number} achieved value: {frozen_trial.value} with "
f"{improvement_percent: .4f}% improvement"
)
else:
print(f"Initial trial {frozen_trial.number} achieved value: {frozen_trial.value}")
def objective(trial):
with mlflow.start_run(nested=True):
# Define hyperparameters
params = {
"objective": "reg:squarederror",
"eval_metric": "rmse",
"booster": trial.suggest_categorical("booster", ["gbtree", "gblinear", "dart"]),
"lambda": trial.suggest_float("lambda", 1e-8, 1.0, log=True),
"alpha": trial.suggest_float("alpha", 1e-8, 1.0, log=True),
}

if params["booster"] == "gbtree" or params["booster"] == "dart":
params["max_depth"] = trial.suggest_int("max_depth", 1, 9)
params["eta"] = trial.suggest_float("eta", 1e-8, 1.0, log=True)
params["gamma"] = trial.suggest_float("gamma", 1e-8, 1.0, log=True)
params["grow_policy"] = trial.suggest_categorical(
"grow_policy", ["depthwise", "lossguide"]
)

# Train XGBoost model
bst = xgb.train(params, dtrain)
preds = bst.predict(dvalid)
error = mean_squared_error(valid_y, preds)

# Log to MLflow
mlflow.log_params(params)
mlflow.log_metric("mse", error)
mlflow.log_metric("rmse", math.sqrt(error))

return error

使用 MLflow 编排超参数调优、模型训练和日志记录

代码的这一部分充当了编排层,将 Optuna 用于超参数调优,并将 MLflow 用于实验跟踪。

启动父运行

我们首先以“Best Run”的名称启动一个父 MLflow 运行。所有后续操作,包括 Optuna 的试验,都嵌套在此父运行下,从而提供了一种有组织的实验结构。

使用 Optuna 进行超参数调优
  • study = optuna.create_study(direction='minimize'):我们创建一个 Optuna Study 对象,旨在最小化我们的目标函数。
  • study.optimize(objective, n_trials=10)objective 函数在 10 次试验中进行优化。
记录最佳参数和指标

在 Optuna 找到最佳超参数后,我们将这些参数以及最佳均方误差(mse)和均方根误差(rmse)记录到 MLflow。

记录附加元数据

使用 mlflow.set_tags,我们记录附加元数据,例如项目名称、优化引擎、模型系列和特征集版本。这有助于更好地对模型运行的上下文进行分类和理解。

模型训练和构件记录
  • 我们使用最佳超参数训练 XGBoost 模型。
  • 各种图表—与需求的关联、特征重要性以及残差—被生成并记录为 MLflow 中的构件。
模型序列化和记录

最后,使用 mlflow.xgboost.log_model 将训练好的模型记录到 MLflow,并附带示例输入和附加元数据。模型存储在指定的构件路径中,并检索其 URI。

此块为何至关重要
  • 端到端工作流:此代码块代表了一个端到端的机器学习工作流,从超参数调优到模型评估和日志记录。
  • 可重复性:关于模型的所有细节,包括超参数、指标和视觉诊断,都已记录下来,确保了实验完全可重现。
  • 分析和比较:由于所有数据都已记录在 MLflow 中,因此更容易分析各种运行的性能并选择最佳模型进行部署。

在接下来的步骤中,我们将探讨如何检索和使用已记录的模型进行推理。

为模型运行设置描述性名称

在继续进行模型训练和超参数调优之前,为我们的 MLflow 运行分配一个描述性名称是很有益的。此名称充当人类可读的标识符,使其更容易跟踪、比较和分析不同的运行。

命名运行的重要性:
  • 按名称引用:虽然 MLflow 为每次运行提供了唯一的标识键,如 run_id,但拥有一个描述性名称可以实现更直观的引用,尤其是在使用特定 API 和导航 MLflow UI 时。

  • 清晰度和上下文:一个精心选择的运行名称可以提供有关正在测试的假设或所做的具体修改的上下文,有助于理解特定运行的目的和理由。

  • 自动命名:如果您未指定运行名称,MLflow 会为您生成一个独特的有趣名称。但是,这可能缺乏手动选择名称的上下文和清晰度。

最佳实践:

在命名运行名称时,请考虑以下几点:

  1. 与代码更改相关:名称应反映为该运行所做的任何代码或参数修改。
  2. 迭代运行:如果您迭代执行多次运行,最好为每次迭代更新运行名称,以避免混淆。

在后续步骤中,我们将为父运行设置名称。请记住,如果您多次执行模型训练,请考虑更新运行名称以获得清晰度。

run_name = "first_attempt"
# Initiate the parent run and call the hyperparameter tuning child run logic
with mlflow.start_run(experiment_id=experiment_id, run_name=run_name, nested=True):
# Initialize the Optuna study
study = optuna.create_study(direction="minimize")

# Execute the hyperparameter optimization trials.
# Note the addition of the `champion_callback` inclusion to control our logging
study.optimize(objective, n_trials=500, callbacks=[champion_callback])

mlflow.log_params(study.best_params)
mlflow.log_metric("best_mse", study.best_value)
mlflow.log_metric("best_rmse", math.sqrt(study.best_value))

# Log tags
mlflow.set_tags(
tags={
"project": "Apple Demand Project",
"optimizer_engine": "optuna",
"model_family": "xgboost",
"feature_set_version": 1,
}
)

# Log a fit model instance
model = xgb.train(study.best_params, dtrain)

# Log the correlation plot
mlflow.log_figure(figure=correlation_plot, artifact_file="correlation_plot.png")

# Log the feature importances plot
importances = plot_feature_importance(model, booster=study.best_params.get("booster"))
mlflow.log_figure(figure=importances, artifact_file="feature_importances.png")

# Log the residuals plot
residuals = plot_residuals(model, dvalid, valid_y)
mlflow.log_figure(figure=residuals, artifact_file="residuals.png")

artifact_path = "model"

mlflow.xgboost.log_model(
xgb_model=model,
name=artifact_path,
input_example=train_x.iloc[[0]],
model_format="ubj",
metadata={"model_data_version": 1},
)

# Get the logged model uri so that we can load it from the artifact store
model_uri = mlflow.get_artifact_uri(artifact_path)
Initial trial 0 achieved value: 1593256.879424474
Trial 1 achieved value: 1593250.8071099266 with  0.0004% improvement
Trial 2 achieved value: 30990.735000917906 with  5041.0552% improvement
Trial 5 achieved value: 22804.947010998963 with  35.8948% improvement
Trial 7 achieved value: 18232.507769997483 with  25.0785% improvement
Trial 10 achieved value: 15670.64645523901 with  16.3482% improvement
Trial 11 achieved value: 15561.843005727616 with  0.6992% improvement
Trial 21 achieved value: 15144.954353687495 with  2.7527% improvement
Trial 23 achieved value: 14846.71981618512 with  2.0088% improvement
Trial 55 achieved value: 14570.287261018764 with  1.8972% improvement
/Users/benjamin.wilson/repos/mlflow-fork/mlflow/mlflow/models/signature.py:333: UserWarning: Hint: Inferred schema contains integer column(s). Integer columns in Python cannot represent missing values. If your input data contains missing values at inference time, it will be encoded as floats and will cause a schema enforcement error. The best way to avoid this problem is to infer the model schema based on a realistic data sample (training dataset) that includes missing values. Alternatively, you can declare integer columns as doubles (float64) whenever these columns may have missing values. See `Handling Integers With Missing Values <https://www.mlflow.org/docs/latest/models.html#handling-integers-with-missing-values>`_ for more details.
input_schema = _infer_schema(input_ex)
/Users/benjamin.wilson/miniconda3/envs/mlflow-dev-env/lib/python3.8/site-packages/_distutils_hack/__init__.py:30: UserWarning: Setuptools is replacing distutils.
warnings.warn("Setuptools is replacing distutils.")

理解 MLflow 中的构件 URI

输出“mlflow-artifacts:/908436739760555869/c8d64ce51f754eb698a3c09239bcdcee/artifacts/model”代表 MLflow 中训练模型构件的唯一统一资源标识符 (URI)。此 URI 是 MLflow 体系结构的关键组成部分,原因如下:

简化模型构件的访问

model_uri 抽象了底层存储细节,提供了一种一致且直接的方式来引用模型构件,无论它们存储在哪里。无论您的构件是位于本地文件系统、云存储桶还是网络挂载上,URI 始终是一个一致的参考点。

存储细节的抽象

MLflow 被设计为存储无关。这意味着,虽然您可能会将后端存储从本地目录切换到 Amazon S3 存储桶,但与 MLflow 的交互方式保持一致。URI 确保您无需了解存储后端的具体细节;您只需要引用模型的 URI。

相关信息和元数据

除了模型文件本身,URI 还提供了对关联元数据、模型构件和其他已记录构件(文件和图像)的访问。这确保您拥有关于模型的全面信息集,有助于可重复性、分析和部署。

总结

model_uri 作为指向您的模型及其关联数据的统一、抽象的引用。它简化了与 MLflow 的交互,确保用户无需担心底层存储机制的具体细节,即可专注于机器学习工作流。

model_uri
'mlflow-artifacts:/908436739760555869/c28196b19e1843bca7e22f07d796e740/artifacts/model'

使用 MLflow 加载已训练的模型

通过以下代码行:

loaded = mlflow.xgboost.load_model(model_uri)

我们正在利用 MLflow 的原生 XGBoost 模型加载器。我们没有使用通用的 pyfunc 加载器(它提供通用的 Python 函数接口来处理模型),而是使用了 XGBoost 特定的加载器。

原生加载的好处:
  • 保真度:使用原生加载器加载模型可确保您使用的是与训练期间完全相同的模型对象。这意味着原始模型的所有细微差别、具体细节和复杂性都得以保留。

  • 功能性:有了原生模型对象,您就可以利用其所有固有的方法和属性。这提供了更大的灵活性,尤其是在推理过程中需要高级功能或细粒度控制时。

  • 性能:使用原生模型对象可能会带来性能优势,尤其是在执行批量推理或在针对特定机器学习框架优化的环境中部署模型时。

总之,通过原生加载模型,我们确保了最大的兼容性和功能性,从而实现了从训练到推理的无缝过渡。

loaded = mlflow.xgboost.load_model(model_uri)
Downloading artifacts:   0%|          | 0/6 [00:00<?, ?it/s]

示例:使用已加载的模型进行批量推理

加载模型后,进行批量推理非常简单。

在下面的单元格中,我们将基于整个源特征集执行预测。尽管在实际应用中,对整个训练和验证数据集特征执行推理操作的实用性非常有限,但我们将在这里使用我们生成的合成数据来演示如何使用原生模型进行推理。

执行批量推理和增强数据

在本节中,我们将使用已加载的 XGBoost 模型对整个数据集执行批量推理。然后,我们将这些预测附加回原始数据集中,以进行比较、分析或进一步处理。

步骤说明:
  1. 创建 DMatrixbatch_dmatrix = xgb.DMatrix(X):我们首先将我们的特征(X)转换为 XGBoost 的优化 DMatrix 格式。这种数据结构专门为 XGBoost 的效率和速度而设计。

  2. 预测inference = loaded.predict(batch_dmatrix):使用先前加载的模型(loaded),我们对整个数据集执行批量推理。

  3. 创建新 DataFrameinfer_df = df.copy():我们创建原始 DataFrame 的副本,以确保我们不会修改原始数据。

  4. 附加预测infer_df["predicted_demand"] = inference:然后将预测添加为新列 predicted_demand 到此 DataFrame 中。

最佳实践:
  • 始终复制数据:在增强或修改数据集时,通常最好使用副本。这确保了原始数据保持不变,保持数据完整性。

  • 批量推理:在对大型数据集进行预测时,使用批量推理(而不是单个预测)可以显著提高速度。

  • DMatrix 转换:虽然转换为 DMatrix 可能看起来是额外的步骤,但对于使用 XGBoost 进行性能而言,它是至关重要的。它确保尽可能快地进行预测。

在后续步骤中,我们可以进一步分析实际需求与模型预测需求之间的差异,可能可视化结果或计算性能指标。

batch_dmatrix = xgb.DMatrix(X)

inference = loaded.predict(batch_dmatrix)

infer_df = df.copy()

infer_df["predicted_demand"] = inference

可视化增强的 DataFrame

下面,我们显示 infer_df DataFrame。这个增强的数据集现在包括实际需求(demand)和模型的预测(predicted_demand)。通过检查此表,我们可以快速了解模型的预测与实际需求值匹配的程度。

infer_df
日期 平均温度 降雨量 周末 假期 每公斤价格 促销 需求 前几天的需求 竞争对手每千克价格 营销强度 predicted_demand
0 2010-01-14 11:52:20.662955 30.584727 1.199291 0 0 1.726258 0 851.375336 851.276659 1.935346 0.098677 953.708496
1 2010-01-15 11:52:20.662954 15.465069 1.037626 0 0 0.576471 0 906.855943 851.276659 2.344720 0.019318 1013.409973
2 2010-01-16 11:52:20.662954 10.786525 5.656089 1 0 2.513328 0 1108.304909 906.836626 0.998803 0.409485 1152.382446
3 2010-01-17 11:52:20.662953 23.648154 12.030937 1 0 1.839225 0 1099.833810 1157.895424 0.761740 0.872803 1352.879272
4 2010-01-18 11:52:20.662952 13.861391 4.303812 0 0 1.531772 0 983.949061 1148.961007 2.123436 0.820779 1121.233032
... ... ... ... ... ... ... ... ... ... ... ... ...
4995 2023-09-18 11:52:20.659592 21.643051 3.821656 0 0 2.391010 0 1140.210762 1563.064082 1.504432 0.756489 1070.676636
4996 2023-09-19 11:52:20.659591 13.808813 1.080603 0 1 0.898693 0 1285.149505 1189.454273 1.343586 0.742145 1156.580688
4997 2023-09-20 11:52:20.659590 11.698227 1.911000 0 0 2.839860 0 965.171368 1284.407359 2.771896 0.742145 1086.527710
4998 2023-09-21 11:52:20.659589 18.052081 1.000521 0 0 1.188440 0 1368.369501 1014.429223 2.564075 0.742145 1085.064087
4999 2023-09-22 11:52:20.659584 17.017294 0.650213 0 0 2.131694 0 1261.301286 1367.627356 0.785727 0.833140 1047.954102

5000 行 × 12 列

总结:反思我们的全面机器学习工作流

在本指南中,我们对端到端的机器学习工作流进行了详细的探索。我们从数据预处理开始,深入研究了 Optuna 的超参数调优,利用 MLflow 进行结构化的实验跟踪,最后完成了批量推理。

主要收获:
  • Optuna 的超参数调优:我们利用 Optuna 的强大功能系统地搜索 XGBoost 模型的最佳超参数,以优化其性能。

  • MLflow 的结构化实验跟踪:通过记录实验、指标、参数和构件,MLflow 的功能得到了充分发挥。我们还探讨了嵌套子运行的好处,使我们能够逻辑地分组和构建实验迭代。

  • 模型解释:各种图表和指标为我们提供了对模型行为的见解。我们学会了欣赏它的优点并找出可能的改进领域。

  • 批量推理:我们探索了对大型数据集进行批量预测的细微之处,以及将这些预测无缝集成回主要数据的方法。

  • 记录视觉构件:我们旅程的很大一部分强调了将图表等视觉构件记录到 MLflow 的重要性。这些视觉效果作为宝贵的参考,捕捉模型的状态、其性能以及可能影响模型性能指标的特征集的任何更改。

通过本指南的学习,您应该对结构良好的机器学习工作流有了扎实的理解。这个基础不仅能让您构建有效的模型,还能确保从数据处理到预测的每一步都透明、可重现且高效。

感谢您与我们一起踏上这段全面的旅程。获得的实践和见解无疑将对您未来的所有机器学习项目都至关重要!