MLflow 与 Optuna:超参数优化和跟踪
构建生产级模型的一个关键部分是确保选择给定模型的参数以创建最佳的推理集。但是,大量的组合及其结果指标可能会让人难以手动跟踪。这就是 MLflow 和 Optuna 等工具发挥作用的地方。
目标:
在本 Notebook 中,您将学习如何将 MLflow 与 Optuna 集成以进行超参数优化。我们将指导您完成以下过程:
- 设置具有 MLflow 跟踪的环境。
- 生成我们的训练和评估数据集。
- 定义一个拟合机器学习模型的部分函数。
- 使用 Optuna 进行超参数调整。
- 利用 MLflow 中的子运行来跟踪超参数调整过程中的每次迭代。
为什么选择 Optuna?
Optuna 是一个 Python 中的开源超参数优化框架。它提供了一种有效的方法来搜索超参数,并结合了最新的研究和技术。通过将其集成到 MLflow 中,可以系统地记录每次试验。
MLflow 中的子运行:
我们将强调的核心功能之一是 MLflow 中的“子运行”概念。在执行超参数调整时,Optuna 中的每次迭代(或试验)都可以被视为“子运行”。这使我们可以将所有运行分组在一个主要的“父运行”下,从而确保 MLflow UI 保持井井有条且易于理解。每个子运行将跟踪使用的特定超参数和结果指标,从而提供整个优化过程的综合视图。
接下来是什么?
数据准备:我们将首先加载和预处理我们的数据集。
模型定义:定义我们旨在优化的机器学习模型。
Optuna 研究:设置 Optuna 研究以找到我们模型的最佳超参数。
MLflow 集成:在 MLflow 中将每次 Optuna 试验跟踪为子运行。
分析:查看 MLflow UI 中跟踪的结果。
在本 Notebook 结束时,您将获得设置高级超参数调整工作流程的实践经验,该工作流程强调使用 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
根据您运行此 Notebook 的位置,您的配置可能因如何初始化与 MLflow 跟踪服务器的接口而异。
在此示例中,我们使用本地运行的跟踪服务器,但还有其他选项可用(最简单的方法是在 Databricks 免费试用版中使用免费的托管服务)。
有关设置跟踪服务器 URI 和配置对托管或自托管 MLflow 跟踪服务器的访问的更多信息,请参见此处的运行 Notebook 指南。
# 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
日期 | 平均温度 | 降雨量 | 周末 | 假期 | 每公斤价格 | 促销 | 需求 | 前几天的需求 | competitor_price_per_kg | marketing_intensity | |
---|---|---|---|---|---|---|---|---|---|---|---|
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 列
检查特征-目标相关性
在深入研究模型构建过程之前,必须了解我们的特征和目标变量之间的关系。即将到来的函数将显示一个图,指示每个特征相对于目标的 correlation coefficient。以下是此步骤至关重要的原因:
-
避免数据泄漏:我们必须确保没有任何特征与目标完全相关(correlation coefficient 为 1.0)。如果存在这种相关性,则表明我们的数据集可能正在“泄漏”有关目标的信息。将此类数据用于超参数调整会误导模型,因为它可以在没有真正学习底层模式的情况下轻松获得完美的分数。
-
确保有意义的关系:理想情况下,我们的特征应与目标具有一定程度的相关性。如果所有特征的 correlation coefficient 都接近于零,则表明线性关系较弱。尽管这不会自动使特征无用,但确实带来了挑战:
- 预测能力:模型可能难以做出准确的预测。
- 过度拟合风险:由于相关性较弱,模型存在拟合噪声而不是真正模式的更高风险,从而导致过度拟合。
- 复杂性:展示特征之间的非线性关系或交互作用将需要更复杂的 visualization 和评估。
-
审计和可追溯性:使用我们的主 MLflow 运行记录此相关性 visualization 可确保可追溯性。它提供了模型训练时的数据特征的快照,这对于审计和可重复性目的来说非常宝贵。
当我们继续时,请记住,虽然理解相关性是一个强大的工具,但它只是拼图中的一块。让我们 visualization 这些关系以获得更多见解!
import matplotlib.pyplot as plt
import seaborn as sns
def plot_correlation_with_demand(df, save_path=None): # noqa: D417
"""
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 生成的图的自动显示。在机器学习实验中,由于多种原因,直接在 Notebook 中显示图形通常没有用。相反,我们的目标是将此图与迭代实验运行的结果相关联。为了实现这一点,我们将图保存到我们的 MLflow 跟踪系统中。这为我们提供了详细的记录,将数据集的状态链接到已记录的模型、其参数和性能指标。
为什么不在 Notebook 中直接显示图?
选择不在 Notebook 中显示图是一个有意的决定,原因有很多。一些关键点包括:
-
Notebook 的短暂性:Notebook 本质上是短暂的;它们并非旨在成为您工作的永久记录。
-
过时的风险:如果您重新运行 Notebook 的某些部分,则显示的图可能会过时或具有误导性,从而在解释结果时构成风险。
-
丢失以前的状态:如果您碰巧重新运行整个 Notebook,则该图将丢失。虽然某些插件可以恢复以前的单元格状态,但设置此功能可能很麻烦且耗时。
相反,将该图记录到 MLflow 可确保我们拥有一个永久的、易于访问的记录,该记录直接与其他实验数据相关联。这对于维护机器学习实验的完整性和可重复性来说非常宝贵。
显示本指南的图
为了本指南的目的,我们仍然花一些时间来检查该图。我们可以通过显式调用从我们的函数返回的图形对象来做到这一点。
correlation_plot
visualization 模型残差以获得诊断见解
plot_residuals
函数用于 visualization 残差 - 模型预测与验证集中实际值之间的差异。残差图是机器学习中至关重要的诊断工具,因为它们可以揭示表明我们的模型未能捕获数据的某些方面,或者模型本身存在系统性问题的模式。
为什么使用残差图?
残差图具有几个优点:
-
识别偏差:如果残差显示趋势(未以零为中心),则可能表明您的模型正在系统地过度或低估目标变量。
-
异方差性:残差在预测值的范围内变化可能会指示“异方差性”,这可能会违反某些建模技术中的假设。
-
异常值:远离零线的点可以被视为异常值,可能需要进一步调查。
自动保存图
与相关性图一样,此函数允许您将残差图保存到特定路径。此功能符合我们更广泛的策略,即将重要图形记录到 MLflow 中,以实现更有效的模型跟踪和审计。
图结构
在散点图中,每个点表示验证集中特定观测值的残差。零处的红色水平线用作参考,指示如果模型的预测是完美的,则残差将位于何处。
为了本指南的目的,我们将生成此图,但在稍后在 MLflow UI 中看到它之前不会对其进行检查。
def plot_residuals(model, dvalid, valid_y, save_path=None): # noqa: D417
"""
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
函数旨在 visualization 我们 XGBoost 模型中使用的每个特征的重要性。了解特征重要性可以为模型的决策过程提供重要的见解,并可以帮助进行特征选择、工程设计和解释。
特征重要性的类型
XGBoost 提供了多种解释特征重要性的方法。此函数支持:
- 权重:特征在树的集合中出现在树中的次数(对于
gblinear
提升器)。 - 增益:特征在树中使用时的平均增益(或对模型的改进)(对于其他提升器类型)。
我们会根据 XGBoost 模型中使用的提升器自动选择适当的重要性类型。
为什么特征重要性很重要
了解特征重要性具有以下几个优点:
- 可解释性:了解哪些特征最具影响力可以帮助我们更好地理解模型。
- 特征选择:可以潜在地删除不重要的特征以简化模型。
- 领域理解:将模型的重要性尺度与特定领域的知识或直觉对齐。
保存和访问该图
此函数返回一个 Matplotlib 图形对象,您可以进一步操作或保存它。与之前的图一样,建议在 MLflow 中记录此图,以获取模型解释性特征的不可变记录。
浏览该图
在结果图中,每个条表示模型中使用的特征。条的长度对应于特征的重要性,由选定的重要性类型计算得出。
我们需要训练一个模型才能生成此图。因此,我们将在训练模型时生成该图,但不显示该图。生成的图形将被记录到 MLflow 中,并在 UI 中可见。
def plot_feature_importance(model, booster): # noqa: D417
"""
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 中的实验本质上是一组已命名的运行。实验中的每次运行都会跟踪其自身的参数、指标、标记和工件。
为什么要创建一个新实验?
- 组织:它有助于将我们的运行组织在特定的任务或项目下,从而更容易比较和分析结果。
- 隔离:通过将不同的任务或项目隔离到单独的实验中,我们可以防止意外覆盖或错误解释结果。
我们在下面定义的 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
用作实验的唯一标识符,使我们能够有效地隔离和管理不同的运行及其关联的数据。
数据预处理
接下来的步骤包括准备用于模型训练的数据集:
-
特征选择:我们从 DataFrame 中删除“日期”和“需求”列,仅保留
X
中的特征列。 -
目标变量:“需求”列被指定为我们的目标变量
y
。 -
数据拆分:我们使用 75-25 的拆分将数据集拆分为训练集(
train_x
、train_y
)和验证集(valid_x
、valid_y
)。 -
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_*
方法用于定义超参数可能值的范围。以下是每个超参数的作用:
objective
和eval_metric
:定义损失函数和评估指标。booster
:要使用的提升类型(gbtree
、gblinear
或dart
)。lambda
和alpha
:正则化参数。- 诸如
max_depth
、eta
和gamma
之类的其他参数特定于基于树的模型(gbtree
和dart
)。
模型训练
使用选择的超参数和预处理的训练数据集 (dtrain
) 训练 XGBoost 模型。对验证集 (dvalid
) 进行预测,并计算均方误差 (mse
)。
使用 MLflow 进行日志记录
所有选定的超参数和指标(mse
和 rmse
)都记录到 MLflow 中,以供以后分析和比较。
mlflow.log_params
:记录超参数。mlflow.log_metric
:记录指标。
为什么此函数很重要
- 自动调整:Optuna 自动执行查找最佳超参数的过程。
- 实验跟踪:MLflow 允许我们跟踪每次运行的超参数和性能指标,从而更易于分析、比较和重现以后的实验。
在下一步中,Optuna 将使用此目标函数来找到我们 XGBoost 模型的最佳超参数集。
内务处理:简化 Optuna 试验的日志记录
当我们开始使用 Optuna 进行超参数调整之旅时,必须了解该过程可以生成大量运行。实际上,如此之多以至于来自默认记录器的标准输出 (stdout) 会很快被淹没,从而产生一页又一页的日志报告。
虽然默认日志记录配置的详细程度在代码开发阶段无疑是有价值的,但启动全面试验可能会导致大量信息不堪重负。考虑到这一点,将每个细节记录到 stdout 中变得不那么实际,特别是当我们有像 MLflow 这样的专用工具来精心跟踪我们的实验时。
为了达到平衡,我们将使用回调来定制我们的日志记录行为。
实现日志记录回调:
我们将要介绍的回调将修改默认的报告行为。我们将仅在新的超参数组合产生优于迄今为止记录的最佳指标值时才接收更新,而不是记录每次试验。
这种方法提供了两个显着的好处:
-
增强的可读性:通过过滤掉大量的日志详细信息并仅关注显示改进的试验,我们可以衡量我们的超参数搜索的有效性。例如,如果我们观察到早期“最佳结果”报告的频率降低,则可能表明更少的迭代足以确定最佳超参数集。另一方面,持续的改进率可能表明我们的特征集需要进一步改进。
-
进度指示器:对于跨越数小时甚至数天的广泛试验尤其重要,接收定期更新可以确保该过程仍在进行中。这些“心跳”通知确认我们的系统正在努力工作,即使它没有每分钟详细信息都淹没 stdout。
此外,MLflow 的用户界面 (UI) 补充了这一策略。由于每个试验都已完成,因此 MLflow 会记录子运行,使其可以在父运行的保护下进行访问。
在接下来的代码中,我们将:
- 调整 Optuna 的日志记录级别以仅报告错误,从而确保消除 stdout 中的混乱。
- 定义一个
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 用于实验跟踪。
启动父运行
我们首先启动一个名为“最佳运行”的父 MLflow 运行。所有后续操作(包括 Optuna 的试验)都嵌套在此父运行下,从而提供了一种结构化的方式来组织我们的实验。
使用 Optuna 进行超参数调整
study = optuna.create_study(direction='minimize')
:我们创建一个 Optuna 研究对象,旨在最小化我们的目标函数。study.optimize(objective, n_trials=10)
:目标函数在 10 次试验中进行了优化。
记录最佳参数和指标
在 Optuna 找到最佳超参数后,我们将这些超参数以及最佳均方误差 (mse
) 和均方根误差 (rmse
) 记录到 MLflow 中。
记录其他元数据
使用 mlflow.set_tags
,我们记录其他元数据,例如项目名称、优化引擎、模型系列和特征集版本。这有助于更好地对模型运行的上下文进行分类和理解。
模型训练和工件日志记录
- 我们使用最佳超参数训练 XGBoost 模型。
- 生成各种图(与需求的 correlation coefficient、特征重要性和残差)并将其作为工件记录在 MLflow 中。
模型序列化和日志记录
最后,使用 mlflow.xgboost.log_model
将训练后的模型记录到 MLflow 中,以及一个示例输入和其他元数据。该模型存储在指定的工件路径中,并检索其 URI。
为什么此块至关重要
- 端到端工作流程:此代码块表示端到端机器学习工作流程,从超参数调整到模型评估和日志记录。
- 可重现性:记录有关模型的所有详细信息,包括超参数、指标和可视诊断,从而确保实验完全可重现。
- 分析和比较:通过将所有数据记录在 MLflow 中,可以更轻松地分析各种运行的性能,并选择最佳模型进行部署。
在接下来的步骤中,我们将探讨如何检索和使用记录的模型进行推理。
为模型运行设置描述性名称
在继续模型训练和超参数调整之前,最好为我们的 MLflow 运行分配一个描述性名称。此名称充当人类可读的标识符,使其更容易跟踪、比较和分析不同的运行。
命名运行的重要性:
-
按名称引用:虽然 MLflow 为每次运行提供唯一的标识键,如
run_id
,但拥有描述性名称可以进行更直观的引用,尤其是在使用特定 API 和导航 MLflow UI 时。 -
清晰度和上下文:精心选择的运行名称可以提供有关正在测试的假设或所做的特定修改的上下文,有助于理解特定运行的目的和基本原理。
-
自动命名:如果您未指定运行名称,MLflow 将为您生成一个唯一的趣味名称。但是,这可能缺乏手动选择名称的上下文和清晰度。
最佳实践:
在命名您的运行时,请考虑以下几点
- 与代码更改的相关性:名称应反映为该运行所做的任何代码或参数修改。
- 迭代运行:如果您正在迭代执行多个运行,最好为每次迭代更新运行名称,以避免混淆。
在后续步骤中,我们将为父运行设置一个名称。请记住,如果您多次执行模型训练,请考虑更新运行名称以使其更清晰。
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 中的 Artifact 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 模型执行批量推理。然后,我们将这些预测追加回原始数据集以进行比较、分析或进一步处理。
步骤说明:
-
创建 DMatrix:
batch_dmatrix = xgb.DMatrix(X)
:我们首先将特征 (X
) 转换为 XGBoost 优化的 DMatrix 格式。此数据结构专为 XGBoost 的效率和速度而设计。 -
预测:
inference = loaded.predict(batch_dmatrix)
:使用先前加载的模型 (loaded
),我们在整个数据集上执行批量推理。 -
创建新的 DataFrame:
infer_df = df.copy()
:我们创建原始 DataFrame 的副本,以确保我们没有修改原始数据。 -
追加预测:
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
日期 | 平均温度 | 降雨量 | 周末 | 假期 | 每公斤价格 | 促销 | 需求 | 前几天的需求 | competitor_price_per_kg | marketing_intensity | 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 的重要性。这些视觉效果充当宝贵的参考,捕获模型的状态、其性能以及可能影响模型性能指标的特征集的任何更改。
在本指南结束时,您应该对结构良好的机器学习工作流程有充分的了解。此基础不仅使您能够制作有效的模型,而且还确保从数据整理到预测的每个步骤都是透明、可重现且高效的。
感谢您陪伴我们完成这次全面的旅程。所收集的实践和见解无疑将在您未来的所有机器学习工作中发挥关键作用!