离散型特征编码

简介

综合转载于:

Category Encoders

关于离散型编码的Python库,里面封装了十几种(包括文中的所有方法)对于离散型特征的编码方法,接口接近于Sklearn通用接口,非常实用,这个库的链接

OneHot Encoder/Dummy Encoder/OHE

大家熟知的OneHot方法就避免了对特征排序的缺点。对于一列有N种取值的特征,Onehot方法会创建出对应的N列特征,其中每列代表该样本是否为该特征的某一种取值。因为生成的每一列有值的都是1,所以这个方法起名为Onehot特征。但是在离散特征的特征值过多的时候不宜使用,因为会导致生成特征的数量太多且过于稀疏。

举个例子,假设我们以学历为例,我们想要研究的类别为小学、中学、大学、硕士、博士五种类别,我们使用one-hot对其编码就会得到:

dummy encoding

哑变量编码直观的解释就是任意的将一个状态位去除。还是拿上面的例子来说,我们用4个状态位就足够反应上述5个类别的信息,也就是我们仅仅使用前四个状态位 [0,0,0,0] 就可以表达博士了。只是因为对于一个我们研究的样本,他已不是小学生、也不是中学生、也不是大学生、又不是研究生,那么我们就可以默认他是博士,是不是。(额,当然他现实生活也可能上幼儿园,但是我们统计的样本中他并不是,^-^)。所以,我们用哑变量编码可以将上述5类表示成:

one-hot编码和dummy编码:区别与联系

通过上面的例子,我们可以看出它们的“思想路线”是相同的,只是哑变量编码觉得one-hot编码太罗嗦了(一些很明显的事实还说的这么清楚),所以它就很那么很明显的东西省去了。这种简化不能说到底好不好,这要看使用的场景。下面我们以一个例子来说明:

假设我们现在获得了一个模型$\mu=\beta_0+\beta_1 X_1+\beta_2 X_2+\beta_3 X_3$,这里自变量满足$X_1+X_2+X_3=1$(因为特征是one-hot获得的,所有只有一个状态位为1,其他都为了0,所以它们加和总是等于1),故我们可以用表$X_3=1-X_1-X_2$示第三个特征,将其带入模型中,得到:

这时,我们就惊奇的发现$\left(a_0, a_1, a_2, a_3\right)$和$\left(a_0+a_3, a_1-a_3, a_2-a_3, 0\right)$这两个参数是等价的!那么我们模型的稳定性就成了一个待解决的问题。这个问题这么解决呢?有三种方法:

  1. 使用L2正则化手段,将参数的选择上加一个限制,就是选择参数元素值小的那个作为最终参数,这样我们得到的参数就唯一了,模型也就稳定了。

  2. 把偏置项去掉,这时我们发现也可以解决同一个模型参数等价的问题。

​ 因为有了bias项,所以和我们去掉bias项的模型是完全不同的模型,不存在参数等价的问题。

  1. 再加上bias项的前提下,使用哑变量编码代替one-hot编码,这时去除了$X_3$,也就不存在之前一种特征可以用其他特征表示的问题了。

总结:我们使用one-hot编码时,通常我们的模型不加bias项 或者 加上bias项然后使用L2正则化手段去约束参数;当我们使用哑变量编码时,通常我们的模型都会加bias项,因为不加bias项会导致固有属性的丢失。

选择建议:我感觉最好是选择正则化 + one-hot编码;哑变量编码也可以使用,不过最好选择前者。虽然哑变量可以去除one-hot编码的冗余信息,但是因为每个离散型特征各个取值的地位都是对等的,随意取舍未免来的太随意。

连续值的离散化为什么会提升模型的非线性能力?

简单的说,使用连续变量的LR模型,模型表示为公式(1),而使用了one-hot或哑变量编码后的模型表示为公式(2)

式中$x_1$表示连续型特征,$\theta_1, \theta_2, \theta_3$分别是离散化后在使用one-hot或哑变量编码后的若干个特征表示。这时我们发现使用连续值的LR模型用一个权值去管理该特征,而one-hot后有三个权值管理了这个特征,这样使得参数管理的更加精细,所以这样拓展了LR模型的非线性能力。

这样做除了增强了模型的非线性能力外,还有什么好处呢?这样做了我们至少不用再去对变量进行归一化,也可以加速参数的更新速度;再者使得一个很大权值管理一个特征,拆分成了许多小的权值管理这个特征多个表示,这样做降低了特征值扰动对模型为稳定性影响,也降低了异常数据对模型的影响,进而使得模型具有更好的鲁棒性

Label Encoder/Ordered Encoder

