今年夏天参与了一个我很喜欢的超参数框架 Optuna ( Optuna - A hyperparameter optimization framework)的文档翻译工作。现在翻译已经基本完成( https://zh-cn.optuna.org),而 Optuna 更成熟的 2.0 版本最近也要发布了。于是我们决定写一个介绍,希望让更多的中文用户了解和使用这个框架,并且能参与到社区中间来。
Tensorflow 和 Pytorch 已经将实现深度学习模型变成一个 10 分钟不到的过程:声明模型,定义参数、优化器,载入训练数据,启动梯度下降。然后一个接着一个 epoch, 模型在测试集上的精度稳步提升... 但是等等,在现实中,训练一个模型从来不会这么顺利,你总会碰到各种各样的问题,比如:
- 结果和paper上写的不一样(优化)
- 有些参数组合根本训练不出来,还把训练脚本搞崩溃了,你却不知道原因所在
- 训出来也不知道这个参数范围是否稳定(参数关系)
上面这些情况说明了超参数选择对于模型性能的重要性。于是为了调参,你开始手动往脚本里加内容:超参数优化不就是 for 循环里套 for 循环嘛,很简单。可很快,你又会其他碰到问题:
- 有一台好机器,for 循环一次却只能执行一个模型,浪费性能
- 优化完的参数输出到了一个 txt,还得自己写解析来分析
- for 循环里有些参数只是运气好,实际部署上去并不一样
总之你会碰到非常多问题,它们会浪费你的时间。而 Optuna 则是帮助你解决上面所有这些问题的一个工具,解放你的双手和时间,让你能更加专注于模型实现。它将定义一个超参数优化过程变得 非常简单,而且易于保存,方便分析,还支持无缝扩展。
Talk is cheap, show me the code. 我们将通过一个例子来展示 Optuna 的上述优点。
定义简单
一个极简的 optuna 的优化程序中只有三个最核心的概念,目标函数(objective),单次试验(trial),和研究(study). 其中 objective 负责定义待优化函数并指定参/超参数数范围,trial 对应着 objective 的单次执行,而 study 则负责管理优化,决定优化的方式,总试验的次数、试验结果的记录等功能。
假如要在 $x,y \in(-10,10)$ 的范围内找到 $f(x,y)=(x+y)^2$ 这个函数的最大值对应的 $x,y$,那我们只需要下面的代码:
import optuna
def objective(trial):
x = trial.suggest_uniform('x', -10, 10)
y = trial.suggest_uniform('y', -10, 10)
return (x + y) ** 2
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)
print(study.best_params)
print(study.best_value)
在上例中,我们首先定义了一个 objective. 在它内部, $f(x,y)=(x+y)^2$ 的结果被作为返回值,其参数空间的 x 和 y 从两个均匀分布中采样。
然后,Optuna 创建了一个“研究” (study), 指定了优化的方向为最大化并且最大实验次数为100,然后将目标函数传入其中,开始优化 (optimize) 过程。最后脚本输出在100次试验中找到的最佳参数组合。
命令行工具
由于 Optuna 还提供了命令行工具,上面的优化过程也可以通过在shell 中直接执行语句来进行。使用命令行工具的话,我们可以省略掉全部其他代码,只需在脚本中声明目标函数的定义即可。
然后,在终端中执行下面的 `optuna` 命令即可开始优化过程:
$ STUDY_NAME=`optuna create-study --storage sqlite:///example.db`
$ optuna study optimize foo.py objective --n-trials=100 --storage sqlite:///example.db --study-name $STUDY_NAME
这一过程和上面的脚本是完全等价的(其中 storage 的部分下文会介绍)。该命令的输出如下:
...
[I 2020-07-01 02:41:34,311] Trial 96 finished with value: 3.1406709262042694 and parameters: {'x': -7.882810516401339, 'y': 9.65500433373743}. Best is trial 92 with value: 383.4423553199605.
[I 2020-07-01 02:41:34,314] Trial 97 finished with value: 353.54920261705433 and parameters: {'x': -8.876138448320777, 'y': -9.926765652297679}. Best is trial 92 with value: 383.4423553199605.
[I 2020-07-01 02:41:34,316] Trial 98 finished with value: 319.81596197762224 and parameters: {'x': -9.502045119809319, 'y': -8.381353941264676}. Best is trial 92 with value: 383.4423553199605.
[I 2020-07-01 02:41:34,319] Trial 99 finished with value: 295.705727918292 and parameters: {'x': -8.387075346077019, 'y': -8.809020952743014}. Best is trial 92 with value: 383.4423553199605.
{'x': -9.984261379947354, 'y': -9.59742279991727}
383.4423553199605
你可能感觉上面的代码和命令,尤其是目标函数的定义平平无奇,因为它构造优化过程的方式非常自然,非常 pythonic. 作为对比,让我们看看另一个超参数优化框架 Hyperopt 实现同样功能需要怎么写:
from hyperopt import hp
from hyperopt import fmin, tpe, space_eval, Trials
# define an objective function
def objective(args):
return -(args['x'] + args['y'])**2
space = {
"x": hp.uniform('x', -10, 10),
"y": hp.uniform('y', -10, 10),
}
trials = Trials()
best = fmin(objective, space, algo=tpe.suggest, max_evals=100, trials=trials)
print(best)
print(space_eval(space, best))
首先,参数空间必须在目标函数外部定义。其次,要存储试验记录的话,需要在外部单独实例化一个 `Trials` 对象。最后,如果需要最大化目标函数的话,你只能在返回值上添加一个负号,因为
>>> from hyperopt import fmax
Traceback (most recent call last):
File "", line 1, in
ImportError: cannot import name 'fmax' from 'hyperopt'
总的来说,Hyperopt 还算不错,但是从易用性上来说,显然 Optuna 还是更胜一筹。
但你可能问,就这?不就是多写两行代码的事情吗?当然不是了,上面只是一个 toy model, 实际上 Optuna 有更多的特性让它在真实的超参数优化环境中非常好用。
易于保存
出于各种目的,我们经常有保存优化过程的需求。比如你可能需要追踪或者debug 一个目标函数的优化过程,比如目标函数的参数空间太大,而一旦机器崩溃,你的优化过程必须从头再来。又或者,你想实现多台机器并行优化一个目标函数,这时候一个能保存优化试验历史并且能从中恢复/继续优化的特性就显得尤其重要。而 Optuna 支持这种特性。
初始化记录
默认情况下,Optuna 使用内存存储来记录试验过程。但是如果在创建 study 时添加一个 storage 参数,Optuna 可以根据你的参数类型使用 SQLite, MySQL 或者 Redis 等流行的数据库来记录你的试验历史。下面是一个使用SQLite 的例子
study_name = 'example-study' # 不同的 study 不能使用相同的名字。因为当存储在同一个数据库中时,这是区分不同 study 的标识符.
study = optuna.create_study(study_name=study_name, storage='sqlite:///example.db')
study.optimize(objective, n_trials=300)
假如 'sqlite:///example.db' 这一 URL 对应的数据库文件不存在,Optuna将创建一个对应的数据库文件并开始新的优化过程。
现在,假设优化过程被打断了,你完全可以重新运行上面的脚本来从上次中断的位置继续优化。只要 optuna 监测到 `'sqlite:///example.db'` 在路径上存在且该数据库中有 study_name 为 'example-study' 的记录,它就会继续未完成的优化过程。
存储有了,如何提取呢?无需手动进数据库写 query 语句,你只需用一行命令即可提取所有的试验记录
df = study.trials_dataframe(attrs=('number', 'value', 'params', 'state'))
这个语句会返回一个 pandas dataframe
# 来自第一个例子
number value params_x params_y state
0 0 97.081081 -4.012360 -5.840613 COMPLETE
1 1 110.359399 8.495576 2.009632 COMPLETE
2 2 270.740964 -9.909762 -6.544446 COMPLETE
3 3 41.726647 -6.698396 0.238780 COMPLETE
4 4 12.735788 4.018620 -7.587344 COMPLETE
.. ... ... ... ... ...
在上面介绍的基础上,Optuna 还提供了一系列可视化接口,让查看不同参数之间的关系变得非常容易。由于集成到了库方法中,过去需要专门写一个脚本来做的事情现在只需要一两行命令即可。
等高线图
在 study.optimize 执行结束以后,通过调用 optuna.visualization.plot_contour,并将 study 和需要可视化的参数传入该方法,Optuna 将返回一张等高线图。例如,当在上面的例子中,我们想要查看参数 x 和 y 的关系以及它们对于函数值贡献的话,只需要执行下面的语句即可:
optuna.visualization.plot_contour(study, params=['x', 'y'])
# 如果不指定 params 也是可以的,optuna 将画出所有的参数之间的关系,在这里两者等价。
optuna.visualization.plot_contour(study)
除了等⾼线图之外,Optuna 还提供了⼀系列其他⾮常实⽤的可视化选项,⽐如每个 trial 训练的中间值折线图。 这对理解优化过程细节⾮常有用。下面的折线图来自于 Optuna 的官方文档。
类似地,Optuna 还提供了诸如优化过程历史记录和多维度参数-目标函数关系等绘图接口。
多维度参数-目标函数关系图非常有用,和前面的等高线图不同,多维度参数-目标函数关系图一次性可以展示任意多个参数和目标函数值之间的关系。
其实在上述接口之外,Optuna 还提供了一系列其他绘图接口,比如超参数重要性评估图,参数关系切片图等。具体细节参见其中文文档的API reference-Visualization 部分。
不难看出,作为一个超参数优化框架,Optuna 使用起来很简单,其提供的存储接口、可视化套件也让用户对优化过程进行记录和分析变得非常容易。
其实,这些特征中的每一个都只是解决了超参数优化过程中一个或者数个很小的“痒点”,但是至少在我看来,它们的组合却让 Optuna 变成一个非常适于快速上手的全功能框架。
而且,本文中展示的特性仅仅是 Optuna 全部特性的一小部分。介绍它们只是为了方便你快速上手 Optuna。因为在这些基础特性之外,它还提供了以下功能:
- 极其方便的分布式优化(同一机器上不同线程或者不同机器节点通过关系型数据库共享优化过程)
- 细致的剪枝过程(Optuna 提供了一系列的各种剪枝算法,能提前清除 trial 中无望的部分,节省计算资源)
- 插件机制(Optuna 提供了一系列插件,能和目前市面上流行的很多机器学习框架,比如 Tensorflow 和 Pytorch 等深度整合,让用户无需改变太多现存代码,便可将 Optuna 整合进老模型的优化中)
这些功能我们将在下一篇文章中一一介绍。如果你已经迫不及待了,也可以直接参考 Optuna 的官方文档和对应的中文翻译。
哪里可以找到 OPtuna?
Optuna 官网:https://optuna.org
Optuna 中文文档:https://zh-cn.optuna.org