机器学习之PyTorch和Scikit-Learn第4章 构建优秀的训练数据集 - 数据预处理Part 1

473 阅读14分钟

其它章节内容请见机器学习之PyTorch和Scikit-Learn

数据质量及所包含的有用信息量是决定机器学习算法能学到多好的关键因素。因此,在将数据集喂给机器学习算法前对其进行检查和预处理绝对很重要。本章中,我们会讨论一些基本数据预处理技术,有助于我们构建很好的机器学习模型。

本章将要讨论的内容有:

  • 删除和替换数据集缺失值
  • 为机器学习算法准备分类数据
  • 为模型构建选择相关特征

处理缺失数据

真实世界中训练样本因各种原因缺失一个或多个值并不罕见。比如数据收集过程中可能会有错误,某些度量可能不可用或是某些字段在调研时被留空了。通常缺失值是数据表中的空白或是占位符字符串,如表示not a number的NaN或是NULL(通常用于表示关联数据库中的未知值)。可惜大部分计算工具都无法处理这种缺失值或是在忽略时产生一些不可预测的结果。 因此,在进一步分析前处理这些缺失值就极为重要了。

本节中,我们会通过一些实用技术通过删除数据集中的条目或通过其它训练样本及特征替换缺失值来处理缺失值。

识别表数据中的缺失值

在讨论处理缺失值的一些技术前,我们先通过CSV(comma-separated values) 文件创建一个示例DataFrame以更好地掌握这一问题:

>>> import pandas as pd
>>> from io import StringIO
>>> csv_data = \
... '''A,B,C,D
... 1.0,2.0,3.0,4.0
... 5.0,6.0,,8.0
... 10.0,11.0,12.0,'''
>>> # If you are using Python 2.7, you need
>>> # to convert the string to unicode:
>>> # csv_data = unicode(csv_data)
>>> df = pd.read_csv(StringIO(csv_data))
>>> df
        A        B        C        D
0     1.0      2.0      3.0      4.0
1     5.0      6.0      NaN      8.0
2    10.0     11.0     12.0      NaN

使用上面的代码,我们通过read_csv函数将CSV格式的数据计入pandas的DataFrame中,注意两个缺失单元格被替换成了NaN。以上示例代码中的StringIO函数只是为了演示。它让我们可以将赋值给csv_data的字符串像硬盘上的常规CSV文件一样读入到pandas的DataFrame中。

对于更大的DataFrame,手动查找缺失值会很费力,这时可使用isnull方法返回一个带布尔值的DataFrame,包含数值时单元格为False,而数据缺失时为True。使用sum方法,我们返回每列缺失值的数量,如下:

>>> df.isnull().sum()
A      0
B      0
C      1
D      1
dtype: int64

这样我们可以计算出每列缺失值的数量,在接下来的小节中,我们会学习处理缺失数据的不同策略。

处理pandas的DataFrame的方便数据

虽然原来开发的scikit-learn只处理NumPy的数组,有时使用pandas的DataFrame预处理数据更为方便。如今大部分的scikit-learn函数都支持DataFrame对象作为入参,但因为在scikit-learn API中对NumPy数组的处理更为成熟,推荐尽可能使用NumPy。注意在将数据喂入scikit-learn估计器之前我们总是可以通过values属性访问DataFrame底层的NumPy数组:

>>> df.values
array([[  1.,   2.,   3.,   4.],
       [  5.,   6.,  nan,   8.],
       [ 10.,  11.,  12.,  nan]])

删除带缺失值的训练样本或特征

处理缺失值最简单的一咱方式是直接从数据集中删除对应的特殊(列)或训练样本(行),带缺失值的行可通过dropna方法进行删除:

>>> df.dropna(axis=0)
      A    B    C    D
0   1.0  2.0  3.0  4.0

类似地,我们可以通过将axis参数设置为1来删除任意行中至少有一个NaN的列:

>>> df.dropna(axis=1)
      A      B
0   1.0    2.0
1   5.0    6.0
2  10.0   11.0

dropna方法还支持另外几个趁手的参数:

>>> # only drop rows where all columns are NaN
>>> # (returns the whole array here since we don't
>>> # have a row with all values NaN)
>>> df.dropna(how='all')
      A      B      C      D
0   1.0    2.0    3.0    4.0
1   5.0    6.0    NaN    8.0
2  10.0   11.0   12.0    NaN
>>> # drop rows that have fewer than 4 real values
>>> df.dropna(thresh=4)
      A      B      C      D
0   1.0    2.0    3.0    4.0
>>> # only drop rows where NaN appear in specific columns (here: 'C')
>>> df.dropna(subset=['C'])
      A      B      C      D
0   1.0    2.0    3.0    4.0
2  10.0   11.0   12.0    NaN

