PyTorch 与 Sklearn 机器学习指南(二)

原文:zh.annas-archive.org/md5/2a872f7dd98f6fbe3043a236f689e451

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:构建良好的训练数据集 - 数据预处理

数据的质量和包含的有用信息量是决定机器学习算法学习效果的关键因素。因此,在将数据提供给机器学习算法之前,确保我们对数据进行必要的检查和预处理是非常关键的。在本章中,我们将讨论关键的数据预处理技术,这些技术将帮助我们构建良好的机器学习模型。

本章中我们将讨论的主题包括:

  • 从数据集中删除和填补缺失值

  • 将分类数据准备好供机器学习算法使用

  • 选择用于模型构建的相关特征

处理缺失数据

在真实应用中,由于各种原因,我们的训练样本可能缺少一个或多个值。数据收集过程中可能出现错误,某些测量可能不适用,或者在调查中可能简单地留空某些字段。我们通常在数据表中看到缺失值作为空格或占位符字符串,例如NaN(代表“不是一个数字”)或NULL(在关系数据库中常用于未知值的指示符)。不幸的是,大多数计算工具无法处理这些缺失值,或者如果我们简单地忽略它们,则会产生不可预测的结果。因此,在进一步分析之前,处理这些缺失值至关重要。

在本节中,我们将通过从数据集中删除条目或从其他训练样本和特征填补缺失值来解决缺失值的几种实用技术。

在表格数据中识别缺失值

在讨论处理缺失值的几种技术之前,让我们从一个逗号分隔值CSV)文件创建一个简单的示例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的字符串读入 pandas 的DataFrame,就像它是硬盘上常规 CSV 文件一样。

对于较大的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 

尽管删除缺失数据似乎是一个方便的方法,但它也有一定的缺点;例如,我们可能会删除太多样本,从而使得可靠的分析变得不可能。或者,如果我们删除了太多特征列,那么我们将面临失去分类器需要用来区分类别的宝贵信息的风险。在下一节中,我们将看一下处理缺失值的最常用替代方法之一:插值技术。

填补缺失值

通常,删除训练样本或整个特征列根本不可行,因为我们可能会损失太多宝贵的数据。在这种情况下,我们可以使用不同的插值技术来估算数据集中其他训练样本的缺失值。其中最常见的插值技术之一是均值插补,我们只需用整个特征列的均值替换缺失值即可。通过使用 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:用均值替换数据中的缺失值

用于缺失数据的其他填补方法

对于包括基于 k 最近邻方法的KNNImputer在内的其他填补技术,以通过最近邻来填补缺失特征,我们建议查阅 scikit-learn 填补文档 scikit-learn.org/stable/modules/impute.html

理解 scikit-learn 估计器 API

在前一节中,我们使用了 scikit-learn 中的SimpleImputer类来填补数据集中的缺失值。SimpleImputer类是 scikit-learn 中所谓的转换器API 的一部分,用于实现与数据转换相关的 Python 类。请注意,scikit-learn 转换器 API 与用于自然语言处理的 transformer 架构不要混淆,我们将在第十六章使用注意力机制改进自然语言处理的 Transformers中更详细地讨论后者。这些估计器的两个关键方法是fittransformfit方法用于从训练数据中学习参数,transform方法使用这些参数来转换数据。任何要转换的数据数组都需要与用于拟合模型的数据数组具有相同数量的特征。

图 4.2展示了一个在训练数据上拟合的 scikit-learn 转换器实例如何用于转换训练数据集以及新的测试数据集:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

我们在第三章使用 Scikit-Learn 进行机器学习分类器导览中使用的分类器属于 scikit-learn 中所谓的估计器,其 API 在概念上与 scikit-learn 转换器 API 非常相似。估计器具有一个predict方法,但也可以有一个transform方法,正如你将在本章后面看到的。正如你可能记得的那样,我们还使用fit方法来学习这些估计器进行分类时的模型参数。然而,在监督学习任务中,我们额外提供类标签来拟合模型,然后可以通过predict方法对新的未标记数据示例进行预测,如图 4.3所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

处理分类数据

到目前为止,我们只处理了数值数据。然而,现实世界的数据集通常包含一个或多个分类特征列。在本节中,我们将利用简单而有效的示例来看如何在数值计算库中处理这种类型的数据。

当我们谈论分类数据时,我们必须进一步区分序数名义特征。序数特征可以理解为可以排序或有序的分类值。例如,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。虽然这种假设是不正确的,分类器仍然可能产生有用的结果。然而,这些结果将不会是最优的。

对于这个问题的一个常见解决方案是使用一种称为 独热编码 的技术。这种方法的理念是为名义特征列中的每个唯一值创建一个新的虚拟特征。在这里,我们将把 color 特征转换为三个新特征:bluegreenred。二进制值可以用来表示示例的特定 color;例如,一个 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.]]) 

注意我们仅对单列 (X[:, 0].reshape(-1, 1)) 应用了 OneHotEncoder,以避免修改数组中的其他两列。如果我们想要选择性地转换多特征数组中的列,我们可以使用 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]]) 

名义数据的附加编码方案

虽然独热编码是编码无序分类变量的最常见方式,但也存在几种替代方法。在处理具有高基数(大量唯一类别标签)的分类特征时,某些技术可能会很有用。例如:

  • 二进制编码,产生多个类似于独热编码的二进制特征,但需要较少的特征列,即log2而不是K – 1,其中K是唯一类别的数量。在二进制编码中,数字首先转换为二进制表示,然后每个二进制数位置将形成一个新的特征列。

  • 计数或频率编码,用训练集中每个类别出现的次数或频率替换每个类别的标签。

这些方法以及额外的分类编码方案都可以通过与 scikit-learn 兼容的category_encoders库来实现:contrib.scikit-learn.org/category_encoders/

虽然这些方法在模型性能方面并不能保证优于独热编码,但我们可以考虑选择分类编码方案作为改进模型性能的额外“超参数”。

可选:编码有序特征

如果我们不确定有序特征类别之间的数值差异,或者两个有序值之间的差异未定义,我们也可以使用阈值编码将其编码为 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 

将数据集分成单独的训练集和测试集。

我们在第一章“使计算机能够从数据中学习”和第三章“使用 Scikit-Learn 进行机器学习分类器之旅”中简要介绍了将数据集划分为用于训练和测试的单独数据集的概念。请记住,在测试集中将预测与真实标签进行比较,可以理解为在我们将模型放入真实世界之前对其进行无偏差的性能评估。在本节中,我们将准备一个新的数据集,即Wine数据集。在我们预处理数据集之后,我们将探讨不同的特征选择技术以减少数据集的维度。

Wine 数据集是另一个开源数据集,可以从 UCI 机器学习库获取(archive.ics.uci.edu/ml/datasets/Wine);它包含了 178 个葡萄酒示例,其中 13 个特征描述了它们不同的化学特性。

获取 Wine 数据集

您可以在本书的代码包中找到 Wine 数据集的副本(以及本书中使用的所有其他数据集),如果您在离线工作或者 UCI 服务器上的数据集临时不可用时,您可以使用该数据集。例如,要从本地目录加载 Wine 数据集,可以将此行替换为

df = pd.read_csv(
    'https://archive.ics.uci.edu/ml/'
    'machine-learning-databases/wine/wine.data',
    header=None
) 

与以下一个:

df = pd.read_csv(
    'your/local/path/to/wine.data', header=None
) 

使用 pandas 库,我们将直接从 UCI 机器学习库中读取开源的 Wine 数据集:

>>> df_wine = pd.read_csv('https://archive.ics.uci.edu/'
...                       'ml/machine-learning-databases/'
...                       'wine/wine.data', header=None)
>>> df_wine.columns = ['Class label', 'Alcohol',
...                    'Malic acid', 'Ash',
...                    'Alcalinity of ash', 'Magnesium',
...                    'Total phenols', 'Flavanoids',
...                    'Nonflavanoid phenols',
...                    'Proanthocyanins',
...                    'Color intensity', 'Hue',
...                    'OD280/OD315 of diluted wines',
...                    'Proline']
>>> print('Class labels', np.unique(df_wine['Class label']))
Class labels [1 2 3]
>>> df_wine.head() 

Wine 数据集中的 13 个不同特征描述了 178 个葡萄酒示例的化学特性,详见以下表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.4:Wine 数据集的样本

这些示例属于三个不同的类别之一,123,这些类别指的是在同一意大利地区种植的三种不同葡萄类型,但来自不同的葡萄酒品种,如数据集摘要所述(archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.names)。

将这个数据集随机划分为独立的测试和训练数据集的便捷方法是使用 scikit-learn 的model_selection子模块中的train_test_split函数:

>>> from sklearn.model_selection import train_test_split
>>> X, y = df_wine.iloc[:, 1:].values, df_wine.iloc[:, 0].values
>>> X_train, X_test, y_train, y_test =\
...     train_test_split(X, y,
...                      test_size=0.3,
...                      random_state=0,
...                      stratify=y) 

首先,我们将特征列 1-13 的 NumPy 数组表示分配给变量X,并将第一列的类标签分配给变量y。然后,我们使用train_test_split函数将Xy随机分割成独立的训练和测试数据集。

通过设置test_size=0.3,我们将 30%的葡萄酒样本分配给X_testy_test,剩余的 70%样本分别分配给X_trainy_train。将类标签数组y作为参数传递给stratify,确保训练和测试数据集具有与原始数据集相同的类比例。

  • 选择合适的比例将数据集划分为训练集和测试集

如果我们将数据集划分为训练集和测试集,必须记住我们正在保留学习算法可能从中受益的宝贵信息。因此,我们不希望将太多信息分配给测试集。然而,测试集越小,对泛化误差的估计就越不准确。将数据集划分为训练集和测试集就是要在这种权衡中找到平衡。在实践中,最常用的划分比例是 60:40、70:30 或 80:20,这取决于初始数据集的大小。然而,对于大型数据集,90:10 或 99:1 的划分也是常见且合适的。例如,如果数据集包含超过 10 万个训练样本,则仅保留 1 万个样本进行测试可能足以得到泛化性能的良好估计。更多信息和插图可以在我的文章《机器学习中的模型评估、模型选择和算法选择》第一章找到,该文章可以在arxiv.org/pdf/1811.12808.pdf免费获取。此外,我们将在第六章 学习模型评估和超参数调优的最佳实践中重新讨论模型评估的主题并进行更详细的讨论。

此外,与其在模型训练和评估后丢弃分配的测试数据,重新在整个数据集上训练分类器是一种常见的做法,因为这可以提高模型的预测性能。虽然这种方法通常是推荐的,但如果数据集很小且测试数据集包含异常值,例如,它可能导致更差的泛化性能。此外,在整个数据集上重新拟合模型之后,我们将没有任何独立的数据来评估其性能。

将特征调整到相同的尺度

  • 特征缩放 是我们预处理流程中一个关键的步骤,容易被忽视。决策树随机森林 是为数不多的两种机器学习算法,我们不需要担心特征缩放。这些算法是尺度不变的。然而,大多数机器学习和优化算法如果特征处于相同的尺度上表现更好,正如我们在第二章 用于分类的简单机器学习算法的训练中实现 梯度下降优化 算法时所看到的那样。

特征缩放的重要性可以通过一个简单的例子来说明。假设我们有两个特征,其中一个特征在 1 到 10 的范围内测量,而第二个特征在 1 到 100,000 的范围内测量。

当我们考虑 Adaline 中的平方误差函数(来自第二章)时,可以说该算法主要忙于根据第二特征中较大的错误来优化权重。另一个例子是使用欧氏距离的k 最近邻KNN)算法:计算的示例间距离将由第二特征轴主导。

现在,有两种常见方法将不同的特征调整到相同的比例:归一化标准化。这些术语在不同领域中通常使用得相当松散,其含义必须从上下文中推断出来。最常见的情况是,归一化是指将特征重新缩放到[0, 1]的范围,这是最小-最大缩放的一种特殊情况。要将我们的数据归一化,我们可以简单地对每个特征列应用最小-最大缩放,其中示例的新值,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,可以计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里,x(i^) 是一个特定的示例,x[min] 是特征列中的最小值,x[max] 是最大值。

最小-最大缩放过程在 scikit-learn 中实现,可以如下使用:

>>> from sklearn.preprocessing import MinMaxScaler
>>> mms = MinMaxScaler()
>>> X_train_norm = mms.fit_transform(X_train)
>>> X_test_norm = mms.transform(X_test) 

虽然通过最小-最大缩放进行标准化是一种常用的技术,当我们需要在有界区间内的值时很有用,但对于许多机器学习算法,特别是像梯度下降这样的优化算法,标准化可能更为实用。原因是许多线性模型,例如第三章中的逻辑回归和 SVM,将权重初始化为 0 或接近 0 的小随机值。使用标准化,我们将特征列居中于均值 0 且标准差为 1,使得特征列具有与标准正态分布(零均值和单位方差)相同的参数,这样更容易学习权重。但是,我们应强调,标准化不会改变分布的形状,也不会将非正态分布的数据转换为正态分布的数据。除了缩放数据以使其具有零均值和单位方差外,标准化还保留有关异常值的有用信息,并使算法对其不敏感,而最小-最大缩放将数据缩放到一定范围的值。

标准化过程可以用以下方程表示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是特定特征列的样本均值,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是相应的标准差。

以下表格说明了两种常用的特征缩放技术——标准化和归一化——在一个由数字 0 到 5 组成的简单示例数据集上的差异:

输入标准化最小-最大归一化
0.0-1.463850.0
1.0-0.878310.2
2.0-0.292770.4
3.00.292770.6
4.00.878310.8
5.01.463851.0

表 4.1:标准化和最小-最大归一化的比较

您可以通过执行以下代码示例手动执行表中显示的标准化和归一化:

>>> ex = np.array([0, 1, 2, 3, 4, 5])
>>> print('standardized:', (ex - ex.mean()) / ex.std())
standardized: [-1.46385011  -0.87831007  -0.29277002  0.29277002
0.87831007  1.46385011]
>>> print('normalized:', (ex - ex.min()) / (ex.max() - ex.min()))
normalized: [ 0\.  0.2  0.4  0.6  0.8  1\. ] 

MinMaxScaler类似,scikit-learn 还实现了一个用于标准化的类:

>>> from sklearn.preprocessing import StandardScaler
>>> stdsc = StandardScaler()
>>> X_train_std = stdsc.fit_transform(X_train)
>>> X_test_std = stdsc.transform(X_test) 

再次强调,我们只需在训练数据上一次性拟合StandardScaler类,然后使用这些参数来转换测试数据集或任何新的数据点。

关于特征缩放的其他更高级的方法可从 scikit-learn 中获取,例如RobustScaler。如果我们处理的数据集很小且包含许多异常值,RobustScaler尤为有用和推荐。同样,如果应用于该数据集的机器学习算法容易过拟合RobustScaler是一个不错的选择。RobustScaler独立于每个特征列操作,去除中位数并根据数据集的第 1 和第 3 四分位数(即 25th 和 75th 分位数)来缩放数据集,使得更极端的值和异常值变得不那么显著。有兴趣的读者可以在官方 scikit-learn 文档中找到关于RobustScaler的更多信息,网址为scikit-learn.org/stable/modules/generated/sklearn.preprocessing.RobustScaler.html

选择有意义的特征

如果我们注意到一个模型在训练数据集上的表现远远优于在测试数据集上的表现,这一观察结果是过拟合的一个强烈指标。正如我们在第三章中讨论的那样,使用 Scikit-Learn 进行机器学习分类器的巡回时,过拟合意味着模型过于密切地拟合了训练数据集中的特定观测值,但在新数据上泛化能力不强;我们称这种模型具有高方差。过拟合的原因是我们的模型对给定的训练数据过于复杂。减少泛化误差的常见解决方案如下:

  • 收集更多的训练数据

  • 引入正则化通过复杂性来惩罚

  • 选择一个具有较少参数的简单模型

  • 减少数据的维度

增加更多的训练数据通常是不适用的。在第六章学习模型评估和超参数调优的最佳实践中,我们将学习一种有用的技术来检查是否增加更多的训练数据是有益的。在接下来的几节中,我们将探讨通过正则化和特征选择来减少过拟合的常见方法,从而通过需要较少参数来拟合数据的简化模型。然后,在第五章通过降维压缩数据,我们将查看其他的特征提取技术。

L1 和 L2 正则化作为抵抗模型复杂性的惩罚项

你还记得第三章讲到的L2 正则化是通过对大的个体权重进行惩罚来减少模型复杂度的一种方法。我们定义了权重向量w的平方 L2 范数如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

另一种减少模型复杂性的方法是相关的L1 正则化

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,我们简单地用权重的绝对值之和替换了权重的平方。与 L2 正则化相比,L1 正则化通常会产生稀疏的特征向量,大多数特征权重将为零。如果我们有一个高维数据集,有许多无关的特征,尤其是在训练样本比无关维度更多的情况下,稀疏性在实践中可能会很有用。从这个意义上讲,L1 正则化可以被理解为一种特征选择技术。

L2 正则化的几何解释

正如前一节提到的,L2 正则化向损失函数添加一个惩罚项,使得相比使用非正则化损失函数训练的模型具有较少极端的权重值。

为了更好地理解 L1 正则化如何促进稀疏性,让我们退一步,从正则化的几何解释开始。我们来绘制两个权重系数w[1]和w[2]的凸损失函数等高线。

