在Kaggle上看到了一个专门训练特征编码的竞赛,其中一个Kernel讲了常用的几种特征编码的手段,基于这篇教程做了些扩展学习。
用于数据分析的特征可能有多种形式,需要将其合理转化成模型能够处理的形式,特别是对非数值的特征,特征编码就是在做这样的工作。
常见特征种类
二值数据:只有两种取值的变量(不一定是0/1,但是可以映射到
{
0
,
1
}
\{0,1\}
{0,1}上)类别数据:多类的数据,如星期一/星期二/…,不一定是非数值的有序数据:如对电影的打分,分数之间是有大小关系的标称(Nominal)数据:和类别数据很像,往往是非数值的,但是不具备类别概念,如人名时序数据:带有瞬时值性质的数据,如日期、时间戳等。从这类数据可以分析随时间的趋势
特别注意区分类别数据特征和标称数据特征,有时标称特征可以删除,有时需要将同标称数据聚合到一起做处理。
以下df_train是全部训练数据,除各个特征外还含有从0开始索引的id列,以及只能取0/1的标签target列。df_train被拆分成了不含target的X,以及仅含target的目标y。
注意,特征编码的过程如果没有用到输出值target,往往需要将所有数据(全部的训练集和测试集)放在一起做,并且要在将训练集划分为实际训练集和验证集这一步骤之前。在后面的例子中只演示了编码的手段,没有考虑这些问题。
1 标号编码(Label encoding)
标号编码是最简单的编码方式,通过为特征中的每个类别设置一个标号(0,1,2…),来将非数值特征映射到自然数的数值空间上。
from sklearn.preprocessing import LabelEncoder
train = pd.DataFrame() # 用于存放编码后的训练数据
label = LabelEncoder() # 标号编码器
for c in X.columns: # 对每个特征列
if X[c].dtype=='object': # 如果是字符串形式的(字符串读到pandas里dtype是object)
train[c] = label.fit_transform(X[c]) # 将整个这一列进行标号编码,写到新的dataframe里
else: # 其它类型的特征(数值,布尔)保持原样写入
train[c] = X[c]
标号编码后,特征数量不变。
2 one-hot编码
one-hot编码已经用的很多了,不再赘述。一个特征有多少种取值,就会派生出多少个小特征,对每个样本而言其中只有一个是1对应于原特征取值,其它都取0,此即"one-hot"。
一种实现是用pandas的get_dummies(),但是很慢:
# 因为one-hot只有0和1,所以变成int8节约下内存
train = pd.get_dummies(X).astype(int8)
可以用sklearn预处理包下的OneHotEncoder:
from sklearn.preprocessing import OneHotEncoder
one = OneHotEncoder() # one-hot编码器
one.fit(X)
train = one.transform(X)
如果不知道用什么编码,直接用one-hot编码后喂入模型的效果往往比直接使用其它编码好。one-hot编码后的特征会变得非常多,得到的是一个稀疏矩阵,其类型是scipy.sparse.csr.csr_matrix(矩阵的稀疏表示)。
one-hot编码可以视为二进制编码(n-hot)在n=1时的情况。
基于决策树的模型不要使用one-hot编码,会导致结点划分不均衡。
3 特征哈希(Feature hashing)
特征哈希先指定一个维度数,哈希后的值将映射到这个空间内。一个较一般的实现是对样本的每个特征取哈希,然后在对应的维度上将其加1,所得结果也是一个稀疏矩阵。
特征哈希除了用于特征编码,也可以用于特征降维。在sklearn.feature_extraction.FeatureHasher中显式指定参数n_features(默认是1048576)即可调整维度。
from sklearn.feature_extraction import FeatureHasher
X_train_hash = X.copy()
for c in X.columns:
X_train_hash[c] = X[c].astype('str') # 均转为字符串类型,以用于后续取哈希值
hashing = FeatureHasher(input_type='string')
train=hashing.transform(X_train_hash.values)
特征哈希可以有多种实现,应当注意sklearn中的具体实现是什么样的。
4 基于统计的类别编码
即将类别特征变成关于它的一种统计量,如将该类别变成该类别在样本中出现的次数,这在有些问题中是有效的,比如纽约和新泽西都是大城市,出现次数都会很多,通过计数的类别编码,模型可以从数值里接受“都是大城市”这个信息。
X_train_stat = X.copy()
for c in X_train_stat.columns:
if(X_train_stat[c].dtype=='object'): # 对字符串的特征(不一定是类别特征!这里笼统地都按照类别特征处理)
X_train_stat[c] = X_train_stat[c].astype('category') # 变成pandas中的[类别数据]
counts = X_train_stat[c].value_counts() # 查看[类别数据]中有哪些不同的值,并计算每个值有多少个重复值
counts = counts.sort_index() # 按值对Series进行排序
counts = counts.fillna(0) # 用0替换所有NaN值
counts += np.random.rand(len(counts))/1000 # 对每个[类别数据]的类别的重复值数增加一个扰动
X_train_stat[c].cat.categories = counts # 为每个类别分配新值(实际上是重命名每个类别),这会写回到dataframe
基于统计的类别编码不会改变特征数目。
5 对循环特征的编码
有写特征(如星期、月份)具备循环性质,一月到十二月,紧接着又回到一月。均匀的循环特征可以视为对圆的均匀分割: 可以用极坐标系上的角度来描述循环特征的每个位置,而角度可以用sin值和cos值唯一确定。
X_train_cyclic = X.copy()
columns = ['day','month'] # 要操作的两列循环特征
for col in columns:
# 对每个循环特征派生两个特征列,如对于4月,是2pi*4/12,取sin和cos即可
X_train_cyclic[col+'_sin'] = np.sin((2*np.pi*X_train_cyclic[col])/max(X_train_cyclic[col]))
X_train_cyclic[col+'_cos'] = np.cos((2*np.pi*X_train_cyclic[col])/max(X_train_cyclic[col]))
X_train_cyclic=X_train_cyclic.drop(columns,axis=1) # 删除原来的循环特征列
6 目标编码(Target encoding)
这是基于特征和目标target值之间的对应关系的一种编码,这类编码没法把测试集和训练集放一起编码(因为测试集没有target)。
下面对每个特征c的每个取值,将其变成使target为1的频率,也即:
这
个
取
值
在
该
特
征
上
出
现
时
,
t
a
r
g
e
t
为
1
的
次
数
这
个
取
值
在
该
特
征
中
的
出
现
总
数
\frac{这个取值在该特征上出现时,target为1的次数}{这个取值在该特征中的出现总数}
这个取值在该特征中的出现总数这个取值在该特征上出现时,target为1的次数
X_target = df_train.copy()
# 这两特征不能当数值处理,不妨转为object类型同其它非数值特征一起做目标编码
X_target['day'] = X_target['day'].astype('object')
X_target['month'] = X_target['month'].astype('object')
for col in X_target.columns:
if (X_target[col].dtype=='object'):
# target非1即0,对类别分组后把target全加起来也就是1的次数;统计分组后的数目即是该类别样本数目
target = dict(X_target.groupby(col)['target'].agg('sum')/X_target.groupby(col)['target'].agg('count'))
# 上一步得到了映射的字典,据此将类别替换为目标编码后的值
X_target[col] = X_target[col].replace(target).values
7 K折目标编码(K-Fold Target encoding)
这是对6 目标编码的改进,可以起到抑制目标编码导致的过拟合的问题(测试数据中的特征到target的情况可能无法用训练数据根据类别的聚合和简单统计频率来描述)。K折目标编码将要编码的样本分成K份,每其中一份中的样本的目标编码,使用的是另外K-1份数据中相同类别的那些样本的频率值。
from sklearn.model_selection import KFold
X_fold = X.copy()
# 数值形式的类别数据->字符串形式
X_fold[['ord_0','day','month']] = X_fold[['ord_0','day','month']].astype('object')
# 二进制数据->0/1数值
X_fold[['bin_3','bin_4']] = X_fold[['bin_3','bin_4']].replace({'Y':1,'N':0,'T':1,"F":0})
# 分成K=5折
kf = KFold(n_splits = 5, shuffle = False, random_state=2019)
for train_ind,val_ind in kf.split(X): # val_ind是K中的1块数据的索引,而train_ind是剩下的K-1块数据的索引
for col in cols:
if(X_fold[col].dtype=='object'):
# 用K-1块数据计算Target encoding,记录到字典
replaced = dict(X.iloc[train_ind][[col,'target']].groupby(col)['target'].mean())
# 用刚刚计算出的映射对这1块内容做Target encoding
X_fold.loc[val_ind,col] = X_fold.iloc[val_ind][col].replace(replaced).values
Target encoding不会改变特征数目,但编码的计算开销会明显比其它编码方式大。