机器学习学习笔记之——数据表示与特征工程

数据表示与特征工程

到目前为止,我们一直假设数据是由浮点数组成的二维数组,其中每一列是描述点的连续特征continuous feature)。对于许多应用而言,数据的收集方式并不是这样的。一种特别常见的特征类型就是分类特征categorical feature),也叫离散特征discrete feature)。这种特征通常并不是数值。分类特征与连续特征之间的区别类似于分类和回归之间的区别,只是前者在输入端而不是输出端。我们已经见过的连续特征的例子包括像素明暗程度和花的尺寸测量。分类特征的例子包括产品的品牌、产品的颜色或产品的销售部门(图书、服装、硬件)。这些都是描述一件产品的属性,但它们不以连续的方式变化。一件产品要么属于服装部门,要么属于图书部门。在图书和服装之间没有中间部门,不同的分类之间也没有顺序(图书不大于服装也不小于服装,硬件不在图书和服装之间,等等)。

无论你的数据包含哪种类型的特征,数据表示方式都会对机器学习模型的性能产生巨大的影响。我们在之前的例子中看到,数据缩放很重要。换句话说,如果你没有缩放数据(比如,缩放到单位方差),那么你用厘米还是英寸表示测量数据的结果将会不同。用额外的特征扩充augment)数据也很有帮助,比如添加特征的交互项(乘积)或更一般的多项式

对于某种特定的应用来说,如何找到最佳数据表示,这个问题被称为特征工程feature engineering),它是数据科学家和机器学习从业者在尝试解决现实问题时的主要任务之一。用正确的方式表示数据,对监督模型性能的影响比所选择的精确参数还要大。

1、分类变量

作为例子,我们将使用美国成年人收入的数据集,该数据集是从 1994 年的普查数据库中导出的。adult 数据集的任务是预测一名工人的收入是高于 50 000 美元还是低于 50 000 美元。 这个数据集的特征包括工人的年龄、雇用方式(独立经营、私营企业员工、政府职员等)、 教育水平、性别、每周工作时长、职业,等等。

这个任务属于分类任务,两个类别是收入 <=50k 和 >50k也可以预测具体收入,那样就变成了一个回归任务。但那样问题将变得更加困难,而理解 50K 的分界线本身也很有趣。 在这个数据集中,age(年龄)和 hours-per-week(每周工作时长)是连续特征,我们知道 如何处理这种特征。但 workclass(工作类型)、education(教育程度)、gender(性别)、 occupation(职业)都是分类特征它们都来自一系列固定的可能取值(而不是一个范 围),表示的是定性属性(而不是数量)。 首先,假设我们想要在这个数据上学习一个 Logistic 回归分类器。Logistic 回归利用下列公式进行预测,预测值为 ŷ:
y ^ = w [ 0 ] ∗ x [ 0 ] + w [ 1 ] ∗ x [ 1 ] + . . . + w [ p ] ∗ x [ p ] + b > 0 \hat{y}=w[0]*x[0]+w[1]*x[1]+...+w[p]*x[p]+b>0 y^=w[0]x[0]+w[1]x[1]+...+w[p]x[p]+b>0
其中 w[i]b 是从训练集中学到的系数,x[i] 是输入特征。当 x[i] 是数字时这个公式才有意义,但如果 x[2] 是 “Masters” 或 “Bachelors” 的话,这个公式则没有意义。显然,在应用 Logistic 回归时,我们需要换一种方式来表示数据。下一节将会说明我们如何解决这一问题。

1.1、One-Hot编码(虚拟变量)

到目前为止,表示分类变量最常用的方法就是使用 one-hot 编码one-hot-encoding)或 N 取一编码one-out-of-N encoding),也叫虚拟变量dummy variable)。虚拟变量背后的思想是将一个分类变量替换为一个或多个新特征,新特征取值为 0 和 1。对于线性二分类 (以及 scikit-learn 中其他所有模型)的公式而言,0 和 1 这两个值是有意义的,我们可以像这样对每个类别引入一个新特征,从而表示任意数量的类别。

比如说,workclass 特征的可能取值包括 “Government Employee”、"Private Employee"、 “Self Employed” 和 “Self Employed Incorporated”。为了编码这 4 个可能的取值,我 们创建了 4 个新特征, 分别叫作 “Government Employee”、"Private Employee"、"Self Employed" 和 “Self Employed Incorporated”。如果一个人的 workclass 取某个值,那么对应的特征取值为 1,其他特征均取值为 0。因此,对每个数据点来说,4 个新特征中只有一个的取值为 1。这就是它叫作 one-hot 编码或 N 取一编码的原因。 其原理如下表所示。利用 4 个新特征对一个特征进行编码。在机器学习算法中使用此数据时,我们将会删除原始的 workclass 特征,仅保留 0-1 特征。

workclassGovernment EmployeePrivate EmployeeSelf EmployedSelf Employed Incorporated
Government Employee1000
Private Employee0100
Self Employed0010
Self Employed Incorporated0001

将数据转换为分类变量的 one-hot 编码有两种方法一种是使用 pandas,一种是使用 scikit-learn。使用 pandas 要稍微简单一些,所以我们选择这种方法。首先,我们使用 pandas 从逗号分隔值(CSV)文件中加载数据:

import pandas as pd
from IPython.display import display

# 文件中没有包含列名称的表头,因此我们传入header=None
# 然后在"names"中显式地提供列名称
data = pd.read_csv(
    "../data/adult.data", header=None, index_col=False,
    names=['age', 'workclass', 'fnlwgt', 'education', 'education-num',
    'marital-status', 'occupation', 'relationship', 'race', 'gender',
    'capital-gain', 'capital-loss', 'hours-per-week', 'native-country',
    'income'])
# 为了便于说明,我们只选了其中几列
data = data[['age', 'workclass', 'education', 'gender', 'hours-per-week',
             'occupation', 'income']]