在这里,我们将考虑均方误差MSE)损失函数,我们在第二章中用于 Adaline 的,它计算真实和预测类标签y外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传之间的平方距离,平均值为所有N个训练集示例。由于 MSE 是球形的,比逻辑回归的损失函数更容易绘制;然而,相同的概念适用。记住,我们的目标是找到最小化训练数据损失函数的权重系数组合,如图 4.5所示(椭圆中心的点):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.5:最小化均方误差损失函数

我们可以将正则化视为向损失函数添加惩罚项以鼓励较小的权重;换句话说,我们惩罚较大的权重。因此,通过增加正则化参数来增强正则化强度,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,我们将权重收缩到零附近,并减少模型对训练数据的依赖。让我们在以下图中以 L2 惩罚项说明这个概念:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.6:将 L2 正则化应用于损失函数

二次 L2 正则化项由阴影球表示。在这里,我们的权重系数不能超出正则化预算——权重系数的组合不能超出阴影区域。另一方面,我们仍然希望最小化损失函数。在惩罚约束下,我们最好的选择是选择 L2 球与未惩罚损失函数轮廓相交的点。正则化参数外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传值越大,惩罚损失增长速度越快,导致 L2 球越窄。例如,如果我们将正则化参数增加至无穷大,权重系数将有效变为零,即 L2 球的中心。总结这个示例的主要信息,我们的目标是最小化未惩罚损失加上惩罚项的总和,这可以理解为添加偏差并偏好简化模型以减少在缺乏足够训练数据来拟合模型时的方差。

L1 正则化下的稀疏解决方案

现在,让我们讨论 L1 正则化和稀疏性。L1 正则化背后的主要概念与我们在前一节中讨论的相似。然而,由于 L1 惩罚是绝对权重系数的总和(请记住 L2 项是二次的),我们可以将其表示为钻石形状的预算,如图 4.7所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.7:将 L1 正则化应用于损失函数

在上述图中,我们可以看到损失函数的轮廓与 L1 钻石在w[1] = 0 处接触。由于 L1 正则化系统的轮廓尖锐,最优解——即损失函数的椭圆和 L1 钻石边界的交点——更可能位于轴上,这鼓励稀疏性。

L1 正则化和稀疏性

L1 正则化如何导致稀疏解的数学细节超出了本书的范围。如果您有兴趣,可以在Trevor Hastie, Robert TibshiraniJerome Friedman, Springer Science+Business Media, 2009第 3.4 节中找到关于 L2 与 L1 正则化的优秀解释。

对于 scikit-learn 中支持 L1 正则化的正则化模型,我们只需将penalty参数设置为'l1'即可获得稀疏解:

>>> from sklearn.linear_model import LogisticRegression
>>> LogisticRegression(penalty='l1',
...                    solver='liblinear',
...                    multi_class='ovr') 

请注意,由于’lbfgs’当前不支持 L1 正则化损失优化,我们还需要选择不同的优化算法(例如,solver='liblinear')。应用于标准化的 Wine 数据,L1 正则化逻辑回归将产生以下稀疏解:

>>> lr = LogisticRegression(penalty='l1',
...                         C=1.0,
...                         solver='liblinear',
...                         multi_class='ovr')
>>> # Note that C=1.0 is the default. You can increase
>>> # or decrease it to make the regularization effect
>>> # stronger or weaker, respectively.
>>> lr.fit(X_train_std, y_train)
>>> print('Training accuracy:', lr.score(X_train_std, y_train))
Training accuracy: 1.0
>>> print('Test accuracy:', lr.score(X_test_std, y_test))
Test accuracy: 1.0 

训练和测试的准确率(均为 100%)表明我们的模型在两个数据集上表现完美。当我们通过lr.intercept_属性访问截距项时,可以看到数组返回了三个值:

>>> lr.intercept_
    array([-1.26317363, -1.21537306, -2.37111954]) 

由于我们通过一对多OvR)方法在多类数据集上拟合了LogisticRegression对象,第一个截距属于拟合类别 1 与类别 2 和 3 的模型,第二个值是拟合类别 2 与类别 1 和 3 的模型的截距,第三个值是拟合类别 3 与类别 1 和 2 的模型的截距:

>>> lr.coef_
array([[ 1.24647953,  0.18050894,  0.74540443, -1.16301108,
         0\.        ,0\.        ,  1.16243821,  0\.        ,
         0\.        ,  0\.        , 0\.        ,  0.55620267,
         2.50890638],
       [-1.53919461, -0.38562247, -0.99565934,  0.36390047,
        -0.05892612, 0\.        ,  0.66710883,  0\.        ,
         0\.        , -1.9318798 , 1.23775092,  0\.        ,
        -2.23280039],
       [ 0.13557571,  0.16848763,  0.35710712,  0\.        ,
         0\.        , 0\.        , -2.43804744,  0\.        ,
         0\.        ,  1.56388787, -0.81881015, -0.49217022,
         0\.        ]]) 

通过lr.coef_属性访问的权重数组包含三行权重系数,即每个类别的一个权重向量。每行包含 13 个权重,其中每个权重都与 13 维 Wine 数据集中的相应特征相乘,以计算净输入:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

访问 scikit-learn 估计器的偏置单元和权重参数

在 scikit-learn 中,intercept_对应于偏置单元,coef_对应于值w[j]。

由于 L1 正则化的结果,正如前面提到的,它作为特征选择的一种方法,我们刚刚训练了一个在该数据集中对潜在的不相关特征具有鲁棒性的模型。严格来说,尽管如此,前面例子中的权重向量未必是稀疏的,因为它们包含的非零条目比零条目多。然而,我们可以通过进一步增加正则化强度(即选择较低的C参数值)来强制稀疏化(增加零条目)。

在本章关于正则化的最后一个例子中,我们将改变正则化强度并绘制正则化路径,即不同正则化强度下不同特征的权重系数:

>>> import matplotlib.pyplot as plt
>>> fig = plt.figure()
>>> ax = plt.subplot(111)
>>> colors = ['blue', 'green', 'red', 'cyan',
...           'magenta', 'yellow', 'black',
...           'pink', 'lightgreen', 'lightblue',
...           'gray', 'indigo', 'orange']
>>> weights, params = [], []
>>> for c in np.arange(-4., 6.):
...     lr = LogisticRegression(penalty='l1', C=10.**c,
...                             solver='liblinear',
...                             multi_class='ovr', random_state=0)
...     lr.fit(X_train_std, y_train)
...     weights.append(lr.coef_[1])
...     params.append(10**c)
>>> weights = np.array(weights)
>>> for column, color in zip(range(weights.shape[1]), colors):
...     plt.plot(params, weights[:, column],
...              label=df_wine.columns[column + 1],
...              color=color)
>>> plt.axhline(0, color='black', linestyle='--', linewidth=3)
>>> plt.xlim([10**(-5), 10**5])
>>> plt.ylabel('Weight coefficient')
>>> plt.xlabel('C (inverse regularization strength)')
>>> plt.xscale('log')
>>> plt.legend(loc='upper left')
>>> ax.legend(loc='upper center',
...           bbox_to_anchor=(1.38, 1.03),
...           ncol=1, fancybox=True)
>>> plt.show() 

绘制的结果图为我们提供了关于 L1 正则化行为的进一步见解。正如我们所见,如果我们使用强正则化参数(C < 0.01),所有特征权重将变为零;其中C是正则化参数的倒数,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.8:正则化强度超参数 C 值的影响

顺序特征选择算法

减少模型复杂性和避免过拟合的另一种方法是通过特征选择进行降维,特别适用于未正则化的模型。主要有两类降维技术:特征选择特征提取。通过特征选择,我们选择原始特征的一个子集,而在特征提取中,我们特征集中提取信息以构建一个新的特征子空间。

在本节中,我们将介绍一类经典的特征选择算法。在下一章,即第五章通过降维来压缩数据,我们将学习不同的特征提取技术,以将数据集压缩到一个更低维度的特征子空间上。

顺序特征选择算法是一类贪婪搜索算法,用于将初始d维特征空间减少到一个k维特征子空间,其中k<d。特征选择算法的动机是自动选择与问题最相关的特征子集,以提高计算效率,或通过移除无关特征或噪声来减少模型的泛化误差,这对于不支持正则化的算法可能非常有用。

经典的顺序特征选择算法是顺序向后选择SBS),其旨在减少初始特征子空间的维数,同时最小化分类器性能下降,以提高计算效率。在某些情况下,如果模型存在过拟合问题,SBS 甚至可以改善模型的预测能力。

贪婪搜索算法

贪婪算法在组合搜索问题的每个阶段都会做出局部最优选择,通常会得到问题的次优解,与穷举搜索算法相比,后者会评估所有可能的组合并保证找到最优解。然而,在实践中,穷举搜索通常计算量过大,而贪婪算法可以提供更简单、计算更高效的解决方案。

SBS 算法的思想非常简单:顺序地从完整特征子集中移除特征,直到新的特征子空间包含所需数量的特征为止。为了确定每个阶段要移除哪个特征,我们需要定义要最小化的准则函数J

由准则函数计算的准则可以简单地是分类器在移除特定特征之前和之后性能差异。然后,在每个阶段,我们可以简单地定义要移除的特征为最大化此准则的特征;或者更简单地说,在每个阶段,我们消除导致去除后性能损失最小的特征。基于前述对 SBS 的定义,我们可以用四个简单步骤概述算法:

  1. 使用k = d初始化算法,其中d是完整特征空间X[d]的维数。

  2. 确定最大化准则的特征x–,其中**x**– = argmax J(X[k] – x),其中 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  3. 从特征集中移除特征x^–:X[k][–1] = X[k] – x^–;k = k – 1。

  4. 如果k等于所需特征的数量,则终止;否则,转到步骤 2

    关于顺序特征算法的资源

    大规模特征选择技术比较研究中,你可以找到对几种顺序特征算法的详细评估,作者是F. FerriP. PudilM. HatefJ. Kittler,页面 403-413,1994 年。

为了练习我们的编码技能和实现我们自己的算法的能力,让我们从头开始用 Python 实现它:

from sklearn.base import clone
from itertools import combinations
import numpy as np
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
class SBS:
    def __init__(self, estimator, k_features,
                 scoring=accuracy_score,
                 test_size=0.25, random_state=1):
        self.scoring = scoring
        self.estimator = clone(estimator)
        self.k_features = k_features
        self.test_size = test_size
        self.random_state = random_state
    def fit(self, X, y):
        X_train, X_test, y_train, y_test = \
            train_test_split(X, y, test_size=self.test_size,
                             random_state=self.random_state)

        dim = X_train.shape[1]
        self.indices_ = tuple(range(dim))
        self.subsets_ = [self.indices_]
        score = self._calc_score(X_train, y_train,
                                 X_test, y_test, self.indices_)
        self.scores_ = [score]
        while dim > self.k_features:
            scores = []
            subsets = []

            for p in combinations(self.indices_, r=dim - 1):
                score = self._calc_score(X_train, y_train,
                                         X_test, y_test, p)
                scores.append(score)
                subsets.append(p)

            best = np.argmax(scores)
            self.indices_ = subsets[best]
            self.subsets_.append(self.indices_)
            dim -= 1

            self.scores_.append(scores[best])
        self.k_score_ = self.scores_[-1]

        return self

    def transform(self, X):
        return X[:, self.indices_]

    def _calc_score(self, X_train, y_train, X_test, y_test, indices):
        self.estimator.fit(X_train[:, indices], y_train)
        y_pred = self.estimator.predict(X_test[:, indices])
        score = self.scoring(y_test, y_pred)
        return score 

在前述实现中,我们定义了k_features参数,以指定我们希望返回的特征数。默认情况下,我们使用 scikit-learn 中的accuracy_score来评估模型(分类器的估计器)在特征子集上的性能。

fit方法的while循环内,通过itertools.combination函数创建的特征子集将被评估和减少,直到特征子集具有所需的维数。在每次迭代中,基于内部创建的测试数据集X_test收集最佳子集的准确度分数到列表self.scores_中。我们稍后将使用这些分数来评估结果。最终特征子集的列索引被赋值给self.indices_,我们可以通过transform方法使用它们返回带有选定特征列的新数据数组。请注意,在fit方法内部,我们没有显式计算准则,而是简单地删除了未包含在性能最佳特征子集中的特征。

现在,让我们看看我们使用 scikit-learn 中的 KNN 分类器实现的 SBS 算法的实际效果:

>>> import matplotlib.pyplot as plt
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn = KNeighborsClassifier(n_neighbors=5)
>>> sbs = SBS(knn, k_features=1)
>>> sbs.fit(X_train_std, y_train) 

尽管我们的 SBS 实现已经在fit函数内部将数据集分割成测试和训练数据集,我们仍将训练数据集X_train提供给算法。然后,SBS 的fit方法将为测试(验证)和训练创建新的训练子集,这也是为什么这个测试集也称为验证数据集。这种方法是为了防止我们的原始测试集成为训练数据的一部分。

记住,我们的 SBS 算法收集了每个阶段最佳特征子集的分数,所以让我们继续进行我们实现更令人兴奋的部分,并绘制在验证数据集上计算的 KNN 分类器的分类准确率。代码如下:

>>> k_feat = [len(k) for k in sbs.subsets_]
>>> plt.plot(k_feat, sbs.scores_, marker='o')
>>> plt.ylim([0.7, 1.02])
>>> plt.ylabel('Accuracy')
>>> plt.xlabel('Number of features')
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

正如我们在图 4.9中看到的,随着特征数量的减少,KNN 分类器在验证数据集上的准确率有所提高,这很可能是由于我们在第三章中讨论的 KNN 算法背景下维度诅咒的减少。此外,在下图中我们可以看到,对于k = {3, 7, 8, 9, 10, 11, 12},分类器在验证数据集上实现了 100%的准确率:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.9:特征数量对模型准确率的影响

出于我们自己的好奇心,让我们看看最小的特征子集(k=3),它在验证数据集上表现出色:

>>> k3 = list(sbs.subsets_[10])
>>> print(df_wine.columns[1:][k3])
Index(['Alcohol', 'Malic acid', 'OD280/OD315 of diluted wines'], dtype='object') 

使用上述代码,我们从sbs.subsets_属性的第 11 个位置获得了三特征子集的列索引,并从 pandas Wine DataFrame中返回了相应的特征名称。

接下来,让我们评估 KNN 分类器在原始测试数据集上的性能:

>>> knn.fit(X_train_std, y_train)
>>> print('Training accuracy:', knn.score(X_train_std, y_train))
Training accuracy: 0.967741935484
>>> print('Test accuracy:', knn.score(X_test_std, y_test))
Test accuracy: 0.962962962963 

在上述代码段中,我们使用完整的特征集合,在训练数据集上获得了约 97%的准确率,在测试数据集上获得了约 96%的准确率,这表明我们的模型已经很好地推广到了新数据。现在,让我们使用选定的三特征子集,看看 KNN 的表现如何:

>>> knn.fit(X_train_std[:, k3], y_train)
>>> print('Training accuracy:',
...       knn.score(X_train_std[:, k3], y_train))
Training accuracy: 0.951612903226
>>> print('Test accuracy:',
...       knn.score(X_test_std[:, k3], y_test))
Test accuracy: 0.925925925926 

当在 Wine 数据集中使用少于原始特征四分之一时,对测试数据集的预测准确率略有下降。这可能表明这三个特征提供的信息并不比原始数据集中的差异信息少。然而,我们也必须记住 Wine 数据集是一个小数据集,并且非常容易受到随机性的影响——也就是说,我们如何将数据集分割为训练和测试子集,以及如何将训练数据集进一步分割为训练和验证子集。

虽然我们通过减少特征数量并没有提高 KNN 模型的性能,但我们缩小了数据集的大小,这在可能涉及昂贵数据收集步骤的真实应用中可能是有用的。此外,通过大幅减少特征数量,我们获得了更简单的模型,更易于解释。

scikit-learn 中的特征选择算法

您可以在 Python 包mlxtendrasbt.github.io/mlxtend/user_guide/feature_selection/SequentialFeatureSelector/找到与我们之前实现的简单 SBS 相关的几种不同顺序特征选择的实现。虽然我们的mlxtend实现带有许多功能,但我们与 scikit-learn 团队合作实现了一个简化的、用户友好的版本,这已经成为最近 v0.24 版本的一部分。使用和行为与我们在本章实现的SBS代码非常相似。如果您想了解更多,请参阅scikit-learn.org/stable/modules/generated/sklearn.feature_selection.SequentialFeatureSelector.html的文档。

通过 scikit-learn 提供的特征选择算法有很多选择。这些包括基于特征权重的递归向后消除、基于重要性选择特征的基于树的方法以及单变量统计测试。本书不涵盖所有特征选择方法的详细讨论,但可以在scikit-learn.org/stable/modules/feature_selection.html找到一个具有说明性示例的良好总结。

使用随机森林评估特征重要性

在前几节中,您学习了如何使用 L1 正则化通过逻辑回归将无关特征置零,以及如何使用特征选择的 SBS 算法并将其应用于 KNN 算法。从数据集中选择相关特征的另一种有用方法是使用随机森林,这是一种在第三章中介绍的集成技术。使用随机森林,我们可以将特征重要性量化为从森林中所有决策树计算的平均不纯度减少,而不需要假设我们的数据是否可线性分离。方便的是,scikit-learn 中的随机森林实现已经为我们收集了特征重要性值,因此我们可以在拟合RandomForestClassifier后通过feature_importances_属性访问它们。通过执行以下代码,我们现在将在 Wine 数据集上训练 500 棵树的森林,并根据它们各自的重要性测量排名 13 个特征——请记住,在第三章的讨论中,我们不需要在基于树的模型中使用标准化或归一化特征:

>>> from sklearn.ensemble import RandomForestClassifier
>>> feat_labels = df_wine.columns[1:]
>>> forest = RandomForestClassifier(n_estimators=500,
...                                 random_state=1)
>>> forest.fit(X_train, y_train)
>>> importances = forest.feature_importances_
>>> indices = np.argsort(importances)[::-1]
>>> for f in range(X_train.shape[1]):
...     print("%2d) %-*s %f" % (f + 1, 30,
...                             feat_labels[indices[f]],
...                             importances[indices[f]]))
>>> plt.title('Feature importance')
>>> plt.bar(range(X_train.shape[1]),
...         importances[indices],
...         align='center')
>>> plt.xticks(range(X_train.shape[1]),
...            feat_labels[indices], rotation=90)
>>> plt.xlim([-1, X_train.shape[1]])
>>> plt.tight_layout()
>>> plt.show()
 1) Proline                         0.185453
 2) Flavanoids                      0.174751
 3) Color intensity                 0.143920
 4) OD280/OD315 of diluted wines    0.136162
 5) Alcohol                         0.118529
 6) Hue                             0.058739
 7) Total phenols                   0.050872
 8) Magnesium                       0.031357
 9) Malic acid                      0.025648
 10) Proanthocyanins                0.025570
 11) Alcalinity of ash              0.022366
 12) Nonflavanoid phenols           0.013354
 13) Ash                            0.013279 

执行代码后,我们创建了一个图表,根据它们的相对重要性对 Wine 数据集中的不同特征进行了排序;请注意,特征重要性值已经标准化,使它们总和为 1.0。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.10:基于 Wine 数据集的基于随机森林的特征重要性

根据 500 棵决策树中平均不纯度减少,我们可以得出,葡萄酒的脯氨酸和黄酮类水平、颜色强度、OD280/OD315 波谱和酒精浓度是数据集中最具区分性的特征。有趣的是,绘图中排名前两位的特征也出现在我们在前一节实施的 SBS 算法的三特征子集选择中(酒精浓度和稀释葡萄酒的 OD280/OD315)。

然而,就可解释性而言,随机森林技术有一个值得注意的重要。如果两个或更多特征高度相关,一个特征可能排名很高,而另一个特征的信息可能未能完全捕获。另一方面,如果我们只关心模型的预测性能而不是特征重要性值的解释,那么我们就不需要担心这个问题。

结束对特征重要性值和随机森林的讨论,值得一提的是,scikit-learn 还实现了一个SelectFromModel对象,该对象在模型拟合后基于用户指定的阈值选择特征。如果我们希望将RandomForestClassifier作为特征选择器和 scikit-learn Pipeline对象中的中间步骤,这将非常有用,您将在第六章中学到有关模型评估和超参数调整的最佳实践。例如,我们可以将threshold设置为0.1,使用以下代码将数据集减少到最重要的五个特征:

>>> from sklearn.feature_selection import SelectFromModel
>>> sfm = SelectFromModel(forest, threshold=0.1, prefit=True)
>>> X_selected = sfm.transform(X_train)
>>> print('Number of features that meet this threshold',
...       'criterion:', X_selected.shape[1])
Number of features that meet this threshold criterion: 5
>>> for f in range(X_selected.shape[1]):
...     print("%2d) %-*s %f" % (f + 1, 30,
...                             feat_labels[indices[f]],
...                             importances[indices[f]]))
 1) Proline                         0.185453
 2) Flavanoids                      0.174751
 3) Color intensity                 0.143920
 4) OD280/OD315 of diluted wines    0.136162
 5) Alcohol                         0.118529 

总结

我们通过查看确保正确处理缺失数据的有用技术开始了本章。在将数据馈送到机器学习算法之前,我们还必须确保正确编码分类变量,本章中我们看到如何将有序和名义特征值映射为整数表示。

此外,我们简要讨论了 L1 正则化,它可以通过减少模型的复杂性来帮助我们避免过拟合。作为移除不相关特征的替代方法,我们使用了顺序特征选择算法从数据集中选择有意义的特征。

在下一章中,您将了解到另一种有用的降维方法:特征提取。它允许我们将特征压缩到一个较低维度的子空间,而不是像特征选择那样完全删除特征。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,与作者进行每月的问我任何事会话:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第五章:通过降维压缩数据

第四章构建良好的训练数据集 – 数据预处理中,您已经了解了使用不同特征选择技术来降低数据集维度的不同方法。作为降维的替代方法,特征提取允许您通过将数据转换到比原始维度更低的新特征子空间来总结数据集的信息内容。数据压缩是机器学习中的重要主题,它帮助我们存储和分析在技术发展的现代时代中产生和收集的增加量的数据。

在本章中,我们将涵盖以下主题:

  • 主成分分析用于无监督数据压缩

  • 线性判别分析作为一种监督降维技术,旨在最大化类别可分性。

  • 关于非线性降维技术的简要概述和用于数据可视化的 t-分布随机邻居嵌入

通过主成分分析实现无监督降维

类似于特征选择,我们可以使用不同的特征提取技术来减少数据集中的特征数量。特征选择和特征提取的区别在于,特征选择算法如顺序向后选择保留原始特征,而特征提取则通过转换或投影数据到新的特征空间来实现降维。

在降维的背景下,特征提取可以被理解为一种数据压缩的方法,其目标是保留大部分相关信息。实际上,特征提取不仅用于改善存储空间或学习算法的计算效率,而且可以通过减少维度诅咒(特别是在使用非正则化模型时)来提高预测性能。

主成分分析中的主要步骤

在本节中,我们将讨论主成分分析PCA),这是一种广泛应用于不同领域的无监督线性变换技术,最突出的用途是特征提取和降维。PCA 的其他流行应用包括探索性数据分析、股市交易中信号去噪以及生物信息学领域中基因组数据和基因表达水平的分析。

PCA 帮助我们基于特征之间的相关性来识别数据中的模式。简而言之,PCA 旨在找到高维数据中最大方差的方向,并将数据投影到一个具有与原始空间相等或更少维度的新子空间中。新子空间的正交轴(主成分)可以被解释为考虑到新特征轴互为正交的约束条件下的最大方差方向,如图 5.1所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.1:使用 PCA 在数据集中找到最大方差的方向。

图 5.1中,x[1]和x[2]是原始特征轴,PC 1PC 2是主成分。

如果我们使用 PCA 进行降维,我们构造一个d×k维的变换矩阵W,它允许我们将训练示例的特征向量x映射到一个新的k维特征子空间,该子空间的维度少于原始的d维特征空间。例如,过程如下。假设我们有一个特征向量x

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

然后由变换矩阵外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传变换。

xW = z

得到输出向量:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

将原始d维数据变换到这个新的k维子空间(通常k << d)的结果是,第一个主成分将具有可能的最大方差。所有随后的主成分将具有最大方差,考虑到这些成分与其他主成分是不相关的(正交的)。需要注意的是,PCA 方向对数据缩放非常敏感,如果希望给所有特征赋予相同的重要性,并且这些特征是在不同尺度上测量的,则在 PCA 之前需要标准化特征。

在更详细地查看用于降维的 PCA 算法之前,让我们用几个简单的步骤总结这个方法:

  1. 标准化d维数据集。

  2. 构建协方差矩阵。

  3. 将协方差矩阵分解为其特征向量和特征值。

  4. 将特征值按降序排序,以排名对应的特征向量。

  5. 选择k个特征向量,这些特征向量对应于k个最大的特征值,其中k是新特征子空间的维度(外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)。

  6. 从“顶部”的k个特征向量构造投影矩阵W

  7. 使用投影矩阵Wd维输入数据集X转换为新的k维特征子空间。

在接下来的部分中,我们将逐步使用 Python 执行 PCA 作为学习练习。然后,我们将看到如何使用 scikit-learn 更方便地执行 PCA。

特征分解:将矩阵分解为特征向量和特征值

特征分解,将一个方阵分解成所谓的特征值特征向量,是本节描述的 PCA 过程的核心。

协方差矩阵是方阵的一种特殊情况:它是对称矩阵,这意味着矩阵等于其转置,A = A^T。

当我们将这样的对称矩阵分解时,特征值是实数(而不是复数),特征向量彼此正交(垂直)。此外,特征值和特征向量是成对出现的。如果我们将协方差矩阵分解为其特征向量和特征值,与最高特征值相关联的特征向量对应于数据集中方差的最大方向。在这里,这个“方向”是数据集特征列的线性变换。

尽管本书不涉及特征值和特征向量的详细讨论,但可以在维基百科上找到相对详尽的处理方法和指向其他资源的指针,网址为en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors

逐步提取主成分

在本小节中,我们将解决 PCA 的前四个步骤:

  1. 数据标准化

  2. 构建协方差矩阵

  3. 获得协方差矩阵的特征值和特征向量

  4. 将特征值按降序排列以排名特征向量

首先,我们将加载在第四章 构建良好的训练数据集 - 数据预处理 中使用过的葡萄酒数据集:

>>> import pandas as pd
>>> df_wine = pd.read_csv(
...     'https://archive.ics.uci.edu/ml/'
...     'machine-learning-databases/wine/wine.data',
...     header=None
... ) 

获取葡萄酒数据集

您可以在本书的代码包中找到葡萄酒数据集的副本(以及本书中使用的所有其他数据集),如果您离线工作或 UCI 服务器在archive.ics.uci.edu/ml/machine-learning-databases/wine/wine.data暂时不可用时,您可以使用它。例如,要从本地目录加载葡萄酒数据集,可以替换以下行:

df = pd.read_csv(
    'https://archive.ics.uci.edu/ml/'
    'machine-learning-databases/wine/wine.data',
    header=None
) 

使用以下方法:

df = pd.read_csv(
    'your/local/path/to/wine.data',
    header=None
) 

接下来,我们将逐步处理葡萄酒数据,将其分为独立的训练和测试数据集—分别使用数据的 70%和 30%,并将其标准化为单位方差:

>>> from sklearn.model_selection import train_test_split
>>> X, y = df_wine.iloc[:, 1:].values, df_wine.iloc[:, 0].values
>>> X_train, X_test, y_train, y_test = \
...     train_test_split(X, y, test_size=0.3,
...                      stratify=y,
...                      random_state=0)
>>> # standardize the features
>>> from sklearn.preprocessing import StandardScaler
>>> sc = StandardScaler()
>>> X_train_std = sc.fit_transform(X_train)
>>> X_test_std = sc.transform(X_test) 

完成了通过执行上述代码的强制预处理之后,让我们进入第二步:构建协方差矩阵。这是一个对称的d×d维协方差矩阵,其中d是数据集中的维数,它存储不同特征之间的成对协方差。例如,人口水平上两个特征x[j]和x[k]之间的协方差可以通过以下方程计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传分别是特征jk的样本均值。请注意,如果我们标准化了数据集,样本均值将为零。两个特征之间的正协方差表明特征一起增加或减少,而负协方差表明特征以相反的方向变化。例如,三个特征的协方差矩阵可以写成如下形式(注意外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是希腊大写字母 sigma,与求和符号不要混淆):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

协方差矩阵的特征向量代表主成分(方差最大的方向),而相应的特征值则定义了它们的大小。在 Wine 数据集的情况下,我们将从 13×13 维度的协方差矩阵中获得 13 个特征向量和特征值。

现在,进入我们的第三步,让我们获取协方差矩阵的特征对。如果您上过线性代数课程,可能已经了解到特征向量v满足以下条件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是一个标量:特征值。由于手动计算特征向量和特征值有些冗长且复杂,我们将使用 NumPy 的linalg.eig函数来获取 Wine 协方差矩阵的特征对:

>>> import numpy as np
>>> cov_mat = np.cov(X_train_std.T)
>>> eigen_vals, eigen_vecs = np.linalg.eig(cov_mat)
>>> print('\nEigenvalues \n', eigen_vals)
Eigenvalues
[ 4.84274532  2.41602459  1.54845825  0.96120438  0.84166161
  0.6620634   0.51828472  0.34650377  0.3131368   0.10754642
  0.21357215  0.15362835  0.1808613 ] 

使用numpy.cov函数,我们计算了标准化训练数据集的协方差矩阵。使用linalg.eig函数,我们进行了特征分解,得到一个向量(eigen_vals),其中包含 13 个特征值,并且将相应的特征向量存储为 13×13 维矩阵的列(eigen_vecs)。

NumPy 中的特征分解

numpy.linalg.eig函数被设计用于操作对称和非对称方阵。但是,在某些情况下,您可能会发现它返回复数特征值。

相关函数numpy.linalg.eigh已实现对分解 Hermetian 矩阵的操作,这是一种在处理诸如协方差矩阵等对称矩阵时更稳定的数值方法;numpy.linalg.eigh总是返回实数特征值。

总和和解释的方差

由于我们希望通过将数据集压缩到新的特征子空间来降低数据集的维度,因此我们只选择包含大部分信息(方差)的特征向量(主成分)子集。特征值定义了特征向量的大小,因此我们必须按特征值的大小降序排序;我们对基于其对应特征值的值选择前k个最具信息的特征向量感兴趣。但在收集这些k个最具信息的特征向量之前,让我们绘制解释方差比率方差。一个特征值的解释方差比率,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,简单地是一个特征值,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,与所有特征值的总和之比:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用 NumPy 的cumsum函数,我们可以计算解释方差的累积和,然后通过 Matplotlib 的step函数绘制:

>>> tot = sum(eigen_vals)
>>> var_exp = [(i / tot) for i in
...            sorted(eigen_vals, reverse=True)]
>>> cum_var_exp = np.cumsum(var_exp)
>>> import matplotlib.pyplot as plt
>>> plt.bar(range(1,14), var_exp, align='center',
...         label='Individual explained variance')
>>> plt.step(range(1,14), cum_var_exp, where='mid',
...          label='Cumulative explained variance')
>>> plt.ylabel('Explained variance ratio')
>>> plt.xlabel('Principal component index')
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

结果图表明,第一个主成分单独解释了约 40%的方差。

此外,我们可以看到前两个主成分组合在一起解释了数据集中近 60%的方差:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.2:主成分所捕获的总方差的比例

尽管解释方差图表提醒我们,我们在第四章中通过随机森林计算的特征重要性值,我们应该提醒自己 PCA 是一种无监督方法,这意味着忽略了类标签的信息。而随机森林使用类成员信息来计算节点不纯度,方差则测量了沿特征轴的值的分布范围。

特征变换

现在我们已经成功地将协方差矩阵分解为特征对,让我们继续进行最后三个步骤,将葡萄酒数据集转换到新的主成分轴上。本节中我们将处理的剩余步骤如下:

  1. 选择k个特征向量,这些特征向量对应k个最大的特征值,其中k是新特征子空间的维度 (外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)。

  2. 从“顶部”k个特征向量构建投影矩阵W

  3. 使用投影矩阵Wd维输入数据集X转换为新的k维特征子空间。

或者,更简单地说,我们将按特征值降序对特征对进行排序,从所选特征向量构建投影矩阵,并使用投影矩阵将数据变换到低维子空间。

首先,我们按特征值降序对特征对进行排序:

>>> # Make a list of (eigenvalue, eigenvector) tuples
>>> eigen_pairs = [(np.abs(eigen_vals[i]), eigen_vecs[:, i])
...                 for i in range(len(eigen_vals))]
>>> # Sort the (eigenvalue, eigenvector) tuples from high to low
>>> eigen_pairs.sort(key=lambda k: k[0], reverse=True) 

接下来,我们收集与两个最大特征值对应的两个特征向量,以捕获数据集中约 60%的方差。请注意,出于说明目的,选择了两个特征向量,因为我们将在后面的小节中通过二维散点图绘制数据。实际上,主成分的数量必须通过计算效率和分类器性能之间的权衡来确定:

>>> w = np.hstack((eigen_pairs[0][1][:, np.newaxis],
...                eigen_pairs[1][1][:, np.newaxis]))
>>> print('Matrix W:\n', w)
Matrix W:
[[-0.13724218   0.50303478]
 [ 0.24724326   0.16487119]
 [-0.02545159   0.24456476]
 [ 0.20694508  -0.11352904]
 [-0.15436582   0.28974518]
 [-0.39376952   0.05080104]
 [-0.41735106  -0.02287338]
 [ 0.30572896   0.09048885]
 [-0.30668347   0.00835233]
 [ 0.07554066   0.54977581]
 [-0.32613263  -0.20716433]
 [-0.36861022  -0.24902536]
 [-0.29669651   0.38022942]] 

通过执行上述代码,我们创建了一个 13×2 维的投影矩阵W,由前两个特征向量构成。

镜像投影

取决于您使用的 NumPy 和 LAPACK 版本,您可能会获得矩阵W及其符号翻转的情况。请注意,这不是问题;如果v是矩阵外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的特征向量,则有:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,v是特征向量,–v也是特征向量,我们可以如下展示。使用基本代数,我们可以将方程两边乘以标量外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

