机器学习调参原理:贝叶斯优化(并基于高斯过程回归的实现SMBO算法)

 超参数调节,在机器学习中是一个重要,但是又较为困难的环节,特别是当模型训练的时间成本以及算力成本较为高昂时,调参的难度也随之大大提升了。

除去较为依赖从业人员以及模型使用者先验知识的人工调参外,为了进行最佳超参数搜索,人们还会使用网格搜索(GridSearch)与随机搜索(RandomSearch)。前者是将所有的超参数组合一个个地毯式搜索,而后者则是在此基础上从“地毯式搜索”改为“随即搜索”,效能有了显著的提升。

但是,上面2者都是没有先验知识参与的,已经搜索过的参数没有对之后的参数搜索进行指导,而使用贝叶斯优化,则是会将历史搜索的记录用以作为先验知识来帮助判断下一步如何调参。

一、优化的难点以及解决方法

我们想要优化的目标是:找到一组最佳的超参X(注意此处的X不是数据集中的自变量),能够使得训练出来的模型的损失值Y(Y也不是因变量)最小。与正常的机器学习不同,这里的Y(损失值)与X(超参)本身的关系更为复杂,因为模型的结构本身是按照优化函数:f(自变量)来设计的,当我们将超参作为随机变量来看待时,这个函数便不一定是凸函数,不一定关于一、二阶可微,传统的梯度下降法不再适用,损失函数如同“黑箱”一般,难以优化。

为了解决这一难题,贝叶斯优化采取了这样的策略:

1、针对X与Y进行采样,并且根据采样对其建模获得一个“代理模型”,这一模型的作用是用以反映损失函数关于超参的函数的分布(你直观上可以把它当作是“函数本身和随机变量一样,也有一个分布”,但是更深的数学知识我还在思考)

2、使用采集函数,选择在“代理模型”中最好的点,将超参与损失函数加入到已知的X与Y中,进行下一轮采样与代理模型的建立。

为此,SMBO算法建立,以此在计算机上对模型使用贝叶斯优化。

二、高斯过程回归

本文介绍基于高斯过程回归的SMBO算法。