# IPython.display可以在Jupyter notebook中输出漂亮的格式
print(data.head())
'''
   age          workclass   ...            occupation  income
0   39          State-gov   ...          Adm-clerical   <=50K
1   50   Self-emp-not-inc   ...       Exec-managerial   <=50K
2   38            Private   ...     Handlers-cleaners   <=50K
3   53            Private   ...     Handlers-cleaners   <=50K
4   28            Private   ...        Prof-specialty   <=50K

[5 rows x 7 columns]
'''
  1. 检查字符串编码的分类数据

    读取完这样的数据集后,最好先检查每一列是否包含有意义的分类数据。在处理人工(比如网站用户)输入的数据时,可能没有固定的类别,拼写和大小写也存在差异,因此可能需要预处理。举个例子,有人可能将性别填为 “male”(男性),有人可能填为 “man”(男人),而我们希望能用同一个类别来表示这两种输入。检查列的内容有一个好办法,就是使用 pandas Series(Series 是 DataFrame 中单列对应的数据类型)的 value_counts 函数,以显示唯一值及出现次数:

    print(data.gender.value_counts())
    '''
     Male      21790
     Female    10771
    Name: gender, dtype: int64
    '''
    

    可以看到,在这个数据集中性别刚好有两个值:MaleFemale,这说明数据格式已经很好,可以用 one-hot 编码来表示。在实际的应用中,你应该查看并检查所有列的值。为简洁起见,这里我们将跳过这一步。

    pandas 编码数据有一个非常简单的方法,就是使用 get_dummies 函数。get_dummies 函数自动变换所有具有对象类型(比如字符串)的列或所有分类的列(这是 pandas 中的一个特殊概念):

    print("Original features:\n", list(data.columns),"\n")
    data_dummies = pd.get_dummies(data)
    print("Features after get_dummies:\n", list(data_dummies.columns))
    '''
    Original features:
     ['age', 'workclass', 'education', 'gender', 'hours-per-week', 'occupation', 'income'] 
    
    Features after get_dummies:
     ['age', 'hours-per-week', 'workclass_ ?', 'workclass_ Federal-gov', 'workclass_ Local-gov', 'workclass_ Never-worked', 'workclass_ Private', 'workclass_ Self-emp-inc', 'workclass_ Self-emp-not-inc', 'workclass_ State-gov', 'workclass_ Without-pay', 'education_ 10th', 'education_ 11th', 'education_ 12th', 'education_ 1st-4th', 'education_ 5th-6th', 'education_ 7th-8th', 'education_ 9th', 'education_ Assoc-acdm', 'education_ Assoc-voc', 'education_ Bachelors', 'education_ Doctorate', 'education_ HS-grad', 'education_ Masters', 'education_ Preschool', 'education_ Prof-school', 'education_ Some-college', 'gender_ Female', 'gender_ Male', 'occupation_ ?', 'occupation_ Adm-clerical', 'occupation_ Armed-Forces', 'occupation_ Craft-repair', 'occupation_ Exec-managerial', 'occupation_ Farming-fishing', 'occupation_ Handlers-cleaners', 'occupation_ Machine-op-inspct', 'occupation_ Other-service', 'occupation_ Priv-house-serv', 'occupation_ Prof-specialty', 'occupation_ Protective-serv', 'occupation_ Sales', 'occupation_ Tech-support', 'occupation_ Transport-moving', 'income_ <=50K', 'income_ >50K']
    '''
    

    你可以看到,连续特征 agehours-per-week 没有发生变化,而分类特征的每个可能取值都被扩展为一个新特征

    print(data_dummies.head())
    '''
       age  hours-per-week      ...       income_ <=50K  income_ >50K
    0   39              40      ...                   1             0
    1   50              13      ...                   1             0
    2   38              40      ...                   1             0
    3   53              40      ...                   1             0
    4   28              40      ...                   1             0
    
    [5 rows x 46 columns]
    '''
    

    下面我们可以使用 values 属性将 data_dummies 数据框(DataFrame)转换为 NumPy 数组,然后在其上训练一个机器学习模型。在训练模型之前,注意要把目标变量(现在编码为两个 income 列)从数据中分离出来将输出变量或输出变量的一些导出属性包含在特征表示中,这是构建监督学习模型时一个非常常见的错误

    在这个例子中,我们仅提取包含特征的列,也就是从 ageoccupation_ Transport-moving 的所有列。这一范围包含所有特征,但不包含目标:

    features = data_dummies.ix[:, 'age':'occupation_ Transport-moving']
    # 提取 NumPy 数组
    X = features.values
    y = data_dummies['income_ >50K'].values
    print("X.shape: {}  y.shape: {}".format(X.shape, y.shape))
    # X.shape: (32561, 44)  y.shape: (32561,)
    

    现在数据的表示方式可以被 scikit-learn 处理,我们可以像之前那样继续下一步:

    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)
    logreg = LogisticRegression()
    logreg.fit(X_train, y_train)
    print("Test score: {:.2f}".format(logreg.score(X_test, y_test)))
    # Test score: 0.81
    

    在这个例子中,我们对同时包含训练数据和测试数据的数据框调用 get_dummies。这一点很重要,可以确保训练集和测试集分类变量的表示方式相同。

    假设我们的训练集和测试集位于两个不同的数据框中。如果我们在这个数据上构建机器学习模型,那么它的表现会很差,因为它认为每一列表示的是相同的内容(因为位置相同),而实际上表示的却是非常不同的内容。要想解决这个问题,可以在同时包含训练数据点和测试数据点的数据框上调用 get_dummies,也可以确保调用 get_dummies 后训练集和测试集的列名称相同,以保证它们具有相同的语义。

1.2、数字可以编码分类变量