由于矩阵乘法对标量乘法是结合的,我们可以将其重新排列为以下形式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,我们可以看到外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是具有相同特征值外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的特征向量,适用于外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。因此,v和–v都是特征向量。

利用投影矩阵,我们现在可以将一个例子x(表示为 13 维行向量)转换到 PCA 子空间(主成分一和二),得到x′,现在是一个由两个新特征组成的二维例子向量:

x′ = xW

>>> X_train_std[0].dot(w)
array([ 2.38299011,  0.45458499]) 

类似地,我们可以通过计算矩阵点积,将整个 124×13 维的训练数据集转换为两个主成分:

X′ = XW

>>> X_train_pca = X_train_std.dot(w) 

最后,让我们将转换后的 Wine 训练数据集可视化为一个 124×2 维的矩阵,在二维散点图中显示:

>>> colors = ['r', 'b', 'g']
>>> markers = ['o', 's', '^']
>>> for l, c, m in zip(np.unique(y_train), colors, markers):
...     plt.scatter(X_train_pca[y_train==l, 0],
...                 X_train_pca[y_train==l, 1],
...                 c=c, label=f'Class {l}', marker=m)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在图 5.3中所看到的,数据沿第一主成分(x轴)的分布比第二主成分(y轴)更广泛,这与我们在前一小节创建的解释方差比例图一致。然而,我们可以看出线性分类器很可能能够很好地分离这些类别:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.3:通过 PCA 将 Wine 数据集投影到二维特征空间

尽管我们在前面的散点图中对类标签信息进行了编码以说明问题,但我们必须记住,PCA 是一种不使用任何类标签信息的无监督技术。

在 scikit-learn 中的主成分分析

虽然在前一小节中详细的方法帮助我们理解 PCA 的内部工作,但现在我们将讨论如何使用 scikit-learn 中实现的 PCA 类。

PCA 类是 scikit-learn 的另一个转换器类之一,我们首先使用训练数据拟合模型,然后使用相同的模型参数转换训练数据和测试数据集。现在,让我们在 Wine 训练数据集上使用 scikit-learn 中的 PCA 类,通过逻辑回归对转换后的示例进行分类,并通过我们在 第二章分类简单机器学习算法的训练 中定义的 plot_decision_regions 函数可视化决策区域:

from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, test_idx=None, resolution=0.02):
    # setup marker generator and color map
    markers = ('o', 's', '^', 'v', '<')
    colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
    cmap = ListedColormap(colors[:len(np.unique(y))])
    # plot the decision surface
    x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
                           np.arange(x2_min, x2_max, resolution))
    lab = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    lab = lab.reshape(xx1.shape)
    plt.contourf(xx1, xx2, lab, alpha=0.3, cmap=cmap)
    plt.xlim(xx1.min(), xx1.max())
    plt.ylim(xx2.min(), xx2.max())
    # plot class examples
    for idx, cl in enumerate(np.unique(y)):
        plt.scatter(x=X[y == cl, 0],
                    y=X[y == cl, 1],
                    alpha=0.8,
                    c=colors[idx],
                    marker=markers[idx],
                    label=f'Class {cl}',
                    edgecolor='black') 

为了您的方便,您可以将前述的 plot_decision_regions 代码放入当前工作目录中的单独代码文件中,例如 plot_decision_regions_script.py,并将其导入到当前的 Python 会话中:

>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.decomposition import PCA
>>> # initializing the PCA transformer and
>>> # logistic regression estimator:
>>> pca = PCA(n_components=2)
>>> lr = LogisticRegression(multi_class='ovr',
...                         random_state=1,
...                         solver='lbfgs')
>>> # dimensionality reduction:
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> X_test_pca = pca.transform(X_test_std)
>>> # fitting the logistic regression model on the reduced dataset:
>>> lr.fit(X_train_pca, y_train)
>>> plot_decision_regions(X_train_pca, y_train, classifier=lr)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

通过执行此代码,我们现在应该可以看到将训练数据的决策区域减少为两个主成分轴:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.4: 使用 scikit-learn 的 PCA 进行降维后的训练示例和逻辑回归决策区域

当我们将 scikit-learn 中的 PCA 投影与我们自己的 PCA 实现进行比较时,我们可能会看到生成的图是彼此的镜像。请注意,这不是这两个实现中的任何一个错误的原因;这种差异的原因是,依赖于特征求解器,特征向量可以具有负或正的符号。

并不重要,但如果我们希望,可以通过将数据乘以 -1 简单地将镜像图像还原;请注意,特征向量通常缩放为单位长度 1。为了完整起见,让我们绘制转换后测试数据集上的逻辑回归决策区域,以查看它是否能很好地分离类别:

>>> plot_decision_regions(X_test_pca, y_test, classifier=lr)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

在执行上述代码并为测试数据集绘制决策区域之后,我们可以看到逻辑回归在这个小的二维特征子空间上表现相当不错,仅在测试数据集中错误分类了一些示例:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.5: 在基于 PCA 特征空间中的测试数据点与逻辑回归决策区域

如果我们对不同主成分的解释方差比感兴趣,可以简单地使用 n_components 参数设置为 None 初始化 PCA 类,这样所有主成分都会被保留,并且可以通过 explained_variance_ratio_ 属性访问解释的方差比:

>>> pca = PCA(n_components=None)
>>> X_train_pca = pca.fit_transform(X_train_std)
>>> pca.explained_variance_ratio_
array([ 0.36951469, 0.18434927, 0.11815159, 0.07334252,
        0.06422108, 0.05051724, 0.03954654, 0.02643918,
        0.02389319, 0.01629614, 0.01380021, 0.01172226,
        0.00820609]) 

请注意,当我们初始化 PCA 类时,设置 n_components=None,以便按排序顺序返回所有主成分,而不进行降维。

评估特征贡献

在本节中,我们将简要介绍如何评估原始特征对主成分的贡献。正如我们所学,通过 PCA,我们创建代表特征线性组合的主成分。有时,我们有兴趣知道每个原始特征对给定主成分的贡献有多少。这些贡献通常称为负荷量

可以通过将特征向量按特征值的平方根进行缩放来计算因子负荷。然后,可以将结果值解释为原始特征与主成分之间的相关性。为了说明这一点,让我们绘制第一主成分的负荷。

首先,我们通过将特征向量乘以特征值的平方根来计算 13×13 维负荷矩阵:

>>> loadings = eigen_vecs * np.sqrt(eigen_vals) 

接着,我们绘制第一主成分的负荷量 loadings[:, 0],这是矩阵中的第一列:

>>> fig, ax = plt.subplots()
>>> ax.bar(range(13), loadings[:, 0], align='center')
>>> ax.set_ylabel('Loadings for PC 1')
>>> ax.set_xticks(range(13))
>>> ax.set_xticklabels(df_wine.columns[1:], rotation=90)
>>> plt.ylim([-1, 1])
>>> plt.tight_layout()
>>> plt.show() 

图 5.6 中,我们可以看到,例如,酒精与第一主成分呈负相关(约为 -0.3),而苹果酸呈正相关(约为 0.54)。请注意,数值为 1 表示完全正相关,而数值为-1 对应完全负相关:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.6:与第一主成分的特征相关性

在上述代码示例中,我们计算了我们自己的 PCA 实现的因子负荷。我们可以以类似的方式从适合的 scikit-learn PCA 对象中获取负荷,其中 pca.components_ 表示特征向量,pca.explained_variance_ 表示特征值:

>>> sklearn_loadings = pca.components_.T * np.sqrt(pca.explained_variance_) 

为了将 scikit-learn PCA 的负荷与我们之前创建的负荷进行比较,让我们创建一个类似的条形图:

>>> fig, ax = plt.subplots()
>>> ax.bar(range(13), sklearn_loadings[:, 0], align='center')
>>> ax.set_ylabel('Loadings for PC 1')
>>> ax.set_xticks(range(13))
>>> ax.set_xticklabels(df_wine.columns[1:], rotation=90)
>>> plt.ylim([-1, 1])
>>> plt.tight_layout()
>>> plt.show() 

正如我们所看到的,条形图看起来一样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.7:使用 scikit-learn 计算的特征与第一主成分的相关性

在探讨 PCA 作为无监督特征提取技术之后,下一节将介绍线性判别分析LDA),这是一种线性变换技术,考虑了类标签信息。

通过线性判别分析进行监督数据压缩

LDA 可以作为一种特征提取技术,用于增加计算效率并减少非正则化模型中因维度诅咒而导致的过拟合程度。LDA 的一般概念与 PCA 非常相似,但是 PCA 试图找到数据集中方差最大的正交组件轴,而 LDA 的目标是找到优化类别可分性的特征子空间。在接下来的几节中,我们将更详细地讨论 LDA 与 PCA 之间的相似之处,并逐步介绍 LDA 方法。

主成分分析与线性判别分析

PCA 和 LDA 都是用于减少数据集维数的线性变换技术;前者是无监督算法,而后者是有监督的。因此,我们可能认为在分类任务中,LDA 是比 PCA 更优越的特征提取技术。然而,A.M. Martinez 报告称,在某些情况下,通过 PCA 预处理在图像识别任务中会导致更好的分类结果,例如,如果每个类别只包含少量示例(PCA 与 LDA,作者A. M. MartinezA. C. KakIEEE 模式分析与机器智能交易,23(2):228-233,2001 年)。

费舍尔 LDA

LDA 有时也被称为费舍尔 LDA。罗纳德·A·费舍尔最初在 1936 年为双类分类问题制定了费舍尔线性判别在分类问题中使用多次测量,作者R. A. Fisher遗传学年刊,7(2):179-188,1936 年)。1948 年,C. Radhakrishna Rao 在假设等类别协方差和正态分布类别的情况下推广了费舍尔线性判别,现在我们称之为 LDA(在生物分类问题中利用多次测量,作者C. R. Rao英国皇家统计学会系列 B(方法学),10(2):159-203,1948 年)。

图 5.8总结了用于双类问题的 LDA 概念。来自类别 1 的示例显示为圆圈,来自类别 2 的示例显示为交叉点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.8:用于双类问题的 LDA 概念

线性判别分析所示的线性判别,如x轴(LD 1),能够很好地分离两个正态分布类别。尽管在y轴上显示的示例线性判别(LD 2)捕捉了数据集的大部分方差,但它作为一个好的线性判别失败了,因为它没有捕捉任何类别区分信息。

LDA 中的一个假设是数据服从正态分布。此外,我们假设类别具有相同的协方差矩阵,并且训练样本彼此统计独立。然而,即使这些假设中的一个或多个(略微)违反,LDA 在降维方面仍然可以表现得相当好(模式分类第 2 版,作者R. O. DudaP. E. HartD. G. Stork纽约,2001 年)。

线性判别分析的内部工作原理

在我们深入代码实现之前,让我们简要总结执行 LDA 所需的主要步骤:

  1. 标准化d维数据集(d为特征数)。

  2. 对于每个类别,计算d维均值向量。

  3. 构建类间散布矩阵S[B]和类内散布矩阵S[W]。

  4. 计算矩阵的特征向量和相应的特征值,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  5. 按降序排列特征值以排列相应的特征向量。

  6. 选择与最大特征值k对应的k个特征向量来构造d×k维的转换矩阵 W;这些特征向量是矩阵的列。

  7. 使用转换矩阵 W 将示例投影到新的特征子空间。

正如我们所见,LDA 在某种意义上与 PCA 相似,因为我们将矩阵分解为特征值和特征向量,这将形成新的低维特征空间。然而,正如前面提到的,LDA 考虑了类标签信息,这以步骤 2中计算的均值向量形式体现出来。在接下来的章节中,我们将更详细地讨论这七个步骤,并附有示例代码实现。

计算散布矩阵

由于我们在本章开头的 PCA 部分已经标准化了 Wine 数据集的特征,我们可以跳过第一步,直接计算均值向量,然后分别用于构造类内散布矩阵和类间散布矩阵。每个均值向量 m[i] 存储关于类i的示例的特征值均值,如 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这导致了三个均值向量:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这些均值向量可以通过以下代码计算,其中我们为三个标签分别计算一个均值向量:

>>> np.set_printoptions(precision=4)
>>> mean_vecs = []
>>> for label in range(1,4):
...     mean_vecs.append(np.mean(
...                X_train_std[y_train==label], axis=0))
...     print(f'MV {label}: {mean_vecs[label - 1]}\n')
MV 1: [ 0.9066  -0.3497  0.3201  -0.7189  0.5056  0.8807  0.9589  -0.5516
0.5416  0.2338  0.5897  0.6563  1.2075]
MV 2: [-0.8749  -0.2848  -0.3735  0.3157  -0.3848  -0.0433  0.0635  -0.0946
0.0703  -0.8286  0.3144  0.3608  -0.7253]
MV 3: [ 0.1992  0.866  0.1682  0.4148  -0.0451  -1.0286  -1.2876  0.8287
-0.7795  0.9649  -1.209  -1.3622  -0.4013] 

使用均值向量,我们现在可以计算类内散布矩阵 S[W]:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这通过对每个单独类 i 的散布矩阵 S[i] 求和来计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label, mv in zip(range(1, 4), mean_vecs):
...     class_scatter = np.zeros((d, d))
...     for row in X_train_std[y_train == label]:
...         row, mv = row.reshape(d, 1), mv.reshape(d, 1)
...         class_scatter += (row - mv).dot((row - mv).T)
...     S_W += class_scatter
>>> print('Within-class scatter matrix: '
...       f'{S_W.shape[0]}x{S_W.shape[1]}')
Within-class scatter matrix: 13x13 

在计算散布矩阵时,我们所做的假设是训练数据集中的类标签是均匀分布的。然而,如果我们打印类标签的数量,我们会发现这一假设是不成立的:

>>> print('Class label distribution:',
...       np.bincount(y_train)[1:])
Class label distribution: [41 50 33] 

因此,我们希望在将它们加总为散布矩阵 S[W] 之前,先对各个散布矩阵 S[i] 进行缩放。当我们将散布矩阵除以类示例数 n[i] 时,我们可以看到,计算散布矩阵实际上与计算协方差矩阵 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是一样的——协方差矩阵是散布矩阵的归一化版本:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

计算缩放的类内散布矩阵的代码如下:

>>> d = 13 # number of features
>>> S_W = np.zeros((d, d))
>>> for label,mv in zip(range(1, 4), mean_vecs):
...     class_scatter = np.cov(X_train_std[y_train==label].T)
...     S_W += class_scatter
>>> print('Scaled within-class scatter matrix: '
...       f'{S_W.shape[0]}x{S_W.shape[1]}')
Scaled within-class scatter matrix: 13x13 

在计算缩放的类内散布矩阵(或协方差矩阵)之后,我们可以继续下一步,计算类间散布矩阵 S[B]:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里,m 是总体均值,包括所有 c 类的示例:

>>> mean_overall = np.mean(X_train_std, axis=0)
>>> mean_overall = mean_overall.reshape(d, 1)
>>> d = 13 # number of features
>>> S_B = np.zeros((d, d))
>>> for i, mean_vec in enumerate(mean_vecs):
...     n = X_train_std[y_train == i + 1, :].shape[0]
...     mean_vec = mean_vec.reshape(d, 1) # make column vector
...     S_B += n * (mean_vec - mean_overall).dot(
...     (mean_vec - mean_overall).T)
>>> print('Between-class scatter matrix: '
...       f'{S_B.shape[0]}x{S_B.shape[1]}')
Between-class scatter matrix: 13x13 

为新特征子空间选择线性判别式

LDA 的剩余步骤与 PCA 的步骤类似。但是,我们不是对协方差矩阵进行特征分解,而是解矩阵的广义特征值问题,如下所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

>>> eigen_vals, eigen_vecs =\
...     np.linalg.eig(np.linalg.inv(S_W).dot(S_B)) 

在计算完特征对之后,我们可以按降序对特征值进行排序:

>>> eigen_pairs = [(np.abs(eigen_vals[i]), eigen_vecs[:,i])
...                for i in range(len(eigen_vals))]
>>> eigen_pairs = sorted(eigen_pairs,
...               key=lambda k: k[0], reverse=True)
>>> print('Eigenvalues in descending order:\n')
>>> for eigen_val in eigen_pairs:
...     print(eigen_val[0])
Eigenvalues in descending order:
349.617808906
172.76152219
3.78531345125e-14
2.11739844822e-14
1.51646188942e-14
1.51646188942e-14
1.35795671405e-14
1.35795671405e-14
7.58776037165e-15
5.90603998447e-15
5.90603998447e-15
2.25644197857e-15
0.0 

在 LDA 中,线性判别子的数量最多为 c – 1,其中 c 是类别标签的数量,因为类间散布矩阵 S[B] 是 c 个秩为一或更低的矩阵之和。事实上,我们只有两个非零特征值(特征值 3-13 并非完全为零,这是由于 NumPy 中的浮点运算)。

共线性

注意,在极少数情况下出现完美共线性(所有对齐的示例点位于一条直线上),协方差矩阵将具有秩为一,这将导致只有一个非零特征值的特征向量。

为了衡量线性判别子(特征向量)捕获了多少类别区分信息,让我们绘制按降序排列的线性判别子,类似于我们在 PCA 部分创建的解释方差图。为简单起见,我们将类别区分信息的内容称为 可区分度

