零基础数据挖掘入门系列(五) - 模型建立与调参

思维导图:零基础入门数据挖掘的学习路径

1. 写在前面

零基础入门数据挖掘是记录自己在Datawhale举办的数据挖掘专题学习中的所学和所想, 该系列笔记使用理论结合实践的方式,整理数据挖掘相关知识,提升在实际场景中的数据分析、数据清洗,特征工程、建模调参和模型融合等技能。所以这个系列笔记共五篇重点内容, 也分别从上面五方面进行整理学习, 既是希望能对知识从实战的角度串联回忆,加强动手能力的锻炼,也希望这五篇笔记能够帮助到更多喜欢数据挖掘的小伙伴,我们一起学习,一起交流吧。

既然是理论结合实践的方式,那么我们是从天池的一个二手车交易价格预测比赛出发进行学习,既可以学习到知识,又可以学习如何入门一个数据竞赛, 下面我们开始吧。

今天是本系列的第五篇文章模型的建立与调参部分,这一块也是很费时间且核心的一部分工作,毕竟特征工程也好,数据清洗也罢,都是为最终的模型来服务的,模型的建立和调参才决定了最终的结果,上限是一回事, 如何更好的去达到这个上限又是一回事, 前一篇我们已经做完了特征工程,好像是有了一个上限,到底应该怎么去达到这个上限呢? 就需要看我们的模型表现了。所以今天重点整理模型的评估和调参的相关技术。

首先,我们会先从简单的线性模型开始,看看如何去建立一个模型以及建立完模型之后要去分析什么东西, 然后我们会学习交叉验证的思想和技术(这个在评估模型的效果是经常会用到)并且会构建一个线下测试集(当然这个针对本比赛), 上面的这些算是建立模型的基础,然后我们会尝试建立更多的模型去解决这个问题,并对比它们的效果, 当把模型选择出来之后,我们还得掌握一些调参的技术发挥模型最大的性能, 模型选择出来之后,也调完参数,但是模型真的就没有问题了吗? 我们其实不知道, 所以最后我们学习绘制学习率曲线看模型是否存在过拟合或者欠拟合的问题并给出相应的解决方法。

大纲如下:

  • 我们先从最简单的模型开始(线性回归 & 交叉验证 & 构建线下测试集)
  • 评估算法模型的框架(这里会给出一个选择模型的框架,适合迁移)
  • 模型的调参技术(贪心调参, GridSearchCV调参和贝叶斯调参)
  • 绘制学习曲线和验证曲线(如何从学习曲线看过拟合欠拟合以及如果发生了过拟合欠拟合问题,我们应该怎么去尝试解决)
  • 对模型建立和调参部分的总结

PS: 本文默认学习者已经具备了机器学习和数据挖掘的基础知识,已经知道了一些基本概念,比如过拟合,欠拟合,正则化,模型复杂度等。 所以关于这些基础知识,本文不会过多的赘述,遇到了会提一下,如果没有掌握这些基本概念,建议先去补一下这些基本的概念,可以参考后面的第一篇链接。并且关于模型的原理部分,这里也不展开论述, 因为关于这个比赛后面我尝试用了五六种模型进行试验,如果单纯讲这些模型的原理,篇幅会超级长,也不是这个系列需要整理的问题了, 这些东西我都放在了链接里面。

Ok, let’s go!

2. 我们先从简单的线性模型开始

这部分也算是一个热身了,我们这个比赛是关于价格预测的,我们也知道了这是个回归的问题, 那么对于回归的问题,我们肯定是要选择一些回归模型来解决,线性模型就是一个比较简单的回归模型了, 所以我们就从这个模型开始,看看针对这个模型,我们会得到什么结果以及这些结果究竟是什么含义。

那么什么是线性回归模型, 线性回归(Linear Regression)是利用称为线性回归方程的最小平方函数对一个或多个自变量和因变量之间关系进行建模的一种回归分析, 简单的说,假设我们预测的二手车价格我们用YY来表示, 而我们构造的特征我们用xix_i, 我们就可以建立这样一个等式来表示它们的关系:
Y=w1x1+w2x2+....+wnxn+bY = w_1x_1+w_2x_2+....+w_nxn+b

训练模型的过程其实就是根据训练集的这些(X,Y)(X, Y)样本来求出合适的权重ww, 然后对新的测试集XX预测相应的YtestY_{test}, 这个YtestY_{test}其实就是我们想要的答案。这就是这部分的逻辑,下面看实现。

首先导入上次特征工程处理完毕后保存的数据集:

# 导入之前处理好的数据
data = pd.read_csv('./pre_data/pre_data.csv')
data.head()

# 然后训练集和测试集分开
train = data[:train_data.shape[0]]
test = data[train_data.shape[0]:]    # 这个先不用

# 选择那些数值型的数据特征
continue_fea = ['power', 'kilometer', 'v_2', 'v_3', 'v_4', 'v_5', 'v_6', 'v_10', 'v_11', 'v_12', 'v_14',
                'v_std', 'fuelType_price_average', 'gearbox_std', 'bodyType_price_average', 'brand_price_average',
                'used_time', 'estivalue_price_average', 'estivalueprice_std', 'estivalue_price_min']
train_x = train[continue_fea]
train_y = train_data['price']

可以看一下,我这边处理的data数据的结果:
在这里插入图片描述
我这边data会有38个特征。

下面,我们建立线性模型,建立模型如果用sklearn的话还是非常简单的,包括训练和预测。

from sklearn.linear_model import LinearRegression

model = LinearRegression(normalize=True)
model.fit(train_x, train_y)