adult 数据集的例子中,分类变量被编码为字符串。一方面,可能会有拼写错误;但另一方面,它明确地将一个变量标记为分类变量。无论是为了便于存储还是因为数据的收集方式,分类变量通常被编码为整数。例如,假设 adult 数据集中的人口普查数据是利用问卷收集IDEworkclass 的回答被记录为 0(在第一个框打钩)、1(在第二个框打钩)、2(在第三个框打钩),等等。现在该列包含数字 0 到 8,而不是像 “Private” 这样的字符串。如果有人观察表示数据集的表格,很难一眼看出这个变量应该视为连续变量还是分类变量。但是,如果知道这些数字表示的是就业状况,那么很明显它们是不同的状态,不应该用单个连续变量来建模。

分类特征通常用整数进行编码。它们是数字并不意味着它们必须被视为连续特征。一个整数特征应该被视为连续的还是离散的(one-hot 编码),有时并不明确。如果在被编码的语义之间没有顺序关系(比如 workclass 的例子),那么特征必须被视为离散特征。对于其他情况(比如五星好评),哪种编码更好取决于具体的任务和数据,以及使用哪种机器学习算法。

pandasget_dummies 函数将所有数字看作是连续的,不会为其创建虚拟变量。为了解决这个问题,你可以使用 scikit-learnOneHotEncoder,指定哪些变量是连续的、哪些变量是离散的,你也可以将数据框中的数值列转换为字符串。为了说明这一点,我们创建一个两列的 DataFrame 对象,其中一列包含字符串,另一列包含整数:

# 创建一个 DataFrame,包含一个整数特征和一个分类字符串特征
demo_df = pd.DataFrame({'Integer Feature':[0, 1, 2, 1],
                        'Categorical Feature':['socks', 'fox', 'socks', 'box']})
print(demo_df)
'''
   Integer Feature Categorical Feature
0                0               socks
1                1                 fox
2                2               socks
3                1                 box
'''

使用 get_dummies 只会编码字符串特征,不会改变整数特征

print(pd.get_dummies(demo_df))
'''
   Integer Feature            ...              Categorical Feature_socks
0                0            ...                                      1
1                1            ...                                      0
2                2            ...                                      1
3                1            ...                                      0
'''

如果你想要为 “Integer Feature” 这一列创建虚拟变量,可以使用 columns 参数显式地给出想要编码的列。于是两个特征都会被当作分类特征处理

demo_df['Integer Feature'] = demo_df['Integer Feature'].astype(str)
print(pd.get_dummies(demo_df, columns=['Integer Feature', 'Categorical Feature']))
'''
   Integer Feature_0            ...              Categorical Feature_socks
0                  1            ...                                      1
1                  0            ...                                      0
2                  0            ...                                      1
3                  0            ...                                      0

[4 rows x 6 columns]
'''

2、分箱、离散化、线性模型与树

数据表示的最佳方法不仅取决于数据的语义,还取决于所使用的模型种类。线性模型与基于树的模型(比如决策树、梯度提升树和随机森林)是两种成员很多同时又非常常用的模型,它们在处理不同的特征表示时就具有非常不同的性质。wave 回归数据集,它只有一个输入特征。下面是线性回归模型与决策树回归在这个数据集上的对比。

正如你所知,线性模型只能对线性关系建模,对于单个特征的情况就是直线。决策树可以构建更为复杂的数据模型,但这强烈依赖于数据表示。有一种方法可以让线性模型在连续数据上变得更加强大,就是使用特征分箱binning,也叫离散化,即 discretization)将其划分为多个特征,如下所述。

我们假设将特征的输入范围(在这个例子中是从 -3 到 3)划分成固定个数的箱子(bin,比如 10 个,那么数据点就可以用它所在的箱子来表示。为了明确这一点,我们首先需要定义箱子。在这个例子中,我们在 -3 和 3 之间定义 10 个均匀分布的箱子。我们用 np.linspace 函数创建 11 个元素,从而创建 10 个箱子,即两个连续边界之间的空间:

bins = np.linspace(-3, 3, 11)
print("bins: {}".format(bins))
# bins: [-3.  -2.4 -1.8 -1.2 -0.6  0.   0.6  1.2  1.8  2.4  3. ]

这里第一个箱子包含特征取值在 -3 到 -2.4 之间的所有数据点,第二个箱子包含特征取值在 -2.4 到 -1.8 之间的所有数据点,以此类推。

接下来,我们记录每个数据点所属的箱子。这可以用 np.digitize 函数轻松计算出来:

which_bin = np.digitize(X, bins=bins)
print("\nData points:\n", X[:5])
'''
Data points:
 [[-0.75275929]
 [ 2.70428584]
 [ 1.39196365]
 [ 0.59195091]
 [-2.06388816]]
'''
print("\nBin membership for data points:\n", which_bin[:5])
'''
Bin membership for data points:
 [[ 4]
 [10]
 [ 8]
 [ 6]
 [ 2]]
'''

我们在这里做的是将 wave 数据集中单个连续输入特征变换为一个分类特征,用于表示数据点所在的箱子。要想在这个数据上使用 scikit-learn 模型,我们利用 preprocessing 模块的 OneHotEncoder 将这个离散特征变换为 one-hot 编码。OneHotEncoder 实现的编码与 pandas.get_dummies 相同,但目前它只适用于值为整数的分类变量。

from sklearn.preprocessing import OneHotEncoder
# 使用OneHotEncoder进行变换
encoder = OneHotEncoder(sparse=False)
# encoder.fit找到which_bin中唯一值
encoder.fit(which_bin)
# transform创建one-hot编码
X_binned = encoder.transform(which_bin)
print(X_binned[:5])
'''
[[0. 0. 0. 1. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]
 [0. 0. 0. 0. 0. 0. 0. 1. 0. 0.]
 [0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
 [0. 1. 0. 0. 0. 0. 0. 0. 0. 0.]]
'''

我们指定了 10 个箱子,所以变换后的 X_binned 数据集现在包含 10 个特征:

print("X_binned.shape: {}".format(X_binned.shape))
# X_binned.shape: (120, 10)

现在我们在 one-hot 编码后的数据上构建新的线性模型和新的决策树模型。结果如下,箱子的边界由黑色虚线表示:

line_binned = encoder.transform(np.digitize(line, bins=bins))

reg = LinearRegression().fit(X_binned, y)
plt.plot(line, reg.predict(line_binned), label='linear regression binned')

reg = DecisionTreeRegressor(min_samples_split=3).fit(X_binned, y)
plt.plot(line, reg.predict(line_binned), label='decision tree binned')
plt.plot(X[:, 0], y, 'o', c='k')
plt.vlines(bins, -3, 3, linewidth=1, alpha=.2)
plt.legend(loc="best")
plt.ylabel("Regression output")
plt.xlabel("Input feature")

虚线和实线完全重合,说明线性回归模型和决策树做出了完全相同的预测。对于每个箱子,二者都预测一个常数值。因为每个箱子内的特征是不变的,所以对于一个箱子内的所有点,任何模型都会预测相同的值。比较对特征进行分箱前后模型学到的内容,我们发现,线性模型变得更加灵活了,因为现在它对每个箱子具有不同的取值,而决策树模型的灵活性降低了。分箱特征对基于树的模型通常不会产生更好的效果,因为这种模型可以学习在任何位置划分数据。从某种意义上来看,决策树可以学习如何分箱预测这些数据最为有用。此外,决策树可以同时查看多个特征,而分箱通常针对的是单个特征。不过,线性模型的表现力在数据变换后得到了极大的提高。

对于特定的数据集,如果有充分的理由使用线性模型——比如数据集很大、维度很高,但有些特征与输出的关系是非线性的——那么分箱是提高建模能力的好办法


3、交互特征与多项式特征

想要丰富特征表示,特别是对于线性模型而言,另一种方法是添加原始数据的交互特征interaction feature)和多项式特征polynomial feature)。这种特征工程通常用于统计建模,但也常用于许多实际的机器学习应用中。

