Life's monolog

机器学习比赛中常用的Target Encoding

Word count: 1,304 / Reading time: 6 min
2019/03/17 Share

最近在做机器学习比赛的时候,遇到了Target Encoding。所谓Target Encoding,是一种特征工程方式,根据训练集中的标签信息生成特征,来提高模型的性能。比较常见的是对于二分类问题(即需要预测的标签是0和1),根据训练集中的某一列特征对训练集进行groupby操作,然后计算每个分组内标签的均值,作为新的特征。

例如下图中,根据原始特征id,生成了id_target_mean这一列均值特征。
example

上图展示的是最朴素的一种办法,这种办法非常直观,但是常常不work,带来的最明显的问题就是过拟合,训练集分数会飙升,而验证集的分数会剧烈下降。但Target Encoding确实是一种非常好的特征工程,只是需要一些额外的操作(即Regularization)来防止过拟合,下面就介绍几种带Regularization的Target Encoding方式。

CV Loop

CV即cross validation,这种方式有点类似于交叉验证,利用交叉验证的思路来进行Target Encoding。具体地:

  1. 将训练集分成几份(例如5份);
  2. 对于每一份训练集,该训练集上的均值特征通过在其他份训练集上进行groupby mean等操作得到;
  3. 对于测试集(或验证集),用全部的训练集进行groupby mean等操作得到。

这种方式能够很好地避免过拟合,并且通常使用4折、5折交叉验证就能够得到很好的效果。下面给出python的实现代码,也非常简单:

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
def get_kfold_mean(X_train, X_test, cols, target, folds=5):
"""
Args:
X_train: 训练集
X_test: 测试集或验证集
cols: 特征列表,表示根据这些特征做groupby
target: 标签列名
folds: 交叉验证的折数
Returns:
df: pandas.DataFrame, df.shape[0] = X_train.shape[0] + X_test.shape[0]
包含根据cols生成的target均值特征

"""
skf = StratifiedKFold(n_splits=folds, shuffle=True, random_state=918)
train, test = pd.DataFrame(), pd.DataFrame()

for col in cols:
new_col = col + '_' + target + '_mean'
train[new_col] = np.zeros(X_train.shape[0])

for tr_idx, val_idx in skf.split(X_train, X_train[target]):
X_tr, X_val = X_train.iloc[tr_idx], X_train.iloc[val_idx]
for col in cols:
new_col = col + '_' + target + '_mean'
tmp_means = X_val[col].map(X_tr.groupby(col)[target].mean())
train[new_col][val_idx] = tmp_means

prior = X_train[target].mean()
for col in cols:
new_col = col + '_' + target + '_mean'
train[new_col].fillna(prior, inplace=True)

target_map = X_train.groupby(col)[target].mean()
test[new_col] = X_test[col].map(target_map)
test[new_col].fillna(prior, inplace=True)

return pd.concat([train, test], axis=0).reset_index(drop=True)

一个使用的例子:
kfold-example

但是这种方式在极端情况下也存在leakage的风险,例如LOO(Leave One Out),只有5个样本,用5折交叉,就相当于每次都剔除当前行的样本,所以叫Leave One Out。因此也衍生出一种LOO的Target Encoding方式,即剔除当前行的target信息,python实现也很简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_loo_mean(X_train, X_test, cols, target):
prior = X_train[target].mean()
train, test = pd.DataFrame(), pd.DataFrame()

for col in cols:
new_col = col + '_' + target + '_mean'

target_sum = X_train.groupby(col)[target].transform('sum')
n_objects = X_train.groupby(col)[target].transform('count')

train[new_col] = (target_sum - X_train[target]) / (n_objects - 1)
train[new_col].fillna(prior, inplace=True)

test[new_col] = X_test[col].map(X_train.groupby(col)[target].mean())
test[new_col].fillna(prior, inplace=True)
return pd.concat([train, test], axis=0).reset_index(drop=True)

Add Random Noise

对Target Encoding出的特征加入随机噪声,这个不太好控制,噪声太大会让特征失效,噪声太小还是会过拟合。这种方法通常与LOO一起使用。

Smoothing

这种方式通过超参数$\alpha$来控制regularization的程度,如果$\alpha$等于0,相当于没有regularization,如果$\alpha$等于正无穷,则相当于用global mean。通常需要与其他regularization方法一起使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_smooth_mean(X_train, X_test, cols, target, m=300):
def get_smooth_mean_map(df, by, on, m=300):
mean = df[on].mean()
agg = df.groupby(by)[on].agg(['count', 'mean'])
counts = agg['count']
means = agg['mean']
smooth = (counts * means + m * mean) / (counts + m)
return smooth

prior = X_train[target].mean()
train, test = pd.DataFrame(), pd.DataFrame()

for col in cols:
new_col = col + '_' + target + '_mean'
target_map = get_smooth_mean_map(X_train, by=col, on=target, m=m)
train[new_col] = X_train[col].map(target_map)
test[new_col] = X_test[col].map(target_map).fillna(prior)

return pd.concat([train, test], axis=0).reset_index(drop=True)

Expanding mean

根据累积sum和累积count来生成均值特征。这种方式的好处在于不需要调整超参,但是生成的均值特征的质量可能有高有低,而且取决于数据的顺序(因为是用的累积sum和累积count),具体实现可看代码。CatBoost中内置了这种算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_expanding_mean(X_train, X_test, cols, target):
prior = X_train[target].mean()
train, test = pd.DataFrame(), pd.DataFrame()

for col in cols:
new_col = col + '_' + target + '_mean'

cumsum = X_train.groupby(col)[target].cumsum() - X_train[target]
cumcnt = X_train.groupby(col)[target].cumcount()
train[new_col] = cumsum / cumcnt
train[new_col].fillna(prior, inplace=True)

test[new_col] = X_test[col].map(X_train.groupby(col)[target].mean())
test[new_col].fillna(prior, inplace=True)
return pd.concat([train, test], axis=0).reset_index(drop=True)

总结

在实际使用过程中,没有哪一种方式是Silver Bullet,有时间可以都试一试。但总的来说,还是推荐使用CV loop或者Expanding mean。

参考

CATALOG
  1. 1. CV Loop
  2. 2. Add Random Noise
  3. 3. Smoothing
  4. 4. Expanding mean
  5. 5. 总结
  6. 6. 参考