通过上面这两行代码,我们其实就建立了线性模型,并完成了训练。 .fit方法就是训练模型, 训练的结果就是求出了上面的w和b。我们可以查看一下:

"""查看训练的线性回归模型的截距(intercept)与权重(coef)"""
print('intercept: ' + str(model.intercept_))
sorted(dict(zip(continue_fea, model.coef_)).items(), key=lambda x: x[1], reverse=True)


## 结果:
intercept: -178881.74591832393
[('v_6', 482008.29891714785),
 ('v_std', 23713.66414841167),
 ('v_10', 7035.056136559963),
 ('v_14', 1418.4037751433352),
 ('used_time', 186.48306334062053),
 ('power', 12.19202369791551),
 ('estivalue_price_average', 0.4082359327905722),
 ('brand_price_average', 0.38196351334425965),
 ('gearbox_std', 0.1716754674248321),
 ('fuelType_price_average', 0.023785798378739224),
 ('estivalueprice_std', -0.016868767797045624),
 ('bodyType_price_average', -0.21364358471329278),
 ('kilometer', -155.11999534761347),
 ('estivalue_price_min', -574.6952072539285),
 ('v_11', -1164.0263997737668),
 ('v_12', -1953.0558048250668),
 ('v_4', -2198.03802357537),
 ('v_3', -3811.7514971187525),
 ('v_2', -5116.825271420712),
 ('v_5', -447495.6394686485)]

上面的这些就是我们那个等式中每个x前面的系数, intercept这个代表那个b, 我们上面说过了,有了系数,我们就可以对新的样本进行预测。 预测也很简单,只需要

y_pred = model.predict(x_test)

这样的一句代码就可以实现, 但是这里想探索一点别的东西, 因为后面模型对比中会发现线性模型的效果不好,因为我们前面特征选择的时候也看到过了啊, 并不是所有的数值特征都和price有相关关系啊, 还有一些非线性关系吧, 这些线性模型就捕捉不到了,并且一般线性模型喜欢那种归一化或者标准化的数据, 导致和非线性模型的数据还得分开处理,所以这个比赛不会用到线性模型。

但是关于线性模型还有些好玩的东西我们得了解一下, 比如从这些权重中如何看出哪个特征对线性模型来说更加重要些? 这个其实我们看的是权重的绝对值,因为正相关和负相关都是相关, 越大的说明那个特征对线性模型影响就越大。

其次,我们还可以看一下线性回归的训练效果,绘制一下v_6这个特征和标签的散点图:

subsample_index = np.random.randint(low=0, high=len(train_y), size=50)

plt.scatter(train_x['v_6'][subsample_index], train_y[subsample_index], color='black')
plt.scatter(train_x['v_6'][subsample_index], model.predict(train_x.loc[subsample_index]), color='blue')
plt.xlabel('v_6')
plt.ylabel('price')
plt.legend(['True Price','Predicted Price'],loc='upper right')
print('The predicted price is obvious different from true price')
plt.show()

结果如下:
在这里插入图片描述
从上图中我们可以发现发现模型的预测结果(蓝色点)与真实标签(黑色点)的分布差异较大,且部分预测值出现了小于0的情况,说明我们的模型存在一些问题。 这个还是需要会看的,从这里我们也可以看出或许price这个需要处理一下。

哈哈,我们记得price的分布图吗?

print('It is clear to see the price shows a typical exponential distribution')
plt.figure(figsize=(15,5))
plt.subplot(1,2,1)
sns.distplot(train_y)
plt.subplot(1,2,2)
sns.distplot(train_y[train_y < np.quantile(train_y, 0.9)])

结果是长这个样子:
在这里插入图片描述
通过作图我们发现数据的标签(price)呈现长尾分布,不利于我们的建模预测。原因是很多模型都假设数据误差项符合正态分布,而长尾分布的数据违背了这一假设。参考博客:https://blog.csdn.net/Noob_daniel/article/details/76087829

所以,我们不妨取对数一下:

train_y_ln = np.log1p(train_y)
print('The transformed price seems like normal distribution')
plt.figure(figsize=(15,5))
plt.subplot(1,2,1)
sns.distplot(train_y_ln)
plt.subplot(1,2,2)
sns.distplot(train_y_ln[train_y_ln < np.quantile(train_y_ln, 0.9)])

这样效果是不是就好多了呢?
在这里插入图片描述
那么我们再来训练一下:

model = model.fit(train_x, train_y_ln)

print('intercept:'+ str(model.intercept_))
sorted(dict(zip(continue_fea, model.coef_)).items(), key=lambda x:x[1], reverse=True)

这个权重结果就不显示了,我们依然是画出v_6和price的散点图, 会发现长这样子了:
在这里插入图片描述
好了,线性回归模型我们就探索到这里,引入这块到底要表达个什么意思呢?

  1. 线性模型是很简单的一个模型,我们虽然后面不会用到,但是后面建立模型,训练和预测模型的步骤和线性模型基本上是一致的,依然是.fit(X,Y), .predict(X_test)方法。所以在这里先体会一下如何建立一个模型,并且对它训练和预测。
  2. 线性模型这个操作中,有些方法还是可以用于其他模型的,比如模型训练之后,我们可以通过某种方式去看哪些特征对模型更加重要,这个在特征筛选的时候非常非常有用(还记得嵌入式或者包裹式特征选择方法吗),所以这里算是一个简单的温习操作
  3. 通过查看模型的训练效果,可能还会有意外的收获,就比如这里的price这个分布,从模型的训练效果也可以看出来这个分布可能有问题。