作为第一个例子,我们再看看上图。线性模型对 wave 数据集中的每个箱子都学到一个常数值。但我们知道,线性模型不仅可以学习偏移,还可以学习斜率。想要向分箱数据上的线性模型添加斜率,一种方法是重新加入原始特征(图中的 x 轴)。这样会得到 11 维的数据集。如下图:

X_combined = np.hstack([X, X_binned])
print(X_combined.shape)
# (120, 11)
reg = LinearRegression().fit(X_combined, y)

line_combined = np.hstack([line, line_binned])
plt.plot(line, reg.predict(line_combined), label='linear regression combined')

for bin in bins:
    plt.plot([bin, bin], [-3, 3], ":", c='k')

plt.legend(loc="best")
plt.ylabel("Regression output")
plt.xlabel("Input feature")
plt.plot(X[:, 0], y, 'o', c='k')

在这个例子中,模型在每个箱子中学到了一个偏移,还学到了一个斜率。学到的斜率是向下的,并且在所有箱子中都相同——只有一个 x 轴特征,也就只有一个斜率。因为斜率在所有箱子中是相同的,所以它似乎不是很有用。**我们希望每个箱子都有一个不同的斜率!**为了实现这一点,我们可以添加交互特征或乘积特征,用来表示数据点所在的箱子以及数据点的 x 轴上的位置。这个特征是箱子指示符与原始特征的乘积。我们来创建数据集:

X_product = np.hstack([X_binned, X * X_binned])
print(X_product.shape)
# (120, 20)

这个数据集现在有 20 个特征:数据点所在箱子的指示符与原始特征指示符的乘积你可以将乘积特征看作每个箱子 x 轴特征的单独副本它在箱子内等于原始特征,在其他位置等于零。下图展示了线性模型在这种新表示上的结果:

reg = LinearRegression().fit(X_product, y)

line_product = np.hstack([line_binned, line * line_binned])
plt.plot(line, reg.predict(line_product), label='linear regression product')

for bin in bins:
    plt.plot([bin, bin], [-3, 3], ":", c='k')

plt.plot(X[:, 0], y, 'o', c='k')
plt.ylabel("Regression output")
plt.xlabel("Input feature")
plt.legend(loc="best")

如你所见,现在这个模型中每个箱子都有自己的偏移和斜率

使用分箱是扩展连续特征的一种方法另一种方法是使用原始特征的多项式(polynomial。对于给定特征 x,我们可以考虑 x**2、x**3 等等。这在 preprocessing 模块中 PolynomialFeatures 中实现:

from sklearn.preprocessing import PolynomialFeatures

# 包含直到 x ** 10 的多项式:
# 默认的 "include_bias=True" 添加恒等于 1 的常数特征
poly = PolynomialFeatures(degree=10, include_bias=False)
poly.fit(X)
X_poly = poly.transform(X)

多项式的次数为 10,因此生成了 10 个特征:

print("X_poly.shape: {}".format(X_poly.shape))
# X_poly.shape: (120, 10)

我们比较 X_poly 和 X 的元素:

print("Entries of X:\n{}".format(X[:5]))
'''
Entries of X:
[[-0.75275929]
 [ 2.70428584]
 [ 1.39196365]
 [ 0.59195091]
 [-2.06388816]]
'''
print("Entries of X_poly:\n{}".format(X_poly[:5]))
'''
Entries of X_poly:
[[-7.52759287e-01  5.66646544e-01 -4.26548448e-01  3.21088306e-01
  -2.41702204e-01  1.81943579e-01 -1.36959719e-01  1.03097700e-01
  -7.76077513e-02  5.84199555e-02]
 [ 2.70428584e+00  7.31316190e+00  1.97768801e+01  5.34823369e+01
   1.44631526e+02  3.91124988e+02  1.05771377e+03  2.86036036e+03
   7.73523202e+03  2.09182784e+04]
 [ 1.39196365e+00  1.93756281e+00  2.69701700e+00  3.75414962e+00
   5.22563982e+00  7.27390068e+00  1.01250053e+01  1.40936394e+01
   1.96178338e+01  2.73073115e+01]
 [ 5.91950905e-01  3.50405874e-01  2.07423074e-01  1.22784277e-01
   7.26822637e-02  4.30243318e-02  2.54682921e-02  1.50759786e-02
   8.92423917e-03  5.28271146e-03]
 [-2.06388816e+00  4.25963433e+00 -8.79140884e+00  1.81444846e+01
  -3.74481869e+01  7.72888694e+01 -1.59515582e+02  3.29222321e+02
  -6.79478050e+02  1.40236670e+03]]
'''

你可以通过调用 get_feature_names 方法来获取特征的语义,给出每个特征的指数:

print("Polynomial feature names:\n{}".format(poly.get_feature_names()))
'''
Polynomial feature names:
['x0', 'x0^2', 'x0^3', 'x0^4', 'x0^5', 'x0^6', 'x0^7', 'x0^8', 'x0^9', 'x0^10']
'''

你可以看到,X_poly 的第一列与 X 完全对应,而其他列则是第一列的幂。有趣的是,你可以发现有些值非常大。第二行有大于 20000 的元素,数量级与其他行都不相同。

将多项式特征与线性回归模型一起使用,可以得到经典的多项式回归polynomial regression)模型:

reg = LinearRegression().fit(X_poly, y)

line_poly = poly.transform(line)
plt.plot(line, reg.predict(line_poly), label='polynomial linear regression')
plt.plot(X[:, 0], y, 'o', c='k')
plt.ylabel("Regression output")
plt.xlabel("Input feature")
plt.legend(loc="best")

如你所见,多项式特征在这个一维数据上得到了非常平滑的拟合。但高次多项式在边界上或数据很少的区域可能有极端的表现。

作为对比,下面是在原始数据上学到的核 SVM 模型,没有做任何变换:

from sklearn.svm import SVR

for gamma in [1, 10]:
    svr = SVR(gamma=gamma).fit(X, y)
    plt.plot(line, svr.predict(line), label='SVR gamma={}'.format(gamma))

plt.plot(X[:, 0], y, 'o', c='k')
plt.ylabel("Regression output")
plt.xlabel("Input feature")
plt.legend(loc="best")

使用更加复杂的模型(即核 SVM),我们能够学到一个与多项式回归的复杂度类似的预测结果,且不需要进行显式的特征变换

我们再次观察波士顿房价数据集,作为对交互特征和多项式特征更加实际的应用。我们在前面已经在这个数据集上使用过多项式特征了。现在来看一下这些特征的构造方式,以及多项式特征的帮助有多大。首先加载数据,然后利用 MinMaxScaler 将其缩放到 0 和 1 之间:

from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler

boston = load_boston()
X_train, X_test, y_train, y_test = train_test_split(
    boston.data, boston.target, random_state=0)

# 缩放数据
scaler = MinMaxScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

下面我们提取多项式特征和交互式特征,次数最高为 2:

poly = PolynomialFeatures(degree=2).fit(X_train_scaled)
X_train_poly = poly.transform(X_train_scaled)
X_test_poly = poly.transform(X_test_scaled)
print("X_train.shape: {}".format(X_train.shape))
# X_train.shape: (379, 13)
print("X_train_poly.shape: {}".format(X_train_poly.shape))
# X_train_poly.shape: (379, 105)

原始数据有 13 个特征,现在被扩展到 105 个交互特征。这些新特征表示两个不同的原始特征之间所有可能的交互项,以及每个原始特征的平方。这里 degree=2 的意思是,我们需要由最多两个原始特征的乘积组成的所有特征。利用 get_feature_names 方法可以得到输 入特征和输出特征之间的确切对应关系:

print("Polynomial feature names:\n{}".format(poly.get_feature_names()))
'''

['1', 'x0', 'x1', 'x2', 'x3', 'x4', 'x5', 'x6', 'x7', 'x8', 'x9', 'x10', 'x11', 'x12', 'x0^2', 'x0 x1', 'x0 x2', 'x0 x3', 'x0 x4', 'x0 x5', 'x0 x6', 'x0 x7', 'x0 x8', 'x0 x9', 'x0 x10', 'x0 x11', 'x0 x12', 'x1^2', 'x1 x2', 'x1 x3', 'x1 x4', 'x1 x5', 'x1 x6', 'x1 x7', 'x1 x8', 'x1 x9', 'x1 x10', 'x1 x11', 'x1 x12', 'x2^2', 'x2 x3', 'x2 x4', 'x2 x5', 'x2 x6', 'x2 x7', 'x2 x8', 'x2 x9', 'x2 x10', 'x2 x11', 'x2 x12', 'x3^2', 'x3 x4', 'x3 x5', 'x3 x6', 'x3 x7', 'x3 x8', 'x3 x9', 'x3 x10', 'x3 x11', 'x3 x12', 'x4^2', 'x4 x5', 'x4 x6', 'x4 x7', 'x4 x8', 'x4 x9', 'x4 x10', 'x4 x11', 'x4 x12', 'x5^2', 'x5 x6', 'x5 x7', 'x5 x8', 'x5 x9', 'x5 x10', 'x5 x11', 'x5 x12', 'x6^2', 'x6 x7', 'x6 x8', 'x6 x9', 'x6 x10', 'x6 x11', 'x6 x12', 'x7^2', 'x7 x8', 'x7 x9', 'x7 x10', 'x7 x11', 'x7 x12', 'x8^2', 'x8 x9', 'x8 x10', 'x8 x11', 'x8 x12', 'x9^2', 'x9 x10', 'x9 x11', 'x9 x12', 'x10^2', 'x10 x11', 'x10 x12', 'x11^2', 'x11 x12', 'x12^2']
'''