这个编码方式非常容易理解,就是把所有的相同类别的特征编码成同一个值,例如女=0,男=1,狗狗=2,所以最后编码的特征值是在[0, n-1]之间的整数。

这个编码的缺点在于它随机的给特征排序了,会给这个特征增加不存在的顺序关系,也就是增加了噪声。假设预测的目标是购买力,那么真实Label的排序显然是 女 > 狗狗 > 男,与我们编码后特征的顺序不存在相关性。

label_binarize 二值化编码

举个例子就知道是干嘛用的了,比如特征为【晴天,雨天,阴天,雷暴】则特征转化为【是否晴天,是否雨天,是否阴天,是否雷暴】,用数字来表示【雷暴】就是[0,0,0,1],和onthot看起来很类似,很多时候不那么严格界定,其实等同于onehot,一般来说独热编码的结果是多个0和1个1组成的比如类别特征的处理,但是也存在处理之后出现多个1和多个0的情况,比如文本问题,whatever,不做严格区分,因为很多文章都不划分那么细,反正自己心里有数就行了,实现使用sklearn的label_binarize或者自己用字典来实现。

Frequency Encoder/Count Encoder

这个方法统计训练集中每个特征出现的频率,在某些场景下非常有用(例如推荐系统中商品被购买的次数,直接反映了商品的流行程度),也不容易出现过拟合,但是缺点是在每个特征的取值数分布比较均匀时会遗漏大量的信息。

直方图编码

直方图编码,主要针对类别型特征与类别型标签的一种编码方式,还是举个例子来说明什么是直方图编码吧,最好理解了:

假设类别特征f1=【A,A,B,B,B,C,C】,对应的二分类标签为【0,1,0,1,1,0,0】,则我们是这样来计算类别特征f1中对应的类别的编码值的:

以A为例,类别特征f1的值为A的样本有两个,这两个样本的标签分别为【0,1】,则A被直方图编码为【1/2,1/2】=【0.5,0.5】(A的样本一共有2个所以分母为2,其中一个样本标签为1,一个样本标签为0),实际上就是计算取值为A的样本中,不同类别样本的比例,然后用这个比例来替换原始的类别标签,这里需要强调的是,无论是直方图编码还是我们后面要介绍的target encoding,本质上都是用类别特征的统计量来代替原来的类别值的,没什么神秘的地方,很好理解。

如法炮制,我们来对B进行类别编码,f1值为B的一共3个样本,其中一个样本标签为0,两个样本标签为1,所以B被编码为【1/3,2/3】,很好理解了。同样对于C,一共两个样本,并且两个样本标签均为0,则编码为【2/2,0】。

直方图编码实际上存在着比较多的问题,我们目前针对高基类特征的常用的目标编码或者均值编码实际上可以看作是在直方图编码之上的问题改进。

直方图编码存在以下问题:

  1. 没有考虑到类别特征中不同类别的数量的影响,举个例子,假设样本的某个类别特征为【A,A,A,A,A,A,B】,对应的标签为【0,0,0,1,1,1,0】,则根据直方图编码的公式得到的结果为A:【1/2,1/2】,B:【1,0】,然而这实际上对于A来说是很不公平的,因为B的样本数量太少,计算出来的结果根本不能算是明显的统计特征,而很可能是一种噪音,这实际上是一种非常“过拟合”的计算方式,因为一旦测试集中的样本有多个B之后,B的直方图编码的结果很可能发生非常大的变化;
  2. 假设没有1中出现的情况,所有的类别A,B的数量都比较均匀,直方图编码还是存在着一个潜在的隐患,直方图编码的计算非常依赖于训练集中的样本标签的分布情况,以f1特征的那个例子为例,实际上直方图这么计算的隐含的假设是潜在的所有的数据的在类别f1上的每一个类别计算出来的结果可以用训练集的结果来近似代替,简单说比如我在训练集中算出来A的直方图编码为【1/2,1/2】,即类别为A的样本中有一半标签0的样本,一半标签1的样本,那么一旦测试集的分布情况发生改变,或者是训练集本身的采样过程就是有偏的,则直方图编码的结果就是完全错误的,(比如全样本中,类别为A的样本其实只有10%是标签为0的,90%标签为1的,则这个时候A的直方图编码为【1/10,9/10】,训练集的产生可能是有偏的);

二进制编码与N进制编码

N进制编码是二进制编码的扩展,实际上原理也不难,就是把类别特征的编码结果换了一种进制表示而已:

例如我们的类别有[A,B,C,D,E],正常用labelencoder是使用十进制变成[0,1,2,3,4](注意,这里的编码结果是完全没有任何大小关系,就是字符串转数字而已。)而二进制编码就是用二进制的方式来表示比如上面的A,B,C,D,E一共有5个数字,所以可以用二进制编码为:

[0,0,0],[0,0,1],[0,1,0],[0,1,1],[1,0,0] 就是这么简单。

三进制编码:

[0,0],[0,1],[0,2],[1,0],[1,1]

依次类推……N进制编码。

那么问题来了,这到底有什么卵用。

category_encoders中也实现了,N进制方法叫base_N。二进制方法叫Binary。

Sum Encoder/Deviation Encoder/Effect Encoder

求和编码通过比较某一特征取值下对应标签(或其他相关变量)的均值与标签的均值之间的差别来对特征进行编码。但是据我所知 ,如果做不好细节,这个方法非常容易出现过拟合,所以需要配合留一法或者五折交叉验证进行特征的编码。还有根据方差加入惩罚项防止过拟合的方法。

Leave-one-out Encoder (LOO or LOOE)

这个方法类似于SUM的方法,只是在计算训练集每个样本的特征值转换时都要把该样本排除(消除特征某取值下样本太少导致的严重过拟合),在计算测试集每个样本特征值转换时与SUM相同。可见以下公式:

Helmet Encoder

Helmet编码是仅次于OHE和SumEncoder使用最广泛的编码方法,与SumEncoder不同的是,它比较的是某一特征取值下对应标签(或其他相关变量)的均值与他之前特征的均值之间的差异,而不是和所有特征的均值比较。这个特征同样容易出现过拟合的情况。不知道Helmet这个词是指的什么方面……使用标签时容易出现过拟合。

Target Encoder

先调一波包

1
2
3
4
5
6
7
8
9
from category_encoders import *
import pandas as pd
from sklearn.datasets import load_boston
bunch = load_boston()
y = bunch.target
X = pd.DataFrame(bunch.data, columns=bunch.feature_names)
enc = TargetEncoder(cols=['CHAS', 'RAD']).fit(X, y)
numeric_dataset = enc.transform(X)
print(numeric_dataset.info())

原理也不难;

分类问题

对于C分类问题,目标编码(target encode)后只需要增加C−1个属性列,如果C远远小于N,则相对one-hot-encoding可以节省很多内存. 其出发点是用概率P(y=yi|x=xi)代替属性值x, 其中x表示属性值,y表示类别值. 但实际问题中,经常会遇到x=xi对应的样本数目比较少,导致对P(y=yi|x=xi)的计算不准确. 所以后来的改进结果是引入先验概率P(y=yi),公式转换成 :

细心一点就可以发现,如果上述不引入先验概率P以及lambda项,其实就是我们前面提到的直方图编码。直方图编码是target encode和mean encode的前辈了。

其中$j∈[0,C)$,$n_i$是训练集中xi的样本个数,$λ(n_i)∈[0,1]$负责计算两个概率值的可靠性,针对应用有不同的定义方法,如下是一个例子 :

(我们的category_encoders库使用的就是上面这个例子的计算方式,其中参数$k$和$f$分别是我们的min_sample_leaf和smoothing参数)。二者都是一个可调参数,当$x$在训练集中出现次数$n=k$时,$λ(n)=0.5$,两个概率的可靠性相等,随着$n$的增大,先验概率$P(y=y_i)$的可靠性逐渐降低。

我第一次接触这里的$λ(n)$还是比较奇怪的,长得很奇怪,不过其实带几个数进去算一算也能理解这个项的意义了,公式转换成这样主要是考虑到有的类别xi的数量太少从而编码结果不精确(原因在直方图编码那边已经描述过了),对于数量很大的xi来说,入(n)的引入几乎没有影响,比如n=100000,此时$λ(n)$的计算结果趋近于1,先验项的系数趋近于0,则target_encode计算的结果和直方图编码的计算结果是基本近似的。如果n很小,比如n=2,则入(n)=0.731,此时根据先验项的系数为0.269,即最终编码结果部分受到先验项的影响,从而通过这种方式降低由于n数量太小而导致的编码不精确的问题。类似于用先验的统计值对原来的编码结果进行一个调和加权平均),所以显然,这里的k越大,则意味着先验的影响越大。

回归问题

回归问题同样可以使用均值编码,只需要把概率换成均值:

其中:

表示$x=x_i$对应的$y$均值,

是整个训练集上y的均值。