虽然删除缺失值看上去是方便的方法,但也有一些缺点:比如,最终可能删除过多样本,这样就不可能进行可靠的分析。或是删除了过多的特征列,这样会存在丢失分类器用于区分类的有价值信息的风险。下一节中,我们会学习处理缺失值的一种最常用的替代方法:插值技术。

替换缺失值

通常删除训练样本或去除整个特征列并不可行,因为可能会丢失太多有价值的数据。这时,我们可以使用不同的插值技术来通过数据集中的其它训练样本计算缺失值。其中一种最常见的插值技术称为均值替换法(mean imputation),只需将缺失值替换为整个特征列的平均值。一种简便的实现方式是使用scikit-learn中的SimpleImputer类,代码如下:

>>> from sklearn.impute import SimpleImputer
>>> import numpy as np
>>> imr = SimpleImputer(missing_values=np.nan, strategy='mean')
>>> imr = imr.fit(df.values)
>>> imputed_data = imr.transform(df.values)
>>> imputed_data
array([[  1.,   2.,   3.,   4.],
       [  5.,   6.,  7.5,   8.],
       [ 10.,  11.,  12.,   6.]])

这里,我们将每个NaN值替换为对应的平均值,通过各个特征列分别计算。strategy的其它选项有medianmost_frequent,后者是使用最常出现的值替换缺失值。这对于替换类特征值很有用,比如,存储红、绿、蓝等颜色值编码的特征列。我们会在本章稍后碰到这种数据的案例。

此外,替换缺失值甚至更方便的方式是使用pandas的fillna方法,提供一个替换方法作为其参数。比如,使用pandas,我们可以通过如下命令直接从DataFrame对象中获取相同的替换均值:

>>> df.fillna(df.mean())

图4.1:使用均值替换数据中的缺失值

图4.1:使用均值替换数据中的缺失值

缺失值的其它替换方法

其它替换技术,包含基于k最近邻使用最近邻替换缺失特征的KNNImputer,推荐阅读scikit-learn的替换文档scikit-learn.org/stable/modu…

理解scikit-learn估计器API

上一节中,我们使用了scikit-learn中的SimpleImputer类来替换数据集中的缺失值。SimpleImputer类是scikit-learn中的转换器(transformer)API的一部分,它用于实现与数据转换相关的Python类。(请注意不要混淆scikit-learn中的转换器API与自然语言处理领域的transformer架构,后者会在第16章 Transformers-通过注意力机制改进自然语言处理中详细讲解。)这些估计器中的两个基本方法是fittransformfit方法用于通过训练数据学习参数,transform方法使用这些参数来转换数据。所有待转换的数组需要与用于拟合模型的数组具有相同数量的特征。

图4.2演示了scikit-learn的转换器实例如何拟合训练数据、用于转换训练集及新的测试数据集:

Diagram Description automatically generated

图4.2:使用scikit-learn API进行数据转换

第3章 使用Scikit-Learn的机器学习分类器之旅中使用的分类器属于scikit-learn中的估计器(estimator),其API在概念上与scikit-learn的转换器API非常相近。估计器有一个predict方法,但也可以有transform方法,读者在本章稍后会了解到。读者可能还记得,我们还使用了fit方法来在训练分类估计器时学习模型的参数。但在监督学习任务中,我们还提供了拟合模型的类标签,可用于通过predict方法预测新的未打标签的数据,如图4.3所示:

图4.3:使用scikit-learn API预测分类器等模型

图4.3:使用scikit-learn API预测分类器等模型

处理分类数据

至此,如所处理的都是数值。但在现实的数据集包含一个或多个分类特征列也很常见。本节中,我们会利用简单而有效的案例来学习如何用数学计算库处理这种类型的数据。

在讨论分类数据时,我们需要进一步区分有序(ordinal)特征和标称(nominal)特征。有序特征可理解为能够进行排序的分类值。比如,T恤衫的尺寸就是一个有序特征,因为我们可以定义出排序:XL > L > M。相反,标称特征并没有排序,继续使用前面的例子,可以把T恤衫的颜色看成是标称特征,因为说红色大于蓝色是讲不通的。

使用pandas编码分类数据

在探讨处理这种分类数据的技术之前,我们先新建一个DataFrame来描述这一问题:

>>> import pandas as pd
>>> df = pd.DataFrame([
...            ['green', 'M', 10.1, 'class2'],
...            ['red', 'L', 13.5, 'class1'],
...            ['blue', 'XL', 15.3, 'class2']])
>>> df.columns = ['color', 'size', 'price', 'classlabel']
>>> df
    color  size  price  classlabel
0   green     M   10.1      class2
1     red     L   13.5      class1
2    blue    XL   15.3      class2

