介绍一位可能过时的咖狗神器XGBoost

介绍一位可能过时的咖狗神器XGBoost

Part I:特征工程

Part II:模型部分


前言:

最近做了一个实验室内部的数据挖掘比赛,训练集11w,测试集约2w,属于数据不均衡的二分类问题,正负例之比大约1:350。

本文介绍data mining类比赛的特征工程,以及xgboost的使用与调参,并附上部分代码。(xgboost应该很多人用过了,本文是给还不会用的同学看的)


Part I:特征工程

在很多数据挖掘比赛中,特征工程是非常重要的一部分。(因为用不上深度学习方法啊!不能自动学习特征)因此我们需要人工处理一些特征,而机器学习类算法又比较依赖于好的特征,所以特征工程是肯定要做的。

这里我用pandas来读取csv文件和处理字符特征和缺失值,用sklearn来做数据归一化,注意:pandas可以做的事情sklearn一般也可以做,根据个人喜好选一个即可。


1. 字符特征处理

数据挖掘类的特征一般有两种形式,数值型和字符型,我们一般需要把字符型特征也转换成数值型特征。(也可以直接丢弃一些对任务不重要的字符特征)

常见的字符型特征又可以分成以下三种形式:

1)值域范围比较小的字符特征:比如性别,颜色,这种特征相当于类别,而且类别种类一般比较小。如果我们直接把它们转化成数字类别,模型可能会觉得这也是有顺序的数值型属性,比如,性别男是1,女是2,其中1<2,模型可能会认为属性男<女。

因此,我们可以使用one hot形式,避免这种数值比较所带来的影响。性别有两个属性男和女,那么男可以表示为[1, 0],女可以表示为[0, 1]。

Pandas中提供了get_dummies函数把字符特征转换成one hot形式:

dummies = pd.get_dummies(dframe[feat]) #获取one hot特征
dummies = dummies.rename(columns=lambda x: feat+str(x)) # 改特征名
dframe = pd.concat([dframe,dummies],axis=1) #把新特征加入原始数据集

2)字符+数值组合型字符串特征:这种特征比较常见,比如AT18,而且通常值域范围相对比较大,如果也转换成one hot特征,会加大特征维度,而xgboost对高维特征处理得不够好。(或者可以先one hot,再用pca进行降维?)

一种很粗暴的方法是,我们可以把整个组合型特征看成是一个字符特征,直接映射成一个数值,pandas提供了factorize()函数:

dframe[feat+'_toNum'] = pd.factorize(dframe[feat])[0]

或者,先用正则表达式把字符和数字分别提取出来,再单独对字符进行映射(数字无需再映射),将原始的一个字符串特征转化成两个数值型特征。

dframe[feat+'_str'] = dframe[feat].map( lambda x: re.compile('([a-zA-Z]+)').search(str(x)).group())
dframe[feat+'_str'] = pd.factorize(dframe[feat+'_str'])[0]

dframe[feat+'_num'] = dframe[feat].map(lambda x: match_number(x)).astype(int)+1  
def match_number(value):
    match = re.compile("([0-9]+)").search(str(value))
    if match: return match.group()
    else: return 0

关于正则表达式:re.compile用于编译正则表达式,生成一个pattern;re.search 扫描整个字符串,从字符串中匹配compile所定义的pattern,最后返回第一个成功的匹配;re.group()返回符合这个pattern的字符串。

3)时间型字符特征,这种特征一般包括年月日时分秒,值域非常大。

一种最粗暴的方法是,分别把年月日时分秒提取出来:(我只提取了年月信息,个人觉得其余特征不是很重要,得看具体任务)

dframe['year'] = dframe.apply(lambda x: int(x[feat][:4]), axis=1)
dframe['month'] = dframe.apply(lambda x: int(x[feat][5:7]), axis=1)
#dframe['day'] = dframe.apply(lambda x: int(x[feat][8:10]), axis=1)
#dframe['hour'] = dframe.apply(lambda x: int(x[feat][11:13]), axis=1)
#dframe['minute'] = dframe.apply(lambda x: int(x[feat][14:16]), axis=1)
#dframe['second'] = dframe.apply(lambda x: int(x[feat][17:19]), axis=1)

