不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法
雷锋网 AI 科技评论按:本文是由来自英特尔的数据科学家 Dipanjan Sarkar 在 Medium 上发布的「特征工程」博客下篇,给领域内的研究人员补充特征工程的相关知识,不论学术研究、数据竞赛还是解决商业问题都必不可少。 在上篇中,作者介绍了连续型数值数据的特征工程处理方法 。本篇为下篇,主要介绍离散数据的除了方法。雷锋网 AI 科技评论对原文进行了编译。
背景
在上一篇文章中,我们介绍了许多用于处理结构化的 连续数值数据(continuous numeric data)的特征工程 。而在本篇文章中,我们将继续介绍另一种结构化数据的处理 —— 这种数据本质上是离散的,俗称 分类数据(c ategorical data) 。由于在处理数值数据的时候,我们不必处理属于某一分类类型的数据属性中与每个类别值有关的额外的语义复杂性,因此 处理数值数据通常比处理分类数据来得更加容易 。我们将 结合实际操作 来讨论处理分类数据的几种编码方案,以及处理大规模特征爆炸(通常称为「维度诅咒 curse of dimensionality」)的一些流行技巧。
动机
我认为你现在必须要认识到特征工程的动机和重要性,我们在该系列文章的第一部分中也详细强调了这一点。如果有必要,请快速温习一下。简而言之,机器学习算法不能直接处理分类数据,因此在开始为数据建模之前我们需要对数据进行一些工程处理和转换。
什么是分类数据?
在深入研究特征工程之前,让我们先了解一下分类数据。通常,在 自然界中可分类的任意数据属性都是离散值,这意味着它们属于某一特定的有限类别 。在模型预测的属性或者变量(通常被称为 响应变量 response variables )中,这些也经常被称为类别或者标签。这些离散值在自然界中可以是文本或者数字(甚至是诸如图像这样的非结构化数据)。分类数据有两大类—— 定类(Nominal)和定序(Ordinal) 。
在任意定类分类数据属性中,这些属性值之间 没有顺序的概念 。如下图所示,举个简单的例子,天气分类。我们可以看到,在这个特定的场景中,主要有六个大类,而这些类之间没有任何顺序上的关系(刮风天并不总是发生在晴天之前,并且也不能说比晴天来的更小或者更大)
将天气作为分类属性
与天气相类似的属性还有很多,比如电影、音乐、电子游戏、国家、食物和美食类型等等,这些都属于定类分类属性。
定序分类的属性值则存在着一定的顺序意义或概念。例如,下图中的字母标识了衬衫的大小。显而易见的是,当我们考虑衬衫的时候,它的“大小”属性是很重要的(S 码比 M 码来的小,而 M 码又小于 L 码等等)。
衬衫大小作为定序分类属性
鞋号、受教育水平和公司职位则是定序分类属性的一些其它例子。既然已经对分类数据有了一个大致的理解之后,接下来我们来看看一些特征工程的策略。
分类数据的特征工程
在接受像文本标签这样复杂的分类数据类型问题上,各种机器学习框架均已取得了许多的进步。通常,特征工程中的任意标准工作流都涉及将这些分类值转换为数值标签的某种形式,然后对这些值应用一些 编码方案 。我们将在开始之前导入必要的工具包。
import pandas as pd
import numpy as np
定类属性转换
定类属性由离散的分类值组成,它们没有先后顺序概念 。这里的思想是将这些属性转换成更具代表性的数值格式,这样可以很容易被下游的代码和流水线所理解。我们来看一个关于视频游戏销售的新数据集。这个数据集也可以在 Kaggle 和我的 GitHub 仓库中找到。
vg_df = pd.read_csv('datasets/vgsales.csv', encoding='utf-8')
vg_df[['Name', 'Platform', 'Year', 'Genre', 'Publisher']].iloc[1:7]
游戏销售数据
让我们首先专注于上面数据框中“视频游戏风格(Genre)”属性。显而易见的是,这是一个类似于“发行商(Publisher)”和“平台(Platform)”属性一样的定类分类属性。我们可以很容易得到一个独特的视频游戏风格列表,如下。
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 将每个类别映射到一个数值。
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 中,该变量允许我们将其写回数据表中。
vg_df['GenreLabel'] = genre_labels
vg_df[['Name', 'Platform', 'Year', 'Genre', 'GenreLabel']].iloc[1:7]
视频游戏风格及其编码标签
如果你打算将它们用作预测的响应变量,那么这些标签通常可以直接用于诸如 sikit-learn 这样的框架。但是如前所述,我们还需要额外的编码步骤才能将它们用作特征。
定序属性编码
定序属性是一种带有先后顺序概念的分类属性 。这里我将以本系列文章第一部分所使用的 神奇宝贝数据集 进行说明。让我们先专注于 「世代(Generation)」 属性。
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 代,并且每个神奇宝贝通常属于视频游戏的特定世代(依据发布顺序),而且电视系列也遵循了相似的时间线。这个属性通常是定序的(需要相关的领域知识才能理解),因为属于第一代的大多数神奇宝贝在第二代的视频游戏或者电视节目中也会被更早地引入。神奇宝贝的粉丝们可以看下下图,然后记住每一代中一些比较受欢迎的神奇宝贝(不同的粉丝可能有不同的看法)。
基于不同类型和世代选出的一些受欢迎的神奇宝贝
因此,它们之间存在着先后顺序。一般来说,没有通用的模块或者函数可以根据这些顺序自动将这些特征转换和映射到数值表示。因此,我们可以使用自定义的编码\映射方案。
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]
神奇宝贝世代编码
从上面的代码中可以看出,来自 pandas 库的 map(...) 函数在转换这种定序特征的时候非常有用。
编码分类属性
如果你还记得我们之前提到过的内容,通常对分类数据进行特征工程就涉及到一个转换过程,我们在前一部分描述了一个转换过程,还有一个强制编码过程,我们应用特定的编码方案为特定的每个类别创建虚拟变量或特征分类属性。
你可能想知道,我们刚刚在上一节说到将类别转换为数字标签,为什么现在我们又需要这个?原因很简单。考虑到视频游戏风格,如果我们直接将 GenereLabel 作为属性特征提供给机器学习模型,则模型会认为它是一个连续的数值特征,从而认为值 10 (体育)要大于值 6 (赛车),然而事实上这种信息是毫无意义的,因为 体育类型 显然并不大于或者小于 赛车类型 ,这些不同值或者类别无法直接进行比较。因此我们需要另一套编码方案层,它要能为每个属性的所有不同类别中的每个唯一值或类别创建虚拟特征。
独热编码方案(One-hot Encoding Scheme)
考虑到任意具有 m 个标签的分类属性(变换之后)的数字表示,独热编码方案将该属性编码或变换成 m 个二进制特征向量(向量中的每一维的值只能为 0 或 1)。那么在这个分类特征中每个属性值都被转换成一个 m 维的向量,其中只有某一维的值为 1。让我们来看看神奇宝贝数据集的一个子集。
poke_df[['Name', 'Generation', 'Legendary']].iloc[4:10]
神奇宝贝数据集子集
这里关注的属性是神奇宝贝的「世代(Generation)」和「传奇(Legendary)」状态。第一步是根据之前学到的将这些属性转换为数值表示。
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]
转换后的标签属性
Gen_Label 和 Lgnd_Label 特征描述了我们分类特征的数值表示。现在让我们在这些特征上应用独热编码方案。
# 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)然后看看最终的结果。
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]
神奇宝贝世代和传奇状态的独热编码特征
此时可以看到已经为「世代(Generation)」生成 6 个虚拟变量或者二进制特征,并为「传奇(Legendary)」生成了 2 个特征。这些特征数量是这些属性中不同类别的总数。 某一类别的激活状态通过将对应的虚拟变量置 1 来表示 ,这从上面的数据表中可以非常明显地体现出来。
考虑你在训练数据上建立了这个编码方案,并建立了一些模型,现在你有了一些新的数据,这些数据必须在预测之前进行如下设计。
new_poke_df = pd.DataFrame([['PikaZoom', 'Gen 3', True], ['CharMyToast', 'Gen 4', False]], columns=['Name', 'Generation', 'Legendary'])
new_poke_df
新数据
你可以通过调用之前构建的 LabelEncoder 和 OneHotEncoder 对象的 transform() 方法来处理新数据。请记得我们的工作流程,首先我们要做转换。
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']]
转换之后的分类属性
在得到了数值标签之后,接下来让我们应用编码方案吧!
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]
独热编码之后的分类属性
因此,通过利用 scikit-learn 强大的 API,我们可以很容易将编码方案应用于新数据。
你也可以通过利用来自 pandas 的 to_dummies() 函数轻松应用独热编码方案。
gen_onehot_features = pd.get_dummies(poke_df['Generation'])
pd.concat([poke_df[['Name', 'Generation']], gen_onehot_features], axis=1).iloc[4:10]
使用 pandas 实现的独热编码特征
上面的数据表描述了应用在「世代(Generation)」属性上的独热编码方案,结果与之前的一致。
虚拟编码方案
虚拟编码方案(Dummy coding scheme)与独热编码方案相似,不同之处在于, 在虚拟编码方案中,当应用于具有 m 个不同标签的分类特征时,我们将得到 m-1 个二进制特征 。因此,分类变量的每个值都被转换成 m-1 维向量。额外的特征将被完全忽略,因此如果分类取值范围为 {0, 1, ..., m-1} ,那么第一个(序号为 0)或者第 m 个(序号为 m-1)特征列将被丢弃,然后其对应类别值由一个 0 向量表示。接下来我们尝试通过丢弃第一个特征列(Gen 1)来将神奇宝贝“世代(Generation)”属性转换成虚拟编码。
gen_dummy_features = pd.get_dummies(poke_df['Generation'], drop_first=True)
pd.concat([poke_df[['Name', 'Generation']], gen_dummy_features], axis=1).iloc[4:10]
神奇宝贝世代属性的虚拟编码
当然你也可以通过如下操作来选择丢弃最后一个特征列(Gen 6)。
gen_onehot_features = pd.get_dummies(poke_df['Generation'])
gen_dummy_features = gen_onehot_features.iloc[:,:-1]
pd.concat([poke_df[['Name', 'Generation']], gen_dummy_features], axis=1).iloc[4:10]
神奇宝贝世代属性的虚拟编码
基于上述描述,很明显那些属于被丢弃的类别(这里是 Gen 6)被表示为一个零向量。
效果编码方案
效果编码方案(Effect coding scheme)实际上非常类似于虚拟编码方案,不同的是,对于在虚拟编码方案中被编码为零向量的类别, 在效果编码方案中将被编码为全是 -1 的向量 。下面的例子将清楚地展示这一点。
gen_onehot_features = pd.get_dummies(poke_df['Generation'])
gen_effect_features = gen_onehot_features.iloc[:,:-1]
gen_effect_features.loc[np.all(gen_effect_features == 0, axis=1)] = -1.
pd.concat([poke_df[['Name', 'Generation']], gen_effect_features], axis=1).iloc[4:10]
神奇宝贝世代的效果编码特征
上面的输出清楚地表明,与虚拟编码中的零不同,属于第六代的神奇宝贝现在由 -1 向量表示。
区间计数方案(Bin-counting Scheme)
到目前为止,我们所讨论的编码方案在分类数据方面效果还不错,但是当任意特征的不同类别数量变得很大的时候,问题开始出现。对于具有 m 个不同标签的任意分类特征这点非常重要,你将得到 m 个独立的特征。这会很容易地增加特征集的大小,从而导致在时间、空间和内存方面出现存储问题或者模型训练问题。除此之外,我们还必须处理“ 维度诅咒 ”问题,通常指的是拥有大量的特征,却缺乏足够的代表性样本,然后模型的性能开始受到影响并导致过拟合。
因此,我们需要针对那些可能具有非常多种类别的特征(如 IP 地址),研究其它分类数据特征工程方案。区间计数方案是处理具有多个类别的分类变量的有效方案。在这个方案中,我们使用 基于概率的统计信息和在建模过程中所要预测的实际目标或者响应值 ,而不是使用实际的标签值进行编码。一个简单的例子是,基于过去的 IP 地址历史数据和 DDOS 攻击中所使用的历史数据,我们可以为任一 IP 地址会被 DDOS 攻击的可能性建立概率模型。使用这些信息,我们可以对输入特征进行编码,该输入特征描述了如果将来出现相同的 IP 地址,则引起 DDOS 攻击的概率值是多少。 这个方案需要历史数据作为先决条件,并且要求数据非常详尽。
特征哈希方案
特征哈希方案(Feature Hashing Scheme)是处理大规模分类特征的另一个有用的特征工程方案。在该方案中,哈希函数通常与预设的编码特征的数量(作为预定义长度向量)一起使用,使得特征的哈希值被用作这个预定义向量中的索引,并且值也要做相应的更新。由于哈希函数将大量的值映射到一个小的有限集合中,因此 多个不同值可能会创建相同的哈希 ,这一现象称为 冲突 。典型地,使用带符号的哈希函数,使得从哈希获得的值的符号被用作那些在适当的索引处存储在最终特征向量中的值的符号。这样能够确保实现较少的冲突和由于冲突导致的误差累积。
哈希方案适用于字符串、数字和其它结构(如向量)。你可以将哈希输出看作一个有限的 b bins 集合,以便于当将哈希函数应用于相同的值\类别时,哈希函数能根据哈希值将其分配到 b bins 中的同一个 bin(或者 bins 的子集)。我们可以预先定义 b 的值,它成为我们使用特征哈希方案编码的每个分类属性的编码特征向量的最终尺寸。
因此,即使我们有一个特征拥有超过 1000 个不同的类别,我们设置 b = 10 作为最终的特征向量长度,那么最终输出的特征将只有 10 个特征。而采用独热编码方案则有 1000 个二进制特征。我们来考虑下视频游戏数据集中的「风格(Genre)」属性。
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。
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]
风格属性的特征哈希
基于上述输出,「风格(Genre)」属性已经使用哈希方案编码成 6 个特征而不是 12 个。我们还可以看到,第 1 行和第 6 行表示相同风格的游戏「平台(Platform)」,而它们也被正确编码成了相同的特征向量。
总结
这些例子向你展示了一些在离散分类数据上进行特征工程的主流策略。如果你看过了 这篇文章的上篇 ,你将会发现,比起处理连续数值数据,处理分类数据要难得多,但是也很有趣!我们还谈到了使用特征工程处理大型特征空间的一些方法,但是你应该要记住还有其它技术,包括特征选择和降维方法来处理大型特征空间。我们将在未来的文章中介绍其中的一些方法。
PS: 文中所有代码和数据集都可以从我的 GitHub 上获得。
Via Understanding Feature Engineering (Part 2) — Categorical Data ,雷锋网 (公众号:雷锋网) AI 科技评论编译
相关文章:
不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法
想成为真正的数据科学家,除了资历你还需要这4个技能
Kaggle16000份问卷揭示数据科学家平均画像:30岁,硕士学位,年薪36万
数据科学家必须知道的 10 个深度学习架构
。