第一个新特征是常数特征,这里的名称是 “1”。接下来的 13 个特征是原始特征(名称从 “x0” 到 “x12”)。然后是第一个特征的平方(“x0^2”)以及它与其他特征的组合。

我们对 Ridge 在有交互特征的数据上和没有交互特征的数据上的性能进行对比:

from sklearn.linear_model import Ridge
ridge = Ridge().fit(X_train_scaled, y_train)
print("Score without interactions: {:.3f}".format(
    ridge.score(X_test_scaled, y_test)))
# Score without interactions: 0.621

ridge = Ridge().fit(X_train_poly, y_train)
print("Score with interactions: {:.3f}".format(
    ridge.score(X_test_poly, y_test)))
# Score with interactions: 0.753

显然,在使用 Ridge 时,交互特征和多项式特征对性能有很大的提升。但如果使用更加复 杂的模型(比如随机森林),情况会稍有不同:

from sklearn.ensemble import RandomForestRegressor
rf = RandomForestRegressor(n_estimators=100).fit(X_train_scaled, y_train)
print("Score without interactions: {:.3f}".format(
    rf.score(X_test_scaled, y_test)))
# Score without interactions: 0.809

rf = RandomForestRegressor(n_estimators=100).fit(X_train_poly, y_train)
print("Score with interactions: {:.3f}".format(rf.score(X_test_poly, y_test)))
# Score with interactions: 0.766

你可以看到,即使没有额外的特征,随机森林的性能也要优于 Ridge。添加交互特征和多项式特征实际上会略微降低其性能


4、单变量非线性变换

我们刚刚看到,添加特征的平方或立方可以改进线性回归模型。其他变换通常也对变换某些特征有用,特别是应用数学函数,比如 logexpsin虽然基于树的模型只关注特征的顺序,但线性模型和神经网络依赖于每个特征的尺度和分布。如果在特征和目标之间存在非线性关系,那么建模就变得非常困难,特别是对于回归问题。logexp 函数可以帮助调节数据的相对比例,从而改进线性模型或神经网络的学习效果。我们在前面中对内存价格数据应用过这种函数。在处理具有周期性模式的数据时,sincos 函数非常有用

大部分模型都在每个特征(在回归问题中还包括目标值)大致遵循高斯分布时表现最好, 也就是说,每个特征的直方图应该具有类似于熟悉的 “钟形曲线” 的形状。使用诸如 logexp 之类的变换并不稀奇,但却是实现这一点的简单又有效的方法。在一种特别常见的情况下,这样的变换非常有用,就是处理整数计数数据时。计数数据是指类似 “用户 A 多长时间登录一次?” 这样的特征。计数不可能取负值,并且通常遵循特定的统计模式。下面我们使用一个模拟的计数数据集,其性质与在自然状态下能找到的数据集类似。特征全都是整数值,而响应是连续的:

rnd = np.random.RandomState(0)
X_org = rnd.normal(size=(1000, 3))
w = rnd.normal(size=3)

X = rnd.poisson(10 * np.exp(X_org))
y = np.dot(X_org, w)

我们来看一下第一个特征的前 10 个元素。它们都是正整数,但除此之外很难找出特定的模式。

如果我们计算每个值的出现次数,那么数值的分布将变得更清楚:

print("Number of feature appearances:\n{}".format(np.bincount(X[:, 0])))
'''
Number of feature appearances:
[28 38 68 48 61 59 45 56 37 40 35 34 36 26 23 26 27 21 23 23 18 21 10  9
 17  9  7 14 12  7  3  8  4  5  5  3  4  2  4  1  1  3  2  5  3  8  2  5
  2  1  2  3  3  2  2  3  3  0  1  2  1  0  0  3  1  0  0  0  1  3  0  1
  0  2  0  1  1  0  0  0  0  1  0  0  2  2  0  1  1  0  0  0  0  1  1  0
  0  0  0  0  0  0  1  0  0  0  0  0  1  1  0  0  1  0  0  0  0  0  0  0
  1  0  0  0  0  1  0  0  0  0  0  0  0  0  0  0  0  0  0  0  1]
'''

数字 2 似乎是最常见的,共出现了 68 次(bincount 始终从 0 开始),更大数字的出现次数快速下降。但也有一些很大的数字,比如 134 出现了 2 次 。我们在下图中将计数可视化。

bins = np.bincount(X[:, 0])
plt.bar(range(len(bins)), bins, color='grey')
plt.ylabel("Number of appearances")
plt.xlabel("Value")

特征 X[:, 1] 和 X[:, 2] 具有类似的性质。这种类型的数值分布(许多较小的值和一些非常大的值)在实践中非常常见。但大多数线性模型无法很好地处理这种数据。我们尝试拟合一个岭回归模型:

from sklearn.linear_model import Ridge
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)
score = Ridge().fit(X_train, y_train).score(X_test, y_test)
print("Test score: {:.3f}".format(score))
# Test score: 0.622

你可以从相对较小的 R2 分数中看出,Ridge 无法真正捕捉到 X 和 y 之间的关系。不过应用 对数变换可能有用。由于数据取值中包括 0(对数在 0 处没有定义),所以我们不能直接应 用 log,而是要计算 log(X + 1)

X_train_log = np.log(X_train + 1)
X_test_log = np.log(X_test + 1)

变换之后,数据分布的不对称性变小,也不再有非常大的异常值:

plt.hist(X_train_log[:, 0], bins=25, color='gray')
plt.ylabel("Number of appearances")
plt.xlabel("Value")

在新数据上构建一个岭回归模型,可以得到更好的拟合:

score = Ridge().fit(X_train_log, y_train).score(X_test_log, y_test)
print("Test score: {:.3f}".format(score))
# Test score: 0.875