上面的三个点你get到了吗? 是不是这个热身中也潜藏好多知识 😉, 虽然不用线性模型,但是有些思路和方法可以通用啊!

好了,热身结束之后,下面我们就看一个比较重要的东西,叫做交叉验证,这个常用来做算法的评估,评估就是估计算法在预测新数据的时候能达到什么程度。

2.1 交叉验证

在使用数据集对参数进行训练的时候, 经常会发现人们通常会将整个训练集分为三个部分, 一般分为训练集,验证集和测试集。 这其实是为了保证训练效果而特意设置的。 其中测试集很好理解, 就是完全不参与训练的过程,仅仅用来观测测试效果的数据。而训练集和评估集则牵涉到下面的知识了。

因为在实际的训练中,训练的结果对于训练集的拟合程度通常还是挺好的(初始条件敏感),但是对于训练集之外的数据的拟合程度通常就不那么令人满意了。因此我们通常并不会把所有的数据集都拿来训练,而是分出一部分来(这一部分不参加训练)对训练集生成的参数进行测试,相对客观的判断这些参数对训练集之外的数据的符合程度。这种思想就称为交叉验证(Cross Validation)。 交叉验证中,比较常用的就是K折交叉验证了,它有效的避免过拟合,最后得到的结果也比较具有说服性所以我们重点来看看这一块。

K折交叉验证是将原始数据分成K组,将每个子集数据分别做一次验证集,其余的K-1组子集数据作为训练集,这样会得到K个模型,用这K个模型最终的验证集分类准确率的平均数,作为此K折交叉验证下分类器的性能指标。拿个图来看一下:

关于K折交叉验证详细的原理这里就不描述了,其实也不是那么难理解吧, 就拿这个比赛来说,我们训练集是150000个样本,我们假设做5折交叉验证的话,就是把这150000个样本分成5份, 每份30000, 训练模型的时候,我们选四份作为训练集训练模型,然后在另一份上进行预测得到一个结果。 这样,这五份轮流着做一遍测试集的话正好就是循环了五轮, 得到了五个结果,然后我们去平均即可。 这样的好处就是防止模型更加偏向某份数据,也能看出是否模型存在过拟合。 那么怎么实现呢?
交叉验证, sklearn中提供了一个函数,叫做cross_val_score,我们就是用这个函数实现交叉验证,函数具体的作用可以去查一下sklearn的官方文档。

from sklearn.model_selection import cross_val_score
from sklearn.metrics import mean_absolute_error, make_scorer

def log_transfer(func):
    def wrapper(y, yhat):
        result = func(np.log(y), np.nan_to_num(np.log(yhat)))   # 这个是为了解决不合法的值的
        return result
    return wrapper

# 下面是交叉验证
scores = cross_val_score(model, X=train_x, y=train_y, verbose=1, cv=5, scoring=make_scorer(log_transfer(mean_absolute_error)))

# 使用线性回归模型,对未处理标签的特征数据进行五折交叉验证(Error 1.36)
print('AVG:', np.mean(scores))

# 对处理的标签交叉验证
scores = cross_val_score(model, X=train_x, y=train_y_ln, verbose=1, cv = 5, scoring=make_scorer(mean_absolute_error))
print('AVG:', np.mean(scores))

# 输出五次的验证结果:
scores = pd.DataFrame(scores.reshape(1,-1))
scores.columns = ['cv' + str(x) for x in range(1, 6)]
scores.index = ['MAE']
scores

看一下结果吧:
在这里插入图片描述
这就是k折交叉验证的思想和实现了, 这里再多说一点,就是这种方式不适合处理时间序列,因为时间序列是有先后关系的,五折交叉验证在某些与时间相关的数据集上反而反映了不真实的情况。就拿这次比赛来说,通过2018年的二手车价格预测2017年的二手车价格,这显然是不合理的, 因此我们还可以采用时间顺序对数据集进行分隔。在本例中,我们选用靠前时间的4/5样本当作训练集,靠后时间的1/5当作验证集,最终结果与五折交叉验证差距不大。

split_point = len(train_x) // 5 * 4

# 训练集
xtrain = train_x[:split_point]
ytrain = train_y[:split_point]

# 测试集
xval = train_x[split_point:]
yval = train_y[split_point:]
ytrain_ln = np.log1p(ytrain)
yval_ln = np.log1p(yval)

# 训练
model.fit(xtrain, ytrain_ln)
mean_absolute_error(yval_ln, model.predict(xval))

当然,这里只是提一下,在这个比赛中,涉及的时间关系并不是那么强,所以这里完全可以使用k折交叉验证的方式,并且关于时间序列的话,一般属于自回归的问题,处理起来也不是单纯的像上面这么简单。

2.2 构建一个线下测试集

这里是简单的介绍一个小技巧吧,当然这里是针对这个比赛,因为有时候我们发现在本地上训练数据集得到的结果很好,但是放到线上进行测试的时候往往不是那么理想,这就意味着我们线下的训练有些过拟合了,而我们一般并不知道,毕竟对于线上的测试,我们并没有真实的标签对比不是吗? 所以我们可以先构建一个线下的测试集。 这个实操起来也很简单,就是我们有150000个样本,我们可以用100000个样本来做训练集, 后面的50000做测试集,因为我们已经知道这50000个样本的真实标签,这样训练出来的模型我们就可以直接先测试一下泛化能力,对于后面的调参或者是模型的评估等感觉还是挺好用的。

# 导入数据
data = pd.read_csv('./pre_data/pre_data.csv')

