特征工程与模型调优

机器学习特征工程

机器学习流程与概念

机器学习建模流程

机器学习特征工程一览

机器学习特征工程介绍

特征清洗



数值型数据上的特征工程

数值型数据通常以标量的形式表示数据,描述观测值、记录或者测量值。本文的数值型数据是指连续型数据而不是离散型数据,表示不同类目的数据就是后者。数值型数据也可以用向量来表示,向量的每个值或分量代表一个特征。整数和浮点数是连续型数值数据中最常见也是最常使用的数值型数据类型。即使数值型数据可以直接输入到机器学习模型中,你仍需要在建模前设计与场景、问题和领域相关的特征。因此仍需要特征工程。让我们利用 python 来看看在数值型数据上做特征工程的一些策略。我们首先加载下面一些必要的依赖(通常在 Jupyter botebook 上)。

1
2
3
4
5
6
7
8
9
> import pandas as pd
>
> import matplotlib.pyplot as plt
>
> import numpy as np
>
> import scipy.stats as spstats
>
> %matplotlib inline

原始度量

正如我们先前提到的,根据上下文和数据的格式,原始数值型数据通常可直接输入到机器学习模型中。原始的度量方法通常用数值型变量来直接表示为特征,而不需要任何形式的变换或特征工程。通常这些特征可以表示一些值或总数。让我们加载四个数据集之一的 Pokemon 数据集,该数据集也在 Kaggle 上公布了。

1
2
3
poke_df = pd.read_csv('datasets/Pokemon.csv', encoding='utf-8') 

poke_df.head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

我们的Pokemon数据集截图

Pokemon 是一个大型多媒体游戏,包含了各种口袋妖怪(Pokemon)角色。简而言之,你可以认为他们是带有超能力的动物!这些数据集由这些口袋妖怪角色构成,每个角色带有各种统计信息。

数值

如果你仔细地观察上图中这些数据,你会看到几个代表数值型原始值的属性,它可以被直接使用。下面的这行代码挑出了其中一些重点特征。

1
poke_df[['HP', 'Attack', 'Defense']].head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

带(连续型)数值数据的特征

这样,你可以直接将这些属性作为特征,如上图所示。这些特征包括 Pokemon 的 HP(血量),Attack(攻击)和 Defense(防御)状态。事实上,我们也可以基于这些字段计算出一些基本的统计量。

1
poke_df[['HP', 'Attack', 'Defense']].describe()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

数值特征形式的基本描述性统计量

这样你就对特征中的统计量如总数、平均值、标准差和四分位数有了一个很好的印象。

记数

原始度量的另一种形式包括代表频率、总数或特征属性发生次数的特征。让我们看看 millionsong 数据集中的一个例子,其描述了某一歌曲被各种用户收听的总数或频数。

1
2
3
popsong_df = pd.read_csv('datasets/song_views.csv',encoding='utf-8')

popsong_df.head(10)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

数值特征形式的歌曲收听总数

根据这张截图,显而易见 listen_count 字段可以直接作为基于数值型特征的频数或总数。

二值化

基于要解决的问题构建模型时,通常原始频数或总数可能与此不相关。比如如果我要建立一个推荐系统用来推荐歌曲,我只希望知道一个人是否感兴趣或是否听过某歌曲。我不需要知道一首歌被听过的次数,因为我更关心的是一个人所听过的各种各样的歌曲。在这个例子中,二值化的特征比基于计数的特征更合适。我们二值化 listen_count 字段如下。

1
2
3
4
5
> watched = np.array(popsong_df['listen_count'])
>
> watched[watched >= 1] = 1
>
> popsong_df['watched'] = watched

你也可以使用 scikit-learn 中 preprocessing 模块的 Binarizer 类来执行同样的任务,而不一定使用 numpy 数组。

1
2
3
4
5
6
7
8
9
from sklearn.preprocessing import Binarizer

bn = Binarizer(threshold=0.9)

pd_watched =bn.transform([popsong_df['listen_count']])[0]

popsong_df['pd_watched'] = pd_watched

popsong_df.head(11)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

歌曲收听总数的二值化结构

你可以从上面的截图中清楚地看到,两个方法得到了相同的结果。因此我们得到了一个二值化的特征来表示一首歌是否被每个用户听过,并且可以在相关的模型中使用它。

数据舍入

处理连续型数值属性如比例或百分比时,我们通常不需要高精度的原始数值。因此通常有必要将这些高精度的百分比舍入为整数型数值。这些整数可以直接作为原始数值甚至分类型特征(基于离散类的)使用。让我们试着将这个观念应用到一个虚拟数据集上,该数据集描述了库存项和他们的流行度百分比。

1
2
3
4
5
6
7
items_popularity =pd.read_csv('datasets/item_popularity.csv',encoding='utf-8')

items_popularity['popularity_scale_10'] = np.array(np.round((items_popularity['pop_percent'] * 10)),dtype='int')

