MLOps极致细节:11. MLFlow 超参数调参案例: 随机搜索调参(附代码)
本章节将详细解释如何通过随机搜索,基于MLFlow以及keras的端到端深度学习流程。相关主要代码参见search_random.py
文件。关于整个系列案例如何运行,请参见博客:MLOps极致细节:9. MLFlow 超参数调参案例综述(附代码)。相关代码参见此Gitee链接。
文章目录
1 代码的运行结果
在MLOps极致细节:9. MLFlow 超参数调参案例综述(附代码)中,我们解释了三种运行此代码的方式:
- 我们把此案例相关代码克隆到本地:
git clone https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git
。然后进入mlflow-ex-hyperparametertunning
文件夹,在terminal中运行py文件,比如python search_random.py
; - 我们把此案例相关代码克隆到本地:
git clone https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git
。然后再按照下面章节的步骤运行mlrun
指令(比如mlflow run -e random --experiment-id <individual_runs_experiment_id> ./mlflow-ex-hyperparametertunning/
); - 不用把代码克隆到本地。mlflow也支持直接跑git上的代码(比如
mlflow run -e random --experiment-id <individual_runs_experiment_id> https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git
)。
注意,如果你要运行起代码,请务必先参考上述文档的步骤。
假设我们已经顺利地运行代码。运行完后,我们会看到mlruns
这个文件夹。我们在terminal输入mlflow ui
,我们来看一下保存在mlruns
里面的数据。
说明:
- 这里的
new_run
就是我们新建的experiment的名字。experiment id是1; - 我们看到页面最下面有许多run的结果。这些run的行数应该等于我们这个程序随机搜索的次数(实际上还要加一,有一个run是初始化);
- 这些run的结果第一行是parent run,剩下的是child run;
当我们点击parent run(run的结果的第一行),出现如下图:
说明:
- 在
run command
一栏中,我们可以看到完整的指令:mlflow run https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git -v 01722e9a616bd0a354e9af15c0bf030429969ede -e random -b local -P epochs=32 -P max_p=2 -P max_runs=8 -P metric=rmse -P seed=97531 -P training_data=http://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-white.csv
。由于我们是省略了所有的参数手动输入,而选择默认值,所以我们输入的指令就简短了许多:mlflow run -e random --experiment-id <individual_runs_experiment_id> https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git
; - 点开
tag
,记录了best run的run id。具体怎么写进去的在之后的代码中有详细讲解。
现在,我们回到上一个界面,然后选择需要比较的child run结果,如下图
然后点击Compare
按钮。我们能看到下图
有意思的地方是,MLFlow给了我们一些图标显示,这点非常不错。将页面往下拉,我们可以比较超参数lr
和test_rmse
的关系:
以及另一个超参数momentum
和test_rmse
的关系:
还有更有意思的。如果我们选择Parallel Coordinates Plot
,然后选择如下图:
你能够非常直观地看到不同的lr
和momentum
值对train_rmse
,validate_rmse
,test_rmse
的影响。从中,我们找到有两个run其实都还可以。然后我们分别点进去,观察每一个echo下来train_rmse
,validate_rmse
,test_rmse
的值:
2 参数的设定
在search_random.py
中,参数相关的设定代码如下:
@click.command(help="Perform grid search over train (main entry point).")
@click.option("--max-runs", type=click.INT, default=32, help="Maximum number of runs to evaluate.")
@click.option("--max-p", type=click.INT, default=1, help="Maximum number of parallel runs.")
@click.option("--epochs", type=click.INT, default=50, help="Number of epochs")
@click.option("--metric", type=click.STRING, default="rmse", help="Metric to optimize on.")
@click.option("--seed", type=click.INT, default=97531, help="Seed for the random generator")
@click.option("--training-data", type=click.STRING, default="http://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-white.csv",\
help="Input dataset link.")
关于click的使用说明,请参见我的博客:MLOps极致细节:10. MLFlow 超参数调参案例: 基于keras的端到端深度学习流程(附代码)。
在MLproject
文件中,我们也有相关的参数赋值:
# Use random search to optimize hyperparams of the train entry_point.
random:
parameters:
training_data: {type: string, default: "http://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-white.csv"}
max_runs: {type: int, default: 8}
max_p: {type: int, default: 2}
epochs: {type: int, default: 32}
metric: {type: string, default: "rmse"}
seed: {type: int, default: 97531}
command: "python search_random.py --training-data {training_data}
--max-runs {max_runs}
--max-p {max_p}
--epochs {epochs}
--metric {metric}
--seed {seed}"
所以,我们设定以下参数:
- training_data:训练数据的URI地址;
- max_runs:run的次数。每一个run的循环,我们将随机给
lr
和momentum
赋值; - max_p:多线程计算,每次循环可以并行计算几个线程;
- epochs:我们需要训练多少个loop;
- seed:一个随机数的选择,用于
np.random.seed(seed)
3 MLFlow Projects相关文件的介绍
关于MLFlow Projects
的详细介绍,请参见我之前的博客:MLOps极致细节:4. MLFlow Projects 案例介绍。关于此案例的MLproject
以及conda.yaml
文件介绍,请参见我的博客:MLOps极致细节:10. MLFlow 超参数调参案例: 基于keras的端到端深度学习流程(附代码)。
4 整体逻辑
整体上,search_random.py
的功能就是随机给lr
和momentum
赋值,然后再运行train.py
来进行模型的训练与验证。
我们先新建一个parent run
with mlflow.start_run() as run:
随机给lr
和momentum
赋值,并把这些值放进一个list中。这个list的格式类似[(X,X),(X,X),...,(X,X)]
. List的长度为max_runs
。
runs = [(np.random.uniform(1e-5, 1e-1), np.random.uniform(0, 1.0)) for _ in range(max_runs)]
然后我们打开多线程,对不同的lr
和momentum
值进行模型的训练:
with ThreadPoolExecutor(max_workers=max_p) as executor:
_ = executor.map(
new_eval(epochs, experiment_id, null_train_loss, null_val_loss, null_test_loss),
runs,
)
对于每一个训练,我们都会新建一个child run,然后运行train.py
的程序:
with mlflow.start_run(nested=True) as child_run:
p = mlflow.projects.run(
run_id=child_run.info.run_id,
uri=".",
entry_point="train",
parameters={
"training_data": training_data,
"epochs": str(nepochs),
"learning_rate": str(lr),
"momentum": str(momentum),
"seed": str(seed),
},
experiment_id=experiment_id,
synchronous=False,
)
运行完后,我们会选择validation rmse值最低的那个run最为best run:
for r in runs:
if r.data.metrics["val_rmse"] < best_val_valid:
best_run = r
best_val_train = r.data.metrics["train_rmse"]
best_val_valid = r.data.metrics["val_rmse"]
best_val_test = r.data.metrics["test_rmse"]
在代码中,我写了详细的注释,感兴趣的话可以过一遍代码。
5 ThreadPoolExecutor的使用
从Python3.2开始,标准库为我们提供了concurrent.futures
模块,它提供了ThreadPoolExecutor
(线程池)和ProcessPoolExecutor
(进程池)两个类。
这里我们不对ThreadPoolExecutor
本身做详细描述,感兴趣的朋友可以自行bing一下。这里列举一个ThreadPoolExecutor
和map
的例子。
map(fn, *iterables, timeout=None)
fn: 第一个参数 fn 是需要线程执行的函数;
iterables:第二个参数接受一个可迭代对象;
timeout: 第三个参数 timeout 跟 wait() 的 timeout 一样,但由于 map 是返回线程执行的结果,如果 timeout小于线程执行时间会抛异常 TimeoutError。
map的用法:
import time
from concurrent.futures import ThreadPoolExecutor
def spider(page):
time.sleep(page)
return page
start = time.time()
executor = ThreadPoolExecutor(max_workers=4)
i = 1
for result in executor.map(spider, [2, 3, 1, 4]):
print("task{}:{}".format(i, result))
i += 1
返回
task1:2
task2:3
task3:1
task4:4
6 new_eval函数的解读
其实new_eval
里面就一个eval
函数,我们看到,new_eval
返回的也是eval
。这么写的原因,又要回到上个段落ThreadPoolExecutor
中map
的写法:map(fn, *iterables, timeout=None)
。我们epochs, experiment_id, null_train_loss, null_val_loss, null_test_loss
这些参数都是不变的,变得是存在runs
里面的两个超参数。所以其实每次map的对象是eval
这个函数。以下为此函数的代码:
def eval(parms):
lr, momentum = parms
with mlflow.start_run(nested=True) as child_run:
p = mlflow.projects.run(
run_id=child_run.info.run_id,
uri=".",
entry_point="train",
parameters={
"training_data": training_data,
"epochs": str(nepochs),
"learning_rate": str(lr),
"momentum": str(momentum),
"seed": str(seed),
},
experiment_id=experiment_id,
synchronous=False,
)
succeeded = p.wait()
mlflow.log_params({"lr": lr, "momentum": momentum})
if succeeded:
training_run = tracking_client.get_run(p.run_id)
metrics = training_run.data.metrics
# cap the loss at the loss of the null model
train_loss = min(null_train_loss, metrics[train_metric])
val_loss = min(null_val_loss, metrics[val_metric])
test_loss = min(null_test_loss, metrics[test_metric])
else:
# run failed => return null loss
tracking_client.set_terminated(p.run_id, "FAILED")
train_loss = null_train_loss
val_loss = null_val_loss
test_loss = null_test_loss
mlflow.log_metrics(
{
"train_{}".format(metric): train_loss,
"val_{}".format(metric): val_loss,
"test_{}".format(metric): test_loss,
}
)
return p.run_id, train_loss, val_loss, test_loss
首先,当我们每次运行这个函数的时候,一个新的child run会被新建with mlflow.start_run(nested=True) as child_run:
,这也就是为什么我们运行完软件后,会在experiment id的文件夹下看到许多子文件夹的原因,每一个子文件夹都代表一个child run。
然后,系统就运行train.py
这个文件了,当然,也是以mlflow project的形式:mlflow.projects.run
。我们点进去看一下这个run函数的定义:
def run(
uri,
entry_point="main",
version=None,
parameters=None,
docker_args=None,
experiment_name=None,
experiment_id=None,
backend="local",
backend_config=None,
use_conda=True,
storage_dir=None,
synchronous=True,
run_id=None,
需要主义的是,除了uri
之外,其他的参数都有默认值。
这里需要注意uri的写法。
情况一:如果我们直接通过git来run这个MLFlow Project,即:mlflow run -e random --experiment-id <hyperparam_experiment_id> https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git
。这个情况下,设置 uri="."
,否则会报错。
情况二:如果我们先把这个git clone下来,然后在本地运行这个MLFlow Project,即:mlflow run -e random --experiment-id <hyperparam_experiment_id> ./mlflow-ex-hyperparametertunning
。这个情况下设置uri
为"."会报错:stderr: 'fatal: 'XXX/mlflow-ex-hyperparametertunning' does not appear to be a git repository
。这个情况下,设置 uri="https://gitee.com/yichaoyyds/mlflow-ex-hyperparametertunning.git"
。
如果run成功,那么最终函数返回p.run_id, train_loss, val_loss, test_loss
。