我们来看一看源代码,下面是核心实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def fit_target_encoding(self, X, y):
mapping = {}
for switch in self.ordinal_encoder.category_mapping:
col = switch.get('col')
values = switch.get('mapping')
prior = self._mean = y.mean()
stats = y.groupby(X[col]).agg(['count', 'mean'])
smoove = 1 / (1 + np.exp(-(stats['count'] - self.min_samples_leaf) / self.smoothing))
smoothing = prior * (1 - smoove) + stats['mean'] * smoove
smoothing[stats['count'] == 1] = prior
if self.handle_unknown == 'return_nan':
smoothing.loc[-1] = np.nan
elif self.handle_unknown == 'value':
smoothing.loc[-1] = prior
if self.handle_missing == 'return_nan':
smoothing.loc[values.loc[np.nan]] = np.nan
elif self.handle_missing == 'value':
smoothing.loc[-2] = prior
mapping[col] = smoothing
return mapping

核心中的核心代码:

1
2
3
4
5
prior = self._mean = y.mean() ##计算连续值标签的均值
stats = y.groupby(X[col]).agg(['count', 'mean']) #根据类别特征中的不同的类分别进行groupby,聚合函数为计数group和求平均值mean
smoove = 1 / (1 + np.exp(-(stats['count'] - self.min_samples_leaf) / self.smoothing)) #计算smoove值,
smoothing = prior * (1 - smoove) + stats['mean'] * smoove #计算最终的编码结果smoothing值
smoothing[stats['count'] == 1] = prior#出现次数为1的类别直接用先验值prior代替

为了便于理解还是举个例子吧。

假设特征f1为【A,A,A,B,B,C,C,C,C,D】,对应的连续值标签为【1,2,3,4,5,6,7,8,9,10】则根据上面的源代码我们计算结果如下:

对于A,对应的子数据集为【A,A,A】。【1,2,3】,则y.mean()=(1+2+3+4+……+10)/10=5.5,groupby之后的结果为count=3,mean=(1+2+3)/3=2,因为min_samples_leaf和smoothing默认值为1,则smoove=1/(1+np.exp(-3-min_samples_leaf))=0.982

5.5x(1-0.892)+2x0.892=2.378,调包试了一下,结果差不多,思路没什么问题。

target encode是针对高基数类别特征进行处理手段的最好的选择之一。但它也有缺点,就是容易过拟合,因为所有的统计计算都是基于训练集来的,所以一旦新数据集的分布发生变化,就会产生类似于过拟合所产生的不良的训练效果,所以接下来我们要介绍target encode 的升级版,也是目前最常用的特征编码方法之一,mean encoding。

mean encoding

网上有实现的源码,就不费心思去看论文了,直接根据代码来解释均值编码的原理吧,均值编码的原理和target encoding非常非常类似,只不过为了避免过拟合加入了一些特别的手段而已。首先来看一下初始化的部分:

  • self.categorical_features 用来指定特征变量中的类别变量的变量名
  • self.n_splits 用与指定后面交叉验证的折数(后文详述)
  • self.learned_stats 用于统计量的存放

然后做了一个分类和回归的判断,分类和回归下的均值编码略有不同

判断是否存在先验权重计算函数,没有的话则默认使用下面的公式并且k和f是根据用户给定的字典类型的参数取值的,有的话则使用用户给定的先验权重计算函数来计算先验权重:

如果用户没有提供先验权重计算函数也没有提供k和f的参数值则k,f则使用默认值分别为$k=2$,$f=1$。

然后我们看一下“fit_transform”部分:

首先是copy一个新的特征矩阵Xnew,然后根据分类还是回归问题选择不同的抽样方式(分层抽样or普通抽样),然后我们生成一个字典learned_stas用于存放编码之后的结果。接下来是核心实现的部分:

1
2
3
4
5
6
7
8
for variable, target in product(self.categorical_features, self.target_values):
nf_name = '{}_pred_{}'.format(variable, target)
X_new.loc[:, nf_name] = np.nan
for large_ind, small_ind in skf.split(y, y):
nf_large, nf_small, prior, col_avg_y = MeanEncoder.mean_encode_subroutine(
X_new.iloc[large_ind], y.iloc[large_ind], X_new.iloc[small_ind], variable, target, self.prior_weight_func)
X_new.iloc[small_ind, -1] = nf_small
self.learned_stats[nf_name].append((prior, col_avg_y))

X_new中通过loc函数先占个坑,然后进入交叉验证:(补充:建议原始的输入变量X和y先shuffle一下再进入计算)

