贝叶斯优化

1 贝叶斯优化的基本原理

贝叶斯优化方法是当前超参数优化领域的SOTA手段,可以被认为是当前最为先进的优化框架,它可以被应用于AutoML的各大领域,不止限于超参数搜索HPO的领域,更是可以被用于神经网络架构搜索NAS以及元学习等先进的领域。现代几乎所有在效率和效果上取得优异成果的超参数优化方法都是基于贝叶斯优化的基本理念而形成的,因此贝叶斯优化是整个AutoML中学习的重点。
然而,虽然贝叶斯优化非常强大,但整体的学习难度却非常高。在学习贝叶斯优化之前,学习者不仅需要充分理解机器学习的主要概念和算法、熟悉典型的超参数优化流程,还需要对部分超出微积分、概率论和线性代数的数学知识有所掌握。特别的是,贝叶斯优化算法本身,与贝叶斯优化用于HPO的过程还有区别。在我们课程有限的时间内,我将重点带大家来看贝叶斯优化用于HPO的核心过程。
首先,我们不理会HPO的问题,先来看待下面的例子。假设现在我们知道一个函数\(f(x)\)以及其自变量\(x\)的定义域,并且我们知道函数\(f(x)\)是相对均匀、平稳的函数,并不会出现突然升高或突然下降的情况。现在,我们希望求解出\(x\)的取值范围上\(f(x)\)的最小值,你打算如何求解这个最小值呢?

面对这一问题,我们有一个通俗的解法,那就是将全域的\(x\)带入\(f(x)\)计算出所有\(f(x)\)可能的结果,一旦整个\(f(x)\)的结果被计算出来,那\(f(x)\)的最小值也就一目了然了。

然而,现在的函数\(f(x)\)是一个异常复杂的函数,计算量非常大、需要的时间也很长,同时我们的计算资源非常紧张,最多只能在函数\(f(x)\)上尝试20次左右,所有我们不太可能将全域\(x\)都带入进行计算,然后再求解最小值。于是我们选择在\(x\)的定义域上随机选择了4个点,并将4个点带入\(f(x)\)进行计算,得到了如下结果:

01

好了,现在有了这4个观测值,你能告诉我\(f(x)\)的最小值在哪里吗?你认为最小值点可能在哪里呢?大部分人会倾向于认为,最小值点在已观测出的\(f(x)\)值中,最小的那个值的附近,但也有许多人不这么认为。当我们有了4个观测值,并且知道我们的函数时相对均匀、平稳的函数,那我们可能对函数的整体分布有如下猜测:

02

当我们对函数整体分布有一个猜测时,这个分布上一定会存在该函数的最小值。同时,不同的人可能对函数的整体分布有不同的猜测,不同猜测下对应的最小值也是不同的。

03

04

现在,假设我们邀请了数万个人对该问题做出猜测,每个人所猜测的曲线如下图所示。不难发现,在观测点的附近,每个人猜测的函数值差距不大,但是在远离远侧点的地方,每个人猜测的函数值就高度不一致了。这也是当然的,因为观测点之间函数的分布如何完全是未知的,并且该分布离观测点越远时,我们越不确定真正的函数值在哪里,因此人们猜测的函数值的范围非常巨大。
05
现在,我们将所有猜测求均值,并将任意均值周围的潜在函数值所在的区域用色块表示,可以得到一条所有人猜测的平均曲线。不难发现,色块所覆盖的范围其实就是大家猜测的函数值的上界和下界,而任意\(x\)所对应的上下界差异越大,表示人们对函数上该位置的猜测值的越不确定。因此上下界差异可以衡量人们对该观测点的置信度

在观测点周围,置信度总是很高,远离观测点的地方,置信度总是很低,所以如果我们能够在置信度很低的地方补充一个实际的观测点,我们就可以很快将众人的猜测统一起来。当整个函数上的置信度都非常高时,我们可以说我们得出了一条与真实的\(f(x)\)曲线高度相似的曲线。并且,观测点越多,我们估计出的曲线会越接近真实的\(f(x)\)。然而,由于计算量有限,我们每次进行观测时都要非常谨慎地选择观测点。那现在,如何选择观测点才能够最大程度地帮助我们找到\(f(x)\)上的最小值呢?

有非常多的方法,其中最简单的手段是使用最小值出现的频数进行判断。由于不同的人对函数的整体分布有不同的猜测,不同猜测下对应的最小值也是不同的,根据每个人猜测的函数结果,我们在\(X\)轴上划将定义域区间进行均匀的划分,如果有某个猜测的最小值落在其中一个区间中,我们就对该区间进行计数。当有数万个人进行猜测之后,我们同时也绘制了基于\(X\)轴上不同区间的频数图,频数越高,说明猜测最小值在该区间内的人越多,反之则说明该猜测最小值在该区间内的人越少。

当我们绘制出频数图之后,很明显,我们知道最小值最有可能在的区间就在x=0.7左右的位置。当我们不取新的观测点时,现在\(f(x)\)上可以获得的可靠的最小值就是x=0.6时的点,但我们如果在x=0.7处取新的观测值,我们就很有可能找到比当前x=0.6的点还要小的\(f_min\)。因此,我们可以就此决定,在x=0.7处进行观测。