或者划分成几个时间区域,比如2017年前和2017年后,或者上午下午和晚上。


2. 缺失值处理

缺失值几乎是一定会存在的,常见的解决方法用:

1)直接以0.9的缺失概率丢弃该列。

假如原数据集中缺失值非常多(占到0.9以上),那么这一列特征其实意义不大,我们可以直接丢弃。

for feat in feat_cols:
    if dtrain[feat].isnull().sum() > 100000: #这是我自行估算的数值
        drop_list.append(feat)

我没有直接修改原始数据集,定义了drop_list表示我不用的特征列,str_list表示需要进行处理的字符型特征列(最后也会加入drop_list),最后得到use_list作为我使用的特征列:

drop_list.extend(str_list)
use_list = [x for x in dtrain.columns if x not in drop_list]
train_x, train_y = dtrain[use_list], dtrain['label']

2)使用中位数,或者众数填补。

注意到我们有训练集和测试集,我们也要用训练集中的均值去填补测试集中的缺失值,而不是用测试集的均值进行填补。这里我用的是sklearn中preprocessing模型的函数:

# 将pd形式的数据转化成np形式
train_x, train_y = dtrain[att_list], dtrain['label']
test_x = dtest[att_list]
train_x, test_x = np.asarray(train_x), np.asarray(test_x)

train_imp = Imputer(missing_values='NaN',strategy='mean',axis=0)
train_imp.fit(train_x)
train_x = train_imp.transform(train_x)
test_x = train_imp.transform(test_x)

3)使用随机森林预测缺失值

假设,我们需要预测某一列的缺失值,那么先把该列数据分成有值的部分y_train,和缺失值部分,然后在有值的部分将除该列外的特征定义为x_train,缺失值部分的所对应的新特征就是x_test。我们使用x_train进行训练,作为预测器输入的新特征,使用y_train作为预测的特征值。训练完之后,再使用x_test对缺失值进行预测填补。

y_train = dframe[feat][dframe[feat].notnull()].values
x_train = dframe[dframe[feat].notnull()].drop([feat],axis=1).values
x_test = dframe[dframe[feat].isnull()].drop([feat],axis=1).values
rfc = RandomForestClassifier().fit(x_train, y_train)
dframe[feat][dframe[feat].isnull()] = rfc.predict(x_test)

或者也可以用别的机器学习方法进行预测,预测缺失值一般比直接填补缺失值效果要好一点,但是需要注意过拟合问题。


3. 数据归一化

至此,特征已经全部处理成非空数值型,然后我们来看一下数据特征,观察一下数据的最值,均值,和缺失值:

print(dframe.describe())

可以看到不同特征有很大差别,不同特征往往具有不同的量纲和量纲单位,所以我们要对列特征进行归一化,避免某些值域范围大的特征占到比较大的影响。

1)Z-Score标准化

先减去均值再除以方差,将每一列数据归一化到0均值,方差为1(即标准正态分布),可以衡量分值偏离均值的程度,具体公式为:

X_{scale} = \frac{x-\mu}{\sigma}

sklearn中提供了Normalizer()函数进行归一化:

normalizer = preprocessing.Normalizer().fit(train_x)
train_x = normalizer.transform(train_x)
test_x = normalizer.transform(test_x)
# 转化回pd形式,因为后面会用到DataFrame形式
train_x, test_x = pd.DataFrame(train_x), pd.DataFrame(test_x)

我们需要对训练集和测试集都进行归一化,这里先用训练集的数据进行归一化,再根据训练集的数据特征对测试集进行归一化。

2)Min-Max归一化

对原始数据进行线性变换,将属性缩放到一个[0,1]之间,公式为:

X_{scale} = \frac{x-min}{max-min}

可以看到,这里需要计算数据集中的max和min值,那么当有新数据加入时,可能导致max和min的变化,需要重新定义,这也是这种方法的一个缺陷。

