在数据挖掘流程中,特征工程是极其重要的环节,我们经常要结合实际数据,对某些类型的数据做特定变换,甚至多次变换,除了一些常见的基本变换(参考我之前写的『数据挖掘比赛通用框架』
)外,还有很多非主流的奇技淫巧。所以,尽管有sklearn.pipeline
这样的流水线模式,但依然满足不了一颗爱折腾数据的心。好在,我找到了一个小众但好用的库——sklearn_pandas,能相对简洁地进行特征工程,使其变得优雅而高效。
目前这个项目还在维护,大家有什么想法可以到 sklearn_pandas 的 github 主页提问题,以及获取最新的版本。
本文的pdf版本和数据集可通过关注『数据挖掘机养成记』公众号并回复我还要
获取,pdf版本排版比公众号给力
DataFrameMapper
sklearn_pandas 起初是为了解决这样一个问题:在 sklearn 的旧版本中,很多常见模块(特征变换器、分类器等)对pandas 的DataFrame
类型不支持,必须先用DataFrame
自带的.values
、.as_matrix
之类的方法,将DataFrame
类型转换成numpy 的ndarray
类型,再输入到sklearn 的模块中,这个过程略麻烦。因此 sklearn_pandas提供了一个方便的转换接口,省去自己转换数据的过程。
但当我花了几天时间探索了 sklearn_pandas 的库及其跟 pandas、sklearn 相应模块的联系后,我发现sklearn 0.16.0 向后的版本对 DataFrame的兼容性越来越好,经我实际测试,现在最新的 0.17.1 版本中,model、preprocessing等模块的大部分函数已完全支持 DataFrame 类型的输入,所以我认为:
sklearn_pandas 的重点不再是数据类型转换,而是通过其自创的
DataFrameMapper
类,更简洁地、把 sklearn 的transformer
灵活地运用在DataFrame
当中,甚至可以发挥你的聪明才智,将几乎大部分特征变换在几行代码内完成,而且一目了然。
sklearn_pandas 官方文档提供的例子比较少,我看了下它的源码,有以下重要发现
DataFrameMapper
继承自 sklearn 的BaseEstimator
和TransformerMixin
,所以DataFrameMapper
可以看做 sklearn 的TransformerMixin
类,跟 sklearn 中的其他Transformer
一样,比如可以作为Pipeline
的输入参数
DataFrameMapper
内部机制是先将指定的DataFrame
的列转换成ndarray
类型,再输入到 sklearn 的相应transformer
中
DataFrameMapper
接受的变换类型是 sklearn 的transformer
类,因而除了 sklearn 中常见的变换 (标准化、正规化、二值化等等)还可以用sklearn 的FunctionTransformer
来进行自定义操作
本文先介绍下如何用DataFrameMapper
类型进行特征工程,再将skleanr_pandas、sklearn、pandas 这三个库结合,应用到一个具体的数据挖掘案例中。
DataFrameMapper
做特征工程[注意]在正式进入本节前,建议先阅读本人之前写的『[scikit-learn]特征二值化编码函数的一些坑』,了解sklearn 和 pandas 常见的二值化编码函数的特性和一些注意点。
若输入数据的一行是一个样本,一列是一个特征,那简单的理解,『特征工程』就是列变换。本节将讲解如何用DataFrameMapper
结合sklearn 的Transformer
类,来进行列变换
首先import
本文将会用到的所有类(默认已装好scikit-learn, pandas, sklearn_pandas 等库)
import random import sklearn import pandas as pd import numpy as np import matplotlib.pyplot as plt
# frameworks for ML from sklearn_pandas import DataFrameMapper from sklearn.pipeline import make_pipeline from sklearn.cross_validation import cross_val_score from sklearn.grid_search import GridSearchCV
# transformers for category variables from sklearn.preprocessing import LabelBinarizer from sklearn.preprocessing import MultiLabelBinarizer from sklearn.preprocessing import LabelEncoder from sklearn.preprocessing import OneHotEncoder
# transformers for numerical variables from sklearn.preprocessing import MinMaxScaler from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import Normalizer
# transformers for combined variables from sklearn.decomposition import PCA from sklearn.preprocessing import PolynomialFeatures
# user-defined transformers from sklearn.preprocessing import FunctionTransformer
# classification models from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression
# evaluation from sklearn.metrics import scorer
我们以如下的数据为例
testdata = pd.DataFrame({'pet': ['cat', 'dog', 'dog', 'fish', 'cat', 'dog', 'cat', 'fish'], 'age': [4., 6, 3, 3, 2, 3, 5, 4], 'salary': [90, 24, 44, 27, 32, 59, 36, 27]})
『单列』可以是 1-D array,也可以是 2-D array,为了迎合不同的 transformer,但最终输出都是 2-Darray,具体我们看以下例子
mapper = DataFrameMapper([ ('pet', LabelBinarizer()), ('age', MinMaxScaler()), (['age'], OneHotEncoder()) ]) mapper.fit_transform(testdata)
我们分别对这三列做了二值化编码、最大最小值归一化等,但要注意,OneHotEncoder
接受的是2-D array的输入,其他是 1-Darray,具体请参考我之前写的『[scikit-learn]特征二值化编码函数的一些坑』。上面代码的运行结果如下
array([[ 1. , 0. , 0. , 0.5 , 0. , 0. , 1. , 0. , 0. ], [ 0. , 1. , 0. , 1. , 0. , 0. , 0. , 0. , 1. ], [ 0. , 1. , 0. , 0.25, 0. , 1. , 0. , 0. , 0. ], [ 0. , 0. , 1. , 0.25, 0. , 1. , 0. , 0. , 0. ], [ 1. , 0. , 0. , 0. , 1. , 0. , 0. , 0. , 0. ], [ 0. , 1. , 0. , 0.25, 0. , 1. , 0. , 0. , 0. ], [ 1. , 0. , 0. , 0.75, 0. , 0. , 0. , 1. , 0. ], [ 0. , 0. , 1. , 0.5 , 0. , 0. , 1. , 0. , 0. ]])
分别对应三种变换,前三列和后五列是pet
和age
的二值化编码,第四列是age
的最大最小值归一化。
同样,我们也可以将这些变换『级联』起来(类似 sklearn 里的pipeline
):
mapper = DataFrameMapper([ (['age'],[ MinMaxScaler(), StandardScaler()]), ]) mapper.fit_transform(testdata)
将age
列先最大最小值归一化,再标准化,输出结果:
array([[0.20851441],
[ 1.87662973],
[-0.62554324],
[-0.62554324],
[-1.4596009 ],
[-0.62554324],
[ 1.04257207],
[ 0.20851441]])
除了上面的单列变换,DataFrameMapper
也能处理多列
有时候我们要对很多列做同样操作,比如二值化编码、标准化归一化等,也可以借助于DataFrameMapper
,使得执行更高效、代码更简洁。
mapper = DataFrameMapper([ (['salary','age'], MinMaxScaler()) ]) mapper.fit_transform(testdata)
这里同时对age
和salary
进行归一化,结果如下
array([[ 1. , 0.5 ], [ 0. , 1. ], [ 0.3030303 , 0.25 ], [ 0.04545455, 0.25 ], [ 0.12121212, 0. ], [ 0.53030303, 0.25 ], [ 0.18181818, 0.75 ], [ 0.04545455, 0.5 ]])
同样,这些变换也可以级联
mapper = DataFrameMapper([ (['salary','age'], [MinMaxScaler(),StandardScaler()]) ]) mapper.fit_transform(testdata) array([[ 2.27500192, 0.20851441], [-0.87775665, 1.87662973], [ 0.07762474, -0.62554324], [-0.73444944, -0.62554324], [-0.49560409, -1.4596009 ], [ 0.79416078, -0.62554324], [-0.30452782, 1.04257207], [-0.73444944, 0.20851441]])
多列变换时,除了分别对每列变换,我们有时还需要对某些列进行整体变换,比如 降维(PCA, LDA) 和特征交叉等,也可以很便捷地借助DataFrameMapper
实现
mapper = DataFrameMapper([ (['salary','age'], [MinMaxScaler(), PCA(2)]), (['salary','age'],[MinMaxScaler(), PolynomialFeatures(2)]) ]) mapper.fit_transform(testdata) array([[-0.57202956, -0.4442768 , 1. , 1. , 0.5 , 1. , 0.5 , 0.25 ], [ 0.53920967, -0.32120213, 1. , 0. , 1. , 0. , 0. , 1. ], [-0.12248009, 0.14408706, 1. , 0.3030303 , 0.25 , 0.09182736, 0.07575758, 0.0625 ], [ 0.09382212, 0.28393922, 1. , 0.04545455, 0.25 , 0.00206612, 0.01136364, 0.0625 ], [-0.10553503, 0.45274661, 1. , 0.12121212, 0. , 0.01469238, 0. , 0. ], [-0.31333498, 0.0206881 , 1. , 0.53030303, 0.25 , 0.2812213 , 0.13257576, 0.0625 ], [ 0.2507869 , -0.20998092, 1. , 0.18181818, 0.75 , 0.03305785, 0.13636364, 0.5625 ], [ 0.22956098, 0.07399884, 1. , 0.04545455, 0.5 , 0.00206612, 0.02272727, 0.25 ]])
以上我们对age
和salary
列分别进行了PCA 和生成二次项特征
(写完此文后发现该功能并不是很work)
sklearn 中OneHotEncoder
类和某些处理文本变量的类(比如CountVectorizer
)的默认输出是sparse
类型,而其他很多函数输出是普通的 ndarray,这就导致数据拼接时可能出错。为了统一输出,DataFrameMapper
提供sparse
参数来设定输出稀疏与否,默认是False
。
(稳定版 1.1.0 中没有此功能,development 版本中有 )
从上面的实验中我们可以看到,对于我们指定的列,DataFrameMapper
将忠诚地执行变换,对于未指定的列,则被抛弃。
而真实场景中,对于未指定的列,我们可能也需要做相应处理,所以DataFrameMapper
提供default
参数用于处理这类列:
False
: 全部丢弃(默认)
None
: 原封不动地保留
other transformer
: 将 transformer 作用到所有剩余列上
不难发现,上面我们利用DataFrameMapper
所做的列变换,大多是调用sklearn
中现有的模块(OneHotEncoder
,MinMaxEncoder
,PCA
等),那如果遇到一些需要自己定义的变换,该怎么做呢?比如常见的对长尾特征做log(x+1)
之类的变换?
对 sklearn 熟悉的同学开动一下脑筋,答案马上就有了——那就是FunctionTransformer
,该函数的具体参数细节可参考sklearn 的官方文档,这里简单给个例子
mapper = DataFrameMapper([ (['salary','age'], FunctionTransformer(np.log1p)) ]) mapper.fit_transform(testdata) Out[32]: array([[ 4.51085951, 1.60943791], [ 3.21887582, 1.94591015], [ 3.80666249, 1.38629436], [ 3.33220451, 1.38629436], [3.49650756, 1.09861229], [ 4.09434456, 1.38629436], [ 3.61091791, 1.79175947], [ 3.33220451, 1.60943791]])
以上我们将 numpy 中的函数log1p
(作用等同于log(x+1)
)通过FunctionTransformer
包裹成一个sklearn 的transformer
类,就能直接作用在不同列上啦。
动手能力强的同学还可以自己定义函数,提示一下,用 numpy 的ufunc
,这里就不赘述了,留给大家探索吧。
基于以上内容,以及我对 sklearn、pandas 相关函数的了解,我总结了以下对比表格:
DataFrameMapper | sklearn 、pandas |
---|---|
对列的变换比较灵活,可筛选出一个或多个列,并用一个或多个 sklearn 的 transformer 作用,组合起来极其强大;同时通过继承机制,它本身也可以看做是 sklearn 的 transformer 类,输入 sklearn 的相关类(如Pipeline, FeatureUnion) | pandas.get_dummies只能简单地对一列或多列进行二值化变换,去掉某些列时得用 drop;sklearn.pipeline里的featureUnion只能对整个 DataFrame 做不同变换并简单拼接 |
返回的是ndarray类型,每列feature没有名字(不过,可以通过自己添加 column 的名字生成新的 DataFrame) | pandas.get_dummies可以给新生成的变量取名;对于dict类型的样本,sklearn的DictVectorizer有get_feature_name方法获取变换后的变量名 |
至此,DataFrameMapper
的精髓已悉数传授,想必大家已摩拳擦掌跃跃欲试了吧。OK,接下来进入实战!
鉴于不少网站私自爬取我的原创文章,我决定在文中插入二维码以维护来源,希望不会打扰到各位阅读
在进入实战前,先结合本人前作——『新手数据挖掘的几个常见误区』,简单梳理一下数据挖掘的流程:
数据集被分成训练集、验证集、测试集,其中训练集验证集进行交叉验证,用来确定最佳超参数。在最优参数下,用整个训练集+验证集上进行模型训练,最终在测试集看预测结果
我们这里结合一个实际的业务数据集(获取方式:关注公众号『数据挖掘机养成记』回复我还要
即可),来进行流程讲解。首先加载数据集
df = pd.read_csv("toy_data_sample.csv", dtype = {'Month': object,'Day':object, 'Saler':object}) df.head()
数据集字段如下
这是一个常见的时间序列数据集,所以我们按照时间上的不同,将其划分为训练集(1~5月)和测试集(6月)
Train = df[df.Month<'06'
][df.columns.drop('Month')] Test = df.ix[df.index.difference(Train.index), df.columns.drop(['Month'])] Trainy = Train.ix[:, -1]; Testy = Test.ix[:, -1]
常见的缺失值处理手段有
填充
丢弃
看做新类别
我们先简单统计一下每个字段的空值率
Train.count().apply(lambda x: float(Train.shape[0]-x)/Train.shape[0]) Out[5]: Day 0.000000
Cost 0.000000
Continent 0.000000
Country 0.000000
TreeID 0.000000
Industry 0.000000
Saler 0.329412
Label 0.000000
dtype: float64
这组数据比较理想,只有Saler
字段是缺失的,所以我们只需要看下Saler
和目标变量之间的关系
tmp = pd.DataFrame({'null': Train.Label[Train.Saler.isnull()].value_counts(), 'not_null': Train.Label[Train.Saler.notnull()].value_counts()}) tmp = tmp.apply(lambda x: x/sum(x)) tmp.T.plot.bar(stacked = True)
结果如下
以上结果表明空值对预测结果似乎有些影响,所以我们暂且将空值看做一类新的类别:
Train['Saler'] = Train.Saler.apply(lambda x: "NaN" if pd.isnull(x) else x) Test['Saler'] = Test.Saler.apply(lambda x: "NaN" if pd.isnull(x) else x)
长尾分布也是一种很常见的分布形态,常见于数值类型的变量,最简单的方法是用log(x+1)
处理。在我们的数据集当中,Cost
这个字段便是数值类型,我们看下它的分布:
plt.figure(1) Train.Cost.apply(lambda x: x/10).hist() plt.figure(2) Train.Cost.apply(lambda x: np.log(x+1).round()).hist()
log 变化的效果还是不错的,变量的分布相对均衡了。
通过上面简单的数据探查,我们基本确定了缺失值和长尾特征的处理方法,其他类别变量我们可以做简单的 One-hot编码,整个策略如下
字段 | 变换 |
---|---|
‘Cost’ | Standardization |
‘Cost’ | Log -> Binarization |
‘TreeID’ | Cut-off -> Binarization |
other categorial feature | Binarization |
在确定好特征工程的策略后,我们便可以上我们的大杀器——DataFrameMapper
了,把所有的变换集成到一起
feature_mapper = DataFrameMapper([ (['Cost'], [FunctionTransformer(np.log1p), FunctionTransformer(np.round), LabelBinarizer()]), (['Cost'],[Normalizer(),StandardScaler()]), (['Day'],OneHotEncoder()), (['Day'], FunctionTransformer(lambda x: x%7)), ('Continent', LabelBinarizer()), ('Country', LabelBinarizer()), ('Industry', LabelBinarizer()), ('Saler', [LabelBinarizer()]), ('TreeID', [FunctionTransformer(lambda x: string_cut(x,0,2), validate=False), LabelBinarizer()]), ('TreeID', [FunctionTransformer(lambda x: string_cut(x,2,4), validate=False), LabelBinarizer()]), ])
特征工程完毕后,便是交叉验证。交叉验证最重要的目的是为了寻找最优的超参数(详见本人前作『新手数据挖掘的几个常见误区』),通常我们会借助sklearn 中的KFold
,train_test_split
,metric.score
等来进行交叉验证,这里简化起见,我们直接用 GridSearchCV
,但要注意的是,GridSearchCV
对FunctionTransformer
类的支持不好,尤其有lambda 函数时。所以为简化起见,我们注释掉上面使用了lambda 函数的FunctionTransformer
类(有兴趣的同学可以尝试抛弃GridSearchCV
,手动进行交叉验证)。
这里我们选用最常见的LogisticRegression
,并调整它的超参数——正则系数C
和正则方式penalty
(对此不熟悉的同学赶紧补下『逻辑回归』的基础知识)。同时如前面所讲,我们用pipeline
把特征工程和模型训练都流程化,输入到GridSearchCV
中:
pipe = make_pipeline(feature_mapper,LogisticRegression()) pipe.set_params(logisticregression__C=1,logisticregression__penalty='l1' ) grid = GridSearchCV(pipe, cv=3,param_grid={'logisticregression__C':np.arange(0.1,2,0.3),'logisticregression__penalty': ['l1','l2']}, n_jobs = 4, scoring ='accuracy') grid.fit(Train, Trainy)
我们定义了三折交叉验证(cv = 3),并选用准确率(scoring = ‘accuracy’)作为评估指标,运行结果如下:
print grid.best_params_ , grid.best_score_
Out: {'logisticregression__penalty': 'l2', 'logisticregression__C': 0.10000000000000001}0.752941176471
最佳超参数是取 L2 正则,并且正则系数为 0.1
在得到模型的最优超参数后,我们还需要在训练集+验证集上进行特征变换,并在最优超参数下训练模型,然后将相应特征变换和模型施加到测试集上,最后评估测试集结果。
而现在,这一系列流程被GridSearchCV
大大简化,只需两行代码即可搞定:
predy = grid.predict(Test) scorer.accuracy_score(predy, Testy)
最后结果为0.6166666666666667
,即测试集上的分类准确率。
行文至此,洋洋洒洒千言,但依然只是完成了数据挖掘中最基本的流程,所做的特征变换和选用的模型也都非常简单,所以还有很大的提升空间。
此处以下留两个点,可以动手实践,也欢迎在群里探讨(群二维码见第6节『Bonus』)
当选用的 model 不是 sklearn 中的模块时(比如 xgboost),特征工程还可以用 sklearn_pandas 的DataFrameMapper, 但 sklearn 中傻瓜模式的 pipeline 就无从作用了,必须自己搭建 crossvalidation 流程
bad case 也有分析的价值
从单模型到模型的 ensemble
sklearn_pandas 官方文档、源码及 github 上的 issues
pandas、scikit-learn 官方文档
寒小阳的博客(http://blog.csdn.net/han_xiaoyang/article/details/49797143)