当我们在x=0.7处取出观测值之后,我们就有了5个已知的观测点。现在,我们再让数万人根据5个已知的观测点对整体函数分布进行猜测,猜测完毕之后再计算当前最小值频数最高的区间,然后再取新的观测点对\(f(x)\)进行计算。当允许的计算次数被用完之后(比如,20次),整个估计也就停止了。
你发现了吗?在这个过程当中,我们其实在不断地优化我们对目标函数\(f(x)\)的假设,虽然没有对\(f(x)\)进行全部定义域上的计算,也没有找到最终确定一定是\(f(x)\)分布的曲线,但是随着我们观测的点越来越多,我们对函数的估计是越来越准确的,因此也有越来越大的可能性可以估计出\(f(x)\)真正的最小值。这个优化的过程,就是贝叶斯优化

在贝叶斯优化的数学过程当中,我们主要执行以下几个步骤:

  • 1 定义需要估计的\(f(x)\)以及\(x\)的定义域

  • 2 取出有限的n个\(x\)上的值,求解出这些\(x\)对应的\(f(x)\)(求解观测值)

  • 3 根据有限的观测值,对函数分布进行假设(该假设被称为贝叶斯优化中的先验知识),得出该假设分布上的目标值

  • 4 定义某种规则,确定下一个需要计算的观测点

并持续在2-4步骤中进行循环,直到假设分布上的目标值达到我们的标准,或者所有计算资源被用完为止。以上流程又被称为序贯模型优化(SMBO),是最为经典的贝叶斯优化方法。

在实际的运算过程当中,尤其是超参数优化的过程当中,有以下具体细节需要注意:

  • 当贝叶斯优化不被用于HPO时,一般𝑓(𝑥)可以是完全的黑盒函数(black box function,也译作黑箱函数,即只知道𝑥与𝑓(𝑥)的对应关系,却丝毫不知道函数内部规律、同时也不能写出具体表达式的一类函数),因此贝叶斯优化也被认为是可以作用于黑盒函数估计的一类经典方法。但在HPO过程当中,需要定义的𝑓(𝑥)一般是交叉验证的结果/损失函数的结果,而我们往往非常清楚损失函数的表达式,只是我们不了解损失函数内部的具体规律,因此HPO中的𝑓(𝑥)不能算是严格意义上的黑盒函数。

  • 在HPO中,自变量𝑥就是超参数空间。在上述二维图像表示中,𝑥为一维的,但在实际进行优化时,超参数空间往往是高维且极度复杂的空间。

  • 最初的观测值数量n、以及最终可以取到的最大观测数量m都是贝叶斯优化的超参数,最大观测数量m也决定了整个贝叶斯优化的迭代次数。

  • 在第3步中,根据有限的观测值、对函数分布进行估计的工具被称为概率代理模型(Probability Surrogate model),毕竟在数学计算中我们并不能真的邀请数万人对我们的观测点进行连线。这些概率代理模型自带某些假设,他们可以根据廖廖数个观测点估计出目标函数的分布𝑓∗(包括𝑓∗上每个点的取值以及该点对应的置信度)。在实际使用时,概率代理模型往往是一些强大的算法,最常见的比如高斯过程、高斯混合模型等等。传统数学推导中往往使用高斯过程,但现在最普及的优化库中基本都默认使用基于高斯混合模型的TPE过程。(高斯过程估计均值方差来得到𝑓∗上每个点的取值以及该点对应的置信度)

  • 在第4步中用来确定下一个观测点的规则被称为采集函数(Aquisition Function),采集函数衡量观测点对拟合𝑓∗所产生的影响,并选取影响最大的点执行下一步观测,因此我们往往关注采集函数值最大的点。最常见的采集函数主要是概率增量PI(Probability of improvement,比如我们计算的频数)、期望增量(Expectation Improvement)、置信度上界(Upper Confidence Bound)、信息熵(Entropy)等等。上方gif图像当中展示了PI、UCB以及EI。其中大部分优化库中默认使用期望增量。

在HPO中使用贝叶斯优化时,我们常常会看见下面的图像,这张图像表现了贝叶斯优化的全部基本元素,我们的目标就是在采集函数指导下,让𝑓∗尽量接近𝑓(𝑥)。
image
真正的f(x)(虚线)上取两个观测值,利用概率代理模型来推测出估计的f(实线)和置信度。根据概率代理模型所输出的结果(f)计算出采集函数,采集函数最大值所对应的x点就是下一个观测点。

现在我们已经了解贝叶斯优化的基本流程。与许多算法一样,基础流程足以支撑我们使用已经搭建好的优化库进行超参数优化了,即便我们没有对优化原理的每个细节都了如指掌,我们也可以通过实验反馈出的结果来直接判断是否应该调整我们的代码。接下来,我们会先学习如何应用贝叶斯优化的各类库实现不同的贝叶斯优化算法。

2 贝叶斯优化的实现

