在机器学习中有一句至理名言,叫做“垃圾进,垃圾出”(Garbage In, Garbage Out)。这句话的意思是,如果我们输入的数据质量很差,那么我们得到的结果也会很差。

因此,预处理 (Preprocessing) 并不是什么可有可无的步骤,而是机器学习流程中最核心的起步环节。


异常值处理

Outliers,往往代表看起来很离谱的数据点。它们可能是由于数据录入错误、传感器故障或者其他异常情况引起的。就好像一个病人的血压数据突然飙升到300/200,这显然是一个异常值,因为正常的血压范围通常在120/80左右。

如果我们不处理这些异常值,那么它们可能会对模型的训练产生严重的影响。例如,异常值可能会导致模型过拟合,因为模型会试图去拟合这些离谱的数据点,从而失去了对整体数据分布的把握。

因此,处理异常值是数据预处理中的重要步骤。在scikit-learn中,我们可以使用IsolationForest类来检测和处理异常值:

1
2
3
4
5
6
7
8
9
from sklearn.ensemble import IsolationForest
import numpy as np
# 创建一个包含异常值的数组
data = np.array([[1], [2], [3], [4], [5], [100]])
# 创建一个IsolationForest对象
iso_forest = IsolationForest(contamination=0.1)
# 训练模型并预测异常值
outliers = iso_forest.fit_predict(data)
print("异常值的索引:", np.where(outliers == -1))

输出:

1
异常值的索引: [5]

去除异常值之后,我们就可以得到一个更干净的数据集,这样模型就能够更好地学习到数据的真实模式,而不是被那些离谱的数据点所干扰了。


缺失值处理

在现实世界中,数据的来源往往不够可靠。传感器可能会发生故障,数据记录可能会出现错误,或者某些信息可能根本就没有被收集到。这些情况都会导致数据集中出现缺失值。

如果这些缺失值不加以处理,那么绝大多数的机器学习算法都无法正常工作,因为它们需要完整的数据来进行训练和预测。例如scikit-learn中的大多数算法都会在遇到NaN值时抛出错误。

因此,处理缺失值是数据预处理中的重要步骤。常见的处理方法包括:

删除样本或特征

这是处理缺失值最直观的方法,同时也是最暴力的方法。如果一个样本丢失了关键信息,或者一个特征列有大量的空值,那么我们可以选择将其整行或者整列抹去。

例如,在scikit-learn中,我们可以使用dropna()方法来删除包含缺失值的样本:

1
2
3
4
5
6
7
import pandas as pd
# 创建一个包含缺失值的DataFrame
data = {'A': [1, 2, None, 4], 'B': [5, None, 7, 8]}
df = pd.DataFrame(data)
# 删除包含缺失值的样本
df_cleaned = df.dropna()
print(df_cleaned)

输出:

1
2
3
     A    B
0 1.0 5.0
3 4.0 8.0

dropna()方法会删除包含NaN值的行或列,具体取决于参数设置。虽然这种方法简单直接,但它可能会导致数据量的显著减少,尤其是在缺失值较多的情况下。

我们可以用不同参数来决定dropna()如何处理缺失值,例如:

df.dropna(how='any'):删除任何包含NaN值的行(默认行为)。
df.dropna(how='all'):仅删除所有值都是NaN的行。

df.dropna(thresh=2):仅删除那些至少有2个非NaN值的行。
df.dropna(subset=['A']):仅删除在列A中包含NaN值的行。


但是删除样本和特征之所以叫做“暴力手段”,是因为它可能会导致数据量的显著减少,尤其是在缺失值较多的情况下。这可能会导致模型训练不足,甚至无法训练。

因此,在处理缺失值时,我们需要权衡删除样本或特征的利弊,确保我们不会丢失过多有用的信息。


填充缺失值

除了删除可能有问题的数据,我们也可以选择填充缺失值。就像一个病人的血压数据丢失了,与其删除这位病人的记录,不如用该病人群体的血压均值或者中位数来猜一个值。

我们一般使用 插值 (Interpolation) 方法来填充缺失值。插值是一种通过已知数据点来估计未知数据点的方法。由于我们使用scikit-learn进行机器学习,所以我们可以使用scikit-learn中的SimpleImputer类来实现插值:

1
2
3
4
5
6
7
8
9
from sklearn.impute import SimpleImputer
import numpy as np
# 创建一个包含缺失值的数组
data = np.array([[1, 5], [2, np.nan], [np.nan, 7], [4, 8]])
# 创建一个SimpleImputer对象,使用均值填充缺失值
imputer = SimpleImputer(strategy='mean')
# 对数据进行插值
data_imputed = imputer.fit_transform(data)
print(data_imputed)
1
2
3
4
5
输出:
[[1. 5. ]
[2. 6.66666667]
[2.33333333 7. ]
[4. 8. ]]

Transformer 和 Estimator

在scikit-learn中,数据预处理通常通过两种类型的对象来实现:TransformerEstimator。我们刚才使用的SimpleImputer就是一个Transformer,用于数据的转换和预处理。

Transformer是一个具有fit()transform()方法的对象。fit()方法用于学习数据的统计特征,例如均值和标准差,而transform()方法则用于对数据进行转换,例如填充缺失值或标准化数据。具体能干什么用呢?我们可以用Transformer来实现数据的预处理步骤,例如:

1
2
3
4
5
6
7
8
from sklearn.preprocessing import StandardScaler
# 创建一个包含缺失值的数组
data = np.array([[1, 5], [2, np.nan], [np.nan, 7], [4, 8]])
# 创建一个StandardScaler对象,用于标准化数据
scaler = StandardScaler()
# 对数据进行标准化
data_scaled = scaler.fit_transform(data_imputed)
print(data_scaled)

输出:

1
2
3
4
[[-1.34164079 -1.34164079]
[-0.4472136 -0.4472136 ]
[ 0.4472136 0.4472136 ]
[ 1.34164079 1.34164079]]

不着急,我们在后面会聊到有关标准化的内容。


而Estimator与Transformer在某些地方有些相似。Estimator也有fit()方法,transform()方法则是可选的。同时,Estimator还具有一个predict()方法,用于进行预测。Estimator通常用于训练模型,例如线性回归、决策树等。


处理非数值信息

在现实世界的机器学习任务中,特征往往不仅仅是数值类型的,还可能包含文本、类别等非数值信息。例如商品尺码,颜色,或者类别标签这种文字信息。因此,作为ML工程师,我们需要将这些非数值信息转换成数值形式,以便机器学习算法能够处理。

但是首先,我们需要在逻辑上区分两种不同的类别特征:定序特征 (Ordinal Features)定类特征 (Nominal Features) 。这种区分至关重要,因为它决定了我们应该使用哪种编码方法来处理这些特征。


定序特征

定序特征指那些先天性具有自然排序的特征,例如教育水平(小学、初中、高中、大学)或者客户满意度(非常不满意、不满意、一般、满意、非常满意)。这些特征的不同取值之间存在一种明确的顺序关系。这意味着,我们可以在它们之间建立一个有意义的比较关系,例如“大学”比“高中”更高,或者“满意”比“一般”更好。

因此,对于定序特征,最直观的处理方式就是将其映射为整数。我们可以定义一个映射字典,将每个类别映射到一个整数值。例如:

1
2
3
4
5
6
7
8
9
import pandas as pd
# 创建一个包含定序特征的DataFrame
data = {'Education': ['小学', '初中', '高中', '大学']}
df = pd.DataFrame(data)
# 定义一个映射字典,将教育水平映射为整数
education_mapping = {'小学': 1, '初中': 2, '高中': 3, '大学': 4}
# 使用映射字典将教育水平转换为整数
df['Education_Ordinal'] = df['Education'].map(education_mapping)
print(df)

输出:

1
2
3
4
5
  Education  Education_Ordinal
0 小学 1
1 初中 2
2 高中 3
3 大学 4

这样一来,我们就不仅保留了分类信息,还向模型传达了特征之间的大小顺序。这种数值化的过程是模型能够进行数学计算的基础。


定类特征

但是如果我们遇到了定类特征,我们就没法使用刚才的策略处理数据了。

定类特征与定序特征不同,它们没有内在的顺序关系。例如,颜色(红色、绿色、蓝色)或者性别(男、女)就是典型的定类特征。对于这些特征,我们不能简单地将它们映射为整数,因为这样会引入一种虚假的顺序关系,例如“红色”被映射为1,“绿色”被映射为2,这会让模型误以为“绿色”比“红色”更大。

但是工程师们找到了这个问题的巧妙解决方案,那就是使用独热编码 (One-Hot Encoding)

就拿颜色来说,我们知道颜色之间没有大小之分,那么我们就不给它们分配不同的竖直,而是为每一个颜色创建一个独立的二元特征。例如,我们可以为红色、绿色和蓝色分别创建三个特征:

红色 绿色 蓝色
1 0 0
0 1 0
0 0 1

而创建的这些特征,我们称之为虚拟特征 (Dummy Features)。每个虚拟特征都是一个二元特征,表示一个类别是否存在。如果一个样本是红色的,那么只有在红色特征上会有一个1,其他特征都是0,以此类推。这样,每一个类别在空间中都是正交且平等的,模型能够识别出它们是不同的状态,但是不会误以为它们之间存在大小关系。

在scikit-learn中,我们可以使用OneHotEncoder类来实现独热编码:

1
2
3
4
5
6
7
8
9
from sklearn.preprocessing import OneHotEncoder
import numpy as np
# 创建一个包含定类特征的数组
data = np.array([['红色'], ['绿色'], ['蓝色']])
# 创建一个OneHotEncoder对象
encoder = OneHotEncoder(sparse=False)
# 对数据进行独热编码
data_encoded = encoder.fit_transform(data)
print(data_encoded)

输出:

1
2
3
[[1. 0. 0.]
[0. 1. 0.]
[0. 0. 1.]]

丢弃首列

在独热编码中,如果我们有n个类别,那么我们会创建n个二元特征。但是实际上,我们只需要n-1个特征就可以唯一地表示所有的类别了。因为如果我们知道了前n-1个特征的值,我们就可以推断出第n个特征的值。例如,如果我们有三个颜色(红色、绿色、蓝色),我们只需要创建两个特征(红色和绿色),如果一个样本在红色和绿色特征上都是0,那么我们就知道它是蓝色的。

因此,在独热编码中,我们通常会丢弃首列(drop='first'),以避免引入冗余特征。这不仅可以减少特征的数量,还可以避免多重共线性的问题。


训练集与测试集的划分

我们已经知道,我们会把整个数据集分为训练集和测试集。训练集用于训练模型,而测试集用于评估模型的性能。但是你有没有想过,我们该如何划分训练集和测试集呢?我们应该随机划分吗?还是按照时间顺序划分呢?或者按照某些特定的规则划分呢?

首先简短概括一下过拟合和欠拟合。过拟合是指模型在训练集上表现得非常好,但在测试集上表现得很差。这通常是因为模型过于复杂,捕捉到了训练数据中的噪声和异常值,而不是学习到了数据的真实模式。欠拟合则是指模型在训练集和测试集上都表现得很差。这通常是因为模型过于简单,无法捕捉到数据中的复杂关系。

因此为了保证模型真的在训练集中学到了东西,找到了规律,而不是死记硬背,我们需要确保训练集和测试集之间的分布是相似的。也就是说,训练集和测试集应该来自同一个总体,并且具有相似的特征分布和标签分布。

用常用的UCI Wine Dataset作为例子,在scikit-learn中,我们可以使用train_test_split函数来随机划分训练集和测试集:

1
2
3
4
5
6
7
8
9
from sklearn.model_selection import train_test_split
from sklearn.datasets import load_wine

# 加载UCI Wine Dataset
wine = load_wine()
X, y = wine.data, wine.target

# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

stratify=y参数确保了训练集和测试集中的类别分布与原始数据集相同,这有助于提高模型的泛化能力。

在这里有一个微妙的博弈。如果我们留给模型训练的数据太少,那么模型就无法学到足够的规律,导致欠拟合。但是如果我们留给模型训练的数据太多,那么我们就没有足够的数据来对模型的泛化能力做出评估,可能会导致过拟合。因此对于UCI Wine这样的中等规模数据集,通常我们会选择将20%到30%的数据作为测试集,剩下的70%到80%的数据作为训练集。

还有一个很有意思的细节:有资深算法工程师会选择在完成模型评估后,通常会合并训练集和测试集,利用完整的数据集来重新训练模型,以期获得更好的性能。但是这样做可能会导致更差的泛化能力。为什么?因为我们在评估模型性能时,使用了测试集来评估模型的泛化能力。如果我们在完成评估后又将测试集的数据用于训练,那么我们就失去了一个独立的测试集来评估模型的性能了。这样一来,我们就无法确定模型在未见过的数据上的表现了,可能会导致过拟合。因此,在实际应用中,我们通常会保留一个独立的测试集来评估模型的性能,而不是将其用于训练。


特征缩放