>>> tot = sum(eigen_vals.real)
>>> discr = [(i / tot) for i in sorted(eigen_vals.real,
...                                    reverse=True)]
>>> cum_discr = np.cumsum(discr)
>>> plt.bar(range(1, 14), discr, align='center',
...         label='Individual discriminability')
>>> plt.step(range(1, 14), cum_discr, where='mid',
...          label='Cumulative discriminability')
>>> plt.ylabel('"Discriminability" ratio')
>>> plt.xlabel('Linear Discriminants')
>>> plt.ylim([-0.1, 1.1])
>>> plt.legend(loc='best')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在 图 5.9 中所看到的,仅用前两个线性判别分量就可以捕获葡萄酒训练数据集中 100%的有用信息:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.9:前两个判别分量捕获了 100%的有用信息

现在让我们堆叠两个最具判别性的特征向量列以创建转换矩阵 W

>>> w = np.hstack((eigen_pairs[0][1][:, np.newaxis].real,
...                eigen_pairs[1][1][:, np.newaxis].real))
>>> print('Matrix W:\n', w)
Matrix W:
 [[-0.1481  -0.4092]
  [ 0.0908  -0.1577]
  [-0.0168  -0.3537]
  [ 0.1484   0.3223]
  [-0.0163  -0.0817]
  [ 0.1913   0.0842]
  [-0.7338   0.2823]
  [-0.075   -0.0102]
  [ 0.0018   0.0907]
  [ 0.294   -0.2152]
  [-0.0328   0.2747]
  [-0.3547  -0.0124]
  [-0.3915  -0.5958]] 

将示例投影到新的特征空间

使用我们在前一小节中创建的转换矩阵 W,现在我们可以通过矩阵相乘来转换训练数据集:

X′ = XW

>>> X_train_lda = X_train_std.dot(w)
>>> colors = ['r', 'b', 'g']
>>> markers = ['o', 's', '^']
>>> for l, c, m in zip(np.unique(y_train), colors, markers):
...     plt.scatter(X_train_lda[y_train==l, 0],
...                 X_train_lda[y_train==l, 1] * (-1),
...                 c=c, label= f'Class {l}', marker=m)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower right')
>>> plt.tight_layout()
>>> plt.show() 

正如我们在 图 5.10 中所看到的,现在三个葡萄酒类别在新的特征子空间中完全线性可分:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.10:将数据投影到前两个判别分量后,葡萄酒类别完全可分

使用 scikit-learn 进行 LDA

那逐步实现是理解 LDA 内部工作原理和理解 LDA 与 PCA 之间差异的良好练习。现在,让我们看一下 scikit-learn 中实现的 LDA 类:

>>> # the following import statement is one line
>>> from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA
>>> lda = LDA(n_components=2)
>>> X_train_lda = lda.fit_transform(X_train_std, y_train) 

接下来,让我们看看在 LDA 转换之后,逻辑回归分类器如何处理低维训练数据集:

>>> lr = LogisticRegression(multi_class='ovr', random_state=1,
...                         solver='lbfgs')
>>> lr = lr.fit(X_train_lda, y_train)
>>> plot_decision_regions(X_train_lda, y_train, classifier=lr)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

图 5.11 所示,我们可以看到逻辑回归模型误分类了来自第二类的一个示例:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.11:逻辑回归模型误分类了一个类别

通过降低正则化强度,我们可能可以移动决策边界,使得逻辑回归模型能够在训练数据集中正确分类所有示例。然而,更重要的是,让我们看看在测试数据集上的结果:

>>> X_test_lda = lda.transform(X_test_std)
>>> plot_decision_regions(X_test_lda, y_test, classifier=lr)
>>> plt.xlabel('LD 1')
>>> plt.ylabel('LD 2')
>>> plt.legend(loc='lower left')
>>> plt.tight_layout()
>>> plt.show() 

如我们在图 5.12中所见,逻辑回归分类器能够在测试数据集中只使用一个二维特征子空间,而不是原始的 13 个葡萄酒特征,获得完美的准确度分数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.12:逻辑回归模型在测试数据上的完美表现

非线性降维和可视化

在前一节中,我们介绍了主成分分析(PCA)和线性判别分析(LDA)等线性变换技术进行特征提取。在本节中,我们将讨论为什么考虑非线性降维技术可能是值得的。

特别值得强调的一个非线性降维技术是t 分布随机邻居嵌入t-SNE),因为它经常用于文献中以二维或三维形式可视化高维数据集。我们将看到如何应用 t-SNE 来在二维特征空间中绘制手写图像的图像。

为什么考虑非线性降维?

许多机器学习算法对输入数据的线性可分性有假设。您已经学到了感知器甚至需要完全线性可分的训练数据才能收敛。到目前为止,我们所涵盖的其他算法假设缺乏完全线性可分性是由于噪声引起的:Adaline、逻辑回归和(标准)支持向量机等。

然而,如果我们处理非线性问题,这在实际应用中可能经常遇到,那么线性变换技术如主成分分析(PCA)和线性判别分析(LDA)可能不是最佳选择:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.13:线性与非线性问题的区别

scikit-learn 库实现了一些先进的非线性降维技术,超出了本书的范围。有兴趣的读者可以在scikit-learn.org/stable/modules/manifold.html找到 scikit-learn 当前实现的良好概述,并配有说明性示例。

非线性降维技术的开发和应用通常也被称为流形学习,其中流形指的是嵌入在高维空间中的低维拓扑空间。流形学习算法必须捕捉数据的复杂结构,以便将其投影到一个保持数据点关系的低维空间中。

流形学习的一个经典示例是三维瑞士卷,如图 5.14所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.14:将三维瑞士卷投影到较低的二维空间

尽管非线性降维和流形学习算法非常强大,但需要注意的是,这些技术因难以使用而闻名,如果超参数选择不理想,可能弊大于利。导致这种困难的原因在于我们通常处理的是无法轻易可视化且结构不明显的高维数据集(不像图 5.14中的瑞士卷示例)。此外,除非将数据集投影到二维或三维空间(这通常不足以捕捉更复杂的关系),否则很难甚至不可能评估结果的质量。因此,许多人仍依赖于更简单的技术,如 PCA 和 LDA 进行降维。

通过 t-分布随机近邻嵌入进行数据可视化

在介绍非线性降维及其一些挑战后,让我们看一个涉及 t-SNE 的实际示例,这在二维或三维中经常用于可视化复杂数据集。

简而言之,t-SNE 根据高维(原始)特征空间中数据点的成对距离建模数据点。然后,它找到新的低维空间中成对距离的概率分布,该分布接近原始空间中成对距离的概率分布。换句话说,t-SNE 学习将数据点嵌入到低维空间中,使得原始空间中的成对距离得以保持。您可以在 Maaten 和 Hinton 的原始研究论文《Visualizing data using t-SNE》 中找到有关此方法的更多详细信息,发表于 2018 年《机器学习研究期刊》(www.jmlr.org/papers/volume9/vandermaaten08a/vandermaaten08a.pdf)。然而,正如研究论文的标题所示,t-SNE 是一种用于可视化目的的技术,因为它需要整个数据集进行投影。由于它直接投影点(不像 PCA,它不涉及投影矩阵),我们无法将 t-SNE 应用于新的数据点。

下面的代码显示了如何快速演示 t-SNE 如何应用于一个 64 维数据集。首先,我们从 scikit-learn 加载 Digits 数据集,其中包含低分辨率手写数字(数字 0-9)的图像:

>>> from sklearn.datasets import load_digits
>>> digits = load_digits() 

这些数字是 8×8 的灰度图像。下面的代码绘制了数据集中的前四幅图像,总共包含 1,797 幅图像:

>>> fig, ax = plt.subplots(1, 4)
>>> for i in range(4):
>>>     ax[i].imshow(digits.images[i], cmap='Greys')
>>> plt.show() 

正如我们在图 5.15中看到的那样,图像的分辨率相对较低,为 8×8 像素(即每个图像 64 个像素):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.15:手写数字的低分辨率图像

请注意,digits.data属性使我们能够访问该数据集的表格版本,其中示例由行表示,列对应于像素:

>>> digits.data.shape
(1797, 64) 

接下来,让我们将特征(像素)分配给一个新变量X_digits,并将标签分配给另一个新变量y_digits

>>> y_digits = digits.target
>>> X_digits = digits.data 

然后,我们从 scikit-learn 中导入 t-SNE 类,并拟合一个新的tsne对象。使用fit_transform,我们在一步中执行 t-SNE 拟合和数据转换:

>>> from sklearn.manifold import TSNE
>>> tsne = TSNE(n_components=2, init='pca',
...             random_state=123)
>>> X_digits_tsne = tsne.fit_transform(X_digits) 

使用这段代码,我们将 64 维数据集投影到二维空间。我们指定了init='pca',这在研究文章*《Initialization is critical for preserving global data structure in both t-SNE and UMAP》中被推荐使用 PCA 进行 t-SNE 嵌入的初始化,该文章的作者是KobakLinderman*,发表于*《Nature Biotechnology Volume 39》*,第 156-157 页,2021 年(www.nature.com/articles/s41587-020-00809-z)。

请注意,t-SNE 还包括额外的超参数,如困惑度和学习率(通常称为epsilon),我们在示例中省略了这些(使用了 scikit-learn 的默认值)。实际应用中,我们建议您也探索这些参数。有关这些参数及其对结果影响的更多信息,请参阅文章*《How to Use t-SNE Effectively》,作者是Wattenberg*、ViegasJohnson,发表于*《Distill》*,2016 年(distill.pub/2016/misread-tsne/)。

最后,让我们使用以下代码可视化 2D t-SNE 嵌入:

>>> import matplotlib.patheffects as PathEffects
>>> def plot_projection(x, colors):
...     f = plt.figure(figsize=(8, 8))
...     ax = plt.subplot(aspect='equal')
...     for i in range(10):
...         plt.scatter(x[colors == i, 0],
...                     x[colors == i, 1])
...     for i in range(10):
...         xtext, ytext = np.median(x[colors == i, :], axis=0)
...         txt = ax.text(xtext, ytext, str(i), fontsize=24)
...         txt.set_path_effects([
...             PathEffects.Stroke(linewidth=5, foreground="w"),
...             PathEffects.Normal()])
>>> plot_projection(X_digits_tsne, y_digits)
>>> plt.show() 

与 PCA 类似,t-SNE 是一种无监督方法,在前述代码中,我们仅出于可视化目的使用类标签y_digits(0-9)通过函数颜色参数。Matplotlib 的PathEffects用于视觉效果,使得每个相应数字数据点的类标签显示在中心(通过np.median)。生成的图如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.16:展示了 t-SNE 如何将手写数字嵌入二维特征空间的可视化

正如我们所看到的,尽管不完美,t-SNE 能够很好地分离不同的数字(类别)。通过调整超参数,可能可以实现更好的分离。然而,由于难以辨认的手写,一定程度的类混合可能是不可避免的。例如,通过检查单个图像,我们可能会发现某些数字 3 的实例看起来确实像数字 9,等等。

均匀流形逼近和投影

另一种流行的可视化技术是均匀流形逼近和投影UMAP)。虽然 UMAP 可以产生与 t-SNE 类似好的结果(例如,请参阅之前引用的 Kobak 和 Linderman 的论文),但通常速度更快,并且还可以用于投影新数据,这使其在机器学习背景下作为降维技术更具吸引力,类似于 PCA。对 UMAP 感兴趣的读者可以在原始论文中找到更多信息:UMAP: Uniform manifold approximation and projection for dimension reduction,作者是McInnes, HealyMelville,2018 年(arxiv.org/abs/1802.03426)。UMAP 的 scikit-learn 兼容实现可以在umap-learn.readthedocs.io找到。

摘要

在本章中,您了解了两种用于特征提取的基本降维技术:PCA 和 LDA。使用 PCA,我们将数据投影到低维子空间中,以最大化沿正交特征轴的方差,同时忽略类标签。与 PCA 相反,LDA 是一种用于监督降维的技术,这意味着它考虑训练数据集中的类信息,试图在线性特征空间中最大化类的可分离性。最后,您还了解了 t-SNE,这是一种非线性特征提取技术,可用于在二维或三维中可视化数据。

配备了 PCA 和 LDA 作为基本数据预处理技术,您现在已经准备好学习如何在下一章高效地结合不同的预处理技术并评估不同模型的性能。

加入我们书籍的 Discord 空间

参加每月一次的作者问答活动,可在书籍的 Discord 工作区参与:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第六章:学习模型评估和超参数调整的最佳实践

在前几章中,我们学习了分类的基本机器学习算法,以及在将数据输入这些算法之前如何整理数据。现在,是时候学习通过微调算法和评估模型性能来构建优秀机器学习模型的最佳实践了。在本章中,我们将学习如何执行以下操作:

  • 评估机器学习模型的性能

  • 诊断机器学习算法的常见问题

  • 微调机器学习模型

  • 使用不同性能指标评估预测模型的性能

使用管道流程优化工作流程

当我们在前几章中应用不同的预处理技术,例如特征缩放的标准化在 第四章,“构建良好的训练数据集 - 数据预处理” 中,或数据压缩的主成分分析在 第五章,“通过降维压缩数据” 中,您学到我们必须重用在训练数据拟合期间获得的参数来缩放和压缩任何新数据,例如单独测试数据集中的示例。在本节中,您将学习到一个非常方便的工具,即Pipeline类在 scikit-learn 中。它允许我们拟合一个包含任意数量转换步骤的模型,并将其应用于对新数据进行预测。

加载乳腺癌威斯康星数据集

在本章中,我们将使用乳腺癌威斯康星数据集,该数据集包含 569 个恶性和良性肿瘤细胞的示例。数据集中的前两列存储示例的唯一 ID 编号和相应的诊断结果(M = 恶性,B = 良性)。列 3-32 包含从细胞核数字化图像计算出的 30 个实值特征,可用于构建模型以预测肿瘤是良性还是恶性。乳腺癌威斯康星数据集已存放在 UCI 机器学习库中,有关该数据集的更详细信息可在 archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Diagnostic) 找到。

获取乳腺癌威斯康星数据集

您可以在本书的代码包中找到数据集的副本(以及本书中使用的所有其他数据集),如果您离线工作或者 UCI 服务器在 archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data 暂时不可用时,您可以使用它。例如,要从本地目录加载数据集,您可以替换以下行:

df = pd.read_csv(
    'https://archive.ics.uci.edu/ml/'
    'machine-learning-databases'
    '/breast-cancer-wisconsin/wdbc.data',
    header=None
) 

使用以下内容:

df = pd.read_csv(
    'your/local/path/to/wdbc.data',
    header=None
) 

在本节中,我们将在三个简单步骤中读取数据集并将其分为训练集和测试集:

  1. 我们将从 UCI 网站直接使用 pandas 读取数据集:

    >>> import pandas as pd
    >>> df = pd.read_csv('https://archive.ics.uci.edu/ml/'
    ...                  'machine-learning-databases'
    ...                  '/breast-cancer-wisconsin/wdbc.data',
    ...                  header=None) 
    
  2. 接下来,我们将把这 30 个特征分配给一个 NumPy 数组 X。使用 LabelEncoder 对象,我们将类标签从其原始字符串表示('M''B')转换为整数:

    >>> from sklearn.preprocessing import LabelEncoder
    >>> X = df.loc[:, 2:].values
    >>> y = df.loc[:, 1].values
    >>> le = LabelEncoder()
    >>> y = le.fit_transform(y)
    >>> le.classes_
    array(['B', 'M'], dtype=object) 
    
  3. 在将类标签(诊断)编码为数组 y 后,恶性肿瘤现在表示为类 1,良性肿瘤表示为类 0。我们可以通过在两个虚拟类标签上调用已拟合的 LabelEncodertransform 方法来双重检查此映射:

    >>> le.transform(['M', 'B'])
    array([1, 0]) 
    
  4. 在下一小节构建我们的第一个模型管道之前,让我们将数据集分成一个单独的训练数据集(数据的 80%)和一个单独的测试数据集(数据的 20%):

    >>> from sklearn.model_selection import train_test_split
    >>> X_train, X_test, y_train, y_test = \
    ...     train_test_split(X, y,
    ...                      test_size=0.20,
    ...                      stratify=y,
    ...                      random_state=1) 
    

将转换器和估计器组合成管道

在前一章中,您学习到许多学习算法需要输入特征在相同的尺度上才能获得最佳性能。由于 Breast Cancer Wisconsin 数据集中的特征在不同的尺度上测量,因此在将它们提供给线性分类器(如 logistic 回归)之前,我们将标准化 Breast Cancer Wisconsin 数据集中的列。此外,假设我们希望通过 主成分分析PCA),这是一种介绍在 第五章 中的用于降维的特征提取技术,将我们的数据从初始的 30 维压缩到一个较低的二维子空间。

不需要分别对训练集和测试集进行模型拟合和数据转换步骤,我们可以将 StandardScalerPCALogisticRegression 对象串联在一个管道中:

>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.decomposition import PCA
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.pipeline import make_pipeline
>>> pipe_lr = make_pipeline(StandardScaler(),
...                         PCA(n_components=2),
...                         LogisticRegression())
>>> pipe_lr.fit(X_train, y_train)
>>> y_pred = pipe_lr.predict(X_test)
>>> test_acc = pipe_lr.score(X_test, y_test)
>>> print(f'Test accuracy: {test_acc:.3f}')
Test accuracy: 0.956 