贝叶斯优化是当今黑盒函数估计领域最为先进和经典的方法,在同一套序贯模型下使用不同的代理模型以及采集函数、还可以发展出更多更先进的贝叶斯优化改进版算法,因此,贝叶斯优化的其算法本身就多如繁星,实现各种不同种类的贝叶斯优化的库也是琳琅满目,几乎任意一个专业用于超参数优化的工具库都会包含贝叶斯优化的内容。我们可以在以下页面找到大量可以实现贝叶斯优化方法的HPO库:AutoML | Overview of HPO Packages ,其中大部分库都是由独立团队开发和维护,因此不同的库之间之间的优劣、性格、功能都有很大的差异。在课程中,我们将介绍如下三个可以实现贝叶斯优化的库:bayesian-optimization,hyperopt,optuna。
image
注意,以上三个库都不支持基于Python环境的并行或加速,大多数优化算法库只能够支持基于数据库(如MangoDB,mySQL)的并行或加速,但以上库都可以被部署在分布式计算平台。

  • Bayes_opt
!pip install bayesian-optimization
或
!conda install -c conda-forge bayesian-optimization
  • Hyperopt
#!pip install hyperopt
  • Optuna
!pip install optuna
或
!conda install -c conda-forge optuna
  • Skopt(作为Optuna辅助包安装,也可单独使用)
!pip install scikit-optimize

接下来我们会分别使用三个库来实现贝叶斯优化。在课程中,我们依然使用集成算法中的房价数据作为验证数据,并且呈现出我们之前在不同优化方法上得出的结果作为对比。同时,我们将使用与集成算法中完全一致的随机数种子、以及随机森林算法作为被优化的评估器。

  • 导入库,确认使用数据
#基本工具
import numpy as np
import pandas as pd
import time
import os #修改环境设置
 
#算法/损失/评估指标等
import sklearn
from sklearn.ensemble import RandomForestRegressor as RFR
from sklearn.model_selection import KFold, cross_validate
 
#优化器
from bayes_opt import BayesianOptimization
 
import hyperopt
from hyperopt import hp, fmin, tpe, Trials, partial
from hyperopt.early_stop import no_progress_loss
 
import optuna
print(optuna.__version__)
#3.2.0
 
print(hyperopt.__version__)
#0.2.7


data=pd.read_csv("train_encode.csv",index_col=0)
X = data.iloc[:,:-1]
y = data.iloc[:,-1]
X.head()
X.shape

image

确认该数据集上的历史成果
image

2.1 基于Bayes_opt实现GP优化