为数据集和模型的所有组合寻找最佳变换,这在某种程度上是一门艺术。在这个例子中, 所有特征都具有相同的性质,这在实践中是非常少见的情况。通常来说,只有一部分特征 应该进行变换,有时每个特征的变换方式也各不相同。前面提到过,对基于树的模型而言,这种变换并不重要,但对线性模型来说可能至关重要。对回归的目标变量 y 进行变换有时也是一个好主意。尝试预测计数(比如订单数量)是一项相当常见的任务,而且使用 log(y + 1) 变换也往往有用。

从前面的例子中可以看出,分箱、多项式和交互项都对模型在给定数据集上的性能有很大影响,对于复杂度较低的模型更是这样,比如线性模型和朴素贝叶斯模型。与之相反,基于树的模型通常能够自己发现重要的交互项,大多数情况下不需要显式地变换数据。其他模型,比如 SVM、最近邻和神经网络,有时可能会从使用分箱、交互项或多项式中受益, 但其效果通常不如线性模型那么明显。


5、自动化特征选择

有了这么多种创建新特征的方法,你可能会想要增大数据的维度,使其远大于原始特征的数量。但是,添加更多特征会使所有模型变得更加复杂,从而增大过拟合的可能性。在添加新特征或处理一般的高维数据集时,最好将特征的数量减少到只包含最有用的那些特 征,并删除其余特征。这样会得到泛化能力更好、更简单的模型。但你如何判断每个特征的作用有多大呢?有三种基本的策略:单变量统计univariate statistics)、基于模型的选择model-based selection)和迭代选择iterative selection)。我们将详细讨论这三种策略。所有这些方法都是监督方法,即它们需要目标值来拟合模型。这也就是说,我们需要将数据划分为训练集和测试集,并只在训练集上拟合特征选择。

5.1、单变量统计

单变量统计中,我们计算每个特征和目标值之间的关系是否存在统计显著性,然后选择具有最高置信度的特征。对于分类问题,这也被称为方差分析analysis of varianceANOVA)。这些测试的一个关键性质就是它们是单变量的univariate),即它们只单独考虑每个特征。因此,如果一个特征只有在与另一个特征合并时才具有信息量,那么这个特征将被舍弃。单变量测试的计算速度通常很快,并且不需要构建模型。另一方面,它们完全独立于你可能想要在特征选择之后应用的模型。

想要在 scikit-learn 中使用单变量特征选择,你需要选择一项测试——对分类问题通常是 f_classif(默认值),对回归问题通常是 f_regression——然后基于测试中确定的 p 值 来选择一种舍弃特征的方法所有舍弃参数的方法都使用阈值来舍弃所有 p 值过大的特征 (意味着它们不可能与目标值相关)计算阈值的方法各有不同,最简单的是 SelectKBestSelectPercentile,前者选择固定数量的 k 个特征,后者选择固定百分比的特征。我们 将分类的特征选择应用于 cancer 数据集。为了使任务更难一点,我们将向数据中添加一些没有信息量的噪声特征。我们期望特征选择能能够识别没有信息量的特征并删除它们:

from sklearn.datasets import load_breast_cancer
from sklearn.feature_selection import SelectPercentile
from sklearn.model_selection import train_test_split

cancer = load_breast_cancer()

# 获得确定性的随机数
rng = np.random.RandomState(42)
noise = rng.normal(size=(len(cancer.data), 50))
# 向数据中添加噪声特征
# 前 30 个特征来自数据集,后 50 个是噪声
X_w_noise = np.hstack([cancer.data, noise])

X_train, X_test, y_train, y_test = train_test_split(
    X_w_noise, cancer.target, random_state=0, test_size=.5)
# 使用 f_classif (默认值) 和 SelectPercentile 来选择 50% 的特征
select = SelectPercentile(percentile=50)
select.fit(X_train, y_train)
# 对训练集进行变换
X_train_selected = select.transform(X_train)

print("X_train.shape: {}".format(X_train.shape))
# X_train.shape: (284, 80)
print("X_train_selected.shape: {}".format(X_train_selected.shape))
# X_train_selected.shape: (284, 40)

如你所见,特征的数量从 80 减少到 40(原始特征数量的 50%)。我们可以用 get_ support 方法来查看哪些特征被选中,它会返回所选特征的布尔遮罩(mask)(其可视化见下图):

mask = select.get_support()
print(mask)
'''
[ True  True  True  True  True  True  True  True  True False  True False
  True  True  True  True  True  True False False  True  True  True  True
  True  True  True  True  True  True False False False  True False  True
 False False  True False False False False  True False False  True False
 False  True False  True False False False False False False  True False
  True False False False False  True False  True False False False False
  True  True False  True False False False False]
'''
# 将遮罩可视化——黑色为 True,白色为 False
plt.matshow(mask.reshape(1, -1), cmap='gray_r')
plt.xlabel("Sample index")
plt.yticks(())

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iRfbEk1v-1608883875192)(素材/SelectPercentile选择的特征.png)]

你可以从遮罩的可视化中看出,大多数所选择的特征都是原始特征,并且大多数噪声特征都已被删除。但原始特征的还原并不完美。我们来比较 Logistic 回归在所有特征上的性能与仅使用所选特征的性能:

from sklearn.linear_model import LogisticRegression

# 对测试集数据进行变换
X_test_selected = select.transform(X_test)

lr = LogisticRegression(max_iter=1000)
lr.fit(X_train, y_train)
print("Score with all features: {:.3f}".format(lr.score(X_test, y_test)))
# Score with all features: 0.930

lr.fit(X_train_selected, y_train)
print("Score with only selected features: {:.3f}".format(lr.score(X_test_selected, y_test)))
# Score with only selected features: 0.937

在这个例子中,删除噪声特征可以提高性能,即使丢失了某些原始特征。这是一个非常简单的假想示例,在真实数据上的结果要更加复杂。不过,如果特征量太大以至于无法构建模型,或者你怀疑许多特征完全没有信息量,那么单变量特征选择还是非常有用的

5.2、基于模型的特征选择