make_pipeline 函数接受任意数量的 scikit-learn 转换器(支持 fittransform 方法的对象)作为输入,后跟一个实现 fitpredict 方法的 scikit-learn 估计器。在我们前面的代码示例中,我们提供了两个 scikit-learn 转换器 StandardScalerPCA,以及一个 LogisticRegression 估计器作为 make_pipeline 函数的输入,该函数从这些对象构造了一个 scikit-learn Pipeline 对象。

我们可以将 scikit-learn 的 Pipeline 看作是那些独立转换器和估计器的元估计器或包装器。如果我们调用 Pipelinefit 方法,数据将通过一系列转换器,通过中间步骤上的 fittransform 调用传递,直到到达估计器对象(管道中的最后一个元素)。然后,估计器将被拟合到转换后的训练数据上。

当我们在前面的代码示例中对pipe_lr管道执行fit方法时,StandardScaler首先对训练数据执行了fittransform调用。其次,转换后的训练数据被传递给管道中的下一个对象PCA。类似于前面的步骤,PCA也对缩放后的输入数据执行了fittransform操作,并将其传递给管道的最后一个元素,即评估器。

最后,经过StandardScalerPCA转换后,LogisticRegression评估器被拟合到训练数据中。同样需要注意的是,在管道中的中间步骤数量没有限制;然而,如果我们想要将管道用于预测任务,最后一个管道元素必须是评估器。

与在管道上调用fit类似,如果管道的最后一步是评估器,管道也会实现predict方法。如果我们将数据集提供给Pipeline对象实例的predict调用,数据将通过中间步骤通过transform调用传递。在最后一步,评估器对象将返回对转换数据的预测。

scikit-learn 库的管道非常实用,是我们在本书的其余部分经常使用的包装工具。为了确保您对Pipeline对象的工作原理有深刻理解,请仔细观察图 6.1,该图总结了我们在前面段落中的讨论:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.1:管道对象的内部工作原理

使用 k 折交叉验证评估模型性能

在本节中,您将了解常见的交叉验证技术留出交叉验证k 折交叉验证,这些技术可以帮助我们获得模型泛化性能的可靠估计,即模型在未见数据上的表现如何。

留出法

估计机器学习模型泛化性能的经典和流行方法是留出法。使用留出法,我们将初始数据集分成单独的训练和测试数据集——前者用于模型训练,后者用于估计其泛化性能。然而,在典型的机器学习应用中,我们还对调整和比较不同的参数设置感兴趣,以进一步提高在未见数据上的预测性能。这个过程称为模型选择,该名称指的是我们想要选择最佳值的调整参数(也称为超参数)给定分类问题的情况。然而,如果在模型选择过程中反复使用同一测试数据集,它将成为我们的训练数据的一部分,因此模型更可能过拟合。尽管存在这个问题,许多人仍然使用测试数据集进行模型选择,这不是一个好的机器学习实践。

使用留出法进行模型选择的更好方法是将数据分成三部分:训练数据集、验证数据集和测试数据集。训练数据集用于拟合不同的模型,然后利用验证数据集上的性能进行模型选择。测试数据集的优点在于,在训练和模型选择步骤中,模型之前未见过该数据,因此我们可以获得对其推广到新数据能力的较少偏见的估计。图 6.2说明了留出交叉验证的概念,在这里我们使用验证数据集重复评估使用不同超参数值进行训练后模型的性能。一旦我们对超参数值的调整感到满意,我们就可以估计模型在测试数据集上的泛化性能:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.2:如何使用训练、验证和测试数据集

留出法的一个缺点是性能估计可能对如何将训练数据集分割成训练和验证子集非常敏感;估计值会因数据的不同示例而变化。在下一小节中,我们将看一下更健壮的性能估计技术,即 k 折交叉验证,在这种方法中,我们对训练数据的 k 个子集重复使用留出法k次。

k 折交叉验证

在 k 折交叉验证中,我们将训练数据集随机分成k个不重复的折叠。在这里,k – 1 折叠,即所谓的训练折叠,用于模型训练,而一个折叠,即所谓的测试折叠,用于性能评估。此过程重复k次,以便我们获得k个模型和性能估计。

有替换和无替换抽样

我们在第三章中查看了一个示例,以说明有放回和无放回的抽样。如果您还没有阅读该章节,或者需要复习,请参阅“组合多个决策树通过随机森林”章节中名为“有放回和无放回抽样”的信息框。

然后我们基于不同的、独立的测试折叠计算模型的平均表现,以获得对训练数据的子分区不太敏感的性能估计,与留出方法相比。通常情况下,我们使用 k 折交叉验证进行模型调整,即找到能产生满意泛化性能的最优超参数值,这些值是通过评估模型在测试折叠上的性能来估计的。

一旦我们找到令人满意的超参数值,我们可以在完整的训练数据集上重新训练模型,并使用独立的测试数据集获得最终的性能估计。在 k 折交叉验证之后将模型拟合到整个训练数据集的理由是,首先,我们通常对单个最终模型感兴趣(而不是k个单独的模型),其次,将更多的训练示例提供给学习算法通常会产生更精确和更健壮的模型。

由于 k 折交叉验证是一种无替换的重采样技术,这种方法的优势在于在每次迭代中,每个示例都将仅使用一次,并且训练和测试折叠是不重叠的。此外,所有测试折叠也是不重叠的;也就是说,测试折叠之间没有重叠。图 6.3总结了 k 折交叉验证背后的概念,其中k = 10. 训练数据集被分为 10 个折叠,在 10 次迭代期间,有 9 个折叠用于训练,1 个折叠将用作模型评估的测试数据集。

另外,每个折叠的估计表现,E[i](例如,分类准确度或误差),然后用于计算模型的估计平均表现,E

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.3:k 折交叉验证的工作原理

总之,k 折交叉验证比使用验证集的留出方法更有效地利用数据集,因为在 k 折交叉验证中,所有数据点都用于评估。

在 k 折交叉验证中,一个良好的标准值k是 10,正如经验证据所示。例如,Ron Kohavi 在各种真实世界数据集上的实验表明,10 折交叉验证在偏差和方差之间提供了最佳的权衡(关于准确度估计和模型选择的交叉验证和自举研究Ron Kohavi国际人工智能联合会议(IJCAI),14(12):1137-43,1995 年,www.ijcai.org/Proceedings/95-2/Papers/016.pdf)。

然而,如果我们使用的训练集比较小,增加折数可能是有用的。如果我们增加 k 的值,每次迭代中会使用更多的训练数据,这样平均每个模型估计的泛化性能会有较低的悲观偏差。但是,较大的 k 值也会增加交叉验证算法的运行时间,并导致估计的方差较高,因为训练折会更加相似。另一方面,如果我们处理大数据集,可以选择较小的 k 值,例如 k = 5,仍然可以准确估计模型的平均性能,同时减少在不同折上重新拟合和评估模型的计算成本。

留一法交叉验证

k 折交叉验证的一个特例是留一法交叉验证(LOOCV)方法。在 LOOCV 中,我们将折数设置为训练示例的数量(k = n),因此在每次迭代中只使用一个训练示例进行测试,这是处理非常小数据集的推荐方法。

对标准 k 折交叉验证方法的轻微改进是分层 k 折交叉验证,它可以在类别不平衡的情况下更好地估计偏差和方差,正如在本节中前面引用的 Ron Kohavi 的研究中所示。在分层交叉验证中,保留了每个折中类别标签的比例,以确保每个折都代表训练数据集中的类别比例,我们将通过使用 scikit-learn 中的 StratifiedKFold 迭代器来说明这一点:

>>> import numpy as np
>>> from sklearn.model_selection import StratifiedKFold
>>> kfold = StratifiedKFold(n_splits=10).split(X_train, y_train)
>>> scores = []
>>> for k, (train, test) in enumerate(kfold):
...     pipe_lr.fit(X_train[train], y_train[train])
...     score = pipe_lr.score(X_train[test], y_train[test])
...     scores.append(score)
...     print(f'Fold: {k+1:02d}, '
...           f'Class distr.: {np.bincount(y_train[train])}, '
...           f'Acc.: {score:.3f}')
Fold: 01, Class distr.: [256 153], Acc.: 0.935
Fold: 02, Class distr.: [256 153], Acc.: 0.935
Fold: 03, Class distr.: [256 153], Acc.: 0.957
Fold: 04, Class distr.: [256 153], Acc.: 0.957
Fold: 05, Class distr.: [256 153], Acc.: 0.935
Fold: 06, Class distr.: [257 153], Acc.: 0.956
Fold: 07, Class distr.: [257 153], Acc.: 0.978
Fold: 08, Class distr.: [257 153], Acc.: 0.933
Fold: 09, Class distr.: [257 153], Acc.: 0.956
Fold: 10, Class distr.: [257 153], Acc.: 0.956
>>> mean_acc = np.mean(scores)
>>> std_acc = np.std(scores)
>>> print(f'\nCV accuracy: {mean_acc:.3f} +/- {std_acc:.3f}')
CV accuracy: 0.950 +/- 0.014 

首先,我们使用 sklearn.model_selection 模块中的 StratifiedKFold 迭代器初始化了 y_train 训练数据集中的类标签,并通过 n_splits 参数指定了折数。当我们使用 kfold 迭代器循环遍历 k 个折时,我们使用返回的 train 索引来拟合本章开头设置的 logistic 回归流水线。通过 pipe_lr 流水线,我们确保每次迭代中的示例都被适当地(例如,标准化)缩放。然后,我们使用 test 索引来计算模型的准确率分数,将其收集在 scores 列表中以计算估计的平均准确率和标准偏差。

尽管前面的代码示例有助于说明 k 折交叉验证的工作原理,scikit-learn 还实现了一种 k 折交叉验证评分器,可以更简洁地使用分层 k 折交叉验证来评估我们的模型:

>>> from sklearn.model_selection import cross_val_score
>>> scores = cross_val_score(estimator=pipe_lr,
...                          X=X_train,
...                          y=y_train,
...                          cv=10,
...                          n_jobs=1)
>>> print(f'CV accuracy scores: {scores}')
CV accuracy scores: [ 0.93478261  0.93478261  0.95652174
                      0.95652174  0.93478261  0.95555556
                      0.97777778  0.93333333  0.95555556
                      0.95555556]
>>> print(f'CV accuracy: {np.mean(scores):.3f} '
...       f'+/- {np.std(scores):.3f}')
CV accuracy: 0.950 +/- 0.014 

cross_val_score方法的一个极其有用的特性是,我们可以将不同折叠的评估任务分布到我们机器上的多个中央处理单元CPU)上。如果将n_jobs参数设为1,那么只会使用一个 CPU 来评估性能,就像我们之前的StratifiedKFold示例一样。然而,通过设置n_jobs=2,我们可以将 10 轮交叉验证任务分布到两个 CPU 上(如果机器上有的话),而通过设置n_jobs=-1,我们可以利用机器上所有可用的 CPU 并行计算。

估算泛化性能

请注意,本书不涵盖如何估计交叉验证中泛化性能的方差的详细讨论,但您可以参考关于模型评估和交叉验证的全面文章(《机器学习中的模型评估、模型选择和算法选择》S. Raschka),我们在arxiv.org/abs/1811.12808分享了这篇文章。此文章还讨论了替代的交叉验证技术,例如.632 和.632+自助法交叉验证方法。

此外,您可以在 M. Markatou 等人的优秀文章中找到详细讨论(《分析交叉验证估计泛化误差的方差分析》M. MarkatouH. TianS. BiswasG. M. Hripcsak机器学习研究杂志,6: 1127-1168,2005 年),该文章可在www.jmlr.org/papers/v6/markatou05a.html找到。

使用学习曲线和验证曲线调试算法

在本节中,我们将介绍两个非常简单但功能强大的诊断工具,可以帮助我们改善学习算法的性能:学习曲线验证曲线。在接下来的小节中,我们将讨论如何使用学习曲线来诊断学习算法是否存在过拟合(高方差)或拟合不足(高偏差)的问题。此外,我们还将看看验证曲线,它可以帮助我们解决学习算法的常见问题。

使用学习曲线诊断偏差和方差问题

如果一个模型对于给定的训练数据集过于复杂,例如,想象一下非常深的决策树,那么该模型倾向于过拟合训练数据,并且不能很好地泛化到未见过的数据。通常情况下,增加训练样本的数量可以帮助减少过拟合的程度。

然而,在实践中,收集更多数据往往非常昂贵,或者根本不可行。通过绘制模型训练和验证精度随训练数据集大小变化的曲线,我们可以轻松检测模型是否存在高方差或高偏差问题,以及收集更多数据是否有助于解决这一问题。

但在讨论如何在 scikit-learn 中绘制学习曲线之前,让我们通过以下示例来讨论这两个常见的模型问题:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.4:常见的模型问题

左上图显示的图表显示了高偏差的模型。这个模型具有低训练和交叉验证准确性,表明它在训练数据上拟合不足。解决这个问题的常见方法包括增加模型参数的数量,例如通过收集或构建额外的特征,或通过减少正则化的程度,例如在支持向量机SVM)或逻辑回归分类器中。

右上图显示的图表显示模型存在高方差问题,这通过训练和交叉验证准确性之间的巨大差距来指示。为了解决这个过拟合问题,我们可以收集更多的训练数据,减少模型的复杂性,或增加正则化参数,例如。

对于非正则化模型,还可以通过特征选择(第四章)或特征提取(第五章)减少特征数量,从而减少过拟合程度。虽然收集更多的训练数据通常会减少过拟合的机会,但在训练数据非常嘈杂或模型已经非常接近最优情况时,这并不总是有帮助的。

在下一小节中,我们将看到如何使用验证曲线来解决这些模型问题,但让我们先看看如何使用 scikit-learn 中的学习曲线函数来评估模型:

>>> import matplotlib.pyplot as plt
>>> from sklearn.model_selection import learning_curve
>>> pipe_lr = make_pipeline(StandardScaler(),
...                         LogisticRegression(penalty='l2',
...                                            max_iter=10000))
>>> train_sizes, train_scores, test_scores =\
...                 learning_curve(estimator=pipe_lr,
...                                X=X_train,
...                                y=y_train,
...                                train_sizes=np.linspace(
...                                            0.1, 1.0, 10),
...                                cv=10,
...                                n_jobs=1)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(train_sizes, train_mean,
...          color='blue', marker='o',
...          markersize=5, label='Training accuracy')
>>> plt.fill_between(train_sizes,
...                  train_mean + train_std,
...                  train_mean - train_std,
...                  alpha=0.15, color='blue')
>>> plt.plot(train_sizes, test_mean,
...          color='green', linestyle='--',
...          marker='s', markersize=5,
...          label='Validation accuracy')
>>> plt.fill_between(train_sizes,
...                  test_mean + test_std,
...                  test_mean - test_std,
...                  alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xlabel('Number of training examples')
>>> plt.ylabel('Accuracy')
>>> plt.legend(loc='lower right')
>>> plt.ylim([0.8, 1.03])
>>> plt.show() 

注意,在实例化LogisticRegression对象时,我们传递了max_iter=10000作为额外参数(默认使用 1,000 次迭代),以避免在较小的数据集大小或极端正则化参数值(在下一节中讨论)时出现收敛问题。执行上述代码成功后,我们将获得以下学习曲线图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.5:显示训练和验证数据集准确性的学习曲线

通过learning_curve函数中的train_sizes参数,我们可以控制用于生成学习曲线的训练示例的绝对或相对数量。在这里,我们设置train_sizes=np.linspace(0.1, 1.0, 10),以使用 10 个均匀间隔的相对训练数据集大小。默认情况下,learning_curve函数使用分层 k 折交叉验证来计算分类器的交叉验证准确性,并通过cv参数设置k = 10 以进行 10 折分层交叉验证。

然后,我们简单地计算了返回的交叉验证训练和测试分数的平均精度,这些分数是针对不同大小的训练数据集绘制的,我们使用 Matplotlib 的plot函数进行了绘制。此外,我们使用fill_between函数将平均精度的标准偏差添加到图表中,以指示估计值的变化范围。

正如我们在前面的学习曲线图中所看到的,如果在训练过程中看到了超过 250 个示例,我们的模型在训练和验证数据集上表现相当不错。我们还可以看到,对于训练数据集少于 250 个示例的情况,训练精度会提高,并且验证精度与训练精度之间的差距会扩大——这是过拟合程度增加的指标。

解决过拟合和欠拟合问题的验证曲线

验证曲线是通过解决过拟合或欠拟合等问题来改善模型性能的有用工具。验证曲线与学习曲线相关,但不同于将训练和测试精度作为样本大小的函数进行绘制,我们改变模型参数的值,例如逻辑回归中的反正则化参数C

让我们继续看看如何通过 scikit-learn 创建验证曲线:

>>> from sklearn.model_selection import validation_curve
>>> param_range = [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]
>>> train_scores, test_scores = validation_curve(
...                             estimator=pipe_lr,
...                             X=X_train,
...                             y=y_train,
...                             param_name='logisticregression__C',
...                             param_range=param_range,
...                             cv=10)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(param_range, train_mean,
...          color='blue', marker='o',
...          markersize=5, label='Training accuracy')
>>> plt.fill_between(param_range, train_mean + train_std,
...                  train_mean - train_std, alpha=0.15,
...                  color='blue')
>>> plt.plot(param_range, test_mean,
...          color='green', linestyle='--',
...          marker='s', markersize=5,
...          label='Validation accuracy')
>>> plt.fill_between(param_range,
...                  test_mean + test_std,
...                  test_mean - test_std,
...                  alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xscale('log')
>>> plt.legend(loc='lower right')
>>> plt.xlabel('Parameter C')
>>> plt.ylabel('Accuracy')
>>> plt.ylim([0.8, 1.0])
>>> plt.show() 

使用上述代码,我们获得了参数C的验证曲线图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.6:SVM 超参数 C 的验证曲线图

learning_curve函数类似,validation_curve函数默认使用分层 k 折交叉验证来估计分类器的性能。在validation_curve函数内部,我们指定了要评估的参数。在本例中,它是C,即LogisticRegression分类器的反正则化参数,我们写成'logisticregression__C'以访问 scikit-learn 流水线中的LogisticRegression对象,为我们通过param_range参数设置的指定值范围进行评估。与上一节中的学习曲线示例类似,我们绘制了平均训练和交叉验证精度以及相应的标准偏差。

尽管C值变化时的精度差异微妙,但我们可以看到,当增加正则化强度(C的小值)时,模型略微欠拟合数据。然而,对于较大的C值,即降低正则化强度,则模型倾向于轻微过拟合数据。在这种情况下,C值的甜点似乎在 0.01 到 0.1 之间。

通过网格搜索优化机器学习模型

在机器学习中,我们有两种类型的参数:一种是从训练数据中学习的参数,例如逻辑回归中的权重,另一种是学习算法单独优化的参数。后者是模型的调整参数(或超参数),例如逻辑回归中的正则化参数或决策树的最大深度参数。

在前面的部分中,我们使用验证曲线来通过调整其中一个超参数来改善模型的性能。在本节中,我们将介绍一种名为网格搜索的流行超参数优化技术,它可以通过找到超参数值的最佳组合进一步帮助改善模型的性能。

通过网格搜索调整超参数

网格搜索方法相当简单:它是一种蛮力穷举搜索范式,我们在不同超参数的值列表中指定一组值,计算机对每个组合评估模型性能,以获取从此集合中获得的最优组合值:

>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.svm import SVC
>>> pipe_svc = make_pipeline(StandardScaler(),
...                          SVC(random_state=1))
>>> param_range = [0.0001, 0.001, 0.01, 0.1,
...                1.0, 10.0, 100.0, 1000.0]
>>> param_grid = [{'svc__C': param_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': param_range,
...                'svc__gamma': param_range,
...                'svc__kernel': ['rbf']}]
>>> gs = GridSearchCV(estimator=pipe_svc,
...                   param_grid=param_grid,
...                   scoring='accuracy',
...                   cv=10,
...                   refit=True,
...                   n_jobs=-1)
>>> gs = gs.fit(X_train, y_train)
>>> print(gs.best_score_)
0.9846153846153847
>>> print(gs.best_params_)
{'svc__C': 100.0, 'svc__gamma': 0.001, 'svc__kernel': 'rbf'} 

使用上述代码,我们从 sklearn.model_selection 模块初始化了一个 GridSearchCV 对象来训练和调整 SVM 流水线。我们将 GridSearchCVparam_grid 参数设置为一组字典,以指定我们希望调整的参数。对于线性 SVM,我们只评估了逆正则化参数 C;对于径向基函数RBF)核 SVM,我们调整了 svc__Csvc__gamma 参数。请注意,svc__gamma 参数特定于核 SVM。

GridSearchCV 使用 k 折交叉验证来比较使用不同超参数设置训练的模型。通过 cv=10 设置,它将进行十折交叉验证,并计算这十个折叠中的平均准确率(通过 scoring='accuracy')来评估模型性能。我们设置 n_jobs=-1,以便 GridSearchCV 可以利用所有处理核心并行地加速网格搜索,但如果您的计算机对此设置有问题,您可以将此设置更改为 n_jobs=None 以进行单处理。

在使用训练数据执行网格搜索后,我们通过 best_score_ 属性获取了表现最佳模型的分数,并查看了其参数,这些参数可以通过 best_params_ 属性访问。在这种特定情况下,具有 svc__C = 100.0 的 RBF 核 SVM 模型产生了最佳的 k 折交叉验证准确率:98.5%。

最后,我们使用独立的测试数据集来估计选择的最佳模型的性能,该模型可通过 GridSearchCV 对象的 best_estimator_ 属性获得:

>>> clf = gs.best_estimator_
>>> clf.fit(X_train, y_train)
>>> print(f'Test accuracy: {clf.score(X_test, y_test):.3f}')
Test accuracy: 0.974 

请注意,在完成网格搜索后,手动在训练集上用最佳设置(gs.best_estimator_)拟合模型是不必要的。GridSearchCV类有一个refit参数,如果我们设置refit=True(默认),它将自动将gs.best_estimator_重新拟合到整个训练集上。

通过随机搜索更广泛地探索超参数配置

由于网格搜索是一种穷举搜索,如果最佳超参数配置包含在用户指定的参数网格中,它肯定能找到最优配置。然而,指定大型超参数网格在实践中使网格搜索非常昂贵。采样不同参数组合的替代方法是随机搜索。在随机搜索中,我们从分布(或离散集合)中随机抽取超参数配置。与网格搜索不同,随机搜索不会对超参数空间进行穷举搜索。尽管如此,它仍然能够以更加经济和时间有效的方式探索更广泛的超参数值设置范围。这个概念在图 6.7 中有所体现,图示了通过网格搜索和随机搜索对九个超参数设置进行搜索的固定网格:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.7:比较网格搜索和随机搜索各自采样九种不同的超参数配置

主要观点是,虽然网格搜索只探索离散的、用户指定的选择,但如果搜索空间太少,可能会错过好的超参数配置。有兴趣的读者可以在以下文章中找到关于随机搜索的详细信息,以及经验研究:《超参数优化的随机搜索》由J. BergstraY. Bengio机器学习研究杂志,第 281-305 页,2012 年,www.jmlr.org/papers/volume13/bergstra12a/bergstra12a

现在让我们看看如何利用随机搜索来调整 SVM。Scikit-learn 实现了一个RandomizedSearchCV类,类似于我们在前一小节中使用的GridSearchCV。主要区别在于我们可以在参数网格中指定分布,并指定要评估的超参数配置的总数。例如,让我们考虑在前一小节中网格搜索 SVM 时使用的几个超参数的范围:

>>> param_range = [0.0001, 0.001, 0.01, 0.1,
...                1.0, 10.0, 100.0, 1000.0] 

注意,虽然RandomizedSearchCV可以接受类似的离散值列表作为参数网格的输入,这在考虑分类超参数时非常有用,但它的主要优势在于我们可以用分布来采样这些列表。因此,例如,我们可以用 SciPy 中的以下分布替换前面的列表:

>>> param_range = scipy.stats.loguniform(0.0001, 1000.0) 

例如,使用对数均匀分布而不是常规均匀分布将确保在足够大数量的试验中,与[0.0001, 0.001]范围相比,将从[10.0, 100.0]范围中绘制相同数量的样本。要检查其行为,我们可以通过rvs(10)方法从该分布中绘制 10 个随机样本,如下所示:

>>> np.random.seed(1)
>>> param_range.rvs(10)
array([8.30145146e-02, 1.10222804e+01, 1.00184520e-04, 1.30715777e-02,
       1.06485687e-03, 4.42965766e-04, 2.01289666e-03, 2.62376594e-02,
       5.98924832e-02, 5.91176467e-01]) 

指定分布

RandomizedSearchCV支持任意分布,只要我们可以通过调用rvs()方法从中抽样。可以在这里找到scipy.stats当前可用的所有分布列表:docs.scipy.org/doc/scipy/reference/stats.html#probability-distributions

现在让我们看看RandomizedSearchCV如何运作,并像在前一节中使用GridSearchCV调整 SVM 一样:

>>> from sklearn.model_selection import RandomizedSearchCV
>>> pipe_svc = make_pipeline(StandardScaler(),
...                          SVC(random_state=1))
>>> param_grid = [{'svc__C': param_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': param_range,
...                'svc__gamma': param_range,
...                'svc__kernel': ['rbf']}]
>>> rs = RandomizedSearchCV(estimator=pipe_svc,
...                         param_distributions=param_grid,
...                         scoring='accuracy',
...                         refit=True,
...                         n_iter=20,
...                         cv=10,
...                         random_state=1,
...                         n_jobs=-1)
>>> rs = rs.fit(X_train, y_train)
>>> print(rs.best_score_)
0.9670531400966184
>>> print(rs.best_params_)
{'svc__C': 0.05971247755848464, 'svc__kernel': 'linear'} 

根据此代码示例,我们可以看到其使用方式与GridSearchCV非常相似,不同之处在于我们可以使用分布来指定参数范围,并通过设置n_iter=20来指定迭代次数——20 次迭代。

更具资源效率的超参数搜索与连续减半

将随机搜索的思想进一步发展,scikit-learn 实现了一种称为HalvingRandomSearchCV的连续减半变体,使得寻找适合的超参数配置更加高效。连续减半是指,在一个大的候选配置集合中,逐步淘汰不太有希望的超参数配置,直到只剩下一个配置。我们可以通过以下步骤总结该过程:

  1. 通过随机抽样绘制大量候选配置

  2. 使用有限的资源训练模型,例如,训练数据的一个小子集(与使用整个训练集相对)

  3. 基于预测性能底部 50%的丢弃

  4. 回到步骤 2并增加可用资源量

直到只剩下一个超参数配置为止重复执行上述步骤。注意,还有一个用于网格搜索变体的连续减半实现称为HalvingGridSearchCV,在步骤 1中使用所有指定的超参数配置而不是随机样本。

在 scikit-learn 1.0 中,HalvingRandomSearchCV仍处于实验阶段,因此我们必须首先启用它:

>>> from sklearn.experimental import enable_halving_search_cv 

(上述代码可能在未来版本中不起作用或不被支持。)

启用实验支持后,我们可以像下面展示的那样使用带有连续减半的随机搜索:

>>> from sklearn.model_selection import HalvingRandomSearchCV
>>> hs = HalvingRandomSearchCV(pipe_svc,
...                            param_distributions=param_grid,
...                            n_candidates='exhaust',
...                            resource='n_samples',
...                            factor=1.5,
...                            random_state=1,
...                            n_jobs=-1) 

resource='n_samples'(默认)设置指定我们将训练集大小作为我们在各轮之间变化的资源。通过factor参数,我们可以确定每轮淘汰多少候选者。例如,设置factor=2会淘汰一半的候选者,而设置factor=1.5意味着只有 100%/1.5 ≈ 66% 的候选者进入下一轮。与在RandomizedSearchCV中选择固定迭代次数不同,我们设置n_candidates='exhaust'(默认),这将对超参数配置数量进行采样,以便在最后一轮使用最大数量的资源(在这里:训练样本)。

我们可以像RandomizedSearchCV那样进行搜索:

>>> hs = hs.fit(X_train, y_train)
>>> print(hs.best_score_)
0.9617647058823529
>>> print(hs.best_params_)
{'svc__C': 4.934834261073341, 'svc__kernel': 'linear'}
>>> clf = hs.best_estimator_
>>> print(f'Test accuracy: {hs.score(X_test, y_test):.3f}')
Test accuracy: 0.982 

如果我们将前两个子段中GridSearchCVRandomizedSearchCV的结果与HalvingRandomSearchCV中的模型进行比较,可以看到后者在测试集上表现略优(98.2% 的准确率,而不是 97.4%)。

使用 hyperopt 进行超参数调优

另一个流行的超参数优化库是 hyperopt (github.com/hyperopt/hyperopt),它实现了几种不同的超参数优化方法,包括随机搜索和树结构贝叶斯优化器TPE)方法。TPE 是一种基于概率模型的贝叶斯优化方法,根据过去的超参数评估和相关的性能分数不断更新模型,而不是将这些评估视为独立事件。您可以在超参数优化算法中了解更多关于 TPE 的信息。Bergstra J, Bardenet R, Bengio Y, Kegl B. NeurIPS 2011. pp. 2546–2554,dl.acm.org/doi/10.5555/2986459.2986743

虽然 hyperopt 提供了一个通用的超参数优化接口,但也有一个专门为 scikit-learn 设计的包叫做 hyperopt-sklearn,提供了额外的便利:github.com/hyperopt/hyperopt-sklearn

使用嵌套交叉验证进行算法选择

使用 k 折交叉验证结合网格搜索或随机搜索是通过变化超参数值来微调机器学习模型性能的有用方法,就像我们在前面的子节中看到的那样。如果我们想在不同的机器学习算法之间选择,那么另一个推荐的方法是嵌套交叉验证。在一项关于误差估计偏差的研究中,Sudhir Varma 和 Richard Simon 得出结论,当使用嵌套交叉验证时,相对于测试数据集,估计的真实误差几乎是无偏的(Bias in Error Estimation When Using Cross-Validation for Model Selection by S. Varma and R. Simon, BMC Bioinformatics, 7(1): 91, 2006, bmcbioinformatics.biomedcentral.com/articles/10.1186/1471-2105-7-91)。

在嵌套交叉验证中,我们有一个外部 k 折交叉验证循环将数据分成训练集和测试集,并且内部循环用于在训练集上使用 k 折交叉验证选择模型。在模型选择后,测试集用于评估模型性能。图 6.8 解释了只有五个外部和两个内部折叠的嵌套交叉验证概念,这对于大数据集在计算性能重要时可能会很有用;这种特定类型的嵌套交叉验证也被称为5×2 交叉验证

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.8:嵌套交叉验证的概念

在 scikit-learn 中,我们可以通过以下方式执行嵌套交叉验证和网格搜索:

>>> param_range = [0.0001, 0.001, 0.01, 0.1,
...                1.0, 10.0, 100.0, 1000.0]
>>> param_grid = [{'svc__C': param_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': param_range,
...                'svc__gamma': param_range,
...                'svc__kernel': ['rbf']}]
>>> gs = GridSearchCV(estimator=pipe_svc,
...                   param_grid=param_grid,
...                   scoring='accuracy',
...                   cv=2)
>>> scores = cross_val_score(gs, X_train, y_train,
...                          scoring='accuracy', cv=5)
>>> print(f'CV accuracy: {np.mean(scores):.3f} '
...       f'+/- {np.std(scores):.3f}')
CV accuracy: 0.974 +/- 0.015 

返回的平均交叉验证准确度为我们提供了一个很好的估计,如果我们调整模型的超参数并在未见数据上使用它,我们可以预期什么。

例如,我们可以使用嵌套交叉验证方法比较 SVM 模型和简单的决策树分类器;为简单起见,我们只会调整其深度参数:

>>> from sklearn.tree import DecisionTreeClassifier
>>> gs = GridSearchCV(
...     estimator=DecisionTreeClassifier(random_state=0),
...     param_grid=[{'max_depth': [1, 2, 3, 4, 5, 6, 7, None]}],
...     scoring='accuracy',
...     cv=2
... )
>>> scores = cross_val_score(gs, X_train, y_train,
...                          scoring='accuracy', cv=5)
>>> print(f'CV accuracy: {np.mean(scores):.3f} '
...       f'+/- {np.std(scores):.3f}')
CV accuracy: 0.934 +/- 0.016 

正如我们所见,SVM 模型的嵌套交叉验证性能(97.4%)明显优于决策树的性能(93.4%),因此,我们预计它可能是分类来自与此特定数据集相同总体的新数据的更好选择。

查看不同的性能评估指标

在先前的部分和章节中,我们使用预测准确度评估不同的机器学习模型,这是一种用于总体上量化模型性能的有用指标。然而,还有几个其他性能指标可以用来衡量模型的相关性,例如精确度、召回率、F1 分数马修斯相关系数MCC)。

阅读混淆矩阵

在深入讨论不同评分指标的细节之前,让我们先看看混淆矩阵,这是一个展示学习算法性能的矩阵。

混淆矩阵只是一个简单的方阵,报告了分类器对真正预测(TP)、真负预测(TN)、假正预测(FP)和假负预测(FN)的计数,如 图 6.9 所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.9:混淆矩阵

尽管这些指标可以通过比较实际和预测的类别标签手动计算,但 scikit-learn 提供了一个方便的 confusion_matrix 函数供我们使用,如下所示:

>>> from sklearn.metrics import confusion_matrix
>>> pipe_svc.fit(X_train, y_train)
>>> y_pred = pipe_svc.predict(X_test)
>>> confmat = confusion_matrix(y_true=y_test, y_pred=y_pred)
>>> print(confmat)
[[71  1]
[ 2 40]] 

执行代码后返回的数组为我们提供了关于分类器在测试数据集上所做的不同类型错误的信息。我们可以使用 Matplotlib 的 matshow 函数将这些信息映射到混淆矩阵图示中 图 6.9

>>> fig, ax = plt.subplots(figsize=(2.5, 2.5))
>>> ax.matshow(confmat, cmap=plt.cm.Blues, alpha=0.3)
>>> for i in range(confmat.shape[0]):
...     for j in range(confmat.shape[1]):
...         ax.text(x=j, y=i, s=confmat[i, j],
...                 va='center', ha='center')
>>> ax.xaxis.set_ticks_position('bottom')
>>> plt.xlabel('Predicted label')
>>> plt.ylabel('True label')
>>> plt.show() 

现在,以下带有附加标签的混淆矩阵图应该会使结果稍微容易解释一些:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.10:我们数据的混淆矩阵