bayes-optimization是最早开源的贝叶斯优化库之一,也是为数不多至今依然保留着高斯过程优化的优化库。由于开源较早、代码简单,bayes-opt常常出现在论文、竞赛kernels或网络学习材料当中,因此理解Bayes_opt的代码是极其重要的课题。不过,bayes-opt对参数空间的处理方式较为原始,也缺乏相应的提效/监控功能,对算力的要求较高,因此它往往不是我们进行优化时的第一首选库。通常来说,当且仅当我们必须要实现基于高斯过程的贝叶斯优化,且算法的参数空间中带有大量连续型参数时,我们才会优先考虑Bayes_opt库。 我们可以在github上找到bayes-optmization的官方文档(https://github.com/fmfn/BayesianOptimization) ,想要进一步了解其基本功能与原理的小伙伴可以进行阅读。

from bayes_opt import BayesianOptimization
  • 1 定义目标函数
    目标函数的值即𝑓(𝑥)的值。贝叶斯优化会计算𝑓(𝑥)在不同𝑥上的观测值,因此𝑓(𝑥)的计算方式需要被明确。在HPO过程中,我们希望能够筛选出令模型泛化能力最大的参数组合,因此𝑓(𝑥)应该是损失函数的交叉验证值或者某种评估指标的交叉验证值。 需要注意的是,bayes_opt库存在三个影响目标函数定义的规则:

1.目标函数的输入必须是具体的超参数,而不能是整个超参数空间, 更不能是数据、算法等超参数以外的元素,因此在定义目标函数时,我们需要让超参数作为目标函数的输入。

2.超参数的输入值只能是浮点数,不支持整数与字符串。 因此当算法的实际参数需要输入字符串时,该参数不能使用bayes_opt进行调整,当算法的实际参数需要输入整数时,则需要在目标函数中规定参数的类型。

3.bayes_opt只支持寻找𝑓(𝑥)的最大值,不支持寻找最小值。 因此当我们定义的目标函数是某种损失时,目标函数的输出需要取负(即,如果使用RMSE,则应该让目标函数输出负RMSE,这样最大化负RMSE后,才是最小化真正的RMSE。)当我们定义的目标函数是准确率,或者auc等指标,则可以让目标函数的输出保持原样。

def bayesopt_objective(n_estimators,max_depth,max_features,min_impurity_decrease):
    
    #定义评估器
    #需要调整的超参数等于目标函数的输入,不需要调整的超参数则直接等于固定值
    #默认参数输入一定是浮点数,因此需要套上int函数处理成整数
    reg = RFR(n_estimators = int(n_estimators)
              ,max_depth = int(max_depth)
              ,max_features = int(max_features)
              ,min_impurity_decrease = min_impurity_decrease
              ,random_state=1412
              ,verbose=False #可自行决定是否开启森林建树的verbose
              ,n_jobs=-1)
    
    #定义损失的输出,5折交叉验证下的结果,输出负根均方误差(-RMSE)
    #注意,交叉验证需要使用数据,但我们不能让数据X,y成为目标函数的输入
    cv = KFold(n_splits=5,shuffle=True,random_state=1412)
    validation_loss = cross_validate(reg,X,y
                                     ,scoring="neg_root_mean_squared_error"
                                     ,cv=cv
                                     ,verbose=False
                                     ,n_jobs=-1
                                     ,error_score='raise'
                                     #如果交叉验证中的算法执行报错,则告诉我们错误的理由
                                    )
    
    #交叉验证输出的评估指标是负根均方误差,因此本来就是负的损失
    #目标函数可直接输出该损失的均值
    return np.mean(validation_loss["test_score"])

image

  • 2 定义参数空间
    在任意超参数优化器中,优化器会将参数空格中的超参数组合作为备选组合,一组一组输入到算法中进行训练。在贝叶斯优化中,超参数组合会被输入我们定义好的目标函数𝑓(𝑥)中。

在bayes_opt中,我们使用字典方式来定义参数空间,其中参数的名称为键,参数的取值范围为值。且任意参数的取值范围为双向闭区间, 以下方的空间为例,在n_estimators的取值中,80与100都可以被取到。

以下参数空间与我们在随机森林中获得最高分的随机搜索的范围高度相似。

param_grid_simple = {
'n_estimators': (80,100),
'max_depth':(10,25),
"max_features": (10,20), "min_impurity_decrease":(0,1)
}

image

需要注意的是,bayes_opt只支持填写参数空间的上界与下界,不支持填写步长等参数,且bayes_opt会将所有参数都当作连续型超参进行处理,因此bayes_opt会直接取出闭区间中任意浮点数作为备选参数。例如,取92.28作为n_estimators的值。

这也是为什么在目标函数中,我们需要对整数型超参的取值都套上int函数。假设优化器取出92.28作为n_estimators的值,实际传入随机森林算法的会是int(92.28) = 92,如此我们可以保证算法运行过程中不会因参数类型不符而报错。也因为bayes_opt的这个性质,输入bayes_opt的参数空间天生会比其他贝叶斯优化库更大/更密,因此需要的迭代次数也更多。

  • 3 定义优化目标函数的具体流程
    在有了目标函数与参数空间之后,我们就可以按bayes_opt的规则进行优化了。在任意贝叶斯优化算法的实践过程中,一定都有涉及到随机性的过程——例如,随机抽取点作为观测点,随机抽样部分观测点进行采集函数的计算等等。在大部分优化库当中,这种随机性是无法控制的,即便允许我们填写随机数种子,优化算法也不能固定下来。因此我们可以尝试填写随机数种子,但需要记住优化算法每次运行时一定都会不一样。

虽然,优化算法无法被复现,但是优化算法得出的最佳超参数的结果却是可以被复现的。只要优化完毕之后,可以从优化算法的实例化对象中取出最佳参数组合以及最佳分数,该最佳参数组合被输入到交叉验证中后,是一定可以复现其最佳分数的。如果没能复现最佳分数,则是交叉验证过程的随机数种子设置存在问题,或者优化算法的迭代流程存在问题。(GridSearchCV--自己帮我们分割数据集、自己帮我们进行交叉验证,自己求解出结果,通常会获取最佳参数自己(分割数据集、进行交叉验证,求解出结果)再验证一次,所以搜索最优和重建最优是不同的。然而对于bayes优化而言没有GridSearchCV搜索最优和重建最优是相同的)

def param_bayes_opt(init_points,n_iter):
    
    #定义优化器,先实例化优化器
    opt = BayesianOptimization(bayesopt_objective #需要优化的目标函数
                               ,param_grid_simple #备选参数空间
                               ,random_state=1412 #随机数种子,虽然无法控制住
                              )
    
    #使用优化器,记住bayes_opt只支持最大化
    opt.maximize(init_points = init_points #抽取多少个初始观测值
                 , n_iter=n_iter #一共观测/迭代多少次
                )
    
    #优化完成,取出最佳参数与最佳分数
    params_best = opt.max["params"]
    score_best = opt.max["target"]
    
    #打印最佳参数与最佳分数
    print("\n","\n","best params: ", params_best,
          "\n","\n","best cvscore: ", score_best)
    
    #返回最佳参数与最佳分数
    return params_best, score_best

image

  • 4 定义验证函数(非必须)
    优化后的结果是可以复现的,即我们可以对优化算法给出的最优参数进行再验证,其中验证函数与目标函数高度相似,输入参数或超参数空间、输出最终的损失函数结果。在使用sklearn中自带的优化算法时,由于优化算法自己会执行分割数据、交叉验证的步骤,因此优化算法得出的最优分数往往与我们自身验证的分数不同(因为交叉验证时的数据分割不同)。然而在贝叶斯优化过程中,目标函数中的交叉验证即数据分割都是我们自己规定的,因此原则上来说,只要在目标函数中设置了随机数种子,贝叶斯优化给出的最佳分数一定与我们验证后的分数相同,所以当你对优化过程的代码比较熟悉时,可以不用进行二次验证。
def bayes_opt_validation(params_best):
    
    reg = RFR(n_estimators = int(params_best["n_estimators"]) 
              ,max_depth = int(params_best["max_depth"])
              ,max_features = int(params_best["max_features"])
              ,min_impurity_decrease = params_best["min_impurity_decrease"]
              ,random_state=1412
              ,verbose=False
              ,n_jobs=-1)
 
    cv = KFold(n_splits=5,shuffle=True,random_state=1412)
    validation_loss = cross_validate(reg,X,y
                                     ,scoring="neg_root_mean_squared_error"
                                     ,cv=cv
                                     ,verbose=False
                                     ,n_jobs=-1
                                    )
    return np.mean(validation_loss["test_score"])

image

  • 5 执行实际优化流程
start = time.time()
params_best, score_best = param_bayes_opt(20,280) #初始看20个观测值,后面迭代280次
print('It takes %s minutes' % ((time.time() - start)/60))
validation_score = bayes_opt_validation(params_best)
print("\n","\n","validation_score: ",validation_score)
# |   iter    |  target   | max_depth | max_fe... | min_im... | n_esti... |
# -------------------------------------------------------------------------
# |  1        | -2.948e+0 |  23.2     |  17.52    |  0.06379  |  88.79    |
# |  2        | -2.909e+0 |  14.8     |  17.61    |  0.9214   |  97.58    |
# |  3        | -2.9e+04  |  15.86    |  15.56    |  0.2661   |  87.98    |
# |  4        | -2.887e+0 |  14.05    |  16.84    |  0.06744  |  89.72    |
#  ......
# |  297      | -2.895e+0 |  22.89    |  19.95    |  0.6566   |  91.44    |
# |  298      | -2.975e+0 |  10.13    |  13.87    |  0.9502   |  81.74    |
# |  299      | -2.946e+0 |  16.58    |  15.55    |  0.01365  |  99.89    |
# |  300      | -2.888e+0 |  24.79    |  19.88    |  0.8136   |  88.76    |
# =========================================================================
 
 
#  best params:  {'max_depth': 22.76987255830374, 'max_features': 14.139404019924546, 'min_impurity_decrease': 0.0, 'n_estimators': 89.82134842869006} 
#  best cvscore:  -28346.672687223065
# It takes 2.107581957181295 minutes
#  validation_score:  -28346.672687223065

image

  • 原理上有优越性
    可以看到,基于高斯过程的贝叶斯优化在2.11分钟内锁定了最佳分数28346.673,这是之前使用随机搜索时获得的最佳分数,很可能也是我们当前超参数空间上可以获得的最佳分数。贝叶斯优化作为从原理上高于网格优化的HPO方法,能够以更短的时间获得与随机网格搜索相同的结果,可见其原理上的优越性。

  • 优化过程无法复现,但优化结果可以复现
    但同时要注意,由于贝叶斯优化每次都是随机的,因此我们并不能在多次运行代码时复现出28346.673这个结果,事实上如果我们重复运行,也只有很小的概率可以再次找到这个最低值(这一点对于随机搜索来说也是类似的,如果不规定随机数种子,我们也无法复现最低值)。因此我们在执行贝叶斯优化时,往往会多运行几次观察模型找出的结果。同时,验证分数与目标函数最后输出的分数一模一样,可见最终输出的超参数组合的效力是可以复现的。

  • 效率不足
    不难发现,bayes_opt的速度虽然快,效率却不高。实际上在迭代到170次时,贝叶斯优化就已经找到了最小损失,但由于没有提前停止机制,模型还持续地迭代了130次才停下,如果bayes_opt支持提前停止机制,贝叶斯优化所需的实际迭代时间可能会更少。同时,由于Bayes_opt只能够在参数空间提取浮点数,bayes_opt在随机森林上的搜索效率是较低的,即便在10次不同的迭代中分别取到了[88.89, 88.23, 88.16, 88.59……]等值,在取整之后也只能够获得一个备选值88,但bayes_opt无法辨别这种区别,因此可能取出了众多无效的观测点。如果使用其他贝叶斯优化器,贝叶斯优化的效率将会更高。

支持灵活修改
虽然在我们的代码中没有体现,但bayes_opt是支持灵活修改采集函数与高斯过程中的种种参数的,具体可以参考这里:BayesianOptimization/advanced-tour.ipynb at master· fmfn/BayesianOptimization·GitHub

2.2 基于HyperOpt实现TPE优化

Hyperopt优化器是目前最为通用的贝叶斯优化器之一,Hyperopt中集成了包括随机搜索、模拟退火和TPE(Tree-structured Parzen Estimator Approach)等多种优化算法。相比于Bayes_opt,Hyperopt的是更先进、更现代、维护更好的优化器,也是我们最常用来实现TPE方法的优化器。在实际使用中,相比基于高斯过程的贝叶斯优化,基于高斯混合模型的TPE在大多数情况下以更高效率获得更优结果,该方法目前也被广泛应用于AutoML领域中。TPE算法原理可以参阅原论文这里我们将重点介绍关于Hyperopt中使用TPE进行超参数搜索的过程。

import hyperopt
from hyperopt import hp, fmin, tpe, Trials, partial
from hyperopt.early_stop import no_progress_loss
 
print(hyperopt.__version__)

image

  • 1 定义目标函数
    在定义目标函数𝑓(𝑥)时,我们需要严格遵守需要使用的当下优化库的基本规则。与Bayes_opt一样,Hyperopt也有一些特定的规则会限制我们的定义方式,主要包括:

1.目标函数的输入必须是符合hyperopt规定的字典,不能是类似于sklearn的参数空间字典、不能是参数本身,更不能是数据、算法等超参数以外的元素。因此在自定义目标函数时,我们需要让超参数空间字典作为目标函数的输入。

2.Hyperopt只支持寻找𝑓(𝑥)的最小值,不支持寻找最大值,因此当我们定义的目标函数是某种正面的评估指标时(如准确率,auc),我们需要对该评估指标取负。如果我们定义的目标函数是负损失,也需要对负损失取绝对值。当且仅当我们定义的目标函数是普通损失时,我们才不需要改变输出

def hyperopt_objective(params):
    
    #定义评估器
    #需要搜索的参数需要从输入的字典中索引出来
    #不需要搜索的参数,可以是设置好的某个值
    #在需要整数的参数前调整参数类型
    reg = RFR(n_estimators = int(params["n_estimators"])
              ,max_depth = int(params["max_depth"])
              ,max_features = int(params["max_features"])
              ,min_impurity_decrease = params["min_impurity_decrease"]
              ,random_state=1412
              ,verbose=False
              ,n_jobs=-1)
    
    #交叉验证结果,输出负根均方误差(-RMSE)
    cv = KFold(n_splits=5,shuffle=True,random_state=1412)
    validation_loss = cross_validate(reg,X,y
                                     ,scoring="neg_root_mean_squared_error"
                                     ,cv=cv
                                     ,verbose=False
                                     ,n_jobs=-1
                                     ,error_score='raise'
                                    )
    
    #最终输出结果,由于只能取最小值,所以必须对(-RMSE)求绝对值
    #以求解最小RMSE所对应的参数组合
    return np.mean(abs(validation_loss["test_score"]))
  • 2 定义参数空间
    在任意超参数优化器中,优化器会将参数空格中的超参数组合作为备选组合,一组一组输入到算法中进行训练。在贝叶斯优化中,超参数组合会被输入我们定义好的目标函数𝑓(𝑥)中。

在hyperopt中,我们使用特殊的字典形式来定义参数空间,其中键值对上的键可以任意设置,只要与目标函数中索引参数的键一致即可,键值对的值则是hyperopt独有的hp函数,包括了:

hp.quniform("参数名称", 下界, 上界, 步长) - 适用于均匀分布的浮点数

hp.uniform("参数名称",下界, 上界) - 适用于随机分布的浮点数

hp.randint("参数名称",上界) - 适用于[0,上界)的整数,区间为前闭后开

hp.choice("参数名称",["字符串1","字符串2",...]) - 适用于字符串类型,最优参数由索引表示

hp.choice("参数名称",[*range(下界,上界,步长)]) - 适用于整数型,最优参数由索引表示

hp.choice("参数名称",[整数1,整数2,整数3,...]) - 适用于整数型,最优参数由索引表示

hp.choice("参数名称",["字符串1",整数1,...]) - 适用于字符与整数混合,最优参数由索引表示

在hyperopt的说明当中,并未明确参数取值范围空间的开闭,根据实验,如无特殊说明,hp中的参数空间定义方法应当都为前闭后开区间。我们依然使用在随机森林上获得最高分的随机搜索的参数空间:

param_grid_simple = {'n_estimators': hp.quniform("n_estimators",80,100,1)
                     , 'max_depth': hp.quniform("max_depth",10,25,1)
                     , "max_features": hp.quniform("max_features",10,20,1)
                     , "min_impurity_decrease":hp.quniform("min_impurity_decrease",0,5,1)
                    }

由于hp.choice最终会返回最优参数的索引,容易与数值型参数的具体值混淆,而hp.randint又只能够支持从0开始进行计数,因此我们常常会使用quniform获得均匀分布的浮点数来替代整数。对于需要取整数的参数值,如果采用quniform方式构筑参数空间,则需要在目标函数中使用int函数限定输入类型。例如,在范围[0,5]中取值时,可以取出[0.0, 1.0, 2.0, 3.0,...]这种均匀浮点数,在输入目标函数时,则必须确保参数值前存在int函数。当然,如果使用hp.choice则不会存在该问题。

由于不涉及到连续型变量,因此我们可以计算出当前参数空间的大小:

len([*range(80,100,1)])*len([*range(10,25,1)])*len([*range(10,20,1)])*len([range(0,5,1)])
#3000
  • 3 定义优化目标函数的具体流程
    有了目标函数和参数空间,接下来我们就可以进行优化了。在Hyperopt中,我们用于优化的基础功能叫做fmin,在fmin中,我们可以自定义使用的代理模型(参数algo),一般来说我们有tpe.suggest以及rand.suggest两种选项,前者指代TPE方法,后者指代随机网格搜索方法。我们还可以通过partial功能来修改算法涉及到的具体参数,包括模型具体使用了多少个初始观测值(参数n_start_jobs),以及在计算采集函数值时究竟考虑多少个样本(参数n_EI_candidates)。当然,我们也可以不填写这些参数,就使用默认的参数值。

除此之外,Hyperopt当中还有两个值得注意的功能,一个记录整个迭代过程的trials,另一个是提前停止参数early_stop_fn。其中,trials直译为“实验”或“测试”,表示我们不断尝试的每一种参数组合,这个参数中我们一般输入从hyperopt库中导入的方法Trials(),当优化完成之后,我们可以从保存好的trials中查看损失、参数等各种中间信息;而提前停止参数early_stop_fn中我们一般输入从hyperopt库导入的方法no_progress_loss(),这个方法中可以输入具体的数字n,表示当损失连续n次没有下降时,让算法提前停止。 由于贝叶斯方法的随机性较高,当样本量不足时需要多次迭代才能够找到最优解,因此一般no_progress_loss()中的数值不会设置得太高。在我们的课程中,由于数据量较少,我设置了一个较高的值来避免迭代停止太早。

def param_hyperopt(max_evals=100):
    
    #保存迭代过程
    trials = Trials()
    
    #设置提前停止
    early_stop_fn = no_progress_loss(100)
    
    #定义代理模型
    #algo = partial(tpe.suggest, n_startup_jobs=20, n_EI_candidates=50)
    params_best = fmin(hyperopt_objective #目标函数
                       , space = param_grid_simple #参数空间
                       , algo = tpe.suggest #代理模型你要哪个呢?#suggest默认了使用了多少个初始观测值以及计算采集函数值时究竟考虑多少个样本
                       #, algo = algo
                       , max_evals = max_evals #允许的迭代次数(包含了初始观测点)
                       , verbose=True
                       , trials = trials
                       , early_stop_fn = early_stop_fn
                      )
    
    #打印最优参数,fmin会自动打印最佳分数
    print("\n","\n","best params: ", params_best,
          "\n")
    return params_best, trials
  • 4 定义验证函数(非必要)
def hyperopt_validation(params):    
    reg = RFR(n_estimators = int(params["n_estimators"])
              ,max_depth = int(params["max_depth"])
              ,max_features = int(params["max_features"])
              ,min_impurity_decrease = params["min_impurity_decrease"]
              ,random_state=1412
              ,verbose=False
              ,n_jobs=-1
             )
    cv = KFold(n_splits=5,shuffle=True,random_state=1412)
    validation_loss = cross_validate(reg,X,y
                                     ,scoring="neg_root_mean_squared_error"
                                     ,cv=cv
                                     ,verbose=False
                                     ,n_jobs=-1
                                    )
    return np.mean(abs(validation_loss["test_score"]))
  • 5 执行实际优化流程
params_best, trials = param_hyperopt(30) #1%的空间大小

image

params_best, trials = param_hyperopt(100) #3%的空间大小

image

params_best, trials = param_hyperopt(300) #10%的空间大小

image

hyperopt_validation(params_best)
#28346.672687223065
 
#打印所有搜索相关的记录
trials.trials[0]
# {'state': 2,
#  'tid': 0,
#  'spec': None,
#  'result': {'loss': 28766.452192638408, 'status': 'ok'},
#  'misc': {'tid': 0,
#   'cmd': ('domain_attachment', 'FMinIter_Domain'),
#   'workdir': None,
#   'idxs': {'max_depth': [0],
#    'max_features': [0],
#    'min_impurity_decrease': [0],
#    'n_estimators': [0]},
#   'vals': {'max_depth': [13.0],
#    'max_features': [18.0],
#    'min_impurity_decrease': [4.0],
#    'n_estimators': [80.0]}},
#  'exp_key': None,
#  'owner': None,
#  'version': 0,
#  'book_time': datetime.datetime(2021, 12, 24, 13, 33, 19, 633000),
#  'refresh_time': datetime.datetime(2021, 12, 24, 13, 33, 19, 840000)}
 
#打印全部搜索的目标函数值
trials.losses()[:10]
#[28766.452192638408,
# 29762.22885008687,
# 29233.57333898302,
# 29257.33343872428,
# 29180.63733732971,
# 29249.676793746046,
# 29309.41793204717,
# 28915.33638544984,
# 29122.269575607537,
# 29150.39720576636]

image
由于具有提前停止功能,因此基于TPE的hyperopt优化可能在我们设置的迭代次数被达到之前就停止,也因此hyperopt迭代到实际最优值所需的迭代次数可能更少。同时,TPE方法相比于高斯过程计算会更加迅速,因此在运行277次迭代的情况下,hyperopt只需要1分钟时间,而运行300次迭代的bayes_opt却需要2.11分钟,可见,即便运行同样的迭代次数,hyperopt也是更有优势的,这或许是因为hyperopt的参数空间更加稀疏、在整数型参数搜索上更高效。

不过HyperOpt的缺点也很明显,那就是代码精密度要求较高、灵活性较差,略微的改动就可能让代码疯狂报错难以跑通。同时,HyperOpt所支持的优化算法也不够多,如果我们专注地使用TPE方法,则掌握HyperOpt即可,如果我们希望拥有丰富的HPO手段,则可以更深入地接触Optuna库。

3 基于Optuna实现多种贝叶斯优化

Optuna是目前为止最为成熟、拓展性最强的超参数优化框架,与古旧的bayes_opt相比,Optuna明显是专门为机器学习和深度学习所设计。为了满足机器学习开发者的需求,Optuna拥有强大且固定的API,因此Optuna代码简单,编写高度模块化,是我们介绍的库中代码最为简练的库。Optuna的优势在于,它可以无缝衔接到PyTorch、Tensorflow等深度学习框架上,也可以与sklearn的优化库scikit-optimize结合使用,因此Optuna可以被用于各种各样的优化场景。在我们的课程中,我们将重点介绍Optuna实现贝叶斯优化的过程,其他优化方面内容可以参考以下页面:GitHub - optuna/optuna: A hyperparameter optimization framework 。

import optuna
print(optuna.__version__)
# 3.2.0
  • 1 定义目标函数与参数空间
    Optuna的目标函数相当特别。在其他优化库中,我们需要单独输入参数或参数空间,优化器会在具体优化过程中将参数空间一一放入我们的目标函数进行优化,但在Optuna中,我们并不需要将参数或参数空间输入目标函数,而是需要直接在目标函数中定义参数空间。特别的是,Optuna优化器会生成一个指代备选参数的变量trial(一组参数组合输入我们的目标函数),该变量无法被用户获取或打开,但该变量在优化器中生存,并被输入目标函数。在目标函数中,我们可以通过变量trail所携带的方法来构造参数空间,具体如下所示:
def optuna_objective(trial):
    
    #定义参数空间
    n_estimators = trial.suggest_int("n_estimators",80,100,1) #整数型,(参数名称,下界,上界,步长)
    max_depth = trial.suggest_int("max_depth",10,25,1)
    max_features = trial.suggest_int("max_features",10,20,1)
    #max_features = trial.suggest_categorical("max_features",["log2","sqrt","auto"]) #字符型
    min_impurity_decrease = trial.suggest_int("min_impurity_decrease",0,5,1)
    #min_impurity_decrease = trial.suggest_float("min_impurity_decrease",0,5,log=False) #浮点型
    
    #定义评估器
    #需要优化的参数由上述参数空间决定
    #不需要优化的参数则直接填写具体值
    reg = RFR(n_estimators = n_estimators
              ,max_depth = max_depth
              ,max_features = max_features
              ,min_impurity_decrease = min_impurity_decrease
              ,random_state=1412
              ,verbose=False
              ,n_jobs=-1
             )
    
    #交叉验证过程,输出负均方根误差(-RMSE)
    #optuna同时支持最大化和最小化,因此如果输出-RMSE,则选择最大化
    #如果选择输出RMSE,则选择最小化
    cv = KFold(n_splits=5,shuffle=True,random_state=1412)
    validation_loss = cross_validate(reg,X,y
                                     ,scoring="neg_root_mean_squared_error"
                                     ,cv=cv #交叉验证模式
                                     ,verbose=False #是否打印进程
                                     ,n_jobs=-1 #线程数
                                     ,error_score='raise'
                                    )
    #最终输出RMSE
    return np.mean(abs(validation_loss["test_score"]))
  • 2 定义优化目标函数的具体流程
    在HyperOpt当中我们可以调整参数algo来自定义用于执行贝叶斯优化的具体算法,在Optuna中我们也可以。大部分备选的算法都集中在Optuna的模块sampler中,包括我们熟悉的TPE优化、随机网格搜索以及其他各类更加高级的贝叶斯过程,对于Optuna.sampler中调出的类,我们也可以直接输入参数来设置初始观测值的数量、以及每次计算采集函数时所考虑的观测值量。在Optuna库中并没有集成实现高斯过程的方法,但我们可以从scikit-optimize里面导入高斯过程来作为optuna中的algo设置,而具体的高斯过程相关的参数则可以通过如下方法进行设置:
# 定义优化过程

def optimizer_optuna(n_trials):
    algo=optuna.samplers.TPESampler()
        
    study=optuna.create_study(sampler=algo # 定义样本抽样的算法
                              ,direction='minimize' # 定义目标函数优化方向是最大值,还是最小值
                             )
    
    study.optimize(optuna_objective # 目标函数
                   ,n_trials=n_trials # 设定最大迭代次数(包括最初观测值)
                   ,show_progress_bar=True # 要不要展示进度条
                  )
    print('best parmas:',study.best_trial.params,
         '\n','best score:',study.best_trial.values)
    
    return study.best_trial.params,study.best_trial.values

  • 3 执行实际优化流程
    Optuna库虽然是当今最为成熟的HPO方法之一,但当参数空间较小时,Optuna库在迭代中容易出现抽样BUG,即Optuna会持续抽到曾经被抽到过的参数组合,并且持续报警告说"算法已在这个参数组合上检验过目标函数了"。在实际迭代过程中,一旦出现这个Bug,那当下的迭代就无用了,因为已经检验过的观测值不会对优化有任何的帮助,因此对损失的优化将会停止。如果出现该BUG,则可以增大参数空间的范围或密度。或者使用如下的代码令警告关闭:

import warnings
warnings.filterwarnings('ignore', message='The objective has been evaluated at this point before.')

best_params, best_score = optimizer_optuna(300)
#   0%|          | 0/300 [00:00<?, ?it/s]
 
 
#  best params:  {'n_estimators': 96, 'max_depth': 22, 'max_features': 14, 'min_impurity_decrease': 3} 
 
#  best score:  [28457.22400533479] 

很显然,基于高斯过程的贝叶斯优化是比基于TPE的贝叶斯优化运行更加缓慢的。在Optuna进行调试时,我并没有多次运行并取出Optuna表现最好的值,因此我们可以不将Optuna的结果最终放入表格进行比较,不过在TPE模式下,其运行速度与HyperOpt的运行速度高度接近。在未来的课程中,除非特殊说明,我们将默认使用TPE方法进行优化。

posted @ 2023-07-22 14:03  lipu123  阅读(708)  评论(0编辑  收藏  举报