items_popularity['popularity_scale_100'] = np.array(np.round((items_popularity['pop_percent'] * 100)),dtype='int')

items_popularity

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

不同尺度下流行度舍入结果

基于上面的输出,你可能猜到我们试了两种不同的舍入方式。这些特征表明项目流行度的特征现在既有 1-10 的尺度也有 1-100 的尺度。基于这个场景或问题你可以使用这些值同时作为数值型或分类型特征。

相关性

高级机器学习模型通常会对作为输入特征变量函数的输出响应建模(离散类别或连续数值)。例如,一个简单的线性回归方程可以表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

其中输入特征用变量表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

权重或系数可以分别表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

目标是预测响应 *y*.

在这个例子中,仅仅根据单个的、分离的输入特征,这个简单的线性模型描述了输出与输入之间的关系。

然而,在一些真实场景中,有必要试着捕获这些输入特征集一部分的特征变量之间的相关性。上述带有相关特征的线性回归方程的展开式可以简单表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

此处特征可表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

表示了相关特征。现在让我们试着在 Pokemon 数据集上设计一些相关特征。

1
2
3
atk_def = poke_df[['Attack', 'Defense']]

atk_def.head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

从输出数据框中,我们可以看到我们有两个数值型(连续的)特征,Attack 和 Defence。现在我们可以利用 scikit-learn 建立二度特征。

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
pf = PolynomialFeatures(degree=2,

interaction_only=False,include_bias=False)

res = pf.fit_transform(atk_def)

res

**Output**

**------**

array([[ 49., 49., 2401., 2401., 2401.],

[ 62., 63., 3844., 3906., 3969.],

[ 82., 83., 6724., 6806., 6889.],

...,

[ 110., 60., 12100., 6600., 3600.],

[ 160., 60., 25600., 9600., 3600.],

[ 110., 120., 12100., 13200., 14400.]])

上面的特征矩阵一共描述了 5 个特征,其中包括新的相关特征。我们可以看到上述矩阵中每个特征的度,如下所示。

1
pd.DataFrame(pf.powers_, columns=['Attack_degree','Defense_degree'])

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

基于这个输出,现在我们可以通过每个特征的度知道它实际上代表什么。在此基础上,现在我们可以对每个特征进行命名如下。这仅仅是为了便于理解,你可以给这些特征取更好的、容易使用和简单的名字。

1
2
3
intr_features = pd.DataFrame(res, columns=['Attack','Defense','Attack^2','Attack x Defense','Defense^2'])

intr_features.head(5)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

数值型特征及其相关特征

因此上述数据代表了我们原始的特征以及它们的相关特征。

分区间处理数据

处理原始、连续的数值型特征问题通常会导致这些特征值的分布被破坏。这表明有些值经常出现而另一些值出现非常少。除此之外,另一个问题是这些特征的值的变化范围。比如某个音乐视频的观看总数会非常大(Despacito,说你呢)而一些值会非常小。直接使用这些特征会产生很多问题,反而会影响模型表现。因此出现了处理这些问题的技巧,包括分区间法和变换。

分区间(Bining),也叫做量化,用于将连续型数值特征转换为离散型特征(类别)。可以认为这些离散值或数字是类别或原始的连续型数值被分区间或分组之后的数目。每个不同的区间大小代表某种密度,因此一个特定范围的连续型数值会落在里面。对数据做分区间的具体技巧包括等宽分区间以及自适应分区间。我们使用从 2016 年 FreeCodeCamp 开发者和编码员调查报告中抽取出来的一个子集中的数据,来讨论各种针对编码员和软件开发者的属性。

1
2
3
fcc_survey_df =pd.read_csv('datasets/fcc_2016_coder_survey_subset.csv',encoding='utf-8')

fcc_survey_df[['ID.x', 'EmploymentField', 'Age','Income']].head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

来自FCC编码员调查数据集的样本属性

对于每个参加调查的编码员或开发者,ID.x 变量基本上是一个唯一的标识符而其他字段是可自我解释的。

等宽分区间

就像名字表明的那样,在等宽分区间方法中,每个区间都是固定宽度的,通常可以预先分析数据进行定义。基于一些领域知识、规则或约束,每个区间有个预先固定的值的范围,只有处于范围内的数值才被分配到该区间。基于数据舍入操作的分区间是一种方式,你可以使用数据舍入操作来对原始值进行分区间,我们前面已经讲过。

现在我们分析编码员调查报告数据集的 Age 特征并看看它的分布。

1
2
3
4
5
6
7
8
9
fig, ax = plt.subplots()

fcc_survey_df['Age'].hist(color='#A9C5D3',edgecolor='black',grid=False)

ax.set_title('Developer Age Histogram', fontsize=12)