在我们刚才的葡萄酒数据集中,Alcohol的数值通常在10-15之间,但是Proline的含量在300-1500之间。这种特征值范围的巨大差异可能会导致某些机器学习算法(例如KNN、SVM等)在训练过程中对Proline特征过于敏感,而忽略了Alcohol特征的重要性。因此,我们需要对特征进行缩放,使得它们在相似的数值范围内,这样模型才能更好地学习到每个特征的贡献。

特征缩放 (Feature Scaling) 做的就是这件事,用于将不同特征的数值范围调整到相似的尺度上。

我们常用的特征缩放方法包括标准化 (Standardization)归一化 (Normalization)


归一化

归一化相对简单。它寻找数据集中的极值,然后线性地将所有样本压缩到一个固定的范围内,也就是Min-Max Scaling。归一化的公式如下:

$$X' = \frac{X - X_{min}}{X_{max} - X_{min}}$$

其中,$X$是原始特征值,$X_{min}$和$X_{max}$分别是该特征的最小值和最大值。归一化将特征值线性地映射到0和1之间,这样不同特征的数值范围就变得相似了。

归一化虽然能够保证量级统一,但是它对异常值非常敏感。如果数据集中存在异常值,那么归一化可能会将大部分数据压缩到一个很小的范围内,从而影响模型的性能。

因此...


标准化

...在大多数机器学习任务中,我们更倾向于使用标准化。标准化的数学逻辑是:

$$X' = \frac{X - \mu}{\sigma}$$

通过减去均值 $\mu$ 并除以标准差 $\sigma$,我们把特征转化为了一个均值为0,方差为1的标准正态分布。

这样我们不仅保留了异常值的信息,同时还使得不同特征的数值范围变得相似了。这就能让梯度下降在搜索全局最优解的时候更加稳定且更快速的收敛。

在scikit-learn中,我们可以使用StandardScaler类来实现标准化:

1
2
3
4
5
6
7
8
9
from sklearn.preprocessing import StandardScaler
import numpy as np
# 创建一个包含特征值的数组
data = np.array([[1, 5], [2, 6], [3, 7], [4, 8]])
# 创建一个StandardScaler对象
scaler = StandardScaler()
# 对数据进行标准化
data_scaled = scaler.fit_transform(data)
print(data_scaled)

标准化不仅仅移除了偏差,同时也在重塑数据的分布。数据重塑后,分布更加对称,且具有相似的尺度,这有助于提高模型的性能和泛化能力。但是如果我们原本的数据集的分布非常偏斜,那么标准化可能无法完全解决这个问题。在这种情况下,我们可能需要考虑其他的变换方法,例如对数变换或者Box-Cox变换,以使数据的分布更加接近正态分布。


特征选择

经过特征缩放我们保证每一个特征都能公平地被模型学习,但是我们如何知道哪些特征是重要的,哪些特征是冗余的?

特征选择 (Feature Selection) 就是用来解决这个问题的。它基本上就像是一个剪辑师,剪掉不重要的支线剧情,让模型专注于主线剧情。通过特征选择,我们可以提高模型的性能,减少过拟合,并且让模型更容易解释。这么做有四大好处啊:

  1. 简化模型:通过去掉不重要的特征,我们可以简化模型的结构,使得模型更容易理解和解释。
  2. 提高性能:去掉冗余的特征可以减少模型的复杂度,从而提高模型的泛化能力,减少过拟合的风险。
  3. 降低计算成本:特征选择可以减少模型训练和预测所需的计算资源,特别是在处理高维数据时。
  4. 提高模型的可解释性:通过选择重要的特征,我们可以更好地理解模型的决策过程,识别出哪些特征对预测结果有重要影响。

其中我们做特征选择的主要原因,就是为了防止过拟合。

我们主要把我们的目光聚焦到两种特征选择技术:L1 正则化 (L1 Regularization)序列后向选择 (Sequential Backward Selection)


L1 正则化

我们在之前第三章就聊过L2正则化了。你看公式的最后面:

$$J(\theta) = \frac{1}{m} \sum_{i=1}^{m} L(h_\theta(x^{(i)}), y^{(i)}) + \lambda \sum_{j=1}^{n} \theta_j^2$$

你会发现它的惩罚项是权重的平方和 $||w||_2^2$。但是由于L2正则化的惩罚项是连续且可微的,所以它会将权重缩小到接近于零,但不会完全变为零。这意味着,虽然L2正则化可以减少模型的复杂度,但它并不能真正地进行特征选择,因为所有的特征仍然保留在模型中。

