MLflow 与 Optuna:超参数优化和跟踪
构建生产级模型的关键部分是确保选择给定模型的参数以创建最佳推理集。然而,大量的组合及其结果指标可能会变得难以手动跟踪。这就是 MLflow 和 Optuna 等工具发挥作用的地方。
目标:
在本笔记本中,您将学习如何将 MLflow 与 Optuna 集成以进行超参数优化。我们将引导您完成以下过程:
- 使用 MLflow 跟踪设置您的环境。
- 生成我们的训练和评估数据集。
- 定义一个拟合机器学习模型的部分函数。
- 使用 Optuna 进行超参数调整。
- 利用 MLflow 中的子运行来跟踪超参数调整过程中的每次迭代。
为什么选择 Optuna?
Optuna 是一个基于 Python 的开源超参数优化框架。它提供了一种高效的超参数搜索方法,融合了最新的研究和技术。通过与 MLflow 的集成,每次试验都可以被系统地记录下来。
MLflow 中的子运行:
我们将强调的核心功能之一是 MLflow 中的“子运行”概念。在执行超参数调整时,Optuna 中的每次迭代(或试验)都可以被视为“子运行”。这使我们能够将所有运行归入一个主要的“父运行”下,确保 MLflow UI 保持有序且易于解释。每个子运行将跟踪所使用的特定超参数和结果指标,提供整个优化过程的综合视图。
未来展望:
数据准备:我们将首先加载和预处理数据集。
模型定义:定义一个我们旨在优化的机器学习模型。
Optuna 研究:设置 Optuna 研究以找到我们模型的最佳超参数。
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
日期 | 平均温度 | 降雨量 | 周末 | 假期 | 每公斤价格 | 促销 | 需求 | 前几天的需求 | 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 列
检查特征-目标相关性
在深入模型构建过程之前,了解特征与目标变量之间的关系至关重要。即将推出的函数将显示一个图表,指示每个特征与目标的相关系数。以下是为什么这一步至关重要:
-
避免数据泄漏:我们必须确保没有任何特征与目标完美相关(相关系数为 1.0)。如果存在这种相关性,则表明我们的数据集可能“泄漏”了有关目标的信息。使用此类数据进行超参数调整会误导模型,因为它可以在不真正学习底层模式的情况下轻松实现完美分数。
-
确保有意义的关系:理想情况下,我们的特征应该与目标具有一定程度的相关性。如果所有特征的相关系数都接近零,则表明线性关系较弱。尽管这并不会自动使特征变得无用,但它确实会带来挑战:
- 预测能力:模型可能难以做出准确的预测。
- 过拟合风险:相关性较弱时,模型拟合噪声而非真实模式的风险会增加,从而导致过拟合。
- 复杂性:演示特征之间的非线性关系或交互将需要更复杂的可视化和评估。
-
审计和可追溯性:将此相关性可视化与我们的主要 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
助推器)。 - 增益:特征在树中使用时对模型的平均增益(或改进)(对于其他助推器类型)。
我们根据 XGBoost 模型中使用的助推器自动选择适当的重要性类型。
为什么特征重要性很重要
了解特征重要性具有以下几个优点:
- 可解释性:了解哪些特征影响最大有助于我们更好地理解模型。
- 特征选择:不重要的特征可以被丢弃以简化模型。
- 领域理解:将模型的重要性尺度与领域特定知识或直觉对齐。
保存和访问图表
此函数返回一个 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 中的实验本质上是一组命名的运行。实验中的每次运行都会跟踪其自身的参数、指标、标签和工件。
为什么要创建新实验?
- 组织:它有助于将我们的运行组织在特定的任务或项目下,从而更容易比较和分析结果。
- 隔离:通过将不同的任务或项目隔离到单独的实验中,我们可以防止意外覆盖或误解结果。
我们下面定义的 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 中删除“date”和“demand”列,只保留
X
中的特征列。 -
目标变量:“demand”列被指定为我们的目标变量
y
。 -
数据拆分:我们将数据集拆分为训练(
train_x
、train_y
)和验证(valid_x
、valid_y
)集,使用 75-25 的拆分比例。 -
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)
: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 将为您生成一个独特的有趣名称。但是,这可能缺乏手动选择的名称的上下文和清晰度。
最佳实践:
命名运行时,请考虑以下事项:
- 与代码更改相关:名称应反映为该运行所做的任何代码或参数修改。
- 迭代运行:如果您正在迭代执行多个运行,最好为每次迭代更新运行名称,以避免混淆。
在后续步骤中,我们将为父运行设置一个名称。请记住,如果您多次执行模型训练,请考虑更新运行名称以提高清晰度。
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。我们没有使用提供通用 Python 函数接口的模型通用 pyfunc 加载器,而是使用 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 的重要性。这些视觉效果是宝贵的参考,捕捉了模型的状态、其性能以及可能影响模型性能指标的特征集的任何更改。
在本指南结束时,您应该对结构良好的机器学习工作流有一个扎实的理解。这一基础不仅使您能够构建有效的模型,而且还确保从数据整理到预测的每个步骤都是透明、可重现和高效的。
我们非常感谢您陪伴我们度过这段全面的旅程。所获得的实践和见解无疑将对您未来的所有机器学习工作至关重要!