10. 常见陷阱和推荐实践#
本章的目的是说明使用 scikit-learn 时会遇到的一些常见陷阱和反模式。它提供了 **不** 应执行的操作示例以及相应的正确示例。
10.1. 不一致的预处理#
scikit-learn 提供了一个 数据集转换 库,它可以清理(参见 预处理数据)、减少(参见 无监督降维)、扩展(参见 核近似)或生成(参见 特征提取)特征表示。如果在训练模型时使用了这些数据转换,则也必须在后续数据集上使用它们,无论是测试数据还是生产系统中的数据。否则,特征空间将发生变化,模型将无法有效地执行。
对于以下示例,让我们创建一个只有一个特征的合成数据集
>>> from sklearn.datasets import make_regression
>>> from sklearn.model_selection import train_test_split
>>> random_state = 42
>>> X, y = make_regression(random_state=random_state, n_features=1, noise=1)
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.4, random_state=random_state)
错误的
训练数据集已缩放,但测试数据集未缩放,因此测试数据集上的模型性能比预期差。
>>> from sklearn.metrics import mean_squared_error
>>> from sklearn.linear_model import LinearRegression
>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()
>>> X_train_transformed = scaler.fit_transform(X_train)
>>> model = LinearRegression().fit(X_train_transformed, y_train)
>>> mean_squared_error(y_test, model.predict(X_test))
62.80...
正确的
与其将未转换的 X_test
传递给 predict
,我们应该以与转换训练数据相同的方式转换测试数据。
>>> X_test_transformed = scaler.transform(X_test)
>>> mean_squared_error(y_test, model.predict(X_test_transformed))
0.90...
或者,我们建议使用 Pipeline
,它可以更轻松地将转换与估计器链接起来,并降低遗漏转换的可能性。
>>> from sklearn.pipeline import make_pipeline
>>> model = make_pipeline(StandardScaler(), LinearRegression())
>>> model.fit(X_train, y_train)
Pipeline(steps=[('standardscaler', StandardScaler()),
('linearregression', LinearRegression())])
>>> mean_squared_error(y_test, model.predict(X_test))
0.90...
管道还可以帮助避免另一个常见陷阱:将测试数据泄漏到训练数据中。
10.2. 数据泄漏#
当在构建模型时使用了在预测时不可用的信息时,就会发生数据泄漏。这会导致过于乐观的性能估计(例如来自 交叉验证),从而在将模型用于实际的新数据(例如在生产过程中)时性能较差。
一个常见原因是没有将测试和训练数据子集分开。测试数据绝不应用于做出关于模型的选择。**一般规则是永远不要在测试数据上调用** fit
**方法**。虽然这听起来很明显,但在某些情况下很容易忽略,例如在应用某些预处理步骤时。
尽管训练和测试数据子集都应该接受相同的预处理转换(如上一节所述),但重要的是这些转换只能从训练数据中学习。例如,如果您有一个归一化步骤,其中您除以平均值,则平均值应该是训练子集的平均值,**而不是**所有数据的平均值。如果测试子集包含在平均值计算中,则来自测试子集的信息会影响模型。
10.2.1. 如何避免数据泄漏#
以下是一些关于避免数据泄漏的技巧:
始终首先将数据分成训练和测试子集,尤其是在任何预处理步骤之前。
使用
fit
和fit_transform
方法时,切勿包含测试数据。使用所有数据,例如fit(X)
,会导致得分过于乐观。相反,应该对训练集和测试集都使用
transform
方法,因为所有数据都应该应用相同的预处理。这可以通过在训练集上使用fit_transform
,在测试集上使用transform
来实现。scikit-learn的pipeline是防止数据泄漏的好方法,因为它确保在正确的子数据集上执行适当的方法。pipeline非常适合用于交叉验证和超参数调整函数。
下面详细介绍了预处理过程中数据泄漏的示例。
10.2.2. 预处理过程中的数据泄漏#
注意
在这里,我们选择用特征选择步骤来说明数据泄漏。然而,这种泄漏风险几乎与scikit-learn中的所有转换都相关,包括(但不限于)StandardScaler
、SimpleImputer
和PCA
。
scikit-learn中提供了许多特征选择函数。它们可以帮助去除无关、冗余和噪声特征,以及提高模型构建时间和性能。与任何其他类型的预处理一样,特征选择**只能**使用训练数据。在特征选择中包含测试数据会使模型产生乐观的偏差。
为了演示,我们将创建一个具有10,000个随机生成的特征的二元分类问题。
>>> import numpy as np
>>> n_samples, n_features, n_classes = 200, 10000, 2
>>> rng = np.random.RandomState(42)
>>> X = rng.standard_normal((n_samples, n_features))
>>> y = rng.choice(n_classes, n_samples)
错误的
使用所有数据进行特征选择会导致准确率得分远高于偶然概率,即使我们的目标是完全随机的。这种随机性意味着我们的X
和y
是独立的,因此我们预计准确率约为0.5。然而,由于特征选择步骤“看到了”测试数据,模型具有不公平的优势。在下面的错误示例中,我们首先使用所有数据进行特征选择,然后将数据分割成训练集和测试集进行模型拟合。结果是准确率得分远高于预期。
>>> from sklearn.model_selection import train_test_split
>>> from sklearn.feature_selection import SelectKBest
>>> from sklearn.ensemble import GradientBoostingClassifier
>>> from sklearn.metrics import accuracy_score
>>> # Incorrect preprocessing: the entire data is transformed
>>> X_selected = SelectKBest(k=25).fit_transform(X, y)
>>> X_train, X_test, y_train, y_test = train_test_split(
... X_selected, y, random_state=42)
>>> gbc = GradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train, y_train)
GradientBoostingClassifier(random_state=1)
>>> y_pred = gbc.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.76
正确的
为了防止数据泄漏,最佳实践是**首先**将数据分割成训练集和测试集。然后可以使用仅训练数据集来进行特征选择。请注意,每当我们使用fit
或fit_transform
时,我们只使用训练数据集。现在得分是我们对数据的预期,接近偶然概率。
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, random_state=42)
>>> select = SelectKBest(k=25)
>>> X_train_selected = select.fit_transform(X_train, y_train)
>>> gbc = GradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train_selected, y_train)
GradientBoostingClassifier(random_state=1)
>>> X_test_selected = select.transform(X_test)
>>> y_pred = gbc.predict(X_test_selected)
>>> accuracy_score(y_test, y_pred)
0.46
同样,我们建议使用Pipeline
将特征选择和模型估计器连接起来。pipeline确保仅在执行fit
时使用训练数据,并且仅在计算准确率得分时使用测试数据。
>>> from sklearn.pipeline import make_pipeline
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, random_state=42)
>>> pipeline = make_pipeline(SelectKBest(k=25),
... GradientBoostingClassifier(random_state=1))
>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('selectkbest', SelectKBest(k=25)),
('gradientboostingclassifier',
GradientBoostingClassifier(random_state=1))])
>>> y_pred = pipeline.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.46
pipeline还可以输入到交叉验证函数中,例如cross_val_score
。同样,pipeline确保在拟合和预测过程中使用正确的子数据集和估计器方法。
>>> from sklearn.model_selection import cross_val_score
>>> scores = cross_val_score(pipeline, X, y)
>>> print(f"Mean accuracy: {scores.mean():.2f}+/-{scores.std():.2f}")
Mean accuracy: 0.46+/-0.07
10.3. 控制随机性#
一些scikit-learn对象本质上是随机的。这些通常是估计器(例如RandomForestClassifier
)和交叉验证分割器(例如KFold
)。这些对象的随机性通过它们的random_state
参数来控制,如词汇表中所述。本节扩展了词汇表条目,并描述了关于此细微参数的良好实践和常见陷阱。
注意
推荐总结
为了获得最佳的交叉验证(CV)结果稳健性,在创建估计器时传递RandomState
实例,或者将random_state
保留为None
。将整数传递给CV分割器通常是最安全的选择,并且是首选的;传递RandomState
实例到分割器有时可能有助于实现非常具体的用例。对于估计器和分割器,传递整数与传递实例(或None
)会导致细微但重要的差异,尤其是在CV过程中。在报告结果时,了解这些差异非常重要。
为了在每次执行中获得可重复的结果,请移除任何random_state=None
的使用。
10.3.1. 使用None
或RandomState
实例,以及对fit
和split
的重复调用#
random_state
参数决定根据这些规则,对fit(对于估计器)或split(对于CV分割器)的多次调用是否会产生相同的结果。
如果传入整数,则多次调用
fit
或split
始终产生相同的结果。如果传入
None
或RandomState
实例:fit
和split
每次调用都会产生不同的结果,并且连续调用会探索所有熵源。None
是所有random_state
参数的默认值。
这里我们说明了估计器和交叉验证分割器的这些规则。
注意
由于传入random_state=None
等同于传入来自numpy
的全局RandomState
实例(random_state=np.random.mtrand._rand
),因此我们这里不再明确提及None
。适用于实例的所有内容也适用于使用None
。
10.3.1.1. 估计器#
传入实例意味着多次调用fit
不会产生相同的结果,即使估计器使用相同的数据和相同的超参数进行拟合。
>>> from sklearn.linear_model import SGDClassifier
>>> from sklearn.datasets import make_classification
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(n_features=5, random_state=rng)
>>> sgd = SGDClassifier(random_state=rng)
>>> sgd.fit(X, y).coef_
array([[ 8.85418642, 4.79084103, -3.13077794, 8.11915045, -0.56479934]])
>>> sgd.fit(X, y).coef_
array([[ 6.70814003, 5.25291366, -7.55212743, 5.18197458, 1.37845099]])
从上面的代码片段中我们可以看到,重复调用sgd.fit
产生了不同的模型,即使数据相同。这是因为当调用fit
时,估计器的随机数生成器 (RNG) 被消耗(即被改变),并且这个改变后的 RNG 将用于后续对fit
的调用。此外,rng
对象在所有使用它的对象之间共享,因此这些对象变得相互依赖。例如,共享相同RandomState
实例的两个估计器会相互影响,我们将在后面讨论克隆时看到这一点。在调试时,这一点很重要。
如果我们将整数传递给SGDClassifier
的random_state
参数,我们将每次获得相同的模型,以及相同的分数。当我们传入一个整数时,相同的 RNG 用于所有对fit
的调用。内部发生的情况是,即使在调用fit
时消耗了 RNG,但在fit
的开始,它总是被重置为其原始状态。
10.3.1.2. 交叉验证分割器#
当传入RandomState
实例时,随机交叉验证分割器具有类似的行为;多次调用split
会产生不同的数据分割。
>>> from sklearn.model_selection import KFold
>>> import numpy as np
>>> X = y = np.arange(10)
>>> rng = np.random.RandomState(0)
>>> cv = KFold(n_splits=2, shuffle=True, random_state=rng)
>>> for train, test in cv.split(X, y):
... print(train, test)
[0 3 5 6 7] [1 2 4 8 9]
[1 2 4 8 9] [0 3 5 6 7]
>>> for train, test in cv.split(X, y):
... print(train, test)
[0 4 6 7 8] [1 2 3 5 9]
[1 2 3 5 9] [0 4 6 7 8]
我们可以看到,从第二次调用split
开始,分割就不同了。如果您通过多次调用split
来比较多个估计器的性能,这可能会导致意想不到的结果,我们将在下一节中看到这一点。
10.3.2. 常见陷阱和细微之处#
虽然控制random_state
参数的规则看似简单,但它们确实有一些细微的含义。在某些情况下,这甚至会导致错误的结论。
10.3.2.1. 估计器#
不同的`random_state`类型会导致不同的交叉验证过程。
根据random_state
参数的类型,估计器的行为会有所不同,尤其是在交叉验证过程中。考虑以下代码片段。
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import cross_val_score
>>> import numpy as np
>>> X, y = make_classification(random_state=0)
>>> rf_123 = RandomForestClassifier(random_state=123)
>>> cross_val_score(rf_123, X, y)
array([0.85, 0.95, 0.95, 0.9 , 0.9 ])
>>> rf_inst = RandomForestClassifier(random_state=np.random.RandomState(0))
>>> cross_val_score(rf_inst, X, y)
array([0.9 , 0.95, 0.95, 0.9 , 0.9 ])
我们看到rf_123
和rf_inst
的交叉验证分数不同,这正如预期的那样,因为我们没有传递相同的random_state
参数。但是,这些分数之间的差异比看起来更微妙,并且cross_val_score
**在每种情况下执行的交叉验证过程都显著不同**。
由于
rf_123
传递了一个整数,因此每次调用fit
都使用相同的 RNG:这意味着随机森林估计器的所有随机特征对于交叉验证过程的 5 个fold都将相同。特别是,估计器的(随机选择的)特征子集在所有fold中都将相同。由于
rf_inst
传递了一个RandomState
实例,因此每次调用fit
都从不同的 RNG 开始。结果,对于每个fold,随机特征子集都将不同。
虽然在fold之间具有恒定的估计器 RNG 本身并不错误,但我们通常希望交叉验证结果对于估计器的随机性具有鲁棒性。因此,传递实例而不是整数可能更好,因为它允许估计器 RNG 对于每个fold都不同。
注意
这里,cross_val_score
将使用非随机化的交叉验证分割器(这是默认设置),因此两个估计器都将在相同的分割上进行评估。本节不是关于分割变化的。此外,我们将整数还是实例传递给make_classification
与我们的说明目的无关:重要的是我们传递给RandomForestClassifier
估计器的参数。
克隆#
传递RandomState
实例的另一个细微副作用是clone
函数的工作方式。
>>> from sklearn import clone
>>> from sklearn.ensemble import RandomForestClassifier
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> a = RandomForestClassifier(random_state=rng)
>>> b = clone(a)
由于将RandomState
实例传递给了a
,a
和b
并非严格意义上的克隆,而是统计意义上的克隆:即使在相同数据上调用fit(X, y)
,a
和b
仍然是不同的模型。此外,a
和b
会相互影响,因为它们共享相同的内部随机数生成器 (RNG):调用a.fit
会消耗b
的RNG,调用b.fit
会消耗a
的RNG,因为它们是相同的。这一点对于共享random_state
参数的任何估计器都是正确的;它并非克隆所特有的。
如果传递的是整数,则a
和b
将是精确克隆,并且它们不会相互影响。
警告
即使clone
很少在用户代码中使用,但在 scikit-learn 代码库中被广泛调用:特别是,大多数接受未拟合估计器的元估计器都会在内部调用clone
(GridSearchCV
,StackingClassifier
,CalibratedClassifierCV
等)。
10.3.2.2. 交叉验证拆分器#
当传递RandomState
实例时,每次调用split
时,交叉验证拆分器都会产生不同的拆分。在比较不同的估计器时,这可能会导致高估估计器之间性能差异的方差。
>>> from sklearn.naive_bayes import GaussianNB
>>> from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import KFold
>>> from sklearn.model_selection import cross_val_score
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(random_state=rng)
>>> cv = KFold(shuffle=True, random_state=rng)
>>> lda = LinearDiscriminantAnalysis()
>>> nb = GaussianNB()
>>> for est in (lda, nb):
... print(cross_val_score(est, X, y, cv=cv))
[0.8 0.75 0.75 0.7 0.85]
[0.85 0.95 0.95 0.85 0.95]
直接比较LinearDiscriminantAnalysis
估计器与GaussianNB
估计器**在每个fold上的**性能将是一个错误:**评估估计器的拆分是不同的**。cross_val_score
会在内部对相同的KFold
实例调用cv.split
,但是每次拆分都会不同。对于任何通过交叉验证执行模型选择的工具(例如GridSearchCV
和RandomizedSearchCV
)也如此:由于cv.split
会被多次调用,因此在不同search.fit
调用之间,分数在fold之间不可比较。然而,在单个search.fit
调用中,由于搜索估计器只调用一次cv.split
,因此fold之间的比较是可能的。
为了在所有情况下获得可比较的fold间结果,应将整数传递给交叉验证拆分器:cv = KFold(shuffle=True, random_state=0)
。
注意
虽然不建议使用RandomState
实例进行fold间的比较,但是只要使用足够的fold和数据,就可以预期平均分数可以得出某个估计器是否优于另一个估计器的结论。
注意
在这个例子中,重要的是传递给KFold
的内容。为了说明的目的,我们将RandomState
实例或整数传递给make_classification
并不重要。此外,LinearDiscriminantAnalysis
和GaussianNB
都不是随机估计器。
10.3.3. 一般建议#
10.3.3.1. 在多次执行中获得可重复的结果#
为了在多次程序执行中获得可重复(即恒定)的结果,我们需要移除所有使用random_state=None
(这是默认值)的情况。推荐的方法是在程序顶部声明一个rng
变量,并将其传递给任何接受random_state
参数的对象。
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import train_test_split
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(random_state=rng)
>>> rf = RandomForestClassifier(random_state=rng)
>>> X_train, X_test, y_train, y_test = train_test_split(X, y,
... random_state=rng)
>>> rf.fit(X_train, y_train).score(X_test, y_test)
0.84
现在可以保证该脚本的结果始终为 0.84,无论运行多少次。将全局rng
变量更改为不同的值应会影响结果,正如预期的那样。
也可以将rng
变量声明为整数。但是,正如下一节所示,这可能会导致交叉验证结果不够稳健。
注意
我们不建议通过调用np.random.seed(0)
来设置全局numpy
种子。详情请参见此处的讨论。
10.3.3.2. 交叉验证结果的稳健性#
当我们通过交叉验证评估随机估计器的性能时,我们希望确保估计器能够对新数据产生准确的预测,但我们也希望确保估计器对其随机初始化具有稳健性。例如,我们希望SGDClassifier
的随机权重初始化在所有fold中都始终保持良好:否则,当我们在新数据上训练该估计器时,我们可能会运气不好,随机初始化可能会导致性能下降。类似地,我们希望随机森林对于每个树将使用的随机选择的特征集具有稳健性。
基于这些原因,最好通过让估计器在每个fold上使用不同的RNG来评估交叉验证性能。这是通过将RandomState
实例(或None
)传递给估计器初始化来完成的。
当我们传递一个整数时,估计器将在每个fold上使用相同的RNG:如果估计器的性能良好(或糟糕),如CV评估的那样,这可能仅仅是因为我们对该特定种子幸运(或不幸)。传递实例会导致更稳健的CV结果,并使各种算法之间的比较更公平。它还有助于限制将估计器的RNG视为可以调整的超参数的诱惑。
我们将RandomState
实例还是整数传递给CV分割器,只要split
只调用一次,就不会影响稳健性。当split
被多次调用时,fold之间的比较就不再可能了。因此,将整数传递给CV分割器通常更安全,并且涵盖了大多数用例。