ax.set_xlabel('Age', fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

描述开发者年龄分布的直方图

上面的直方图表明,如预期那样,开发者年龄分布仿佛往左侧倾斜(上年纪的开发者偏少)。现在我们根据下面的模式,将这些原始年龄值分配到特定的区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Age Range: Bin

\---------------

0 - 9 : 0

10 - 19 : 1

20 - 29 : 2

30 - 39 : 3

40 - 49 : 4

50 - 59 : 5

60 - 69 : 6

... and so on

我们可以简单地使用我们先前学习到的数据舍入部分知识,先将这些原始年龄值除以 10,然后通过 floor 函数对原始年龄数值进行截断。

1
2
3
fcc_survey_df['Age_bin_round'] = np.array(np.floor(np.array(fcc_survey_df['Age']) / 10.))

fcc_survey_df[['ID.x', 'Age','Age_bin_round']].iloc[1071:1076]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

通过舍入法分区间

你可以看到基于数据舍入操作的每个年龄对应的区间。但是如果我们需要更灵活的操作怎么办?如果我们想基于我们的规则或逻辑,确定或修改区间的宽度怎么办?基于常用范围的分区间方法将帮助我们完成这个。让我们来定义一些通用年龄段位,使用下面的方式来对开发者年龄分区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Age Range : Bin

\---------------

0 - 15 : 1

16 - 30 : 2

31 - 45 : 3

46 - 60 : 4

61 - 75 : 5

75 - 100 : 6

基于这些常用的分区间方式,我们现在可以对每个开发者年龄值的区间打标签,我们将存储区间的范围和相应的标签。

1
2
3
4
5
6
7
8
9
10
11
bin_ranges = [0, 15, 30, 45, 60, 75, 100]

bin_names = [1, 2, 3, 4, 5, 6]

fcc_survey_df['Age_bin_custom_range'] = pd.cut(np.array(fcc_survey_df['Age']),bins=bin_ranges)

fcc_survey_df['Age_bin_custom_label'] = pd.cut(np.array(fcc_survey_df['Age']),bins=bin_ranges, labels=bin_names)

\# view the binned features

fcc_survey_df[['ID.x', 'Age', 'Age_bin_round','Age_bin_custom_range','Age_bin_custom_label']].iloc[10a71:1076]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

开发者年龄的常用分区间方式

自适应分区间

使用等宽分区间的不足之处在于,我们手动决定了区间的值范围,而由于落在某个区间中的数据点或值的数目是不均匀的,因此可能会得到不规则的区间。一些区间中的数据可能会非常的密集,一些区间会非常稀疏甚至是空的!自适应分区间方法是一个更安全的策略,在这些场景中,我们让数据自己说话!这样,我们使用数据分布来决定区间的范围。

基于分位数的分区间方法是自适应分箱方法中一个很好的技巧。量化对于特定值或切点有助于将特定数值域的连续值分布划分为离散的互相挨着的区间。因此 q 分位数有助于将数值属性划分为 q 个相等的部分。关于量化比较流行的例子包括 2 分位数,也叫中值,将数据分布划分为2个相等的区间;4 分位数,也简称分位数,它将数据划分为 4 个相等的区间;以及 10 分位数,也叫十分位数,创建 10 个相等宽度的区间,现在让我们看看开发者数据集的 Income 字段的数据分布。

1
2
3
4
5
6
7
8
9
fig, ax = plt.subplots()

fcc_survey_df['Income'].hist(bins=30, color='#A9C5D3',edgecolor='black',grid=False)

ax.set_title('Developer Income Histogram',fontsize=12)

ax.set_xlabel('Developer Income', fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

描述开发者收入分布的直方图

上述的分布描述了一个在收入上右歪斜的分布,少数人赚更多的钱,多数人赚更少的钱。让我们基于自适应分箱方式做一个 4-分位数或分位数。我们可以很容易地得到如下的分位数。

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
quantile_list = [0, .25, .5, .75, 1.]

quantiles =

fcc_survey_df['Income'].quantile(quantile_list)

quantiles



**Output**

**------**

0.00 6000.0

0.25 20000.0

0.50 37000.0

0.75 60000.0

1.00 200000.0

Name: Income, dtype: float64

现在让我们在原始的分布直方图中可视化下这些分位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fig, ax = plt.subplots()

fcc_survey_df['Income'].hist(bins=30, color='#A9C5D3',edgecolor='black',grid=False)

for quantile in quantiles:

qvl = plt.axvline(quantile, color='r')

ax.legend([qvl], ['Quantiles'], fontsize=10)

ax.set_title('Developer Income Histogram with Quantiles',fontsize=12)

ax.set_xlabel('Developer Income', fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

带分位数形式描述开发者收入分布的直方图

上面描述的分布中红色线代表了分位数值和我们潜在的区间。让我们利用这些知识来构建我们基于分区间策略的分位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
quantile_labels = ['0-25Q', '25-50Q', '50-75Q', '75-100Q']

fcc_survey_df['Income_quantile_range'] = pd.qcut(

fcc_survey_df['Income'],q=quantile_list)

fcc_survey_df['Income_quantile_label'] = pd.qcut(

fcc_survey_df['Income'],q=quantile_list,labels=quantile_labels)

fcc_survey_df[['ID.x', 'Age', 'Income','Income_quantile_range',

'Income_quantile_label']].iloc[4:9]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

基于分位数的开发者收入的区间范围和标签

通过这个例子,你应该对如何做基于分位数的自适应分区间法有了一个很好的认识。一个需要重点记住的是,分区间的结果是离散值类型的分类特征,当你在模型中使用分类数据之前,可能需要额外的特征工程相关步骤。我们将在接下来的部分简要地讲述分类数据的特征工程技巧。

统计变换

我们讨论下先前简单提到过的数据分布倾斜的负面影响。现在我们可以考虑另一个特征工程技巧,即利用统计或数学变换。我们试试看 Log 变换和 Box-Cox 变换。这两种变换函数都属于幂变换函数簇,通常用来创建单调的数据变换。它们的主要作用在于它能帮助稳定方差,始终保持分布接近于正态分布并使得数据与分布的平均值无关。

Log变换

log 变换属于幂变换函数簇。该函数用数学表达式表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

读为以 b 为底 x 的对数等于 y。这可以变换为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

表示以b为底指数必须达到多少才等于x。自然对数使用 b=e,e=2.71828,通常叫作欧拉常数。你可以使用通常在十进制系统中使用的 b=10 作为底数。

当应用于倾斜分布时 Log 变换是很有用的,因为他们倾向于拉伸那些落在较低的幅度范围内自变量值的范围,倾向于压缩或减少更高幅度范围内的自变量值的范围。从而使得倾斜分布尽可能的接近正态分布。让我们对先前使用的开发者数据集的 Income 特征上使用log变换。

1
2
3
fcc_survey_df['Income_log'] = np.log((1+fcc_survey_df['Income']))

fcc_survey_df[['ID.x', 'Age', 'Income','Income_log']].iloc[4:9]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

开发者收入log变换后结构

Income_log 字段描述了经过 log 变换后的特征。现在让我们来看看字段变换后数据的分布。

基于上面的图,我们可以清楚地看到与先前倾斜分布相比,该分布更加像正态分布或高斯分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
income_log_mean =np.round(np.mean(fcc_survey_df['Income_log']), 2)

fig, ax = plt.subplots()

fcc_survey_df['Income_log'].hist(bins=30,color='#A9C5D3',edgecolor='black',grid=False)

plt.axvline(income_log_mean, color='r')

ax.set_title('Developer Income Histogram after Log Transform',fontsize=12)

ax.set_xlabel('Developer Income (log scale)',fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

ax.text(11.5, 450, r'$\mu$='+str(income_log_mean),fontsize=10)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

经过log变换后描述开发者收入分布的直方图

Box-Cox变换

Box-Cox 变换是另一个流行的幂变换函数簇中的一个函数。该函数有一个前提条件,即数值型值必须先变换为正数(与 log 变换所要求的一样)。万一出现数值是负的,使用一个常数对数值进行偏移是有帮助的。数学上,Box-Cox 变换函数可以表示如下。

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

生成的变换后的输出y是输入 x 和变换参数的函数;当 λ=0 时,该变换就是自然对数 log 变换,前面我们已经提到过了。λ 的最佳取值通常由最大似然或最大对数似然确定。现在让我们在开发者数据集的收入特征上应用 Box-Cox 变换。首先我们从数据分布中移除非零值得到最佳的值,结果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
income = np.array(fcc_survey_df['Income'])

income_clean = income[~np.isnan(income)]

l, opt_lambda = spstats.boxcox(income_clean)

print('Optimal lambda value:', opt_lambda)



**Output**

**------**

Optimal lambda value: 0.117991239456

现在我们得到了最佳的值,让我们在取值为 0 和 λ(最佳取值 λ )时使用 Box-Cox 变换对开发者收入特征进行变换。

1
2
3
4
5
fcc_survey_df['Income_boxcox_lambda_0'] = spstats.boxcox((1+fcc_survey_df['Income']),lmbda=0)

fcc_survey_df['Income_boxcox_lambda_opt'] = spstats.boxcox(fcc_survey_df['Income'],lmbda=opt_lambda)

fcc_survey_df[['ID.x', 'Age', 'Income', 'Income_log','Income_boxcox_lambda_0','Income_boxcox_lambda_opt']].iloc[4:9]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

经过 Box-Cox 变换后开发者的收入分布

变换后的特征在上述数据框中描述了。就像我们期望的那样,Income_log 和 Income_boxcox_lamba_0具有相同的取值。让我们看看经过最佳λ变换后 Income 特征的分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>income_boxcox_mean = np.round(np.mean(fcc_survey_df['Income_boxcox_lambda_opt']),2)
>
>fig, ax = plt.subplots()
>
>fcc_survey_df['Income_boxcox_lambda_opt'].hist(bins=30, color='#A9C5D3',edgecolor='black', grid=False)
> plt.axvline(income_boxcox_mean, color='r')
>
> ax.set_title('Developer Income Histogram after Box–Cox Transform',fontsize=12)
>
> ax.set_xlabel('Developer Income (Box–Cox transform)',fontsize=12)
>
> ax.set_ylabel('Frequency', fontsize=12)
>
> ax.text(24, 450, r'$\mu$='+str(income_boxcox_mean),fontsize=10)
>

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

经过Box-Cox变换后描述开发者收入分布的直方图

分布看起来更像是正态分布,与我们经过 log 变换后的分布相似。

类别型数据上的特征工程

在深入研究特征工程之前,让我们先了解一下分类数据。通常,在自然界中可分类的任意数据属性都是离散值,这意味着它们属于某一特定的有限类别。在模型预测的属性或者变量(通常被称为响应变量 response variables)中,这些也经常被称为类别或者标签。这些离散值在自然界中可以是文本或者数字(甚至是诸如图像这样的非结构化数据)。分类数据有两大类——定类(Nominal)和定序(Ordinal)

在任意定类分类数据属性中,这些属性值之间没有顺序的概念。如下图所示,举个简单的例子,天气分类。我们可以看到,在这个特定的场景中,主要有六个大类,而这些类之间没有任何顺序上的关系(刮风天并不总是发生在晴天之前,并且也不能说比晴天来的更小或者更大)

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

将天气作为分类属性

与天气相类似的属性还有很多,比如电影、音乐、电子游戏、国家、食物和美食类型等等,这些都属于定类分类属性。

定序分类的属性值则存在着一定的顺序意义或概念。例如,下图中的字母标识了衬衫的大小。显而易见的是,当我们考虑衬衫的时候,它的“大小”属性是很重要的(S 码比 M 码来的小,而 M 码又小于 L 码等等)。

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

衬衫大小作为定序分类属性

鞋号、受教育水平和公司职位则是定序分类属性的一些其它例子。既然已经对分类数据有了一个大致的理解之后,接下来我们来看看一些特征工程的策略。

在接受像文本标签这样复杂的分类数据类型问题上,各种机器学习框架均已取得了许多的进步。通常,特征工程中的任意标准工作流都涉及将这些分类值转换为数值标签的某种形式,然后对这些值应用一些编码方案。我们将在开始之前导入必要的工具包。

1
2
3
import pandas as pd

import numpy as np

定类属性转换(LabelEncoding)

定类属性由离散的分类值组成,它们没有先后顺序概念。这里的思想是将这些属性转换成更具代表性的数值格式,这样可以很容易被下游的代码和流水线所理解。我们来看一个关于视频游戏销售的新数据集。这个数据集也可以在 Kaggle 和我的 GitHub 仓库中找到。

1
2
3
vg_df = pd.read_csv('datasets/vgsales.csv', encoding='utf-8')

vg_df[['Name', 'Platform', 'Year', 'Genre', 'Publisher']].iloc[1:7]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

游戏销售数据

让我们首先专注于上面数据框中“视频游戏风格(Genre)”属性。显而易见的是,这是一个类似于“发行商(Publisher)”和“平台(Platform)”属性一样的定类分类属性。我们可以很容易得到一个独特的视频游戏风格列表,如下。

1
2
3
4
5
6
7
8
9
genres = np.unique(vg_df['Genre'])

genres

Output

\------

array(['Action', 'Adventure', 'Fighting', 'Misc', 'Platform', 'Puzzle', 'Racing', 'Role-Playing', 'Shooter', 'Simulation', 'Sports', 'Strategy'], dtype=object)

输出结果表明,我们有 12 种不同的视频游戏风格。我们现在可以生成一个标签编码方法,即利用 scikit-learn 将每个类别映射到一个数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from sklearn.preprocessing import LabelEncoder

gle = LabelEncoder()

genre_labels = gle.fit_transform(vg_df['Genre'])

genre_mappings = {index: label for index, label in enumerate(gle.classes_)}

genre_mappings

Output

\------

{0: 'Action', 1: 'Adventure', 2: 'Fighting', 3: 'Misc', 4: 'Platform', 5: 'Puzzle', 6: 'Racing', 7: 'Role-Playing', 8: 'Shooter', 9: 'Simulation', 10: 'Sports', 11: 'Strategy'}

因此,在 LabelEncoder 类的实例对象 gle 的帮助下生成了一个映射方案,成功地将每个风格属性映射到一个数值。转换后的标签存储在 genre_labels 中,该变量允许我们将其写回数据表中。

1
2
3
vg_df['GenreLabel'] = genre_labels

vg_df[['Name', 'Platform', 'Year', 'Genre', 'GenreLabel']].iloc[1:7]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

视频游戏风格及其编码标签

如果你打算将它们用作预测的响应变量,那么这些标签通常可以直接用于诸如 sikit-learn 这样的框架。但是如前所述,我们还需要额外的编码步骤才能将它们用作特征。

定序属性编码

定序属性是一种带有先后顺序概念的分类属性。这里我将以本系列文章第一部分所使用的神奇宝贝数据集进行说明。让我们先专注于 「世代(Generation)」 属性。

1
2
3
4
5
6
7
8
9
10
11
> poke_df = pd.read_csv('datasets/Pokemon.csv', encoding='utf-8')
>
> poke_df = poke_df.sample(random_state=1, frac=1).reset_index(drop=True)
>
> np.unique(poke_df['Generation'])
>
> Output
>
> \------
>
> array(['Gen 1', 'Gen 2', 'Gen 3', 'Gen 4', 'Gen 5', 'Gen 6'], dtype=object)

根据上面的输出,我们可以看到一共有 6 代,并且每个神奇宝贝通常属于视频游戏的特定世代(依据发布顺序),而且电视系列也遵循了相似的时间线。这个属性通常是定序的(需要相关的领域知识才能理解),因为属于第一代的大多数神奇宝贝在第二代的视频游戏或者电视节目中也会被更早地引入。神奇宝贝的粉丝们可以看下下图,然后记住每一代中一些比较受欢迎的神奇宝贝(不同的粉丝可能有不同的看法)。

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

基于不同类型和世代选出的一些受欢迎的神奇宝贝

因此,它们之间存在着先后顺序。一般来说,没有通用的模块或者函数可以根据这些顺序自动将这些特征转换和映射到数值表示。因此,我们可以使用自定义的编码\映射方案。

1
2
3
4
5
gen_ord_map = {'Gen 1': 1, 'Gen 2': 2, 'Gen 3': 3, 'Gen 4': 4, 'Gen 5': 5, 'Gen 6': 6} 

poke_df['GenerationLabel'] = poke_df['Generation'].map(gen_ord_map)

poke_df[['Name', 'Generation', 'GenerationLabel']].iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

神奇宝贝世代编码

从上面的代码中可以看出,来自 pandas 库的 map(…) 函数在转换这种定序特征的时候非常有用。

编码分类属性–独热编码方案(One-hot Encoding Scheme)

如果你还记得我们之前提到过的内容,通常对分类数据进行特征工程就涉及到一个转换过程,我们在前一部分描述了一个转换过程,还有一个强制编码过程,我们应用特定的编码方案为特定的每个类别创建虚拟变量或特征分类属性。

你可能想知道,我们刚刚在上一节说到将类别转换为数字标签,为什么现在我们又需要这个?原因很简单。考虑到视频游戏风格,如果我们直接将 GenereLabel 作为属性特征提供给机器学习模型,则模型会认为它是一个连续的数值特征,从而认为值 10 (体育)要大于值 6 (赛车),然而事实上这种信息是毫无意义的,因为体育类型显然并不大于或者小于赛车类型,这些不同值或者类别无法直接进行比较。因此我们需要另一套编码方案层,它要能为每个属性的所有不同类别中的每个唯一值或类别创建虚拟特征。

考虑到任意具有 m 个标签的分类属性(变换之后)的数字表示,独热编码方案将该属性编码或变换成 m 个二进制特征向量(向量中的每一维的值只能为 0 或 1)。那么在这个分类特征中每个属性值都被转换成一个 m 维的向量,其中只有某一维的值为 1。让我们来看看神奇宝贝数据集的一个子集。

1
poke_df[['Name', 'Generation', 'Legendary']].iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

神奇宝贝数据集子集

这里关注的属性是神奇宝贝的「世代(Generation)」和「传奇(Legendary)」状态。第一步是根据之前学到的将这些属性转换为数值表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from sklearn.preprocessing import OneHotEncoder, LabelEncoder

\# transform and map pokemon generations

gen_le = LabelEncoder()

gen_labels = gen_le.fit_transform(poke_df['Generation'])

poke_df['Gen_Label'] = gen_labels

\# transform and map pokemon legendary status

leg_le = LabelEncoder()

leg_labels = leg_le.fit_transform(poke_df['Legendary'])

poke_df['Lgnd_Label'] = leg_labels

poke_df_sub = poke_df[['Name', 'Generation', 'Gen_Label', 'Legendary', 'Lgnd_Label']]

poke_df_sub.iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

转换后的标签属性

Gen_LabelLgnd_Label 特征描述了我们分类特征的数值表示。现在让我们在这些特征上应用独热编码方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# encode generation labels using one-hot encoding scheme

gen_ohe = OneHotEncoder()

gen_feature_arr = gen_ohe.fit_transform(poke_df[['Gen_Label']]).toarray()

gen_feature_labels = list(gen_le.classes_)

gen_features = pd.DataFrame(gen_feature_arr, columns=gen_feature_labels)

\# encode legendary status labels using one-hot encoding scheme

leg_ohe = OneHotEncoder()

leg_feature_arr = leg_ohe.fit_transform(poke_df[['Lgnd_Label']]).toarray()

leg_feature_labels = ['Legendary_'+str(cls_label) for cls_label in leg_le.classes_]

leg_features = pd.DataFrame(leg_feature_arr, columns=leg_feature_labels)

通常来说,你可以使用 fit_transform 函数将两个特征一起编码(通过将两个特征的二维数组一起传递给函数,详情查看文档)。但是我们分开编码每个特征,这样可以更易于理解。除此之外,我们还可以创建单独的数据表并相应地标记它们。现在让我们链接这些特征表(Feature frames)然后看看最终的结果。

1
2
3
4
5
poke_df_ohe = pd.concat([poke_df_sub, gen_features, leg_features], axis=1)

columns = sum([['Name', 'Generation', 'Gen_Label'], gen_feature_labels, ['Legendary', 'Lgnd_Label'], leg_feature_labels], [])

poke_df_ohe[columns].iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

神奇宝贝世代和传奇状态的独热编码特征

此时可以看到已经为「世代(Generation)」生成 6 个虚拟变量或者二进制特征,并为「传奇(Legendary)」生成了 2 个特征。这些特征数量是这些属性中不同类别的总数。某一类别的激活状态通过将对应的虚拟变量置 1 来表示,这从上面的数据表中可以非常明显地体现出来。

考虑你在训练数据上建立了这个编码方案,并建立了一些模型,现在你有了一些新的数据,这些数据必须在预测之前进行如下设计。

1
2
3
new_poke_df = pd.DataFrame([['PikaZoom', 'Gen 3', True], ['CharMyToast', 'Gen 4', False]], columns=['Name', 'Generation', 'Legendary'])

new_poke_df

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

新数据

你可以通过调用之前构建的 LabelEncoderOneHotEncoder 对象的 transform() 方法来处理新数据。请记得我们的工作流程,首先我们要做转换。

1
2
3
4
5
6
7
8
9
new_gen_labels = gen_le.transform(new_poke_df['Generation'])

new_poke_df['Gen_Label'] = new_gen_labels

new_leg_labels = leg_le.transform(new_poke_df['Legendary'])

new_poke_df['Lgnd_Label'] = new_leg_labels

new_poke_df[['Name', 'Generation', 'Gen_Label', 'Legendary', 'Lgnd_Label']]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

转换之后的分类属性

在得到了数值标签之后,接下来让我们应用编码方案吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
new_gen_feature_arr = gen_ohe.transform(new_poke_df[['Gen_Label']]).toarray()

new_gen_features = pd.DataFrame(new_gen_feature_arr, columns=gen_feature_labels)

new_leg_feature_arr = leg_ohe.transform(new_poke_df[['Lgnd_Label']]).toarray()

new_leg_features = pd.DataFrame(new_leg_feature_arr, columns=leg_feature_labels)

new_poke_ohe = pd.concat([new_poke_df, new_gen_features, new_leg_features], axis=1)

columns = sum([['Name', 'Generation', 'Gen_Label'], gen_feature_labels, ['Legendary', 'Lgnd_Label'], leg_feature_labels], [])

new_poke_ohe[columns]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

独热编码之后的分类属性

因此,通过利用 scikit-learn 强大的 API,我们可以很容易将编码方案应用于新数据。

你也可以通过利用来自 pandas 的 to_dummies() 函数轻松应用独热编码方案。

1
2
3
gen_onehot_features = pd.get_dummies(poke_df['Generation'])

pd.concat([poke_df[['Name', 'Generation']], gen_onehot_features], axis=1).iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

使用 pandas 实现的独热编码特征

上面的数据表描述了应用在「世代(Generation)」属性上的独热编码方案,结果与之前的一致。

区间计数方案(Bin-counting Scheme)

到目前为止,我们所讨论的编码方案在分类数据方面效果还不错,但是当任意特征的不同类别数量变得很大的时候,问题开始出现。对于具有 m 个不同标签的任意分类特征这点非常重要,你将得到 m 个独立的特征。这会很容易地增加特征集的大小,从而导致在时间、空间和内存方面出现存储问题或者模型训练问题。除此之外,我们还必须处理“维度诅咒”问题,通常指的是拥有大量的特征,却缺乏足够的代表性样本,然后模型的性能开始受到影响并导致过拟合。

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

因此,我们需要针对那些可能具有非常多种类别的特征(如 IP 地址),研究其它分类数据特征工程方案。区间计数方案是处理具有多个类别的分类变量的有效方案。在这个方案中,我们使用基于概率的统计信息和在建模过程中所要预测的实际目标或者响应值,而不是使用实际的标签值进行编码。一个简单的例子是,基于过去的 IP 地址历史数据和 DDOS 攻击中所使用的历史数据,我们可以为任一 IP 地址会被 DDOS 攻击的可能性建立概率模型。使用这些信息,我们可以对输入特征进行编码,该输入特征描述了如果将来出现相同的 IP 地址,则引起 DDOS 攻击的概率值是多少。这个方案需要历史数据作为先决条件,并且要求数据非常详尽。

特征哈希方案

特征哈希方案(Feature Hashing Scheme)是处理大规模分类特征的另一个有用的特征工程方案。在该方案中,哈希函数通常与预设的编码特征的数量(作为预定义长度向量)一起使用,使得特征的哈希值被用作这个预定义向量中的索引,并且值也要做相应的更新。由于哈希函数将大量的值映射到一个小的有限集合中,因此多个不同值可能会创建相同的哈希,这一现象称为冲突。典型地,使用带符号的哈希函数,使得从哈希获得的值的符号被用作那些在适当的索引处存储在最终特征向量中的值的符号。这样能够确保实现较少的冲突和由于冲突导致的误差累积。

哈希方案适用于字符串、数字和其它结构(如向量)。你可以将哈希输出看作一个有限的 b bins 集合,以便于当将哈希函数应用于相同的值\类别时,哈希函数能根据哈希值将其分配到 b bins 中的同一个 bin(或者 bins 的子集)。我们可以预先定义 b 的值,它成为我们使用特征哈希方案编码的每个分类属性的编码特征向量的最终尺寸。

因此,即使我们有一个特征拥有超过 1000 个不同的类别,我们设置 b = 10 作为最终的特征向量长度,那么最终输出的特征将只有 10 个特征。而采用独热编码方案则有 1000 个二进制特征。我们来考虑下视频游戏数据集中的「风格(Genre)」属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
unique_genres = np.unique(vg_df[['Genre']])

print("Total game genres:", len(unique_genres))

print(unique_genres)

Output

\------

Total game genres: 12

['Action' 'Adventure' 'Fighting' 'Misc' 'Platform' 'Puzzle' 'Racing' 'Role-Playing' 'Shooter' 'Simulation' 'Sports' 'Strategy']

我们可以看到,总共有 12 中风格的游戏。如果我们在“风格”特征中采用独热编码方案,则将得到 12 个二进制特征。而这次,我们将通过 scikit-learn 的 FeatureHasher 类来使用特征哈希方案,该类使用了一个有符号的 32 位版本的 Murmurhash3 哈希函数。在这种情况下,我们将预先定义最终的特征向量大小为 6。

1
2
3
4
5
6
7
from sklearn.feature_extraction import FeatureHasher

fh = FeatureHasher(n_features=6, input_type='string')

hashed_features = fh.fit_transform(vg_df['Genre'])

hashed_features = hashed_features.toarray()pd.concat([vg_df[['Name', 'Genre']], pd.DataFrame(hashed_features)], axis=1).iloc[1:7]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

风格属性的特征哈希

基于上述输出,「风格(Genre)」属性已经使用哈希方案编码成 6 个特征而不是 12 个。我们还可以看到,第 1 行和第 6 行表示相同风格的游戏「平台(Platform)」,而它们也被正确编码成了相同的特征向量。

时间型

avatar
avatar
avatar

文本型

avatar
avatar
avatar
avatar

文章目录
  1. 1. 机器学习特征工程
    1. 1.1. 机器学习流程与概念
    2. 1.2. 机器学习建模流程
    3. 1.3. 机器学习特征工程一览
    4. 1.4. 机器学习特征工程介绍
    5. 1.5. 特征清洗
    6. 1.6. 数值型数据上的特征工程
      1. 1.6.0.1. 我们的Pokemon数据集截图
    7. 1.6.1. 数值
      1. 1.6.1.1. 带(连续型)数值数据的特征
    8. 1.6.2. 记数
      1. 1.6.2.1. 数值特征形式的歌曲收听总数
    9. 1.6.3. 二值化
      1. 1.6.3.1. 歌曲收听总数的二值化结构
    10. 1.6.4. 数据舍入
      1. 1.6.4.1. 不同尺度下流行度舍入结果
    11. 1.6.5. 相关性
      1. 1.6.5.1. 数值型特征及其相关特征
    12. 1.6.6. 分区间处理数据
      1. 1.6.6.1. 来自FCC编码员调查数据集的样本属性
    13. 1.6.7. 等宽分区间
      1. 1.6.7.1. 描述开发者年龄分布的直方图
      2. 1.6.7.2. 通过舍入法分区间
      3. 1.6.7.3. 开发者年龄的常用分区间方式
    14. 1.6.8. 自适应分区间
      1. 1.6.8.1. 带分位数形式描述开发者收入分布的直方图
      2. 1.6.8.2. 基于分位数的开发者收入的区间范围和标签
    15. 1.6.9. 统计变换
    16. 1.6.10. Log变换
      1. 1.6.10.1. 开发者收入log变换后结构
    17. 1.6.11. Box-Cox变换
      1. 1.6.11.1. 经过 Box-Cox 变换后开发者的收入分布
  2. 1.7. 类别型数据上的特征工程
    1. 1.7.1. 定类属性转换(LabelEncoding)
    2. 1.7.2. 定序属性编码
    3. 1.7.3. 编码分类属性–独热编码方案(One-hot Encoding Scheme)
    4. 1.7.4. 区间计数方案(Bin-counting Scheme)
    5. 1.7.5. 特征哈希方案
  3. 1.8. 时间型
  4. 1.9. 文本型
|