train = data[:train_data.shape[0]]
test = data[train_data.shape[0]:]    # 这个先不用

# 选数据
X = train[:100000]
Y= train_data['price'][:100000]
Y_ln = np.log1p(Y)

XTest = train[100000:]   # 模拟一个线下测试集, 看看模型的泛化能力
Ytrue = train_data['price'][100000:]

好了,关于模型的基础知识方面就整理这么多, 主要是先做了个热身, 然后了解了一下K折交叉验证的思想,最后就是一个小的技巧吧。

下面就开始进入正题部分, 如何去选择模型?

3. 评估算法模型的框架

模型选择的时候,可以根据我们数据的特征和优化目标先选出很多个模型作为备选, 因为我们分析完数据不能立刻得出哪个算法对需要解决的问题更有效。

就拿这个比赛来说,我们直观上认为, 由于我们的优化问题是预测价格,这是一个回归问题,我们肯定使用回归模型(Regressor系列), 但是回归模型太多, 我们又知道部分数据呈线性分布,线性回归算法和正则化的回归算法可能对解决问题比较有效, 由于数据的离散化, 通过决策树算法及相应的集成算法也一般会表现出色, 所以我们可以锁定几个模型都尝试一下。

我一般习惯建立一个字典, 把这些模型放到字典里面,然后分别进行交叉验证,可视化结果来判断哪个模型针对当前问题表现比较好, 这样从这里面选出3-4个进行下面的环节,也就是模型的调参工作。这里给出一个我常用的一个评估算法模型的一个框架, 在这里,采用10交叉验证来分离数据, 通过绝对值误差来比较算法的准确度, 误差越小,准确度越高。

由于这里面会用到线性的一些模型,所以这里会给出两个框架吧算是,一个是基于原始的数据,一个是基于正态的数据(这里利用了Pipeline自动处理流程), 这两个框架都可以做迁移,用到别的选择模型的任务中。

num_folds = 10
seed = 7

# 把所有模型写到一个字典中
models = {}
models['LR'] = LinearRegression()
models['Ridge'] = Ridge()
models['LASSO'] = Lasso()
models['DecisionTree'] = DecisionTreeRegressor()
models['RandomForest'] = RandomForestRegressor()
models['GradientBoosting'] = GradientBoostingRegressor()
models['XGB'] = XGBRegressor(n_estimators = 100, objective='reg:squarederror')
models['LGB'] = LGBMRegressor(n_estimators=100)
#models['SVR'] = SVR()   # 支持向量机运行不出来

results = []
for key in models:
    kfold = KFold(n_splits=num_folds, random_state=seed)
    cv_result = cross_val_score(models[key], X, Y_ln, cv=kfold, scoring=make_scorer(mean_absolute_error))
    results.append(cv_result)
    print('%s: %f (%f)' % (key, cv_result.mean(), cv_result.std()))
    
# 评估算法 --- 箱线图
fig1 = plt.figure(figsize=(15, 10))
fig1.suptitle('Algorithm Comparison')
ax = fig1.add_subplot(111)
plt.boxplot(results)
ax.set_xticklabels(models.keys())
plt.show()


## 结果:
LR: 0.192890 (0.001501)
Ridge: 0.196279 (0.001616)
LASSO: 0.515573 (0.003923)
DecisionTree: 0.190959 (0.002524)
RandomForest: 0.142333 (0.001489)
GradientBoosting: 0.178403 (0.001903)
XGB: 0.178492 (0.001441)
LGB: 0.147875 (0.001397)

再看一下箱线图的结果:
在这里插入图片描述
这样,各个模型的效果是不是就一目了然了呢? 从上图可以发现, 随机森林和LGB的效果还是好一些的,后面可以基于这两个进行调参,当然xgboost的效果可能由于参数的原因表现不是那么理想,这里也作为了我们调参备选。 毕竟现在还是xgb和lgb的天下嘛?

那么调参究竟有没有影响呢? 我这里做了一个实验, 可以先看一下:

model2 = LGBMRegressor(n_estimators=100)
model2.fit(X, Y_ln)
pred2 = model2.predict(XTest)
print("mae: ", mean_absolute_error(Ytrue, np.expm1(pred2)))

# 结果:
mae:  713.9408513079144

上面这个是没有调参的LGB, 下面再看一下baseline里面的那个LGB:

def bulid_modl_lgb(x_train, y_train):
    estimator = LGBMRegressor(num_leaves=127, n_estimators=150)
    param_grid = {'learning_rage': [0.01, 0.05, 0.1, 0.2]}
    gbm = GridSearchCV(estimator, param_grid)
    gbm.fit(x_train, y_train)
    return gbm
 
model_lgb = bulid_modl_lgb(X, Y_ln)
val_lgb = model_lgb.predict(XTest)
MAE_lgb = mean_absolute_error(Ytrue, np.expm1(val_lgb))
print(MAE_lgb)


## 结果:
591.4221480289154

同样的LGB, 调参的话误差降到591, 不调参是713, 这个效果是不是差别很大啊? 所以调参还是很重要的。但是在调参之前,还是先给出另一个正态了模板。

正态化数据?

  • 在这里猜测也许因为原始数据中心不同特征属性的度量单位不一样, 导致有的算法不是很好。 接下来通过正态化,再次评估这些算法。
  • 在这里对训练数据进行转换处理, 讲所有的数据特征值转为0位中位值, 标准差是1的数据
  • 对数据正态化时, 为了防止数据泄露, 采用“Pipeline”来正态化数据和对模型进行评估
from sklearn.pipeline import Pipeline