sklearn中提供了MinMaxScaler()函数进行归一化:

min_max_scaler = preprocessing.MinMaxScaler()
minmax_train = min_max_scaler.fit_transform(train_x)
minmax_test = min_max_scaler.transform(test_x)
train_x, test_x = pd.DataFrame(minmax_train), pd.DataFrame(minmax_test)


4. 重采样

对于不均衡数据,理论上有以下两种方法可以缓解:

1)欠采样,丢弃训练集的一些负例,使得正负例比重接近。

df_pos = dtrain[dtrain['label']==1] 
df_neg = dtrain[dtrain['label']==1] 
df_neg_sample = df_neg.sample(frac=0.4)  #对负样本进行采样
df = pd.concat([df_pos, df_neg_sample], ignore_index=True)

这里我只是选择了40%的负样本,如果严格按照正负例比例相等的话,要丢弃更多负样本,可能会损失很多重要信息。

2)过采样,增加训练集中的正例。

可以对正例特征增加一些干扰,作为新的正例。


Part II:模型部分

在众多分类算法中,xgboost就是kaggle大杀器级别,效果好,速度快(并行处理,注意这里的并行处理不是以树为单位并行,是以特征为单位并行),内置交叉验证,正则化提升,很适合不均衡数据,可以自定义优化目标和评价指标,工具包还能自己处理缺失值和字符型特征,也可以比较好得处理相关特征,省心又省事。

我在这个task上,随便跑个xgboost就能比svm和神经网络效果好。但是可能会过拟合,虽然在pubilc board上排名高,有可能在private board上扑街。

另外说明:xgboost优点不需要做太多特征工程。

关于归一化:因为树会对特征值进行排序,自动选择特征,那么我们做不做归一化,特征的顺序都是不变的,所以可以不用做归一化。

关于缺失值:xgb可以自己学习缺失值的分裂方向,对于某个缺失的特征,xgb会分别计算其属于左子树和右子树的损失减少量得分,然后选择比较好的分裂方向。

关于onehot:onehot比较适合处理无序的类别特征,但是会增加特征维度,增加树的深度,因此onehot用在xgb 我只用于处理值域比较小的类别特征。


1. 模型定义

这里我用的是xgboost.sklearn中的XGBClassifier分类器,和xgb包是不同的。

题外话:我安装xgboost看教程装了好久都装不上,最后我万念俱灰一句 pip install xgboost 竟然直接就装好了。

首先,我们先来定义一个基础分类器:

from xgboost.sklearn import XGBClassifier
xgb = XGBClassifier(learning_rate =0.1, n_estimators=5000, max_depth=5,
		min_child_weight=1, gamma=0, subsample=0.9,
		colsample_bytree=0.8, reg_alpha=1e-5,
		objective= 'binary:logistic',
		nthread=4, scale_pos_weight=1, seed=27)

参数说明如下:

Booster本身的参数:max_depth、min_child_weight和gamma控制了模型复杂度,alpha用于正则化,subsample和colsample_bytree进行随机化。

max_depth是数的最大深度,默认值是6,常用值是3-10,通过把值调小来控制过拟合,因为比较深的树可能会学习到特定样本的一些特定特征。

min_child_weight是子节点中最小的样本权重和,作为是否停止拆分过程的阈值。通过把值调大来控制过拟合,但是如果这个阈值设置过大,也看导致欠拟合。

subsample是用于训练模型的子样本占整个样本集合的比例,也用于控制过拟合。通过把值调小来控制过拟合,使得模型更加conservative,太小也有欠拟合风险。

colsample_bytree是在建树时对特征采样的比例。

alpha是L1正则的惩罚系数,这里不对偏置项进行正则。


Task所需要的参数:

learning_rate是学习率,seed是随机种子,

objective是目标函数,二分类问题可以使用'binary:logistic',多分类使用'multi:softmax'且设置类别,线性回归使用'reg:linear',逻辑回归使用'reg:logistic'。


2. 模型训练

