特征工程是我们在做机器学习工作中的重要一环,根据我的工作经验,其实特征工程可以分为两个部分,一个是特征生产,另一个是特征选择,今天我们就针对特征选择来进行一下探讨,并给大家介绍一个我在比赛中学到的新的把戏。想看这个把戏的可以直接跳转到使用Null importance 进行特征选择
特征选择的常见方法
特征选择是机器学习里面一个很老的话题了,常见的特征选择方法我觉得可以归为两大类,一类是基于贪心算法的,接近于暴力搜索。另一类是基于具体的机器学习模型。
基于贪心算法的特征选择
基于贪心算法的特征选择也有两种方式,一种是从零到全部,另一种是从全部到零,其实效果都差不多。前者被称为包裹式,后者被称为过滤式。简单来说包裹式就是:
- 随机选中一个特征,作为特征组F跑一遍模型,记分数$s_1$
- 随机选中一个未被选中过的特征与F一起跑一遍模型,记分数$s_2$。
- 如果$s_2 > s_1+b$,将新的特征加入到F中,若否,不加入。这里的b为可调节的阈值,通常为零。
- 重复2~3步骤,直到所有特征都被选择过,输出最终的特征组F。
过滤式其实就是一模一样的思路,只不过从大到小而已:
- 选取全部特征,作为特征组F跑一遍模型,记分数$s_1$
- 随机选中F中的一个特征屏蔽,跑一遍模型,记分数$s_2$。
- 如果$s_2 > s_1-b$,将该特征从F中去除,若否,不则不去除。
- 重复2~3步骤,直到所有特征都被测试过,输出最终的特征组F。
这就是非常简单常用的贪心算法特征性选择。
基于模型的特征选择
另外一种比较常用的特征选择方法,是基于特定机器学习模型的特性的,使用较多的是基于L1正则化的嵌入式特征选择,和基于随机森林的feature importance。
使用L1正则化进行特征选择
对于L1正则化,网上有很多详细解说的资料和图片,我这里就简单的讲下我的理解方式好了。L1正则化和L2正则化都是防止过拟合的方法,它们作为量化的惩罚项来阻止你将自己的模型变得越来越复杂。
举例来说假如我们要用线性回归来做一个理论,$y = k_1x_1+k_2x_2+k_3x_3$,这个式子就是我们理论假设,假设当然是在同样解释力下,越简单越好,L1正则化通过$|k_1|+|k_2|+|k_3|$来衡量复杂度,L2正则化通过$k_1^2+k_2^2+k_3^2)$来衡量。
可以看出,L2正则化中(1,0,0)这一参数组合的复杂度是比(0.5,0.5,0)的复杂度要高的,但是在L1正则化中(1,0,0)的复杂度和(0.5,0.5,0)、(0.4,0.3,0.3)是一样的,所以使用L1正则化时,更容易使一些不重要的特征的系数降为零,起到特征选择的效果。
因此与其他的特征选择方式不同,它本身并不会输出一个特征组,而是在训练的过程中把不重要的特征的系数调成零。当然如果你是在使用线性回归或者逻辑斯蒂回归之类的算法的话,可以输出各个特征最终的系数,来确认哪些特征被剔除了。
实际上L1、L2正则化的应用相当广泛,从线性回归(使用L1正则化的线性回归即lasso回归,使用L2的即是岭回归)到xgboost,lightgbm中,都可以使用l1、l2正则化。它一般作为loss的一个附加项来在训练过程中进行控制:
$$
loss’ = loss + alpha(|k_1|+|k_2|+|k_3|)+lambda(k_1^2+k_2^2+k_3^2)
$$
其中α和λ分别是L1、L2正则化的系数,用来控制其“惩罚力度”
使用Feature Importance进行特征选择
feature importance是随机森林的一个特色技能,随机森林会训练多个决策树,不同的决策树之间相互独立,并且在训练每个决策树时,仅使用部分特征与数据(关于随机森林想了解更多?可以参看谐门算法:集成算法们中的bagging与随机森林一节,决策树相关可以看谐门算法:手写智障决策树),这一特性得通过统计每个特征在不同决策树中的表现来为特征打分提供了可能。
在sklearn的决策树里提供了一个函数来输出feature importance,可以这样使用:
123456789 | from sklearn.ensemble import RandomForestClassifierclf = RandomForestClassifier()clf.fit(X,y)#输出feature importancesplit = clf.feature_importance(importance_type='split')gain = clf.feature_importance(importance_type="gain") |
上面我们看到随机森林可以输出两种feature importance,一种是split,一种是gain。
split就是特征在所有决策树中被用来分割的总次数。
gain就是特征在所有决策树种被用来分割后带来的增益(gain)总和(关于增益(gain)是决策树原理中的一个概念,可以参看谐门算法:手写智障决策树中关于分割与信息增益的部分)
两者都可以作为评价特征有用程度的指标。
不过这个方法当然也是有缺点的,主要一个就是,我们拿到一个feature importance之后,它只是一个数值,我们怎么知道数值大于多少才是有用的,数值小于多少就应该抛弃呢?我们接下来要讲的Null Importance方法就是试图解决这一问题。
使用Null importance 进行特征选择
有的时候模型其实很蠢,很多根本就和目标没有关联的东西,它也可以瞎扯一通和目标关联上,这种虚假的关联到测试集上当然就扑街了,这就是我们常说的过拟合。那么回到我们一开始的问题,如何在feature importance中画出一条线,来区分这个特征是否有用呢?
思想很简单,就是将特征分数与随机假特征的分数(即Null Importance)进行对比,假如特征的分数并不能明显超过null importance,那么证明这个特征是一个无用的特征。
实现
具体的实现方法可以有很多,我们这里介绍的一种来自于知名Kaggle Master级选手olivier的kernel(Feature Selection with Null Importances),主要代码均一致,我做了些小调整便于演示。
首先我们随手选一个数据集来作为演示用的数据集,额,鸢尾花之前用过了,波士顿房价就你了。
1234567 | from sklearn.datasets import load_boston,load_irisboston = load_boston()data = pd.DataFrame(boston["data"])#我们做两个随机的噪声项进去,看看噪声项的表现是怎么样的data["noise_1"] = np.random.normal(size=len(X))#正态分布data["noise_2"] = np.random.randint(100,size=len(X))#随机整数data["target"] = boston["target"] |
接下来是olivier的实现思路,他的实现不是添加新的噪声项,而是把原特征与目标的对应关系打乱。这样的好处是保存了特征本身的数值分布,同一个特征与打乱之后的自身比对,更有参考价值。
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253 | import pandas as pdimport numpy as npfrom sklearn.metrics import roc_auc_scorefrom sklearn.model_selection import KFoldimport timefrom lightgbm import LGBMClassifierimport lightgbm as lgbimport matplotlib.pyplot as pltimport matplotlib.gridspec as gridspecimport seaborn as snsdef (data, shuffle, seed=None): # 特征 train_features = [f for f in data if f not in ['target']] y = data['target'].copy() #在造null importance时打乱 if shuffle: # 为了打乱而不影响原始数据,采用了.copy().sample(frac=1.0)这种有点奇怪的做法 y = data['target'].copy().sample(frac=1.0) # 使用lgb的随机森林模式,据说会比sklearn的随机森林快点 dtrain = lgb.Dataset(data[train_features], y, free_raw_data=False, silent=True) lgb_params = { 'objective': 'regression_l2', 'boosting_type': 'rf', 'subsample': 0.623, 'colsample_bytree': 0.7, 'num_leaves': 127, 'max_depth': 8, 'seed': seed, 'bagging_freq': 1, 'n_jobs': 4 } #训练 clf = lgb.train(params=lgb_params, train_set=dtrain, num_boost_round=200) # Get feature importances imp_df = pd.DataFrame() imp_df["feature"] = list(train_features) imp_df["importance_gain"] = clf.feature_importance(importance_type='gain') imp_df["importance_split"] = clf.feature_importance(importance_type='split') return imp_df#我们先来看下原始的feature_importance好了actual_imp = get_feature_importances(data=data,shuffle=False)actual_imp.sort_values("importance_gain",ascending=False) |
两个噪音项竟然不是倒数第一,后面那几个特征也算有实力了。接下来我们制造一批Null Importance
123456789101112131415161718 | null_imp_df = pd.DataFrame()nb_runs = 80import timestart = time.time()dsp = ''for i in range(nb_runs): # 获取当前轮feature impotance imp_df = get_feature_importances(data=data, shuffle=True) imp_df['run'] = i + 1 # 加到合集上去 null_imp_df = pd.concat([null_imp_df, imp_df], axis=0) # 擦除旧信息 for l in range(len(dsp)): print('b', end='', flush=True) # 显示当前轮信息 spent = (time.time() - start) / 60 dsp = 'Done with %4d of %4d (Spent %5.1f min)' % (i + 1, nb_runs, spent) print(dsp, end='', flush=True) |
跑好了,选个特征来看看,就挑12号吧
1 | display_distributions(actual_imp_df_=actual_imp, null_imp_df_=null_imp_df, feature_=12) |
证明12号这个特征还是挺有用的,实力超过打乱后的自己很多,我们来挑个辣鸡的,9号,就你了。
9号你这个跟打乱了的null importance根本就没差别嘛。我们再来看看noise_1吧
到这里其实我们已经知道具体怎么使用null importance来鉴别特征了,你可以设置一个指标来简单的划分出特征,具体的方式可以有很多,比方说是否大于平均值啦,是否大于最大值啦太多了,看自己理解。olivier的方法是使用真实的importance分数和自己null importance的75%分位数(即四分之三位数,类似于中位数,50%分位数就是中位数)进行对比,计算出一个新的分数。公式是:
$$
score = log((1+actual_importance)/(1+null_importance_75))
$$
用这个score来表示特征的重要性,0就代表和随机无关联数据没啥区别,数字越大代表重要性越高。代码实现如下:
1234567891011121314151617181920212223 | feature_scores = []for _f in actual_imp['feature'].unique(): f_null_imps_gain = null_imp_df.loc[null_imp_df['feature'] == _f, 'importance_gain'].values f_act_imps_gain = actual_imp.loc[actual_imp['feature'] == _f, 'importance_gain'].mean() gain_score = np.log(1e-10 + f_act_imps_gain / (1 + np.percentile(f_null_imps_gain, 75))) # Avoid didvide by zero f_null_imps_split = null_imp_df.loc[null_imp_df['feature'] == _f, 'importance_split'].values f_act_imps_split = actual_imp.loc[actual_imp['feature'] == _f, 'importance_split'].mean() split_score = np.log(1e-10 + f_act_imps_split / (1 + np.percentile(f_null_imps_split, 75))) # Avoid didvide by zero feature_scores.append((_f, split_score, gain_score))scores_df = pd.DataFrame(feature_scores, columns=['feature', 'split_score', 'gain_score'])plt.figure(figsize=(16, 16))gs = gridspec.GridSpec(1, 2)# Plot Split importancesax = plt.subplot(gs[0, 0])sns.barplot(x='split_score', y='feature', data=scores_df.sort_values('split_score', ascending=False).iloc[0:70], ax=ax)ax.set_title('Feature scores wrt split importances', fontweight='bold', fontsize=14)# Plot Gain importancesax = plt.subplot(gs[0, 1])sns.barplot(x='gain_score', y='feature', data=scores_df.sort_values('gain_score', ascending=False).iloc[0:70], ax=ax)ax.set_title('Feature scores wrt gain importances', fontweight='bold', fontsize=14)plt.tight_layout() |
杂七杂八
其实如果嫌烦的话,跟我一样加几个噪音项进去,充当null importance作为特征的参考物,我觉得也是挺可行的。
靴靴
今天就到这里啦,靴靴。
原文:大专栏 谐门武学:特征选择与Null Importance