许多人在处理分类变量 categorical variables 时苦苦挣扎,因此有必要专写一章,介绍如何处理分类变量。
本章介绍了分类变量的不同种类,并介绍了处理分类变量问题时应选择何种方法。
分类变量主要分为两类:
-
Nominal :无序的类别,例如性别。
-
Ordinal :有序的类别,且顺序很重要,通常有层级、级别之分,例如尺寸里有小、中、大三类。
也有按类别总量对分类变量做定义的。把只有两个类别的分类变量称为二分类 binary 。把具有周期意义的分类变量称为循环类 cyclic ,例如一周的七天、一天的 24 小时。
有许多不同的分类变量定义,人们说要根据分类变量的类型来处理它。然而,我觉得没这个必要,所有分类变量均可使用同样的方式来处理。
在开始学习前,我们需要一个数据集,最适合用来理解分类变量的数据集有 cat-in-the-dat 数据集。
下载地址:
拿到数据的第一步,进行数据探索。
import pandas as pd
df = pd.read_csv('cat-in-dat-2/train.csv')
df.head()
数据格式如下。
该数据集包含所有种类的分类变量。
-
Nominal
-
Ordinal
-
Cyclical
-
Binary
然后,该数据集是个二分类问题。目标类型对于我们学习分类变量无关,但是本章最后我们将构建一个端到端的模型,因此需要了解目标类型的数据分布情况,用以选择度量指标。
import seaborn as sns
sns.countplot(x='target', data=df)
分布图如下。
可以看到,数据集的 target 并不平衡,因此最好使用 Precision、Recall、F1、AUC 这种指标。使用 F1、AUC 更好,它们同时考虑了 Precision 和 Recall ,其中 AUC 还可以帮助我们选择 threshold 。
然后查看数据集各列属性的整体情况。
print(df.columns)
for col in df.columns:
print(f'{col}: {df[col].unique()}')
从结果中可看到,该数据集有
-
5 个 binary 变量。
-
10 个 nominal 变量。
-
6 个 ordinal 变量。
-
2 个 cyclic 变量。
-
1 个 binary target。
以 ord_2 特征举例,它包含了六个不同的分类。
-
Freezing
-
Warm
-
Cold
-
Boiling Hot
-
Hot
-
Lava Hot
计算机是不能直接理解这六个分类的语义,因此其排序需要认为处理,通常将其转为数字,语义大小依次取 0-N。
print(f'Before:\n{df.ord_2.value_counts()}\n')
mapping = {
"Freezing": 0,
"Warm": 1,
"Cold": 2,
"Boiling Hot": 3,
"Hot": 4,
"Lava Hot": 5
}
df.loc[:, 'ord_2'] = df.ord_2.map(mapping)
print(f'After:\n{df.ord_2.value_counts()}')
这种转换方式通常称为标签编码 Label Encoding,即我们将分类编码为数值。
了解该概念后,可以直接使用 scikit-learn 提供的 LabelEncoder 来执行此处理。
import pandas as pd
from sklearn import preprocessing
df = pd.read_csv('cat-in-dat-2/train.csv')
df.loc[:, 'ord_2'] = df.ord_2.fillna('NONE')
lbl_enc = preprocessing.LabelEncoder()
df.loc[:, 'ord_2'] = lbl_enc.fit_transform(df.ord_2.values)
此处多了对 ord_2 列进行 fillna 操作,因为 LabelEncoder 不能处理 NaN,而原始数据中各列都含有 NaN。
对于模型,我们在基于树的模型中选择一个。
-
Decision Tree
-
Random Forest
-
Extra Trees
-
Others
-
XGBoost
-
GBM
-
LightGBM
-
对于这种方法编码后的变量,取值为 0-N,是不能直接使用线性模型的,如 SVM、神经网络 等。线性模型需要的输入是归一化的或者正则化的。
要使用线性模型,我们得把数值的分类转换为二进制编码。
Freezing --> 0 --> 0 0 0
Warm --> 1 --> 0 0 1
Cold --> 2 --> 0 1 0
Boiling Hot --> 3 --> 0 1 1
Hot --> 4 --> 1 0 0
Lava Hot --> 5 --> 1 0 1
然而这种直接转换的方式,把一列特征扩展到三列特征,如果有很多列特征都需要转换,那么会导致特征数量急剧增长。
可以使用稀疏矩阵的方式存储这种扩充后的多列特征,在稀疏矩阵中,只存储值为 1 的位置,不关系所有的 0。
很难想象稀疏矩阵是什么样子,下边通过例子来演示稀疏矩阵。
假如我们的数据中只有三条数据,每条数据只有上边提到的 ord_2 特征。
| Index | Feature |
|---|---|
| 0 | Warm |
| 1 | Hot |
| 2 | Lava Hot |
现在,将其转换为二进制表示。
| Index | Feature0 | Feature1 | Feature2 |
|---|---|---|---|
| 0 | 0 | 0 | 1 |
| 1 | 1 | 0 | 0 |
| 2 | 1 | 0 | 1 |
转换后的特征矩阵为 3x3 矩阵,假设矩阵中每个元素占 8 字节,那么整体需要 8x3x3=72 字节。
使用代码演示该过程。
import numpy as np
examples = np.array([
[0, 0, 1],
[1, 0, 0],
[1, 0, 1]
])
examples.nbytes
然而,我们有必要存储矩阵中的全部元素吗?只有值为 1 的元素对特征的表示有贡献,有什么办法可以不存储值为 0 的元素?
一种方式是,使用某种字典的格式,只存储值为 1 的元素和该元素在矩阵中的位置。
(0, 2) : 1
(1, 0) : 1
(2, 0) : 1
(2, 2) : 1
这种表示方式占用的内存更少,在本例中占用 8x4=32 字节。
代码演示如下。
from scipy import sparse
sparse_examples = sparse.csr_matrix(examples)
print(sparse_examples.data.nbytes)
print(
sparse_examples.data.nbytes +
sparse_examples.indptr.nbytes +
sparse_examples.indices.nbytes
)
结果显示 data 存储只占用 32 字节,整个稀疏矩阵占用 64 字节,都小于原始矩阵的 72 字节。
在具有上千样本,每个样本有上万特征的数据集中,稀疏矩阵对内存使用的减少会非常显著。
import numpy as np
from scipy import sparse
n_rows = 10000
n_cols = 100000
example = np.random.binominal(1, p=0.05, size=(n_rows, n_cols))
print(f'Dense Array Size: {example.nbytes}')
sparse_example = sparse.csr_matrix(example)
print(f'Sparse Array Size: {sparse_example.data.nbytes}')
full_size = (
sparse_example.data.nbytes +
sparse_example.indptr.nbytes +
sparse_example.indices.nbytes
)
print(f'Sparse Array Full Size: {full_size}')
结果显示,密集数组 Dense Array 占用 8G 内存,稀疏数值只用了 400M 内存。
这就是为什么在处理具有很多 0 值的数组时,通常使用稀疏数组的原因。
需要注意,上边演示的只是一种转换稀疏矩阵的方法,还有很多其他方法可以表示不同格式的稀疏矩阵。
尽管二元特征的稀疏表示能节省许多内存,还有另一种表示方式,可以节省更多内存,该表示方式名为 One Hot Encoding。
One Hot Encoding 也是一种二元特征编码,取值只有 0 或 1。
假设我们使用向量表示 ord_2 特征,向量大小和类别数量一致,在下边的例子里,每个向量都只有一个非 0 点位置。
Freezing 0 0 0 0 0 1
Warm 0 0 0 0 1 0
Cold 0 0 0 1 0 0
Boiling Hot 0 0 1 0 0 0
Hot 0 1 0 0 0 0
Lava Hot 1 0 0 0 0 0
每个向量中含有 6 个元素,这是因为该分类中有 6 个不同类型。
还以前边使用的 3 个样本的数据集为例,其 One Hot 表示为。
| Index | F0 | F1 | F2 | F3 | F4 | F5 |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 | 0 |
| 1 | 0 | 1 | 0 | 0 | 0 | 0 |
| 2 | 1 | 0 | 0 | 0 | 0 | 0 |
这样,构成一个 3x6 数值,下边通过代码查看内存占用量。
import numpy as np
from scipy import sparse
print(f'Dense Array Size: {example.nbytes}')
sparse_example = sparse.csr_matrix(example)
print(f'Sparse Array Size: {sparse_example.data.nbytes}')
full_size = (
sparse_example.data.nbytes +
sparse_example.indptr.nbytes +
sparse_example.indices.nbytes
)
print(f'Sparse Array Full Size: {full_size}')
从结果中可见,密集矩阵的内存占用量变大,而稀疏矩阵的内存占用量更小。
下边通过更大的数据集来检查该发现。
import numpy as np
from sklearn import preprocessing
example = np.random.randint(1000, size=1000000)
ohe = preprocessing.OneHotEncoder(sparse=False)
ohe_example = ohe.fit_transform(example.reshape(-1, 1))
print(f"Size of dense array: {ohe_example.nbytes}")
ohe = preprocessing.OneHotEncoder(sparse=True)
ohe_example = ohe.fit_transform(example.reshape(-1, 1))
print(f"Size of dense array: {ohe_example.nbytes}")
full_size = (
ohe_example.data.nbytes +
ohe_example.indptr.nbytes +
ohe_example.indices.nbytes
)
print(f"Full size of sparse array: {full_size}")
密集矩阵大约占用 8G 内存,而稀疏矩阵只用了 8M。
上述三种方式:Label Encoding,Binary Encoding,One Hot Encoding,是处理分类变量最重要的三种手段。当然也有其他方法可用来处理分类变量,比如将其映射为数值。
现在回到 cat-in-the-dat-2 数据集,ord_2 列中 Boiling Hot 的个数有多少个?
df[df.ord_2 == 'Boiling Hot'].shape
可得到 84790 行。
我们也可以通过 groupby 计算该列所有分类的个数。
df.groupby(['ord_2'])['id'].count()
接着,假如想要把当前 ord_2 分类的数量作为一列新特征使用,可通过 transform 方法构建该列特征。
df.groupby(['ord_2'])['id'].transform('count')
也可以按多个列分组并统计个数。
df.groupby(['ord_1', 'ord_2'])['id'].count().reset_index(name='count')
另一个组合特征的技巧是,拼接已有特征,并将其视作新特征。
df['new_feature'] = (
df.ord_1.astype(str) +
'_' +
df.ord_2.astype(str)
)
具体使用这些技巧需要根据数据和特征的类型来决定。在一些领域内,组合特征创建新特征的技巧可能很有用。
如果你不在乎内存和 CPU 占用率,你甚至可以对所有特征的组合进行测试,选择其中最优效果的组合特征。
处理分类变量
每次见到分类变量,都要进行以下处理。
-
填充 NaN,这一步非常重要。
-
使用 LabelEncoder 将该分类变量特征转换为整数特征。如果没处理 NaN,那么在这一步里必须处理它。
-
在整数特征上创建 One Hot Encoding。
-
开始构建机器模型。
在分类变量特征里,处理 NaN 非常关键,否则你会在 LabelEncoder 中遇到臭名昭著的 Error:
ValueError: y contains previously unseen labels: [nan, nan, nan, nan, nan, nan, nan, nan]
这个错误意思是你的数据里有 NaN。
一种简单的处理 NaN 的思路是直接丢弃这种数据,然而这种处理方式并不理想,该列特征值为 NaN ,并不表示其他列特征没有信息,之间丢弃整行显然会丢失信息。
另一种处理 NaN 的思路是将其视作一个新的类型,这也是最常见的处理方式。
df.ord_2.value_counts()
df.ord_2.fillna('NONE').value_counts()
可见有 18075 条数据被忽视了。通过添加 NONE 类型,这些数据又回来了。虽然分类数量增加了 1,但这不影响模型的训练,模型学习的数据越多,它的效果越好。
现在假设 ord_2 特征里没有 NaN 值,且各类别的数据量都很大,即没有占总样本量比例极小的类别。
你基于该样本训练好了模型,模型预测效果与该列特征取值有很大关联,当模型部署到生产环境后,遇到了一个之前从来没有过的 ord_2 类别,接着模型就会因为不认识该数据而报错,然后你不得不在该列特征里新加一种类别去涵盖刚才的未知数据。
这个例子里的新类别就是著名的稀有类别 rare category。稀有类别是那些不经常见到的类别,通常包含非常多类。你可以使用最近邻模型来尝试预测该未知类别。如果要精确预测该类别,得在训练数据里添加该类别。
如果你有一个固定的测试集,可以将测试集添加到训练数据里,以获取全部特征类别。这个操作很像半监督学习,使用训练集里不存在的数据来提升模型效果。这也解决了某类别在训练集里很稀有,而在测试集里常见的情况。如此一来,你的模型会更健壮。
许多人认为这样做会导致过拟合,然而是否会过拟合,与交叉验证方案的关系更紧密,不取决于测试集和训练集合并。
这意味着首先要划分 folds,在每个 fold 里,训练集和测试集要执行相同的预处理过程。假设你要合并训练集和测试集,你就得在每个 fold 里合并训练集和验证集,以保证验证集能够覆盖测试集。
演示代码如下。
import pandas as pd
from sklearn import preprocessing
train = pd.read_csv('cat-in-dat-2/train.csv')
test = pd.read_csv('cat-in-dat-2/test.csv')
test.loc[:, 'target'] = -1
data = pd.concat([train, test]).reset_index(drop=True)
features = [x for x in data.columns if x not in ['id', 'target']]
for feat in features:
lbl_enc = preprocessing.LabelEncoder()
temp_col = data[feat].fillna('NONE').astype(str).values
data.loc[:, feat] = lbl_enc.fit_transform(temp_col)
train = data[data['target'] != -1].reset_index(drop=True)
test = data[data['target'] == -1].reset_index(drop=True)
这种合并训练集和测试集,以获取全部类别取值的技巧,只适用于测试集固定的情况。对于在事实变动的测试集上预测某个类型的问题,不能使用该技巧。
我们在训练时,将 NaN 处理成 NONE,并表示为 Unknown,这样在实际使用时,遇到新类别的输入数据,会把该类别处理为 NONE。这非常像自然语言处理问题,在固定词汇表上构建模型,有专门的 UNK 表示 Unkown。
因此,你可以假设训练集和测试集有同样的类别数量,或者在训练集里引入 Unknown 类别来处理所以新类别。
现在我们把目光移到 ord_4 列上。
首先填充 NaN ,然后统计个类别数量。
df.ord_4.fillna('NONE').value_counts()
---
N 39978
P 37890
...
NONE 17930
...
J 1950
L 1657
可以看到,某些类型出现接近 40000 次,而某些类型只出现数千次。NONE 出现了 17930 次。
接着定义什么类别属于稀有类别:出现次数少于 2000 的类别被当作稀有类别。
df.loc[
df.ord_4.value_counts()[df.ord_4].values < 2000,
'ord_4'
] = 'RAER'
df.ord_4.value_counts()
---
N 39978
P 37890
...
NONE 17930
...
RAER 3607
把 ord_4 特征里类别出现次数少于 2000 的替换为 RAER,这样在测试时,新的未知类别会被映射为 RAER,缺失值会被映射为 NONE。
该处理方式可以保证模型妥善处理新特征类别。
到此为止,我们学到了所有处理分类变量所需要的方法。
接下来开始动手构建模型,并一步步优化模型。
在构建模型之前,必须做好交叉验证划分。已知 target 特征分布不均衡,我们选择 StratifiedKFold 来划分数据。参考第四章代码。
# create_folds.py
import pandas as pd
from sklearn import model_selection
import config
if __name__ == '__main__':
temp = pd.read_csv(config.ORIGIN_FILE)
temp['kfold'] = -1
df = temp.sample(frac=1).reset_index(drop=True)
y = df.target.values
kf = model_selection.StratifiedKFold(n_splits=config.FOLD_NUM)
for fold, (trn_, val_) in enumerate(kf.split(X=df, y=y)):
df.loc[val_, 'kfold'] = fold
df.to_csv(config.TRAINING_FILE, index=False)
接下来,构建一个最简单的逻辑回归模型,使用数据的 one-hot 编码。
# ohe_logres.py
import pandas as pd
from sklearn import linear_model, preprocessing, metrics
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
features = [f for f in df.columns if f not in ['id', 'target', 'kfold']]
for col in features:
df.loc[:, col] = df[col].astype('str').fillna('NONE')
train = df[df.kfold != fold].reset_index(drop=True)
valid = df[df.kfold == fold].reset_index(drop=False)
ohe = preprocessing.OneHotEncoder()
full_data = pd.concat(
[train[features], valid[features]],
axis=0
)
ohe.fit(full_data[features])
x_train = ohe.transform(train[features])
x_valid = ohe.transform(valid[features])
model = linear_model.LogisticRegression()
model.fit(x_train, train.target.values)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(valid.target.values, valid_preds)
print(auc_score)
if __name__ == '__main__':
run(0)
执行 python ohe_logres.py 得到输出 AUC 大约为 0.78 。
许多人在该问题上之间选择了树类模型,比如随机森林。如果在该数据集上使用随机森林,那么不需要做 one-hot encoding,使用 label encoding 就够了。
随机森林代码如下。
# lbl_rf.py
import pandas as pd
from sklearn import ensemble, preprocessing, metrics
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
features = [x for x in df.columns if x not in ['id', 'target', 'kfold']]
for col in features:
df.loc[:, col] = df[col].astype(str).fillna('NONE')
for col in features:
lbl_enc = preprocessing.LabelEncoder()
lbl_enc.fit(df[col])
df.loc[:, col] = lbl_enc.transform(df[col])
train = df[df.kfold != fold].reset_index(drop=True)
valid = df[df.kfold == fold].reset_index(drop=True)
x_train = train[features].values
x_valid = valid[features].values
model = ensemble.RandomForestClassifier()
model.fit(x_train, train.target.values)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(valid.target.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
执行 python lbl_rf.py 得到输出 AUC 大约为 0.71 。
震惊,默认参数的随机森林模型性能比简单的逻辑回归还差。
这就是我们为什么要从最简单的模型开始的原因,随机森林模型的粉丝之间跳过简单模型,他们认为简单模型效果不可能比随机森林更好,这是错的。
在上述代码里,随机森林耗费了更多的时间,然而预测性能却更差了。
我们可以使用稀疏的 one-hot-encoding 的数据在随机森林上在训练一次,而且,我们可以进一步使用分解方法来简化稀疏矩阵,这种分解方法也是自然语言处理中抽取话题的常用方法。
代码如下。
# ohe_svd_rf.py
import pandas as pd
from sklearn import ensemble, preprocessing, metrics, decomposition
from scipy import sparse
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
features = [x for x in df.columns if x not in ['id', 'target', 'kfold']]
for col in features:
df.loc[:, col] = df[col].astype(str).fillna('NONE')
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
ohe_enc = preprocessing.OneHotEncoder()
full_data = pd.concat(
[df_train[features], df_valid[features]],
axis=0
)
ohe_enc.fit(full_data[features])
x_train = ohe_enc.transform(df_train[features])
x_valid = ohe_enc.transform(df_valid[features])
svd = decomposition.TruncatedSVD(n_components=120)
full_sparse = sparse.vstack((x_train, x_valid))
svd.fit(full_sparse)
x_train = svd.transform(x_train)
x_valid = svd.transform(x_valid)
model = ensemble.RandomForestClassifier(n_jobs=-1)
model.fit(x_train, df_train.target.values)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(df_valid.target.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
执行 python ohe_svd_rf.py 得到输出 AUC 大约为 0.70 。
可以看到结果更差,似乎基于 One Hot Encoding 的逻辑回归才是该问题的最优解。
下边再试一下 XGBoost 模型,这是一种非常流行的梯度增强算法,也是基于树的算法,因此可以使用 Label Encoding。
# lbl_xgb.py
import pandas as pd
import xgboost as xgb
from sklearn import metrics, preprocessing
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
features = [x for x in df.columns if x not in ['id', 'target', 'kfold']]
for col in features:
lbl_enc = preprocessing.LabelEncoder()
lbl_enc.fit(df[col])
df.loc[:, col] = lbl_enc.transform(df[col])
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
x_train = df_train[features].values
x_valid = df_valid[features].values
model = xgb.XGBClassifier(
n_jobs=-1,
max_depth=7,
n_estimators=200
)
model.fit(x_train, df_train.target.values)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(df_valid.target.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
执行 python lbl_xgb.py 得到输出 AUC 大约为 0.76 。
该模型效果比随机森林好不少,之后通过调整超参数的方式可以对其进行进一步优化。
也可以通过特征工程,减少某些不会增加模型效果的列,但在这个数据集上似乎没有可以演示的提升手段了。
接下来让我们换一个 US Adult Census Data 数据集,以此演示这些模型优化手段。
数据下载地址
该数据集包含一系列特征,目标是预测薪水等级。
该数据集中包含以下特征:
-
age
-
workclass
-
fnlwgt
-
education
-
education.num
-
marital.status
-
occupation
-
relationship
-
race
-
sex
-
capital.gain
-
capital.loss
-
hours.per.week
-
native.country
-
income
大多数列的含义是不言自明的,至于那些不易理解的列,不用在乎它。
首先检查 income 列,统计一下各类数量。
import pandas as pd
df = pd.read_csv('../input/adult.csv')
df.income.value_counts()
可以看到 7841 个人的收入超过 5w 刀,占总样本的 24%,因此我们仍然选择 AUC 做评估指标。
同时,为了演示,我们丢掉所有的数值属性列:
-
fnlwg
-
age
-
capital.gain
-
capital.loss
-
hours.per.week
首先快速尝试 One Hot Encoding + 逻辑回归。当然,第一步还是要创建交叉验证数据集,但在此不展示该代码,代码和之前一样。
# ohe_logres.py
import pandas as pd
from sklearn import linear_model, preprocessing, metrics
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
num_cols = [
'fnlwgt',
'age',
'capital.gain',
'capital.loss',
'hours.per.week'
]
df = df.drop(num_cols, axis=1)
target_mapping = {
'<=50K': '0',
'>50K': '1'
}
df.loc[:, 'income'] = df.income.map(target_mapping)
features = [f for f in df.columns if f not in ['income', 'kfold']]
for col in features:
df.loc[:, col] = df[col].astype('str').fillna('NONE')
train = df[df.kfold != fold].reset_index(drop=True)
valid = df[df.kfold == fold].reset_index(drop=True)
ohe = preprocessing.OneHotEncoder()
full_data = pd.concat(
[train[features], valid[features]],
axis=0
)
ohe.fit(full_data[features])
x_train = ohe.transform(train[features])
x_valid = ohe.transform(valid[features])
model = linear_model.LogisticRegression()
model.fit(x_train, train.income.values)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(valid.income.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
执行 python -W ignore ohe_logres.py 得到 AUC 约为 0.88 。
然后尝试一下不做超参数调整的 XGBoost 模型,使用 Label Encoding 做分类变量转换。
# lbl_xgb.py
import pandas as pd
import xgboost as xgb
from sklearn import metrics, preprocessing
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
num_cols = [
'fnlwgt',
'age',
'capital.gain',
'capital.loss',
'hours.per.week'
]
df = df.drop(num_cols, axis=1)
target_mapping = {
'<=50K': 0,
'>50K': 1
}
df.loc[:, 'income'] = df.income.map(target_mapping).astype(str)
features = [f for f in df.columns if f not in ['income', 'kfold']]
for col in features:
df.loc[:, col] = df[col].astype('str').fillna('NONE')
for col in features:
lbl_enc = preprocessing.LabelEncoder()
lbl_enc.fit(df[col])
df.loc[:, col] = lbl_enc.transform(df[col])
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
x_train = df_train[features].values
x_valid = df_valid[features].values
lbl_enc = preprocessing.LabelEncoder()
y_train = lbl_enc.fit_transform(df_train.income.values)
model = xgb.XGBClassifier(
n_jobs=-1
)
model.fit(x_train, y_train)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(df_valid.income.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
执行 python lbl_xgb.py 得到 AUC 约为 0.88 。
该结果似乎还不错,接下来调整模型 max_depth 为 7,n_estimators 为 200 ,得到的 AUC 还是 0.88,没什么变化。
这表示一个数据集上的模型参数并不适用于另一个数据集,需要重新调整参数。
下边让我们试一下包含数值特征的 XGBoost 模型。
# lbl_xgb_num.py
import pandas as pd
import xgboost as xgb
from sklearn import metrics, preprocessing
import config
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
num_cols = [
'fnlwgt',
'age',
'capital.gain',
'capital.loss',
'hours.per.week'
]
target_mapping = {
'<=50K': 0,
'>50K': 1
}
df.loc[:, 'income'] = df.income.map(target_mapping).astype(str)
features = [f for f in df.columns if f not in ['income', 'kfold']]
for col in features:
if col not in num_cols:
df.loc[:, col] = df[col].astype('str').fillna('NONE')
for col in features:
if col not in num_cols:
lbl_enc = preprocessing.LabelEncoder()
lbl_enc.fit(df[col])
df.loc[:, col] = lbl_enc.transform(df[col])
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
x_train = df_train[features].values
x_valid = df_valid[features].values
lbl_enc = preprocessing.LabelEncoder()
y_train = lbl_enc.fit_transform(df_train.income.values)
model = xgb.XGBClassifier(
n_jobs=-1
)
model.fit(x_train, y_train)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(df_valid.income.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
在处理数值特征时,保留原始数据,不做编码。
基于树的模型可以处理混合特征数据,而且不用对数值特征做归一化处理。
然而,当使用线性模型时,必须对数值特征做归一化处理。
执行 python lbl_xgb_num.py 得到 AUC 约为 0.91 。
接下来,我们尝试着添加一些特征,我们拿所有类别特征列,对其两两组合,创建新特征。这一操作称为特征工程。
# lbl_xgb_num_feat.py
import itertools
import pandas as pd
import xgboost as xgb
from sklearn import metrics, preprocessing
import config
def feature_engineering(df, cat_cols):
combi = itertools.combinations(cat_cols, 2)
for c1, c2 in combi:
df.loc[
:, c1 + '_' + c2
] = df[c1].astype(str) + '_' + df[c2].astype(str)
return df
def run(fold):
df = pd.read_csv(config.TRAINING_FILE)
num_cols = [
'fnlwgt',
'age',
'capital.gain',
'capital.loss',
'hours.per.week'
]
target_mapping = {
'<=50K': 0,
'>50K': 1
}
df.loc[:, 'income'] = df.income.map(target_mapping).astype(str)
features = [f for f in df.columns if f not in ['income', 'kfold']
and f not in num_cols]
df = feature_engineering(df, features)
features = [f for f in df.columns if f not in ['income', 'kfold']]
for col in features:
if col not in num_cols:
df.loc[:, col] = df[col].astype('str').fillna('NONE')
for col in features:
if col not in num_cols:
lbl_enc = preprocessing.LabelEncoder()
lbl_enc.fit(df[col])
df.loc[:, col] = lbl_enc.transform(df[col])
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
x_train = df_train[features].values
x_valid = df_valid[features].values
lbl_enc = preprocessing.LabelEncoder()
y_train = lbl_enc.fit_transform(df_train.income.values)
model = xgb.XGBClassifier(
n_jobs=-1
)
model.fit(x_train, y_train)
valid_preds = model.predict_proba(x_valid)[:, 1]
auc_score = metrics.roc_auc_score(df_valid.income.values, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
for i in range(config.FOLD_NUM):
run(i)
执行 python lbl_xgb_num_feat.py 得到 AUC 约为 0.92 。
这是一种特别原始的基于分类特征的新特征创建方式,在实际应用时,我们需要仔细检查数据,寻找有意义的特征组合。
如果非得这么全部组合,我们还需要搭配某种特征选择方法来寻找最优特征。
从结果来看,直接往数据上添加一堆特征而不修改模型参数,是可以提升模型性能的。
另一种在分类数据上的特征工程方法是,目标值编码 Targe Encoding。
然而,使用 Target Encoding 时必须十分注意,因为该方法可能导致模型过拟合。
Target Encoding 的处理过程是,给定某个特征,分组该特征里各个类别的数据,在每个类别组内,计算该组数据的 Target 平均值(最大值、最小值),然后将该数值作为新特征,添加到该组数据中。
使用 Target Encoding 方法,必须先做 Cross Validation 划分出数据 folds ,然后在所有 fold 上进行计算。
演示代码如下。
# target_encoding.py
import copy
import pandas as pd
from sklearn import metrics, preprocessing
import xgboost as xgb
import config
def mean_target_encoding(data):
df = copy.deepcopy(data)
num_cols = [
'fnlwgt',
'age',
'capital.gain',
'capital.loss',
'hours.per.week'
]
target_mapping = {
'<=50K': 0,
'>50K': 1
}
df.loc[:, 'income'] = df.income.map(target_mapping)
features = [f for f in df.columns if f not in ['income', 'kfold']
and f not in num_cols]
for col in features:
if col not in num_cols:
df.loc[:, col] = df[col].astype('str').fillna('NONE')
for col in features:
if col not in num_cols:
lbl_enc = preprocessing.LabelEncoder()
lbl_enc.fit(df[col])
df.loc[:, col] = lbl_enc.transform(df[col])
encoded_dfs = []
for fold in range(config.FOLD_NUM):
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
for col in features:
mapping_dict = dict(
df_train.groupby(col)['income'].mean()
)
df_valid.loc[
:, col + '_'
] = df_valid[col].map(mapping_dict)
encoded_dfs.append(df_valid)
encoded_df = pd.concat(encoded_dfs, axis=0)
return encoded_df
def run(df, fold):
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
features = [f for f in df.columns if f not in ['income', 'kfold']]
x_train = df_train[features].values
x_valid = df_valid[features].values
lbl_enc = preprocessing.LabelEncoder()
y_train = lbl_enc.fit_transform(df_train.income.values)
model = xgb.XGBClassifier(
n_jobs=-1,
max_depth=7,
)
model.fit(x_train, y_train)
valid_preds = model.predict_proba(x_valid)[:, 1]
lbl_enc = preprocessing.LabelEncoder()
y_valid = lbl_enc.fit_transform(df_valid.income.values)
auc_score = metrics.roc_auc_score(y_valid, valid_preds)
print(f'Fold: {fold}, AUC: {auc_score}')
if __name__ == '__main__':
df = pd.read_csv(config.TRAINING_FILE)
df = mean_target_encoding(df)
for i in range(config.FOLD_NUM):
run(df, i)
运行后得到 AUC 大约为 0.93 。
似乎我们提升了模型性能,然而你需要非常小心,使用 Target Encoding 很可能导致过拟合。
在使用 Target Encoding 方法时,最好搭配使用某种平滑方法或者添加编码噪音。Scikit-learn 提供的 contrib 包具有平滑 Target Encoding 的方法,所谓平滑是指某种正则化处理,以帮助模型不会过拟合。
处理分类特征是一个复杂的任务,有很多资源会讲如何处理分类特征,本章应该可以帮你在处理分类特征上开一个好头。
实际上,在处理大部分问题时,你不需要做超过 one hot encoding 和 label encoding 之外的处理。
最后,在结束本章之前,我们在该数据集上试一下神经网络模型。
首先了解一下实体嵌入 Entity Embedding 概念,在 Entity Embedding 中,分类被表示为向量。
可以通过二进制编码或者 one hot 编码来表示该分类,但是,如果有成千上万个类别,将会导致巨大的矩阵,大大增加训练耗费的时间,因此需要通过向量来表示分类,该向量大小可以人为控制。
思路很简单,在每个分类特征上都构造一个嵌入层 Embedding Layer,因此每个分类都会映射到一个 embedding。
接着将每个 embedding 按照特征位置拼接在一起,构成输入数据的完整 embedding。
神经网络一次可以处理一批输入,即一批同等大小的 Embedding 。
# entity_embedding.py
import numpy as np
import pandas as pd
from sklearn import metrics, preprocessing
from keras import layers, models, utils
from keras import backend as K
def create_model(data, cat_col):
inputs = []
outputs = []
for c in cat_col:
num_unique_values = len(data[c].unique())
embed_dim = int(min(np.ceil(num_unique_values / 2), 50))
inp = layers.Input(shape=(1,))
out = layers.Embedding(
num_unique_values + 1,
embed_dim,
name=c
)(inp)
out = layers.SpatialDropout1D(0.3)(out)
out = layers.Reshape(target_shape=(embed_dim,))(out)
inputs.append(inp)
outputs.append(out)
x = layers.Concatenate()(outputs)
x = layers.BatchNormalization()(x)
x = layers.Dense(300, activation='relu')(x)
x = layers.Dropout(0.3)(x)
x = layers.BatchNormalization()(x)
x = layers.Dense(300, activation='relu')(x)
x = layers.Dropout(0.3)(x)
x = layers.BatchNormalization()(x)
y = layers.Dense(2, activation='softmax')(x)
model = models.Model(inputs=inputs, outputs=y)
model.compile(loss='binary_crossentropy', optimizer='adam')
return model
def run(fold):
df = pd.read_csv("../input/cat_train_folds.csv")
features = [f for f in df.columns if f not in ("id", "target", "kfold")]
for col in features:
df.loc[:, col] = df[col].astype(str).fillna('NONE')
for col in features:
lbl_enc = preprocessing.LabelEncoder()
df.loc[:, col] = lbl_enc.fit_transform(df[col].values)
df_train = df[df.kfold != fold].reset_index(drop=True)
df_valid = df[df.kfold == fold].reset_index(drop=True)
model = create_model(df, features)
xtrain = [
df_train[features].astype(float).values[:, k] for k in range(len(features))
]
xvalid = [
df_valid[features].astype(float).values[:, k] for k in range(len(features))
]
ytrain = df_train.target.astype(float).values
yvalid = df_valid.target.astype(float).values
ytrain_cat = utils.to_categorical(ytrain)
yvalid_cat = utils.to_categorical(yvalid)
model.fit(
xtrain,
ytrain_cat,
validation_data=(xvalid, yvalid_cat),
verbose=1,
batch_size=1024,
epochs=3
)
valid_preds = model.predict(xvalid)[:, 1]
print(metrics.roc_auc_score(yvalid, valid_preds))
K.clear_session()
if __name__ == '__main__':
run(0)
可以看到该方法产生了最好了结果,而且在 GPU 环境下运算的非常快。
该模型也可以做进一步优化,而且在使用神经网络时,你不需要做任何特征工程。因此,在大规模分类特征数据集上,很值得试一试神经网络方法。
当嵌入大小与类别数量一样时,该嵌入即位 one hot embedding。
这一章基本都在讲特征工程,下一章将详细介绍数值特征与混合特征的特征工程。