pipelines = {}
pipelines['ScalerLR'] = Pipeline([('Scaler', StandardScaler()), ('LR', LinearRegression())])
pipelines['ScalerRidge'] = Pipeline([('Scaler', StandardScaler()), ('Ridge', Ridge())])
pipelines['ScalerLasso'] = Pipeline([('Scaler', StandardScaler()), ('Lasso', Lasso())])
pipelines['ScalerTree'] = Pipeline([('Scaler', StandardScaler()), ('Tree', DecisionTreeRegressor())])
pipelines['ScalerForest'] = Pipeline([('Scaler', StandardScaler()), ('Forest', RandomForestRegressor())])
pipelines['ScalerGBDT'] = Pipeline([('Scaler', StandardScaler()), ('GBDT', GradientBoostingRegressor())])
pipelines['ScalerXGB'] = Pipeline([('Scaler', StandardScaler()), ('XGB', XGBRegressor(n_estimators = 100, objective='reg:squarederror'))])
pipelines['ScalerLGB'] = Pipeline([('Scaler', StandardScaler()), ('LGB', LGBMRegressor(n_estimators=100))])

results = []
for key in pipelines:
    kfold = KFold(n_splits=num_folds, random_state=seed)
    cv_result = cross_val_score(pipelines[key], X, Y_ln, cv=kfold, scoring=make_scorer(mean_absolute_error))
    results.append(cv_result)
    print('%s: %f (%f)' % (key, cv_result.mean(), cv_result.std()))


# 评估算法 --- 箱线图
fig2 = plt.figure(figsize=(15, 10))
fig2.suptitle('Algorithm Comparison')
ax = fig2.add_subplot(111)
plt.boxplot(results)
ax.set_xticklabels(models.keys())

当然,我这里不用这个,因为我试验了一下,效果不如上面的好。

所以下面选定模型调参就选择随机森林, LGB, 然后xgb和gradientBoostingRegressor备选。

4. 模型的调参

同特征工程一样,模型参数调节也是一项非常繁琐但又非常重要的工作。

根据模型复杂程度的不同,需要调节的参数数量也不尽相同。简单如逻辑回归,需要调节的通常只有正则项系数C;复杂如随机森林,需要调节的变量会多出不少,最核心的如树的数量n_estimators,树的深度max_depth等等。参数越多,调参的难度自然也越来越大,因为参数间排列组合的可能性越来越多. 在训练样本比较少的情况下,sklearn的GridSearchCV是个不错的选择,可以帮助我们自动寻找指定范围内的最佳参数组合。但实际情况是,GridSearch通常需要的运行时间过长,长到我们不太能够忍受的程度。所以更多的时候需要我们自己手动先排除掉一部分数值,然后或自己组合不断的尝试和调整。

模型的调参这里,有三种方式:

  • 贪心调参
  • 网格搜索调参
  • 贝叶斯调参

这里给出一个模型可调参数及范围选取的参考:

在这里插入图片描述

下面的实验,我是以LGB作为实验的,因为这里为了整理调参方式,其他的模型也都是这个思路,所以为了减少篇幅,只对LGB做的实验,还有个原因就是LGB会很快。如果是针对随机森林,下面的三种调参方式我一种也没有跑完,因为数据量太大,参数太多的原因吧。 所以围绕着LGB试试看看下面的三种方式究竟有什么区别

objective = ['regression', 'regression_l1', 'mape', 'huber', 'fair']
num_leaves = [10, 55, 70, 100, 200]
max_depth = [ 10, 55, 70, 100, 200]
n_estimators = [200, 400, 800, 1000]
learning_rate =  [0.01, 0.05, 0.1, 0.2]

4. 1 贪心调参

拿当前对模型影响最大的参数调优,直到最优化;再拿下一个影响最大的参数调优,如此下去,直到所有的参数调整完毕。这个方法的缺点就是可能会调到局部最优而不是全局最优,但是省时间省力,巨大的优势面前,可以一试。

下面就拿这个比赛来看一下:

# 先建立一个参数字典
best_obj = dict()

# 调objective
for obj in objective:
    model = LGBMRegressor(objective=obj)
    score = np.mean(cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)))
    best_obj[obj] = score
 
# 上面调好之后,用上面的参数调num_leaves
best_leaves = dict()
for leaves in num_leaves:
    model = LGBMRegressor(objective=min(best_obj.items(), key=lambda x:x[1])[0], num_leaves=leaves)
    score = np.mean(cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)))
    best_leaves[leaves] = score

# 用上面两个最优参数调max_depth
best_depth = dict()
for depth in max_depth:
    model = LGBMRegressor(objective=min(best_obj.items(), key=lambda x:x[1])[0],
                          num_leaves=min(best_leaves.items(), key=lambda x: x[1])[0],
                          max_depth=depth)
    score = np.mean(cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)))
    best_depth[depth] = score

# 调n_estimators
best_nstimators = dict()
for nstimator in n_estimators:
    model = LGBMRegressor(objective=min(best_obj.items(), key=lambda x:x[1])[0],
                          num_leaves=min(best_leaves.items(), key=lambda x: x[1])[0],
                          max_depth=min(best_depth.items(), key=lambda x:x[1])[0],
                          n_estimators=nstimator)
    
    score = np.mean(cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)))
    best_nstimators[nstimator] = score
 