首先,我们需要把原始训练集划分多份训练集和验证集,进行交叉验证,避免模型过拟合。useTrainCV用于设置交叉验证,cv_folds设置分成几份数据集,一般进行五折交叉验证。

xgb_param = alg.get_xgb_params()
xgtrain = xgb.DMatrix(x.values, label=y.values)
cvresult = xgb.cv(xgb_param, xgtrain, 
		num_boost_round=alg.get_params()['n_estimators'], nfold=cv_folds,
            metrics='auc', early_stopping_rounds=60)
alg.set_params(n_estimators=cvresult.shape[0])

并且设置early_stopping_rounds,也是对付过拟合的一种方法。

在xgboost中,直接使用model.fit(x, y, eval_metric),就可以根据评估指标,对x和y进行拟合来训练模型了。

alg.fit(x, y, eval_metric='auc')

根据具体任务,评估指标可以设置为auc,acc等等。


3. 模型预测

根据训练数据fit好模型后,我们需要在验证集或者测试集上进行预测。

使用alg.predict可以得到预测的类别,使用predict_proba可以得到预测的类别概率,我们使用auc评估指标,所以提交的是预测的类别概率。

dtrain_predictions = alg.predict(x)
dtrain_predprob = alg.predict_proba(x)[:,1]

如果是在验证集上,根据label值可以查看对应的衡量指标:

print ("Accuracy : %.4g" % metrics.accuracy_score(y.values, dtrain_predictions))
print ("AUC Score (Train): %f" % metrics.roc_auc_score(y, dtrain_predprob))


4. 模型调优

同一个模型,不同参数可能会有不同的结果,我们想尽量找到比较优秀的参数,就需要进行调参工作,根据模型结果不断尝试新参数,提高模型结果。

假设我们现在要对max_depth进行调优,那么我们先设置max_depth的调参范围:

param_test = {
    'max_depth' : [i for i in range(3,10)]
}

然后固定其余参数,使用网格法选择最佳的一组参数:

gsearch = GridSearchCV(
             estimator = XGBClassifier(learning_rate =0.1, gamma=0.1, subsample=0.9, 
	               n_estimators=140, max_depth=9, min_child_weight=1, 
		       colsample_bytree=0.9, reg_alpha=1e-5, 
                       objective= 'binary:logistic', nthread=4, scale_pos_weight=1, seed=27), 
	     param_grid = param_test, scoring='roc_auc',n_jobs=4,iid=False, cv=5)
gsearch.fit(x, y)
print(gsearch.grid_scores_, '\n', gsearch.best_params_, '\n', gsearch.best_score_)

另外附上我的一些调参范围设置:

param_test = {
	'max_depth': [i for i in range(3,10)],
	#'min_child_weight': [i for i in range(1,6)]

	#'subsample':[i/10.0 for i in range(6,10)],
	#'subsample':[i/100.0 for i in range(85,100,5)],

	#'colsample_bytree':[i/10.0 for i in range(6,10)]
	#'colsample_bytree':[i/100.0 for i in range(85,100,5)]
        
	#'gamma':[i/10.0 for i in range(0,5)]
        #'learning_rate':[1e-4, 1e-3, 1e-5, 1e-2, 0.1]
	#'reg_alpha':[1e-4, 1e-6, 1e-3, 1e-5, 1e-2, 0.1, 1, 10]
	#'n_estimators': [140, 100, 80, 200]
}

可以先把范围设置比较大一些,找到一个比较好的区域后,再进一步细化区域。

在设置参数的时候,可以先观察模型在训练集和验证集上的误差和方差,先判断模型是过拟合还是欠拟合,再进行调参。

如果方差比较大,说明可能过拟合,如果偏差比较大,说明可能欠拟合。

如果过拟合:比如把max_depth调小,min_child_weight调大,subsample调小等等。

如果欠拟合:与上面相反。

调整好参数后,可以再观察一下误差和方差,看有没有改进。


xgboost代码篇到此,后面如果有时间的话,可能会写一个xgboost原理篇。

参考链接:Complete Guide to Parameter Tuning in XGBoost

编辑于 2018-04-23 20:34