然后我们就进入了核心的实现 MeanEncoder.mean_encode_subroutine(静态函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@staticmethod
def mean_encode_subroutine(X_train, y_train, X_test, variable, target, prior_weight_func):
X_train = X_train[[variable]].copy()
X_test = X_test[[variable]].copy()

if target is not None:
nf_name = '{}_pred_{}'.format(variable, target)
X_train['pred_temp'] = (y_train == target).astype(int) # classification
else:
nf_name = '{}_pred'.format(variable)
X_train['pred_temp'] = y_train # regression
prior = X_train['pred_temp'].mean()

col_avg_y = X_train.groupby(by=variable, axis=0)['pred_temp'].agg({'mean': 'mean', 'beta': 'size'})
col_avg_y['beta'] = prior_weight_func(col_avg_y['beta'])
col_avg_y[nf_name] = col_avg_y['beta'] * prior + (1 - col_avg_y['beta']) * col_avg_y['mean']
col_avg_y.drop(['beta', 'mean'], axis=1, inplace=True)

nf_train = X_train.join(col_avg_y, on=variable)[nf_name].values
nf_test = X_test.join(col_avg_y, on=variable).fillna(prior, inplace=False)[nf_name].values

return nf_train, nf_test, prior, col_avg_y

还是举个例子好理解吧,假设这里categorical_features=[‘f1’,’f2’,’f3’],target=[0,1,2],那么这里我们以variable=‘f1’,target=0为例来计算,首先是去原始数据中标签为0的样本的f1特征:

1
X_train['pred_temp'] = (y_train == target).astype(int)

然后是根据X_train[‘pred_temp’]的来计算target为0的样本的占比情况以作为prior先验概率的值。

1
prior = X_train['pred_temp'].mean()

然后接下来的计算方式和target encoding是一致的:

1
2
3
4
col_avg_y = X_train.groupby(by=variable, axis=0)['pred_temp'].agg({'mean': 'mean', 'beta': 'size'})
col_avg_y['beta'] = prior_weight_func(col_avg_y['beta'])
col_avg_y[nf_name] = col_avg_y['beta'] * prior + (1 - col_avg_y['beta']) * col_avg_y['mean']
col_avg_y.drop(['beta', 'mean'], axis=1, inplace=True)

唯一不同的方式是,mean encoding这里用到了交叉计算的方式,以5折交叉为例,在80%的数据上计算编码结果得到转换的规则,然后将剩下20%的数据按照转换规则进行转换,最后将结果返回:

1
2
3
4
nf_train = X_train.join(col_avg_y, on=variable)[nf_name].values
nf_test = X_test.join(col_avg_y, on=variable).fillna(prior, inplace=False)[nf_name].values

return nf_train, nf_test, prior, col_avg_y

最后:

1
X_new.iloc[small_ind, -1] = nf_small

把测试集的转换结果赋给原始数据的copy。

综上所属,mean encoding的原理和target encoding基本是一样的,只不过比target encoding多了一个交叉计算的步骤,假设有10000条数据,target encoding是直接在这10000条数据上进行编码结果的计算的,而mean encoding则是每次用类似与模型训练的方法,比如五折交叉计算,用80%的数据计算编码结果然后赋给剩下的20%,重复5次则所有特征都编码完毕,这样的好处就是一定程度上降低过拟合的影响。

实际上均值编码的思路挺好,但凡是涉及到标签的编码方式多少都会有信息泄漏的问题,并且如果训练集和测试集的标签分布很不均衡,有监督编码很容易称为罪魁祸首。

M-Estimate Encoder

相当于 一个简化版的Target Encoder

其中 $y^+$ 代表所有正Label的个数,$m$ 是一个调参的参数,$m$ 越大过拟合的程度就会越小,同样的在处理连续值时$n^+$ 可以换成label的求和,$y^+$ 换成所有正label的求和。

James-Stein Encoder

James-Stein Encoder 同样是基于target的一种算法。算法的思想很简单,对于特征的每个取值 $k$ 可以根据下面的公式获得:

其中 $B$ 由以下公式估计:

但是它有一个要求是target必须符合正态分布,这对于分类问题是不可能的,因此可以把 $y$ 先转化成概率的形式。或者在实际操作中,使用grid search的方法选择一个比较好的B值。

Weight of Evidence Encoder

Weight Of Evidence 同样是基于target的方法。

woe编码仅仅针对于二分类问题,实际上woe编码的方法很容易就可以扩展到多类,后面会写。单纯从woe的公式就可以看出woe编码存在的问题:

  1. 分母可能为0的问题;
  2. 类似于直方图编码,没有考虑到不同类别数量的大小,例如类别特征为【A,A,A,A,A,A,B】而标签为【0,0,0,1,1,1,1】这样的情况计算出来的woe明显对A这个类别不公平
  3. 应用局限性太大了,只能针对二分类问题,并且特征也必须为离散特征。
  4. 训练集计算的woe编码结果可能和测试集计算的woe编码结果存在较大差异(所有基于统计特征的编码方式的通病)

首先我们调个包,使用到的是注明scikit-learn contrib分支中的category_encoders:

1
2
3
4
5
6
7
8
from category_encoders import *
import pandas as pd
from sklearn.datasets import load_boston
bunch = load_boston()
y = bunch.target > 22.5
X = pd.DataFrame(bunch.data, columns=bunch.feature_names)
enc = WOEEncoder(cols=['CHAS', 'RAD']).fit(X, y)
numeric_dataset = enc.transform(X)

通过查看内部核心实现代码,对比原始公式:

源代码中大致实现了上图的计算逻辑,为了避免除0的问题,引入了“regulation”这个参数(用户自定义,默认为1)来进行拉普拉斯平滑。

1
2
nominator = (stats['sum'] + self.regularization) / (self._sum + 2*self.regularization)
denominator = ((stats['count'] - stats['sum']) + self.regularization) / (self._count - self._sum + 2*self.regularization)

所以对于很小样本的数据进行woe编码计算的结果会和实际计算上有出入,不过说实话如果样本数量很小个人认为没有太多编码的必要吧,统计学意义非常不明显。

这里我们只解决了上面除0的问题,对于问题二,我们可以使用IV值的思路,针对类别特征中不同类别的数量给woe的公式施加一个惩罚项:

这样就把样本数量的问题也考虑进去了。实现也很简单,计算出woe编码结果之后再计算惩罚项然后相乘即可,不赘述了。

针对问题3,如果要拓展多多分类,我想到的思路是使用直方图编码的思路:修改的思路是,分子为类别特征中第i个类别中的 y_i/y_sum,分母为所有训练样本中的yi/y_sum,举个例子把,例如类别特征为【A,A,A,B,B】,标签为【0,0,1,2,1】,则对于A,类别0的编码的计算过程为ln(2/3 / 2/5)依次类推,不过就是不知道这种编码结果效果好不好。

针对问题4,没想出来什么好的办法。

Catboost Encoder

根据官方描述,是把catboost对类别特征的编码方案直接提取出来写成一个独立的模块了,catboost还没怎么仔细研究过,先熟悉一下编码方案也是好的,直接看源代码吧:

核心代码,进入看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def fit_leave_one_out(self, X_in, y, cols=None):
X = X_in.copy(deep=True)
if cols is None:
cols = X.columns.values
self._mean = y.mean() # 计算标签值的均值
return {col: self.fit_column_map(X[col], y) for col in cols}

def fit_column_map(self, series, y):
category = pd.Categorical(series)
categories = category.categories
codes = category.codes.copy()
codes[codes == -1] = len(categories)
categories = np.append(categories, np.nan)
return_map = pd.Series(dict([(code, category) for code, category in enumerate(categories)]))
result = y.groupby(codes).agg(['sum', 'count'])
return result.rename(return_map)

为了方便叙述这里拿一些示例数据进去试试看:

这是要转化的类别特征

这是标签数据,二分类标签:

最终输出的result为:

从这里可以看出,一开始是计算了所有标签值的均值(无论是分类还是回归问题都是),接下来的处理比较kaggle,catboost的分类编码方案把所有的np.nan,也就是缺失值都当作类别变量加入到最终的编码方案中,在kaggle上也很常见经常会把缺失值转化为一个‘missing’的类别,把缺失也当作一种信息。 另外gbdt系列的算法在缺失值特别多的情况下非常容易过拟合,比如10000个样本中某个类别特征A存在80%的缺失率,那么分裂的时候,lightgbm(xgb无法处理类别特征这里不讨论)会直接在剩下的20%的样本中进行分裂与决策,然后剩下的80%的样本归入增益大的分支,显然这样非常容易过拟合。另外可以看到category这个数据类型将缺失值的标签默认设置为-1。

中间一大堆检查调用之类的无关的步骤省略直接进入核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def transform_leave_one_out(self, X_in, y, mapping=result):
"""
Leave one out encoding uses a single column of floats to represent the means of the target variables.
"""
X = X_in.copy(deep=True)
random_state_ = check_random_state(self.random_state)

# Prepare the data
if y is not None: #如果存在y的话,把X中的所有缺失值用-999来代替
# Convert bools to numbers (the target must be summable)
y = y.astype('double')

# Cumsum and cumcount do not work nicely with None.
# This is a terrible workaround that will fail, when the
# categorical input contains -999.9
for cat_col in X.select_dtypes('category').columns.values:
X[cat_col] = X[cat_col].cat.add_categories(-999.9)
X = X.fillna(-999.9)

for col, colmap in mapping.items():
level_notunique = colmap['count'] > 1 #数量等于1的类别全部忽略剩下的留下来

unique_train = colmap.index #根据训练集得到的类别的标识
unseen_values = pd.Series([x for x in X_in[col].unique() if x not in unique_train])#如果新的数据集存在
#训练集中没有的类别则存放到unseen_values中

is_nan = X_in[col].isnull()#返回数据集中缺失值的位置
is_unknown_value = X_in[col].isin(unseen_values.dropna()) #获取测试集中带有训练集未出现标签的样本
#但是排除缺失值

if self.handle_unknown == 'error' and is_unknown_value.any(): #建议还是报错比较好,要不然自己都不知道
#底层怎么处理新数据的
raise ValueError('Columns to be encoded can not contain new values')

if y is None: # Replace level with its mean target; if level occurs only once, use global mean

level_means = ((colmap['sum'] + self._mean) / (colmap['count'] + 1)).where(level_notunique, self._mean)

X[col] = X[col].map(level_means)

else:
# Simulation of CatBoost implementation, which calculates leave-one-out on the fly.
# The nice thing about this is that it helps to prevent overfitting. The bad thing
# is that CatBoost uses many iterations over the data. But we run just one iteration.
# Still, it works better than leave-one-out without any noise.
# See:
# https://tech.yandex.com/catboost/doc/dg/concepts/algorithm-main-stages_cat-to-numberic-docpage/
temp = y.groupby(X[col]).agg(['cumsum', 'cumcount'])
X[col] = (temp['cumsum'] - y + self._mean) / (temp['cumcount'] + 1)

if self.handle_unknown == 'value':
X.loc[is_unknown_value, col] = self._mean
elif self.handle_unknown == 'return_nan':
X.loc[is_unknown_value, col] = np.nan

if self.handle_missing == 'value':
X.loc[is_nan & unseen_values.isnull().any(), col] = self._mean
elif self.handle_missing == 'return_nan':
X.loc[is_nan, col] = np.nan

if self.sigma is not None and y is not None:
X[col] = X[col] * random_state_.normal(1., self.sigma, X[col].shape[0])

return X

首先来看一下y=None的情况,(我没弄明白都fit完了为什么还要根据y来transform)我们来一步一步测试一下看看每步的输出:

首先是:

1
2
3
4
5
6
7
8
9
10
11
12
13
            level_notunique = colmap['count'] > 1 #数量等于1的类别全部忽略剩下的留下来

unique_train = colmap.index #根据训练集得到的类别的标识
unseen_values = pd.Series([x for x in X_in[col].unique() if x not in unique_train])#如果新的数据集存在
#训练集中没有的类别则存放到unseen_values中

is_nan = X_in[col].isnull()#返回数据集中缺失值的位置
is_unknown_value = X_in[col].isin(unseen_values.dropna()) #获取测试集中带有训练集未出现标签的样本
#但是排除缺失值

if self.handle_unknown == 'error' and is_unknown_value.any(): #建议还是报错比较好,要不然自己都不知道
#底层怎么处理新数据的
raise ValueError('Columns to be encoded can not contain new values')

把类别数量为1的类别忽略,得到类别数量大于1的

得到所有类别的标识(包括类别为1的)unique_train = colmap.index:

获取transform数据集中没见过的类别,因为这里我们直接用训练集的数据来transform,所以这一项为空:

1
unseen_values = pd.Series([x for x in X.unique() if x not in unique_train])

is_nan = X.isnull()#返回数据集中缺失值的位置

is_unknown_value = X.isin(unseen_values.dropna())

获取原始类别数据中在transform数据集中未出现,这里用的就是原始数据所以当然没有unknown的数据了:

然后进入核心部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    if y is None:    # Replace level with its mean target; if level occurs only once, use global mean

level_means = ((colmap['sum'] + self._mean) / (colmap['count'] + 1)).where(level_notunique, self._mean)

X[col] = X[col].map(level_means)

else:
# Simulation of CatBoost implementation, which calculates leave-one-out on the fly.
# The nice thing about this is that it helps to prevent overfitting. The bad thing
# is that CatBoost uses many iterations over the data. But we run just one iteration.
# Still, it works better than leave-one-out without any noise.
# See:
# https://tech.yandex.com/catboost/doc/dg/concepts/algorithm-main-stages_cat-to-numberic-docpage/
temp = y.groupby(X[col]).agg(['cumsum', 'cumcount'])
X[col] = (temp['cumsum'] - y + self._mean) / (temp['cumcount'] + 1)

if self.handle_unknown == 'value':
X.loc[is_unknown_value, col] = self._mean
elif self.handle_unknown == 'return_nan':
X.loc[is_unknown_value, col] = np.nan

if self.handle_missing == 'value':
X.loc[is_nan & unseen_values.isnull().any(), col] = self._mean
elif self.handle_missing == 'return_nan':
X.loc[is_nan, col] = np.nan

if self.sigma is not None and y is not None:
X[col] = X[col] * random_state_.normal(1., self.sigma, X[col].shape[0])

return X

$Replace level with its mean target; if level occurs only once, use global mean

不提供标签y的情况下

把类别特征用它对应的数量来代替,如果类别只出现一次则用全部类别的数量之和除以类别数来代替。

1
level_means =((colmap['sum']+ self._mean)/(colmap['count']+1)).where(level_notunique, self._mean)#

这里的操作比较独特,对每一个类别对应的标签为1的样本的数量加上所有标签的均值得到分子,分母就是每个类别对应的总样本的数量,加一的操作是为了避免出现除0的操作,后面的where操作是这样的,对于每一个数量仅仅为1的类别,我们直接将其编码为标签值的均值 mean,否则就按照这里说的处理方式处理,这里where起到一个非常方便的if else的判断作用。

1
2
3
4
5
6
7
8
9
if self.handle_unknown == 'value':
X.loc[is_unknown_value, col] = self._mean
elif self.handle_unknown == 'return_nan':
X.loc[is_unknown_value, col] = np.nan

if self.handle_missing == 'value':
X.loc[is_nan & unseen_values.isnull().any(), col] = self._mean
elif self.handle_missing == 'return_nan':
X.loc[is_nan, col] = np.nan

这里是判断transform数据的缺失值和未见标签的编码方案,用标签均值代替或者直接用np.nan代替两种选择。

总结一下:

catboost编码的大体思路是:把类别特征中的每一个类别进行,假设有一个类别特征中有一个类别A,类别A中对应的好样本有1000个,坏样本有100个,总的好样本有10000个,坏样本有3000个则:

1
level_means =((colmap['sum']+ self._mean)/(colmap['count']+1)).where(level_notunique, self._mean)

的转换,根据这个转换公式,A的编码结果为: (100+0.3)/(1100+1)=0.09109900090826521

实际上0.3和1对于结果影响基本没有,去掉之后A的编码结果就是它对应的bad rate,mean 和 1 只是为了起到平滑的作用而已,而对于出现次数仅1次的类别来说直接用全部数据的bad rate来编码。原来catboost的编码这么简单吗。

效果分析与讨论

数据集使用了八个存在离散型变量的数据集,最后的结果加权如下:

  • 不使用交叉验证的情况:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    HelmertEncoder	        0.9517
    SumEncoder 0.9434
    FrequencyEncoder 0.9176
    CatBoostEncoder 0.5728
    TargetEncoder 0.5174
    JamesSteinEncoder 0.5162
    OrdinalEncoder 0.4964
    WOEEncoder 0.4905
    MEstimateEncoder 0.4501
    BackwardDifferenceEncode0.4128
    LeaveOneOutEncoder 0.0697
  • 使用交叉验证的情况:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    CatBoostEncoder		0.9726
    OrdinalEncoder 0.9694
    HelmertEncoder 0.9558
    SumEncoder 0.9434
    WOEEncoder 0.9326
    FrequencyEncoder 0.9315
    BackwardDifferenceEncode0.9108
    TargetEncoder 0.8915
    JamesSteinEncoder 0.8555
    MEstimateEncoder 0.8189
    LeaveOneOutEncoder 0.0729

下面是Kaggle上大佬们给出的一些建议,具体原因尚未分析。

  • 对于无序的离散特征,实战中使用 OneHot, Hashing, LeaveOneOut, and Target encoding 方法效果较好,但是使用OneHot时要避免高基类别的特征以及基于决策树的模型,理由如下图所示。

    但是在实战中,我发现使用Xgboost处理高维稀疏的问题效果并不会很差。例如在IJCAI-18商铺中用户定位比赛中,一个很好的baseline就是把高维稀疏的wifi信号向量直接当做特征放到Xgboost里面,也可以获得很好的预测结果。不知道是不是因为Xgboost对于稀疏特征的优化导致。

  • 对于有序离散特征,尝试 Ordinal (Integer), Binary, OneHot, LeaveOneOut, and Target. Helmert, Sum, BackwardDifference and Polynomial 基本没啥用,但是当你有确切的原因或者对于业务的理解的话,可以进行尝试。

  • 对于回归问题而言,Target 与 LeaveOneOut 方法可能不会有比较好的效果。

  • LeaveOneOut、 WeightOfEvidence、 James-Stein、M-estimator 适合用来处理高基数特征。Helmert、 Sum、 Backward Difference、 Polynomial 在机器学习问题里的效果往往不是很好(过拟合的原因)

一分一毛,也是心意。