可以从以上输出看出,新创建的DataFrame包含一个标称特征(color)、一个有序特征(size)和一个数值特征(price) 列。类标签(假设我们创建了一个用于监督学习任务的数据集)存储于最后一列。本书中讨论的分类学习算法不使用分类标签中的有序信息。

映射有序特征

为确保学习算法能正确解释有序特征,我们需要将分类字符串值转化成整数。不幸的是没有现成的函数可以自动获取我们的size特征标签的正确排序,所以我们需要手动定义一个映射。在如下的简单示例中,我们假设知道特征间的数值差,如XL = L + 1 = M + 2:

>>> size_mapping = {'XL': 3,
...                 'L': 2,
...                 'M': 1}
>>> df['size'] = df['size'].map(size_mapping)
>>> df
    color  size  price  classlabel
0   green     1   10.1      class2
1     red     2   13.5      class1
2    blue     3   15.3      class2

如果稍后我们希望将整数值转换加原始的字符串形态,只需要定义一个反向映射字典,inv_size_mapping = {v: k for k, v in size_mapping.items()},可用于pandas map方法执行转换的特征列,类似于我们此前所使用的size_mapping字典。可以这样使用:

>>> inv_size_mapping = {v: k for k, v in size_mapping.items()}
>>> df['size'].map(inv_size_mapping)
0   M
1   L
2   XL
Name: size, dtype: object

编码类标签

很多机器学习库要将类标签要编码为整数值。虽然scikit-learn中大部分分类估计器会在内部将类标签转化为整数,以整型数组提供类标签避免技术问题被视为一种良好实践。要编码类标签,我们可以使用类似前面讨论的有序特征映射的方法。我们要记住类标签不是有序的,对具体字符串标签赋哪个整数都没有问题。因此我们可以简单地枚举类标签,从0开始:

>>> import numpy as np
>>> class_mapping = {label: idx for idx, label in
...                  enumerate(np.unique(df['classlabel']))}
>>> class_mapping
{'class1': 0, 'class2': 1}

接着,我们可以使用映射字典来将类标签转换为整数:

>>> df['classlabel'] = df['classlabel'].map(class_mapping)
>>> df
    color  size  price  classlabel
0   green     1   10.1           1
1     red     2   13.5           0
2    blue     3   15.3           1

我们可以像下面这样翻转映射字典中的键值对,来将转化的标签映射回原始字符串形式:

>>> inv_class_mapping = {v: k for k, v in class_mapping.items()}
>>> df['classlabel'] = df['classlabel'].map(inv_class_mapping)
>>> df
    color  size  price  classlabel
0   green     1   10.1      class2
1     red     2   13.5      class1
2    blue     3   15.3      class2

相应地,在scikit-learn中有一个方便的LabelEncoder类直接实现了这一功能:

>>> from sklearn.preprocessing import LabelEncoder
>>> class_le = LabelEncoder()
>>> y = class_le.fit_transform(df['classlabel'].values)
>>> y
array([1, 0, 1])

注意fit_transform方法只是一个分别调用fittransform的快捷方式,我们可以使用inverse_transform方法来将整型类标签转换回原始字符串形式:

>>> class_le.inverse_transform(y)
array(['class2', 'class1', 'class2'], dtype=object)

 对标称特征执行独热编码

在之前的映射有序特征一节中,我们使用了一个简单的字典映射方法将有序的size特征转化为整数。因scikit-learn的分类估计器将类标签看作不带排序的分类数据(标称),我们使用LabelEncoder来将字符串标签编码为整数。可以使用类似的方法转换数据集中的标称color列,如下:

>>> X = df[['color', 'size', 'price']].values
>>> color_le = LabelEncoder()
>>> X[:, 0] = color_le.fit_transform(X[:, 0])
>>> X
array([[1, 1, 10.1],
       [2, 2, 13.5],
       [0, 3, 15.3]], dtype=object)

执行以上代码后,NumPy数组X的第一列存储着新的color值,按如下进行编码:

  • blue = 0
  • green = 1
  • red = 2

如果到此为止将数组喂给分类器,就会犯处理分类数据最常见的一个错误。读者能发现问题在哪吗?如果颜色值没有固定的顺序,普通的分类模型,比如前面章节中讲解的,会假定green大于bluered大于green。虽然这一假设是错误的,分类器仍会产生有用的结果。但这些结果不是最优的。

解决这一问题的常用技术称为独热编码(one-hot encoding)。该方法背后的思想是问为标称特征列中的每个独立值新建一个虚拟特征。此处我们将color特征转化为3个特征:bluegreenred。然后用二进制值表示具体样本的颜色:比如blue样本可编码为blue=1green=0red=0。我们可以使用scikit-learn的preprocessing模块中的OneHotEncoder来执行这一转换:

>>> from sklearn.preprocessing import OneHotEncoder
>>> X = df[['color', 'size', 'price']].values
>>> color_ohe = OneHotEncoder()
>>> color_ohe.fit_transform(X[:, 0].reshape(-1, 1)).toarray()
    array([[0., 1., 0.],
           [0., 0., 1.],
           [1., 0., 0.]])

注意我们只对一列应用了OneHotEncoder(X[:, 0].reshape(-1, 1)),以避免数组中的另两列也受到修改。如果希望有选择性地转换数组中的多个特征,可以使用ColumnTransformer,它接收一个(name, transformer, column(s))元组列表如下:

>>> from sklearn.compose import ColumnTransformer
>>> X = df[['color', 'size', 'price']].values
>>> c_transf = ColumnTransformer([
...     ('onehot', OneHotEncoder(), [0]),
...     ('nothing', 'passthrough', [1, 2])
... ])
>>> c_transf.fit_transform(X).astype(float)
    array([[0.0, 1.0, 0.0, 1, 10.1],
           [0.0, 0.0, 1.0, 2, 13.5],
           [1.0, 0.0, 0.0, 3, 15.3]])

在以上示例代码中,我们通过passthrough参数指定了只想修改第一列而不动另外两列。

通过独热编码创建虚拟特征更方便的方式是使用pandas中实现的get_dummies方法。应用于DataFrameget_dummies方法只会转化字符串列而保持其它列不变:

>>> pd.get_dummies(df[['price', 'color', 'size']])
    price  size  color_blue  color_green  color_red
0    10.1     1           0            1          0
1    13.5     2           0            0          1
2    15.3     3           1            0          0

我们在使用独热编码数据集时,需要铭记它带来了多重共线性,对于某些方法会是一个问题(比如需要求逆的矩阵)。如果特征高度相关联,矩阵在计算上就很难求逆,这会导致数值不稳定的预估。为减少变量间的关联,我们可以删除独热编码数组中的一个特征列。但删除一个特征列后我们不会丢失重要信息,比如在删除color_blue列后,因为存在color_green=0color_red=0特征信息仍保留,它表示观察的结果必定是blue

如果使用get_dummies函数,可以通过对drop_first参数传递True来去除第一列,如以下代码所示:

>>> pd.get_dummies(df[['price', 'color', 'size']],
...                drop_first=True)
    price  size  color_green  color_red
0    10.1     1            1          0
1    13.5     2            0          1
2    15.3     3            0          0

为通过OneHotEncoder删除冗余列,我们需要设置drop='first'categories='auto'如下:

>>> color_ohe = OneHotEncoder(categories='auto', drop='first')
>>> c_transf = ColumnTransformer([
...            ('onehot', color_ohe, [0]),
...            ('nothing', 'passthrough', [1, 2])
... ])
>>> c_transf.fit_transform(X).astype(float)
array([[  1. ,  0. ,  1. ,  10.1],
       [  0. ,  1. ,  2. ,  13.5],
       [  0. ,  0. ,  3. ,  15.3]])

标称数据的其它编码模式

虽然独热编码是编码无序分类变量最常见的方式,但还存在一些其它方法。有些技术对于处理具有高基数(大量独特分类)的分类特殊时非常有用。举例如下:

  • 二进制编码:产生多个类似独热编码的二进制特征,但需要更少的特征列:即K – 1变为log2(K),其中K是唯一分类数。在二进制编码中,数字首先转化为二进制形式,然后每个二进制位置会形成一个新特征列。
  • 计数或频次编码,将每个分类的标签替换为其在训练集中出现的次数或频次。

这些方法,以及其它分类编码模式,位于与scikit-learn兼容的category_encoders库中:contrib.scikit-learn.org/category_en…

虽然这些方法不能保证在模型表现上优于独热编码,但我们可以把分类编码模式看成是提升模型表现的又一个“超参数”。

可选:编码序数特征

如果不确定序数特征分类间的差别或是两个未定义的序数值之间的差别,也可以使用0/1值阈值编码来对它们进行编码。比如,我们可以将值为MLXL的特征size分割为两个新特征x > Mx > L。思考原DataFrame

>>> df = pd.DataFrame([['green', 'M', 10.1,
...                     'class2'],
...                    ['red', 'L', 13.5,
...                     'class1'],
...                    ['blue', 'XL', 15.3,
...                     'class2']])
>>> df.columns = ['color', 'size', 'price',
...               'classlabel']
>>> df

可以使用pandas的DataFrameapply方法来编码自定义lambda表达式,通过阈值方式来编码这些变量:

>>> df['x > M'] = df['size'].apply(
...     lambda x: 1 if x in {'L', 'XL'} else 0)
>>> df['x > L'] = df['size'].apply(
...     lambda x: 1 if x == 'XL' else 0)
>>> del df['size']
>>> df