# 调learning_rate
best_lr = dict()
for lr in learning_rate:
    model = LGBMRegressor(objective=min(best_obj.items(), key=lambda x:x[1])[0],
                          num_leaves=min(best_leaves.items(), key=lambda x: x[1])[0],
                          max_depth=min(best_depth.items(), key=lambda x:x[1])[0],
                          n_estimators=min(best_nstimators.items(), key=lambda x:x[1])[0],
                          learning_rate=lr)
    score = np.mean(cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)))
    best_lr[lr] = score

上面的过程建议放在不同的cell里面运行, 我们可以可视化这个过程的结果:

sns.lineplot(x=['0_initial','1_turning_obj','2_turning_leaves',
                '3_turning_depth','4_turning_estimators', '5_turning_lr'],
             y=[0.143 ,min(best_obj.values()), min(best_leaves.values()), min(best_depth.values()),
               min(best_nstimators.values()), min(best_lr.values())])

结果如下:
在这里插入图片描述
可以发现贪心的调参策略还是不错的, 我们还可以打印最后的调参结果:

print("best_obj:", min(best_obj.items(), key=lambda x: x[1]))
print("best_leaves:", min(best_leaves.items(), key=lambda x: x[1]) )
print('best_depth:', min(best_depth.items(), key=lambda x: x[1]))
print('best_nstimators: ', min(best_nstimators.items(), key=lambda x: x[1]))
print('best_lr:', min(best_lr.items(), key=lambda x: x[1]))


## 结果如下:
best_obj: ('regression_l1', 0.1457016215267976)
best_leaves: (100, 0.132929241004274)
best_depth: (20, 0.13275966837758682)
best_nstimators:  (1000, 0.11861541074643345)
best_lr: (0.05, 0.11728267187328578)

4.2 GridSearchCV 调参

GridSearchCV,它存在的意义就是自动调参,只要把参数输进去,就能给出最优化的结果和参数。但是这个方法适合于小数据集,一旦数据的量级上去了,很难得出结果。这个在这里面优势不大, 因为数据集很大,不太能跑出结果,但是也整理一下,有时候还是很好用的。

from sklearn.model_selection import GridSearchCV

# 这个我这边电脑运行时间太长,先不跑了
parameters = {'objective':objective, 'num_leaves':num_leaves, 'max_depth':max_depth,
             'n_estimators': n_estimators, 'learning_rate':learning_rate}

model = LGBMRegressor()
clf = GridSearchCV(model, parameters, cv=5)
clf = clf.fit(X, Y_ln)

# 输出最优参数
clf.best_params_

上面这个我电脑没跑出来,因为我设置的分类器的个数有点多了。

4.3 贝叶斯调参

这个需要安装个包: pip install bayesian-optimization

贝叶斯优化用于机器学习调参,主要思想是,给定优化的目标函数(广义的函数,只需指定输入和输出即可,无需知道内部结构以及数学性质),通过不断地添加样本点来更新目标函数的后验分布(高斯过程,直到后验分布基本贴合于真实分布。简单的说,就是考虑了上一次参数的信息,从而更好的调整当前的参数。

与常规的网格搜索或者随机搜索的区别是:

  • 贝叶斯调参采用高斯过程,考虑之前的参数信息,不断地更新先验;网格搜索未考虑之前的参数信息
  • 贝叶斯调参迭代次数少,速度快;网格搜索速度慢,参数多时易导致维度爆炸
  • 贝叶斯调参针对非凸问题依然稳健;网格搜索针对非凸问题易得到局部最优

使用方法:

  • 定义优化函数(rf_cv, 在里面把优化的参数传入,然后建立模型, 返回要优化的分数指标)
  • 定义优化参数
  • 开始优化(最大化分数还是最小化分数等)
  • 得到优化结果
from  bayes_opt import BayesianOptimization

# 定义优化函数
def rf_cv(num_leaves, max_depth, subsample, min_child_samples):
    model = LGBMRegressor(objective='regression_l1', num_leaves=int(num_leaves),
                         max_depth=int(max_depth), subsample=subsample,
                         min_child_samples = int(min_child_samples))
    val = cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)).mean()
    
    return 1-val

# 定义优化参数
rf_bo = BayesianOptimization(
    rf_cv, 
    {
        'num_leaves':(2, 100),
        'max_depth':(2, 100),
        'subsample':(0.1, 1),
        'min_child_samples':(2, 100)
    }
)

#开始优化
num_iter = 25
init_points = 5
rf_bo.maximize(init_points=init_points,n_iter=num_iter)

#显示优化结果
rf_bo.res["max"]


#附近搜索(已经有不错的参数值的时候)
rf_bo.explore(
     {'n_estimators': [10, 100, 200],
      'min_samples_split': [2, 10, 20],
      'max_features': [0.1, 0.5, 0.9],
      'max_depth': [5, 10, 15]
     })

这个可以看一下结果:

在这里插入图片描述
基于上面的思路,我们可以对随机森林进行调参:

对Random Forest来说,增加“子模型数”(n_estimators)可以明显降低整体模型的方差,且不会对子模型的偏差和方差有任何影响。模型的准确度会随着“子模型数”的增加而提高。由于减少的是整体模型方差公式的第二项,故准确度的提高有一个上限。在不同的场景下,“分裂条件”(criterion)对模型的准确度的影响也不一样,该参数需要在实际运用时灵活调整。调整“最大叶节点数”(max_leaf_nodes)以及“最大树深度”(max_depth)之一,可以粗粒度地调整树的结构:叶节点越多或者树越深,意味着子模型的偏差越低,方差越高;同时,调整“分裂所需最小样本数”(min_samples_split)、“叶节点最小样本数”(min_samples_leaf)及“叶节点最小权重总值”(min_weight_fraction_leaf),可以更细粒度地调整树的结构:分裂所需样本数越少或者叶节点所需样本越少,也意味着子模型越复杂。一般来说,我们总采用bootstrap对样本进行子采样来降低子模型之间的关联度,从而降低整体模型的方差。适当地减少“分裂时考虑的最大特征数”(max_features),给子模型注入了另外的随机性,同样也达到了降低子模型之间关联度的效果。详细的可以参考:

下面是我们的调参代码:

# 定义优化函数
def rf_cv(n_estimators,  max_depth):
    model = RandomForestRegressor(n_estimators=int(n_estimators), 
                         max_depth=int(max_depth))
    val = cross_val_score(model, X, Y_ln, verbose=0, cv=5, scoring=make_scorer(mean_absolute_error)).mean()
    
    return 1-val

rf_bo = BayesianOptimization(
    rf_cv, 
    {
        'n_estimators':(100, 200),
        'max_depth':(2, 100)
    }
)

rf_bo.maximize()

当然,这一个也没跑出结果来,随机森林的速度处理这种问题和xgb, lgb等还是不能比。 可以尝试跑跑xgb调调吧。

5. 绘制学习曲线与验证曲线

从上面的步骤中,我们通过算法模型的评估框架选择出了合适的几个模型, 又通过模型的调参步骤确定了模型的合适参数,这样我们基本上就得到了一个我们认为的比较好的模型了, 但是这个模型真的就是好的模型了吗? 我们还不能确定是否存在过拟合或者欠拟合, 关于这两个的概念这里不多描述, 从字面里面就是欠拟合就是模型没有训练好, 导致在训练集和测试集效果都比较差,这个也叫做高偏差。 而过拟合就是模型训练过了, 在训练集上的效果非常好,而测试集上的效果很差, 泛化能力弱, 这个也叫做高方差。 可是,说是这么说,我们在实际中究竟应该怎么判断呢? 学习曲线的绘制就是一个非常好的方式, 可以帮助我们看一下我们调试好的模型还有没有过拟合或者欠拟合的问题, 好帮助我们进行下一步的工作。

关于学习曲线:

  • 学习曲线是不同训练集大小,模型在训练集和验证集上的得分变化曲线。
  • 学习曲线图的横座标是x_train的数据量,纵座标是对应的train_score, test_score。随着训练样本的逐渐增加,算法练出的模型的表现能力;

PS: 表现能力:也就是模型的预测准确率,使用均方误差表示;学习率上体现了模型相对于训练集和测试集两类数据的均方误差。

如何绘制学习曲线:sklearn.model_selection.learning_curve

train_sizes,train_scores,test_score = learning_curve ( estimator, X, y, groups=None, train_sizes=array([0.1, 0.33, 0.55, 0.78, 1. ]), cv=’warn’, scoring=None)

参数的主要说明如下:

通过cv设置交叉验证,取几次(组)数据,通过train_sizes设置每一次取值,在不同训练集大小上计算得分。

  • estimator:估计器,用什么模型进行学习;
  • cv:交叉验证生成器,确定交叉验证拆分策略;


画训练集的曲线时,横轴为train_sizes, 纵轴为train_scores_mean; train_scores为二维数组,行代表train_sizes不同时的得分,列表示取cv组数据。

画测试集的曲线时:横轴为train_sizes, 纵轴为test_scores_mean; test_scores为二维数组

learning_curve为什么运行时间那么长:模型要进行train_sizes * cv次运行

那么,我们就基于一个训练好的模型,画一下学习曲线,看看这个学习曲线究竟怎么观察:

from sklearn.model_selection import learning_curve, validation_curve

def plot_learning_curve(estimator, title, X, y, ylim=None, cv=None, n_jobs=1, train_size=np.linspace(.1, 1.0, 5)):
    plt.figure()
    plt.title(title)
    if ylim is not None:
        plt.ylim(*ylim)
    plt.xlabel('Training example')  
    plt.ylabel('score')  
    train_sizes, train_scores, test_scores = learning_curve(estimator, X, y, cv=cv, n_jobs=n_jobs, train_sizes=train_size, scoring = make_scorer(mean_absolute_error))  
    train_scores_mean = np.mean(train_scores, axis=1)  
    train_scores_std = np.std(train_scores, axis=1)  
    test_scores_mean = np.mean(test_scores, axis=1)  
    test_scores_std = np.std(test_scores, axis=1)  
    plt.grid()#区域  
    plt.fill_between(train_sizes, train_scores_mean - train_scores_std,  
                     train_scores_mean + train_scores_std, alpha=0.1,  
                     color="r")  
    plt.fill_between(train_sizes, test_scores_mean - test_scores_std,  
                     test_scores_mean + test_scores_std, alpha=0.1,  
                     color="g")  
    plt.plot(train_sizes, train_scores_mean, 'o-', color='r',  
             label="Training score")  
    plt.plot(train_sizes, test_scores_mean,'o-',color="g",  
             label="Cross-validation score")  
    plt.legend(loc="best")  
    return plt  

# 假设已经调好了LGB的参数,我们可以绘制一下曲线看看这个模型有没有什么问题
model = LGBMRegressor(n_estimators=1000, leaves=200, learning_rate=0.05, objective='regression_l1')
model.fit(X, Y_ln)
pred2 = model.predict(XTest)
print("mae: ", mean_absolute_error(Ytrue, np.expm1(pred2)))

# 画出学习曲线
plot_learning_curve(model, 'LGB', X[:10000], Y_ln[:10000], ylim=(0.0, 1), cv=5, n_jobs=1) 