基于模型的特征选择使用一个监督机器学习模型来判断每个特征的重要性,并且仅保留最重要的特征。用于特征选择的监督模型不需要与用于最终监督建模的模型相同特征选择模型需要为每个特征提供某种重要性度量,以便用这个度量对特征进行排序。决策树和基于决策树的模型提供了 feature_importances_ 属性,可以直接编码每个特征的重要性。线性模型系数的绝对值也可以用于表示特征重要性。正如我们在《监督学习》所见,L1 惩罚的线性模型学到的是稀疏系数,它只用到了特征的一个很小的子集这可以被视为模型本身的一种特征选择形式,但也可以用作另一个模型选择特征的预处理步骤。与单变量选择不同, 基于模型的选择同时考虑所有特征,因此可以获取交互项(如果模型能够获取它们的话)。 要想使用基于模型的特征选择,我们需要使用 SelectFromModel 变换器:

from sklearn.feature_selection import SelectFromModel
from sklearn.ensemble import RandomForestClassifier
select = SelectFromModel(
    RandomForestClassifier(n_estimators=100, random_state=42),
    threshold="median")

SelectFromModel 类选出重要性度量(由监督模型提供)大于给定阈值的所有特征。为了得到可以与单变量特征选择进行对比的结果,我们使用中位数作为阈值,这样就可以选择一半特征。我们用包含 100 棵树的随机森林分类器来计算特征重要性。这是一个相当复杂的 模型,也比单变量测试要强大得多。下面我们来实际拟合模型:

select.fit(X_train, y_train)
X_train_l1 = select.transform(X_train)
print("X_train.shape: {}".format(X_train.shape))
# X_train.shape: (284, 80)
print("X_train_l1.shape: {}".format(X_train_l1.shape))
# X_train_l1.shape: (284, 40)

我们可以再次查看选中的特征:

mask = select.get_support()
# visualize the mask. black is True, white is False
plt.matshow(mask.reshape(1, -1), cmap='gray_r')
plt.xlabel("Sample index")
plt.yticks(())

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dQMcdyX3-1608883875193)(素材/使用RandomForestClassifier的SelectFromModel选择的特征.png)]

这次,除了两个原始特征,其他原始特征都被选中。由于我们指定选择 40 个特征,所以 也选择了一些噪声特征。我们来看一下其性能:

X_test_l1 = select.transform(X_test)
score = LogisticRegression(max_iter=1000).fit(X_train_l1, y_train).score(X_test_l1, y_test)
print("Test score: {:.3f}".format(score))
# Test score: 0.951

利用更好的特征选择,性能也得到了提高。

5.3、迭代特征选择

在单变量测试中,我们没有使用模型,而在基于模型的选择中,我们使用了单个模型来选择特征。在迭代特征选择中,将会构建一系列模型,每个模型都使用不同数量的特征。有两种基本方法:开始时没有特征,然后逐个添加特征,直到满足某个终止条件或者从所有特征开始,然后逐个删除特征,直到满足某个终止条件。由于构建了一系列模型,所以这些方法的计算成本要比前面讨论过的方法更高。其中一种特殊方法是递归特征消除recursive feature eliminationRFE),它从所有特征开始构建模型,并根据模型舍弃最不重要的特征,然后使用除被舍弃特征之外的所有特征来构建一个新模型,如此继续,直到仅剩下预设数量的特征。为了让这种方法能够运行,用于选择的模型需要提供某种确定特征重要性的方法,正如基于模型的选择所做的那样。下面我们使用之前用过的同一个随机森林模型,得到的结果如下图所示:

from sklearn.feature_selection import RFE
select = RFE(RandomForestClassifier(n_estimators=100, random_state=42),
             n_features_to_select=40)

select.fit(X_train, y_train)
# 将选中的特征可视化:
mask = select.get_support()
plt.matshow(mask.reshape(1, -1), cmap='gray_r')
plt.xlabel("Sample index")
plt.yticks(())

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oI0SstqU-1608883875194)(素材/使用随机森林分类器模型的递归特征消除选择的特征.png)]

与单变量选择和基于模型的选择相比,迭代特征选择的结果更好,但仍然漏掉了一个特征。运行上述代码需要的时间也比基于模型的选择长得多,因为对一个随机森林模型训练了 40 次,每运行一次删除一个特征。我们来测试一下使用 RFE 做特征选择时 Logistic 回归模型的精度:

X_train_rfe = select.transform(X_train)
X_test_rfe = select.transform(X_test)

score = LogisticRegression(max_iter=1000).fit(X_train_rfe, y_train).score(X_test_rfe, y_test)
print("Test score: {:.3f}".format(score))
# Test score: 0.951

这里,在 RFE 内部使用的随机森林的性能,与在所选特征上训练一个 Logistic 回归模型得到的性能相同。换句话说,只要我们选择了正确的特征,线性模型的表现就与随机森林一样好。 如果你不确定何时选择使用哪些特征作为机器学习算法的输入,那么自动化特征选择可能特别有用。它还有助于减少所需要的特征数量,加快预测速度,或允许可解释性更强的模型。在大多数现实情况下,使用特征选择不太可能大幅提升性能,但它仍是特征工程工具箱中一个非常有价值的工具。


6、小结与展望

本章讨论了如何处理不同的数据类型(特别是分类变量)。我们强调了使用适合机器学习算法的数据表示方式的重要性,例如 one-hot 编码过的分类变量。还讨论了通过特征工程生成新特征的重要性,以及利用专家知识从数据中创建导出特征的可能性。特别是线性模型,可能会从分箱、添加多项式和交互项而生成的新特征中大大受益。对于更加复杂的非线性模型(比如随机森林和 SVM),在无需显式扩展特征空间的前提下就可以学习更加复杂的任务。在实践中,所使用的特征(以及特征与方法之间的匹配)通常是使机器学习方法表现良好的最重要的因素

  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值