(此处参考了高斯过程回归 - 知乎

高斯过程回归,是“代理模型”的一种,用以寻找到最优函数的“分布”。假定y=f(x)服从于均值为0的高斯过程,那么我们就只需要求得这个高斯过程的方差函数(由核函数决定)就行了。

对于服从于联合高斯分布的(x1,x2)而言,期望与方差为:

\mu = \binom{\mu_1}{\mu_2}\Sigma=\binom{\Sigma_{11} \Sigma_{12}}{\Sigma_{21} \Sigma_{22}}

其后验分布为:

\mu_{2|1} = \mu_2+\Sigma_{21} \Sigma^{-1}_{11}(X_1-\mu_1) \Sigma_{2|1}=\Sigma_{22}-\Sigma_{21}\Sigma^{-1}_{11}\Sigma_{12} 

带入到高斯过程中,我们假设y服从高斯过程,将待测试点X*集合上的结果记录为f*(可以视为y*),有先验知识y,则

\binom{y}{f^*}~N(0,\binom{K+\sigma^2I,K_*}{K'_*,K_{**}})(不知道为什么,服从符号~不显示)

其中\sigma^2为预先给定的噪声的方差,K_{**}=K(X_i^*,X_j^*) K_*=K(X_i^*,X_j),X为已知点,X*为未知点。

后验分布可以照着上面依葫芦画瓢写出:

\mu_{f} =0+K'_*(K+\sigma^2I)^{-1}y K_{*}=K_{**}-K'_{*}(K+\sigma^2I)^{-1}K_{*}

因此,只要我们有历史数据X Y,给定噪声方差 \sigma^2与高斯过程的核函数K,就能求到待测X*关于y的函数所服从的分布了。

三、采集函数

有了f*的分布以后,我们就要找到一个最好的点,以此加入(X,Y)中作为下一个迭代的已知量。此处直接给出贝叶斯优化中最为常用的采集函数:Expected improvment。

其优化目标为:EI = max(0,y'-y*) ,其中y'为已知的最小loss,这个公式可以理解为:在所有的待选y*中,让y下降最大的那对(x,y)。

将其展开可得EI = (f'-\mu(f))*\Phi (Z)+\sigma(f)\phi(Z) Z=max(0,f'-\mu(f))/\sigma(f)(此处公式推导应该在本科有学过,但是笔者有些生疏遗忘了……)。

根据最大的EI找到下一个要加入的(x,y)。

四、代码实现

import numpy as np
import matplotlib as mpl
import seaborn as sns
import matplotlib.pyplot as plt
import itertools
from scipy.stats import norm
class Bayes_Optimization:
    def __init__(self,model,x,y,n_initial_points,trial_time,paras,step_nums=100,loss=None,sigma=0.1):
        """
        model:要调参的模型,此处传入构建函数而非对象名
        x:自变量
        y:因变量
        loss:模型评价所用到的损失函数
        sigma:高斯核中的带宽
        trial_time:实验次数
        paras:要调的模型超参,仅支持连续类型变量;value格式为:[最小值,最大值]
        step_nums:每个参数根据上下届切成多少份;对应optuna中suggsest_float中的step,不过step是步长,此处是总步数。
        本来应该对每个超参设一个step_nums,此处由于是作学习用,犯懒一下做一个全局设置
        n_initial_points:初始化的点数量,对应optuna中Sampler的n_initial_points。
        暂时只支持一维(仅1个超参),因为当时写高斯过程回归就是按照一维逻辑写的
        """
        self.model = model
        self.paras = paras
        self.para_keys = paras.keys()
        self.sigma = sigma
        self.x = x
        self.y = y
        self.step_nums = step_nums
        self.n_initial_points = n_initial_points
        self.loss = loss if loss else self.mse
        self.cur_model = None
        self.trial_time = trial_time
        
    def kernel(self,x1,x2):
        """
        核函数,可用于求协方差矩阵
        此处使用高斯核函数
        此处的sigma是核函数的一个参数,用来调整带宽
        """
        return np.exp(-(np.linalg.norm(x1-x2)**2)/(2*self.sigma**2))
    
    def Gaussian_Progress_Regression(self,x,x_dot,y,sigma_n=0.05):
        """
        x:已知的超参
        x_dot:待选超参
        y:已知超参所训练出来的模型对应的损失函数
        sigma:高斯核带宽
        sigma_n:噪声的
        注意此处的x和y不再是数据中的自变量与因变量
        """
        
        #假设f的均值为0,此处计算其方差
        K = [self.kernel(i,j) for i in x for j in x]
        K_dot = [self.kernel(i,j) for i in x_dot for j in x]
        K_dot2 = [self.kernel(i,j) for i in x_dot for j in x_dot]
        
        n=x.shape[0]
        n_dot = x_dot.shape[0]
        K = np.array(K).reshape((n,n))
        K_dot = np.array(K_dot).reshape((n_dot,n))
        K_dot2 = np.array(K_dot2).reshape((n_dot,n_dot))
        
        n = K.shape[0]
        res_mu = np.dot(K_dot,np.dot(np.linalg.inv(K+(sigma_n ** 2)*np.eye(n)),y))
        res_sigma = K_dot2 - np.dot(K_dot,np.dot(np.linalg.inv(K+(sigma_n ** 2)*np.eye(n)),K_dot.T))
        
        return res_mu,res_sigma
    
    def acquisition(self,mu,sigma,f_dot):
        """
        x:超参的参数空间
        此处的采集函数为Expected improvement
        希望找到max(0,f'-f(x));f'=min(f)(已知最小值) 也就是比f'越小越好
        (在论文中是越大越好,但是我们这里的f是损失函数,默认是要去minimize的)
        https://www.cnblogs.com/marsggbo/p/9866764.html
        """
        std = np.sqrt(sigma.diagonal())
        Z = np.maximum(f_dot-mu,0)/std
        delta = f_dot-mu
        EI = delta*norm.cdf(Z) + std*norm.pdf(Z)
        return EI
    
    def fit(self,paras):
        """
        此处假设model都是类sklearn的类型
        """
        exec_string = "self.cur_model = self.model("
        for i in paras.keys():
            exec_string += (i+"="+str(paras[i])+",")
        exec_string = exec_string[0:len(exec_string)-1]
        exec_string += ")"
        exec(exec_string)
        self.cur_model.fit(self.x,self.y)
        return self.cur_model
    
    def mse(self,y_pred,y_real):
        return np.mean(np.square(y_pred-y_real))
    
    def optimize(self):
        #随机初始化n_initial_points组超参Parameters作为初始点
        #尽管此处考虑了多维x(多个超参超参)的情况,但是由于GPR没有写成多维的,并且多维计算量大,此处不再作冗余处理
        #x就当作是一维变量就行了
        initial_flag = True
        x= None
        y = None
        for _ in range(self.n_initial_points):
            res_para = {}
            for i in self.para_keys:
                res_para[i] = np.random.uniform(self.paras[i][0],self.paras[i][1])
            model = self.fit(res_para)
            tmp_x = np.array([res_para[i] for i in self.para_keys])
            tmp_y = self.loss(model.predict(self.x),self.y)
            if initial_flag:
                x = np.array([tmp_x])
                y = np.array([tmp_y])
                initial_flag = False
            else:
                x = np.append(x,np.array([tmp_x]),axis=0)
                y = np.append(y,np.array([tmp_y]),axis=0)
        
        #初始化x_dot
        initial_flag = True
        x_dot = None
        for i in self.para_keys:
            tmp_x_dot = np.linspace(self.paras[i][0],self.paras[i][1],self.step_nums)
            if initial_flag:
                x_dot = tmp_x_dot
                initial_flag=False
            else:
                x_dot = np.append(x_dot,np.array([tmp_x_dot]),axis=0)
        
        #循环n_trial次
        for _ in range(self.trial_time):
            mu,sigma = self.Gaussian_Progress_Regression(x,x_dot,y)
            EI = self.acquisition(mu,sigma,y.min())
            x_new = x_dot[np.argmax(EI)]
            res_para = {}
            for i in range(len(list(self.para_keys))):
                res_para[list(self.para_keys)[i]] = x_new#[i]
            model = self.fit(res_para)
            y_new = self.loss(model.predict(self.x),self.y)
            x = np.append(x,np.array([x_new]).reshape((1, 1)),axis=0)
            y = np.append(y,np.array([y_new]),axis=0)
        return x,y

调参测试:

from sklearn.linear_model import Lasso,Ridge
from sklearn.model_selection import train_test_split
import numpy as np
import pandas as pd

构造数据集:

x1 = np.random.normal(loc=20,scale = 0.2,size=1000)
x2 = np.random.normal(loc=10,scale = 0.4,size=1000)
x3 = np.random.normal(loc=5,scale = 0.6,size=1000)
x4 = 0.05*x2+0.3*x3+0.2*x1
x5 = np.random.randn(1000)+200
epsilon = np.random.normal(loc=0,scale = 0.2,size=1000)
y = 0.6*x1**2+0.1*x2**3+0.03*x3+epsilon

df = np.stack([x1,x2,x3,x4,x5,y],axis=1)
df = pd.DataFrame(df,columns=["x1","x2","x3","x4","x5","y"])

X = df.loc[:,["x1","x2","x3","x4"]]
Y = df["y"]

x_train,x_test,y_train,y_test = train_test_split(X,Y)

基准模型:

model_base = Lasso() #默认alpha=1.0
model_base.fit(x_train,y_train)
np.mean(np.square(model_base.predict(x_test)-y_test))

#30.528115634827238

调参:

BO = Bayes_Optimization(Lasso,x_train,y_train,10,100,paras={"alpha":[0.001,1]})
alpha_chosen,loss = BO.optimize()
alpha_chosen[-1]
#array([0.001])

调参之后的结果:

model_new = Lasso(alpha=0.001)
model_new.fit(x_train,y_train)
np.mean(np.square(model_new.predict(x_test)-y_test))

#0.7651724595167761

损失函数从30.528115634827238下降到了0.7651724595167761,认为具备调参功能。

在实际训练模型时,可以使用optuna包进行调参;并且从实验来看,代理模型TPE相较于高斯过程回归效果更好。

相关可供参考的论文:https://arxiv.org/abs/1807.02811

Algorithms for Hyper-Parameter Optimization

一些写得比较清楚的博客:

贝叶斯优化(Bayesian Optimization)深入理解 - marsggbo - 博客园

贝叶斯优化(原理+代码解读) - 知乎 在此夸一下这位知友,他几乎有问必答,我就是评论区中的“荆棘”。在论文写完一年后他依然及时地解答了我的问题,特此感谢。

如文中或代码有错误或是不足之处,还望能不吝指正。

猜你喜欢

转载自blog.csdn.net/thorn_r/article/details/128277916