上面模型的学习曲线如下:
在这里插入图片描述
可以发现, 模型在训练集和测试集上的效果还是不错的, 不过训练集和测试集的准确率之间还有一段小间隔, 可能是有点过拟合。

下面整理一下如何观察这个学习曲线?
这个learning_curve里面有个scoring参数可以设置你想求的值,分类可以设置’accuracy’, 回归问题可以设置’neg_mean_squared_error,总体来说,值都是越大越好, 但是注意这个模型里面设置的是mae error, 这个就是越低越好了。

那么高偏差和高方差应该怎么看呢?引用一个博客里面的图片:
在这里插入图片描述
什么情况下欠拟合:模型在训练集和验证集上准确率相差不大,却都很差,说明模型对已知数据和未知数据都不能准确预测,属于高偏差。 看左上角那个图。

什么情况过拟合:模型在训练集和验证集上的准确率差距很大,说明模型能够很好的拟合已知数据,但是泛化能力很差,属于高方差。 右上角那个图。

右下角那个图是比较合适的一个图了。所以看上面lgb的那个模型,效果还是不错的。

5.2 验证曲线

验证曲线和学习曲线很相近,不同的是这里画出的是不同参数下模型的准确率而不是不同训练集大小下的准确率。 这个用的不多, 所以这里简单的整理一下,可以做个对比:

这个是可以帮助我们可视化一些参数对于训练结果的影响效果的, 比如LGB的学习率这个参数, 主要是看看和学习曲线的绘制区别。

from sklearn.model_selection import validation_curve

param_range =  [0.01, 0.05, 0.1, 0.2]
model = LGBMRegressor()
train_scores, test_scores = validation_curve(model, X, Y_ln, param_name='learning_rate', param_range=param_range, cv=10)
train_mean = np.mean(train_scores, axis=1)
train_std = np.std(train_scores, axis=1)
test_mean = np.mean(test_scores, axis=1)
test_std = np.std(test_scores, axis=1)
plt.plot(param_range, train_mean, color='blue', marker='o', markersize=5, label='training accuracy')
plt.fill_between(param_range, train_mean + train_std, train_mean - train_std, alpha=0.15, color='blue')
plt.plot(param_range, test_mean, color='green', linestyle='--', marker='s', markersize=5, label='validation accuracy')
plt.fill_between(param_range, test_mean + test_std, test_mean - test_std, alpha=0.15, color='green')
plt.grid()
plt.xscale('log')
plt.xlabel('Parameter C')
plt.ylabel('Accuracy')
plt.legend(loc='lower right')
plt.ylim([0.8, 1.0])
plt.show()

这个结果如下:
在这里插入图片描述
从这里可以看出, 学习率在0.2的时候效果比较不错。

那么如果模型真的出现了过拟合或者欠拟合,我们应该怎么解决呢?

  • 对于欠拟合,也就是高偏差的问题,这时候可以尝试增加更多的特征, 好好做做特征工程那块,尽量挖掘更多的信息, 在模型上也可以下功夫,加大模型的复杂度, 减少正则化的系数等。boosting方式可以有效的减少模型的欠拟合。
  • 对于过拟合,也就是高方差的问题,我们可以尝试增加数据样本的数量,去掉一些噪声数据, 或者减少特征数量(这个其实不推荐),或者引入正则化的方式。 在模型上也可以下功夫,比如树模型,我们可以减少树的深度,减少模型的复杂度。 对于随机森林可以有效的减少模型的过拟合。

低偏差,高方差,该如何权衡? 先保证低偏差,这样得到模型在测试集上的最优得分,再调整高方差。

好了,今天的内容整理到这里基本上就结束了。

6. 总结

这一次主要是整理了一些模型选择和调参的一些技巧性的东西, 简单的总结一下, 首先是从简单的线性回归模型开始进行分析, 从里面得到了一些思路和想法。 其次介绍了K折交叉验证的思想和实现方式并且针对这个比赛,构造了一个线下的测试集。 在模型选择这块,通过分析确定了6个模型进行试验,给出了两个评估模型的框架,这两个框架都可以迁移到其他的任务上去。 通过分析交叉验证的结果确定了两个模型进入到调参环节。 模型调参部分,介绍了三种调参方式并且针对LGB进行了演示。 调参完毕之后, 又通过绘制学习曲线来观察模型是否出现了过拟合或者欠拟合的问题,并且给出了相应的解决方式。

总之吧, 模型调参这一块的复杂程度不亚于特征工程,是一个费时间的细活, 并且需要借助于很多经验,所以这一块更加需要多试错, 多思考,多积累经验,一次两次的是不会精通调参技术的, 入门也不一定能入门。 我这也是第一次调参, 我感受到了这一块的费时性和复杂性,待把这个系列整理完后(还剩最后一个模型融合和项目部署), 那时候再好好的玩玩这一块和特征工程。 下面依然是以一个思维导图把知识拎起来:
在这里插入图片描述
对了,如果把特征工程部分和模型调参这部分好好做做的话, 误差又会降低80多, 可以把误差降到500左右了, 所以从开始的baseline的700多,到EDA, 数据清洗后的580, 到现在的500左右, 这样一路走过来有没有点升级打怪的感觉,哈哈,加油 😉,下一次学习模型的融合技巧,可以在这次的基础上进一步提升模型效果, 关于本系列的所有代码,会在整理完最后一篇之后整理一下放入GitHub, 最后一篇的时候在收尾的时候会找机会放上链接。

参考:

PS: 本次数据挖掘学习,专题知识将在天池分享,详情可关注公众号Datawhale。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章