那么L1正则化的惩罚项是什么呢?它的惩罚项是权重的绝对值和 $||w||_1$。同样在后面:

$$J(\theta) = \frac{1}{m} \sum_{i=1}^{m} L(h_\theta(x^{(i)}), y^{(i)}) + \lambda \sum_{j=1}^{n} |\theta_j|$$

由于L1正则化的惩罚项是非连续且不可微的,所以它会将一些权重完全变为零。这意味着,L1正则化不仅可以减少模型的复杂度,还可以真正地进行特征选择,因为它会将不重要的特征的权重变为零,从而将其从模型中剔除。

我们可以尝试使用图像来思考这个问题。如果你在二维平面上画出等值线,你会发现 $w_1^2 + w_2^2 = c$ 形成的是一个圆。由于圆在各个方向上都是平滑对称的,这意味着它对于所有权重的压制是雨露均沾的,不会偏向于某一个特征。

而L1的惩罚项公式 $|w_1| + |w_2| = c$ 描述的不再是一个圆,而是一个旋转了45度的正方形。这个正方形的边界上有很多尖角,这些尖角对应于某些权重为零的情况。这就意味着,L1正则化更倾向于将一些权重压缩到零,从而实现特征选择.

如果我们在图像上初始化一个起始点,并且沿着梯度下降的方向进行优化,我们会发现L1正则化的路径更容易碰到那些尖角,从而使得某些权重变为零。而L2正则化的路径则更平滑,不太可能碰到那些尖角,因此很难将权重完全压缩到零。

因此,这就是为什么我们说L2会让参数向原点收缩,而L1会产生稀疏解的原因了。


我们如何控制模型剔除特征的狠劲?在scikit-learn中,我们可以通过调整正则化强度参数 C 来控制惩罚力度。C是正则化强度的倒数,C越小,正则化强度越大,模型越倾向于剔除特征;C越大,正则化强度越小,模型越倾向于保留特征。因此,如果我们想要一个更稀疏的模型,我们可以选择一个较小的C值;如果我们想要一个更密集的模型,我们可以选择一个较大的C值。


所以你可以清楚的看到在代码里,你会发现应用L1正则化的模型会有很多权重被压缩到零,而应用L2正则化的模型则会有所有权重都被缩小,但没有一个权重完全变为零。

1
2
3
4
5
6
7
8
9
10
11
12
13
from sklearn.linear_model import LogisticRegression
import numpy as np
# 创建一个包含特征值的数组
X = np.array([[1, 5], [2, 6], [3, 7], [4, 8]])
y = np.array([0, 0, 1, 1])
# 创建一个LogisticRegression对象,使用L1正则化
model_l1 = LogisticRegression(penalty='l1', solver='liblinear')
model_l1.fit(X, y)
print("L1正则化的权重:", model_l1.coef_)
# 创建一个LogisticRegression对象,使用L2正则化
model_l2 = LogisticRegression(penalty='l2')
model_l2.fit(X, y)
print("L2正则化的权重:", model_l2.coef_)

输出:

1
2
L1正则化的权重: [[0. 0.]]
L2正则化的权重: [[0.5 0.5]]

序列后向选择

序列后向选择 (Sequential Backward Selection) 像一场残酷的淘汰赛,它的目标是将一个初始的 $d$ 维特征空间逐步缩小到一个更小的 $k$ 维特征子空间,其中 $k < d$。这个过程是通过迭代地删除最不重要的特征来实现的。

但是要做到这一点,我们要定义一个衡量标准,我们称之为 Criterion Function ($J$),它用于评估每个特征子集的性能。这个函数可以是模型的准确率、AUC、F1分数等任何能够反映模型性能的指标。

随后,SBS会一轮一轮进行,并在每一轮中尝试分别剔除现有的每一个特征,并评估使用剩下特征训练的模型性能如何。它会选择那个剔除后性能下降最小的特征进行删除。这个过程会一直持续,直到我们达到预定的特征数量 $k$。

就跟大逃杀一样。

我们可以使用公式来描述这个过程:

$$\text{SBS}(X, y, k) = \arg\min_{j} J(X_{-j}, y)$$

其中,$X_{-j}$ 表示从特征集合 $X$ 中剔除第 $j$ 个特征后的子集,$J(X_{-j}, y)$ 是使用这个子集训练模型后得到的性能指标。SBS会选择那个使得性能指标最小化的特征进行剔除。