假设在这个例子中类别 1(恶性)是正类,我们的模型正确地分类了属于类别 0 的 71 个示例(TN),以及属于类别 1 的 40 个示例(TP)。然而,我们的模型还将两个类别 1 的示例错误地分类为类别 0(FN),并且它预测了一个示例是恶性尽管它是良性肿瘤(FP)。在下一小节中,我们将学习如何利用这些信息来计算各种误差指标。

优化分类模型的精确度和召回率

预测 错误率 (ERR) 和 准确率 (ACC) 都提供关于有多少示例被错误分类的一般信息。错误可以理解为所有错误预测的总和除以总预测数,而准确率分别计算为正确预测的总和除以总预测数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

预测准确率可以直接从误差中计算出来:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

真正率 (TPR) 和 假正率 (FPR) 是性能指标,特别适用于不平衡类别问题:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在肿瘤诊断中,我们更关心恶性肿瘤的检测,以帮助患者进行适当的治疗。然而,减少将良性肿瘤错误分类为恶性肿瘤(FP)也同样重要,以免不必要地让患者担心。与 FPR 相反,TPR 提供了关于已正确识别的正(或相关)示例在总正例(P)池中的分数的有用信息。

性能指标 精确度 (PRE) 和 召回率 (REC) 与 TP 和 TN 率有关,实际上,REC 与 TPR 是同义词。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

换句话说,召回率衡量了多少相关记录(阳性记录)被正确捕捉(真阳性)。精确率则衡量了预测为相关的记录中有多少确实是相关的(真阳性数与假阳性数之和):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

再次以恶性肿瘤检测为例,优化召回率有助于最小化未检测到恶性肿瘤的风险。然而,这会导致在健康患者身上预测出恶性肿瘤(假阳性数较高)。另一方面,如果我们优化精确率,则强调当我们预测患者患有恶性肿瘤时的正确性。然而,这将导致更频繁地错过恶性肿瘤(假阴性数较高)。

为了平衡优化精确率和召回率的利弊,使用它们的调和平均数,即所谓的 F1 分数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

更多关于精确率和召回率的阅读

如果您对如精确率和召回率等不同性能指标的更详细讨论感兴趣,请阅读 David M. W. Powers 的技术报告《评估:从精确率、召回率和 F-Factor 到 ROC、Informedness、Markedness 和相关性》,该报告可以在arxiv.org/abs/2010.16061免费获取。

最后,总结混淆矩阵的一种度量是 MCC,特别受到生物研究背景中的欢迎。MCC 的计算方法如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

与 PRE、REC 和 F1 分数相比,MCC 的范围在-1 到 1 之间,并且考虑了混淆矩阵的所有元素,例如 F1 分数不涉及 TN。虽然 MCC 值比 F1 分数更难解释,但它被认为是一个更优越的度量标准,正如D. ChiccoG. Jurman在文章《二分类评估中 Matthews 相关系数(MCC)优于 F1 分数和准确度的优势》中描述的那样,《BMC Genomics》。pp. 281-305, 2012, bmcgenomics.biomedcentral.com/articles/10.1186/s12864-019-6413-7

这些评分指标都已在 scikit-learn 中实现,并可以从sklearn.metrics模块中导入,如下片段所示:

>>> from sklearn.metrics import precision_score
>>> from sklearn.metrics import recall_score, f1_score
>>> from sklearn.metrics import matthews_corrcoef
>>> pre_val = precision_score(y_true=y_test, y_pred=y_pred)
>>> print(f'Precision: {pre_val:.3f}')
Precision: 0.976
>>> rec_val = recall_score(y_true=y_test, y_pred=y_pred)
>>> print(f'Recall: {rec_val:.3f}')
Recall: 0.952
>>> f1_val = f1_score(y_true=y_test, y_pred=y_pred)
>>> print(f'F1: {f1_val:.3f}')
F1: 0.964
>>> mcc_val = matthews_corrcoef(y_true=y_test, y_pred=y_pred)
>>> print(f'MCC: {mcc_val:.3f}')
MCC: 0.943 

此外,我们可以通过 scoring 参数在GridSearchCV中使用不同的评分指标,而不是精度。有关 scoring 参数接受的不同值的完整列表,请访问scikit-learn.org/stable/modules/model_evaluation.html

请记住,scikit-learn 中的正类是标记为类别1的类。如果我们想指定一个不同的正类标签,我们可以通过make_scorer函数构建自己的评分器,然后直接将其作为参数提供给GridSearchCV中的scoring参数(在本示例中使用f1_score作为度量标准)。

>>> from sklearn.metrics import make_scorer
>>> c_gamma_range = [0.01, 0.1, 1.0, 10.0]
>>> param_grid = [{'svc__C': c_gamma_range,
...                'svc__kernel': ['linear']},
...               {'svc__C': c_gamma_range,
...                'svc__gamma': c_gamma_range,
...                'svc__kernel': ['rbf']}]
>>> scorer = make_scorer(f1_score, pos_label=0)
>>> gs = GridSearchCV(estimator=pipe_svc,
...                   param_grid=param_grid,
...                   scoring=scorer,
...                   cv=10)
>>> gs = gs.fit(X_train, y_train)
>>> print(gs.best_score_)
0.986202145696
>>> print(gs.best_params_)
{'svc__C': 10.0, 'svc__gamma': 0.01, 'svc__kernel': 'rbf'} 

绘制接收者操作特征图

接收者操作特征ROC)图是选择基于分类模型性能的有用工具,与 FPR 和 TPR 相关。这些指标是通过改变分类器的决策阈值计算得出的。ROC 图的对角线可解释为随机猜测,而落在对角线以下的分类模型被认为比随机猜测还差。完美的分类器会落在图的左上角,TPR 为 1,FPR 为 0。根据 ROC 曲线,我们可以计算所谓的ROC 曲线下面积ROC AUC),以描述分类模型的性能。

类似于 ROC 曲线,我们可以计算分类器不同概率阈值下的精确度-召回率曲线。在 scikit-learn 中,还实现了绘制这些精确度-召回率曲线的函数,并在scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html进行了文档化。

执行以下代码示例,我们将绘制一个 ROC 曲线,该曲线用于预测乳腺癌威斯康星数据集中的肿瘤是良性还是恶性,仅使用两个特征。尽管我们将使用先前定义的相同逻辑回归管道,但这次只使用两个特征。这样做是为了使分类任务对分类器更具挑战性,因为我们保留了其他特征中的有用信息,从而使结果的 ROC 曲线更加有趣。出于类似原因,我们还将StratifiedKFold验证器中的折数减少到三。代码如下:

>>> from sklearn.metrics import roc_curve, auc
>>> from numpy import interp
>>> pipe_lr = make_pipeline(
...     StandardScaler(),
...     PCA(n_components=2),
...     LogisticRegression(penalty='l2', random_state=1,
...                        solver='lbfgs', C=100.0)
... )
>>> X_train2 = X_train[:, [4, 14]]
>>> cv = list(StratifiedKFold(n_splits=3).split(X_train, y_train))
>>> fig = plt.figure(figsize=(7, 5))
>>> mean_tpr = 0.0
>>> mean_fpr = np.linspace(0, 1, 100)
>>> all_tpr = []
>>> for i, (train, test) in enumerate(cv):
...     probas = pipe_lr.fit(
...         X_train2[train],
...         y_train[train]
...     ).predict_proba(X_train2[test])
...     fpr, tpr, thresholds = roc_curve(y_train[test],
...                                      probas[:, 1],
...                                      pos_label=1)
...     mean_tpr += interp(mean_fpr, fpr, tpr)
...     mean_tpr[0] = 0.0
...     roc_auc = auc(fpr, tpr)
...     plt.plot(fpr,
...              tpr,
...              label=f'ROC fold {i+1} (area = {roc_auc:.2f})')
>>> plt.plot([0, 1],
...          [0, 1],
...          linestyle='--',
...          color=(0.6, 0.6, 0.6),
...          label='Random guessing (area=0.5)')
>>> mean_tpr /= len(cv)
>>> mean_tpr[-1] = 1.0
>>> mean_auc = auc(mean_fpr, mean_tpr)
>>> plt.plot(mean_fpr, mean_tpr, 'k--',
...          label=f'Mean ROC (area = {mean_auc:.2f})', lw=2)
>>> plt.plot([0, 0, 1],
...          [0, 1, 1],
...          linestyle=':',
...          color='black',
...          label='Perfect performance (area=1.0)')
>>> plt.xlim([-0.05, 1.05])
>>> plt.ylim([-0.05, 1.05])
>>> plt.xlabel('False positive rate')
>>> plt.ylabel('True positive rate')
>>> plt.legend(loc='lower right')
>>> plt.show() 

在前面的代码示例中,我们使用了 scikit-learn 中的已知StratifiedKFold类,并分别使用sklearn.metrics模块中的roc_curve函数计算了pipe_lr管道中LogisticRegression分类器的 ROC 性能,然后通过 SciPy 中的interp函数对三个折叠的平均 ROC 曲线进行了插值,并通过auc函数计算了曲线下面积。得到的 ROC 曲线表明,不同折叠之间存在一定的变化度,而平均 ROC AUC(0.76)介于完美分数(1.0)和随机猜测(0.5)之间。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.11:ROC 图

请注意,如果我们只对 ROC AUC 分数感兴趣,我们也可以直接从 sklearn.metrics 子模块中导入 roc_auc_score 函数,该函数可以类似于前面介绍的其他评分函数(例如 precision_score)进行使用。

报告分类器的性能作为 ROC AUC 可以进一步深入了解分类器对于不平衡样本的性能。然而,虽然准确度分数可以解释为 ROC 曲线上的单个截止点,A.P. Bradley 显示 ROC AUC 和准确度指标大部分情况下是一致的:机器学习算法评估中的 ROC 曲线下面积的使用,作者 A.P. Bradley,Pattern Recognition,30(7): 1145-1159,1997,reader.elsevier.com/reader/sd/pii/S0031320396001422

多类分类的评分指标

到目前为止,我们讨论的评分指标特定于二元分类系统。然而,scikit-learn 也通过一对所有(OvA)分类实现了宏平均和微平均方法,以将这些评分指标扩展到多类问题。微平均是从系统的各个 TP、TN、FP 和 FN 中计算出来的。例如,在 k 类系统中,精度得分的微平均可以计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

宏平均简单地计算为不同系统的平均得分:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Micro-averaging 很有用,如果我们想平等地加权每个实例或预测,而宏平均则权衡所有类别,以评估分类器在最常见的类别标签方面的整体性能。

如果我们在 scikit-learn 中使用二元性能指标来评估多类分类模型,通常默认使用加权宏平均的规范化或加权变体。加权宏平均通过在计算平均值时将每个类别标签的得分按真实实例的数量加权来计算。如果我们处理类别不平衡,即每个标签具有不同数量的实例,加权宏平均非常有用。

虽然在 scikit-learn 中,加权宏平均是多类问题的默认设置,但我们可以通过 average 参数在从 sklearn.metrics 模块导入的不同评分函数中指定平均方法,例如 precision_scoremake_scorer 函数:

>>> pre_scorer = make_scorer(score_func=precision_score,
...                          pos_label=1,
...                          greater_is_better=True,
...                          average='micro') 

处理类别不平衡

我们在本章节中多次提到了类别不平衡问题,但实际上并未讨论如何在发生这种情况时适当地处理。在处理真实世界数据时,类别不平衡是一个非常常见的问题——数据集中某一类或多个类的示例数目过多。我们可以想象几个领域可能会出现这种情况,例如垃圾邮件过滤、欺诈检测或疾病筛查。

想象一下,在本章节中我们使用的威斯康星州乳腺癌数据集中,90% 的患者是健康的。在这种情况下,我们可以通过只预测多数类别(良性肿瘤)来在测试数据集上达到 90% 的准确率,而不需要有监督的机器学习算法的帮助。因此,在这样的数据集上训练模型,使其在测试集上达到约 90% 的准确率,意味着我们的模型并未从提供的特征中学到有用的信息。

在本节中,我们将简要介绍一些技术,可以帮助处理不平衡的数据集。但在讨论解决这个问题的不同方法之前,让我们从我们的数据集中创建一个不平衡的数据集,该数据集最初由 357 例良性肿瘤(类别 0)和 212 例恶性肿瘤(类别 1)组成:

>>> X_imb = np.vstack((X[y == 0], X[y == 1][:40]))
>>> y_imb = np.hstack((y[y == 0], y[y == 1][:40])) 
0), we would achieve a prediction accuracy of approximately 90 percent:
>>> y_pred = np.zeros(y_imb.shape[0])
>>> np.mean(y_pred == y_imb) * 100
89.92443324937027 

因此,在我们对这类数据集拟合分类器时,与其比较不同模型的准确率,更有意义的是专注于其他指标,如精确度、召回率、ROC 曲线——这些指标与我们应用场景的关注点密切相关。例如,我们的首要任务可能是识别大多数患有恶性癌症的患者,以建议进行额外的筛查,因此召回率应成为我们选择的度量标准。在垃圾邮件过滤中,如果系统不确定时不希望将邮件标记为垃圾邮件,则精确度可能是更合适的度量标准。

除了评估机器学习模型外,类别不平衡还会影响模型拟合过程中的学习算法。由于机器学习算法通常优化一个奖励或损失函数,该函数是在拟合过程中对训练样本求和计算得到的,因此决策规则很可能会偏向于多数类别。

换句话说,算法隐式学习一个模型,该模型根据数据集中最丰富的类别优化预测,以在训练期间最小化损失或最大化奖励。

在模型拟合过程中处理类别不平衡的一种方法是对少数类别的错误预测赋予更大的惩罚。通过 scikit-learn,调整这种惩罚只需将 class_weight 参数设置为 class_weight='balanced',对于大多数分类器都已实现。

处理类别不平衡的其他流行策略包括上采样少数类、下采样多数类以及生成合成训练示例。不幸的是,并不存在适用于所有不同问题域的普遍最佳解决方案或技术。因此,在实践中,建议在给定问题上尝试不同的策略,评估结果,并选择最合适的技术。

scikit-learn 库实现了一个简单的 resample 函数,可以通过从数据集中有放回地抽取新样本来帮助上采样少数类。以下代码将从我们不平衡的 Breast Cancer Wisconsin 数据集中获取少数类(这里是类别 1),并重复地抽取新样本,直到它包含与类别 0 相同数量的示例:

>>> from sklearn.utils import resample
>>> print('Number of class 1 examples before:',
...       X_imb[y_imb == 1].shape[0])
Number of class 1 examples before: 40
>>> X_upsampled, y_upsampled = resample(
...         X_imb[y_imb == 1],
...         y_imb[y_imb == 1],
...         replace=True,
...         n_samples=X_imb[y_imb == 0].shape[0],
...         random_state=123)
>>> print('Number of class 1 examples after:',
...       X_upsampled.shape[0])
Number of class 1 examples after: 357 

在重采样之后,我们可以将原始的类别 0 样本与上采样的类别 1 子集堆叠,以获得一个平衡的数据集,如下所示:

>>> X_bal = np.vstack((X[y == 0], X_upsampled))
>>> y_bal = np.hstack((y[y == 0], y_upsampled)) 

因此,使用多数投票预测规则只能达到 50% 的准确率:

>>> y_pred = np.zeros(y_bal.shape[0])
>>> np.mean(y_pred == y_bal) * 100
50 

类似地,我们可以通过从数据集中移除训练示例来对多数类进行下采样。要使用 resample 函数执行下采样,我们可以简单地在前面的代码示例中交换类别 1 标签和类别 0,反之亦然。

生成新的训练数据以解决类别不平衡问题

处理类别不平衡的另一种技术是生成合成训练示例,这超出了本书的范围。可能是最广泛使用的合成训练数据生成算法是Synthetic Minority Over-sampling TechniqueSMOTE),您可以在Nitesh Chawla等人的原始研究文章SMOTE: Synthetic Minority Over-sampling TechniqueJournal of Artificial Intelligence Research,16: 321-357,2002 年中了解更多信息,链接在www.jair.org/index.php/jair/article/view/10302。同时强烈建议查看 imbalanced-learn,这是一个完全专注于不平衡数据集的 Python 库,包括 SMOTE 的实现。您可以在github.com/scikit-learn-contrib/imbalanced-learn了解更多关于 imbalanced-learn 的信息。

摘要

在本章开头,我们讨论了如何在便捷的模型管道中串联不同的转换技术和分类器,这帮助我们更有效地训练和评估机器学习模型。然后我们使用这些管道执行了 k 折交叉验证,这是模型选择和评估的基本技术之一。使用 k 折交叉验证,我们绘制了学习曲线和验证曲线,以诊断学习算法的常见问题,如过拟合和欠拟合。

我们进一步通过网格搜索、随机搜索和逐步缩减法对我们的模型进行了精细调整。然后,我们使用混淆矩阵和各种性能指标来评估和优化模型在特定问题任务中的性能。最后,我们讨论了处理不平衡数据的不同方法,这在许多现实世界的应用中是一个常见问题。现在,您应该已经掌握了构建成功的监督机器学习分类模型所需的基本技术。

在下一章中,我们将探讨集成方法:这些方法允许我们结合多个模型和分类算法,进一步提高机器学习系统的预测性能。

加入我们书籍的 Discord 空间

每月与作者进行问答活动的书籍 Discord 工作空间:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值