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

>>> X_test_transformed = scaler.transform(X_test)
>>> mean_squared_error(y_test, model.predict(X_test_transformed))
1.05...

转换测试数据,与我们转换训练数据的方式相同:

>>> 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. 数据泄露#

数据泄露发生在构建模型时使用了预测时不应该可用的信息。这会导致过于乐观的性能估计,例如来自 cross-validation ,从而在模型实际用于新数据时(例如在生产环境中)性能较差。

一个常见的原因是没有将测试和训练数据子集分开。测试数据永远不应该用于对模型做出选择。一般规则是永远不要在测试数据上调用 fit 。虽然这听起来很明显,但在某些情况下很容易忽略,例如在应用某些预处理步骤时。

尽管训练和测试数据子集应该接受相同的预处理转换(如前一节所述),但重要的是这些转换仅从训练数据中学习。例如,如果你有一个归一化步骤,其中你除以平均值,这个平均值应该是训练子集的平均值,**而不是**所有数据的平均值。如果 测试子集包含在平均计算中,测试子集的信息正在影响模型。

10.2.1. 如何避免数据泄露#

以下是一些避免数据泄露的提示:

  • 始终首先将数据分割为训练和测试子集,特别是在任何预处理步骤之前。

  • 在使用 fitfit_transform 方法时,切勿包含测试数据。使用所有数据,例如 fit(X) ,可能会导致过于乐观的分数。

    相反, transform 方法应该在训练和测试子集上都使用,因为相同的预处理应该应用于所有数据。这可以通过在训练子集上使用 fit_transform 并在测试子集上使用 transform 来实现。

  • scikit-learn 的 pipeline 是防止数据泄露的一个很好的方法,因为它确保在正确的数据子集上执行适当的方法。管道非常适合在交叉验证和超参数调整函数中使用。

下面详细说明了预处理过程中的数据泄露示例。

10.2.2. 预处理过程中的数据泄露#

Note

我们在这里选择用特征选择步骤来说明数据泄露。然而,这种泄露风险与 scikit-learn 中的几乎所有转换都相关,包括(但不限于):class:~sklearn.preprocessing.StandardScalerSimpleImputerPCA

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)

错误

使用所有数据进行特征选择会导致准确度分数远高于随机水平,即使我们的目标完全随机。这种随机性意味着我们的 Xy 是独立的,因此我们期望的准确度大约为 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

>>> # 错误的预处理:整个数据被转换
>>> 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

正确

为了防止数据泄露,良好的做法是**首先**将数据分成训练和测试子集。然后可以使用仅包含训练数据集的特征选择。请注意,每当我们使用 fitfit_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 将特征选择和模型估计器串联起来。管道确保在执行 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

管道还可以输入到交叉验证函数中,例如 cross_val_score 。同样,管道确保在拟合和预测过程中使用正确的数据子集和估计器方法:

>>> from sklearn.model_selection import cross_val_score
>>> scores = cross_val_score(pipeline, X, y)
>>> print(f"平均准确度: {scores.mean():.2f}+/-{scores.std():.2f}")
平均准确度: 0.46+/-0.07

10.3. 控制随机性#

一些 scikit-learn 对象本质上是随机的。这些通常是估计器(例如 RandomForestClassifier )和交叉验证分割器(例如 KFold )。随机性 这些对象的控制通过它们的 random_state 参数进行,如在 Glossary 中所述。本节扩展了术语表条目,并描述了关于这个微妙参数的良好实践和常见陷阱。

Note

推荐总结

为了交叉验证(CV)结果的最佳健壮性,创建估计器时传递 RandomState 实例,或将 random_state 保留为 None 。向 CV 分割器传递整数通常是最安全的选择,且更可取;向分割器传递 RandomState 实例有时可能有助于实现非常特定的用例。对于估计器和分割器,传递整数与传递实例(或 None )会导致细微但显著的差异,尤其是在 CV 过程中。这些差异在报告结果时很重要。

为了在执行过程中获得可重复的结果,请移除任何使用 random_state=None 的情况。

10.3.1. 使用 NoneRandomState 实例,以及对 fitsplit 的重复调用#

random_state 参数决定了对 fit (对于估计器)或对 split (对于 CV 分割器)的多次调用是否会生成相同的结果,根据以下规则:

  • 如果传递了一个整数,多次调用 fitsplit 总是产生相同的结果。

  • 如果传递了 None 或一个 RandomState 实例:每次调用 fitsplit 将产生不同的结果,并且连续调用会探索所有熵源。 None 是所有 random_state 参数的默认值。

我们在这里为估计器和 CV 分割器说明这些规则。

Note

由于传递 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 也产生了不同的模型。这是因为估计器的随机数生成器(RNG)在调用 fit 时被消耗(即改变),并且在后续调用 fit 时会使用这个改变后的 RNG。此外, rng 对象在所有使用它的对象之间共享,因此这些对象变得有些相互依赖。例如,两个共享相同 RandomState 实例的估计器会相互影响,我们将在后面讨论克隆时看到这一点。在调试时记住这一点很重要。

如果我们向 SGDClassifierrandom_state 参数传递一个整数,我们将获得相同的模型,因此每次都会获得相同的分数。当我们传递一个整数时,所有调用 fit 时都使用相同的 RNG。内部发生的情况是,即使 RNG 在调用 fit 时被消耗,它总是在 fit 开始时重置为其原始状态。

10.3.1.2. CV 分割器#

随机化的 CV 分割器在传递 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_123rf_inst 的交叉验证得分是不同的。 不同,正如预期的那样,因为我们没有传递相同的 random_state 参数。然而,这些分数之间的差异比看起来要微妙,而且 cross_val_score 执行的交叉验证过程在每种情况下都有显著不同

  • 由于 rf_123 传递了一个整数,每次调用 fit 都使用相同的随机数生成器(RNG):这意味着随机森林估计器的所有随机特性在交叉验证(CV)过程的每个折叠中都是相同的。特别是,估计器的(随机选择的)特征子集在所有折叠中都是相同的。

  • 由于 rf_inst 传递了一个 RandomState 实例,每次调用 fit 都从一个不同的 RNG 开始。因此,随机特征子集在每个折叠中都是不同的。

虽然在折叠中保持估计器的 RNG 不变并不是本质上的错误,但我们通常希望 CV 结果对估计器的随机性具有鲁棒性。因此,传递一个实例而不是一个整数可能更可取,因为它将允许估计器的 RNG 在每个折叠中变化。

Note

在这里,cross_val_score 将使用非随机的 CV 分割器(这是默认设置),因此两个估计器都将在相同的分割上进行评估。本节不是关于分割的可变性。此外,无论我们向 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)

由于向 a 传递了一个 RandomState 实例, ab 不是严格意义上的克隆,而是统计意义上的克隆:即使对相同的数据调用 fit(X, y)ab 仍然是不同的模型。此外, ab 会相互影响,因为它们共享同一个内部随机数生成器(RNG):调用 a.fit 会消耗 b 的 RNG,调用 b.fit 会消耗 a 的 RNG,因为它们是同一个 RNG。这一点适用于任何共享 random_state 参数的估计器;它不是克隆特有的。

如果传递的是一个整数, ab 将是完全相同的克隆,它们不会相互影响。

Warning

尽管 clone 在用户代码中很少直接使用,但它在 scikit-learn 代码库中被广泛调用:特别是,大多数接受未拟合估计器的元估计器在内部调用 clone (例如 GridSearchCVStackingClassifierCalibratedClassifierCV 等)。

10.3.2.2. CV 分割器#

当传递一个 RandomState 实例时,CV 分割器每次调用 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 估计器 在每个折叠上的表现 将是一个错误: 评估估计器的分割是不同的。实际上,cross_val_score 会在内部调用 cv.split 在同一个 KFold 实例上,但每次分割都会不同。这也适用于任何通过交叉验证进行模型选择的工具,例如 GridSearchCVRandomizedSearchCV :在不同调用 search.fit 之间的分数是不可比较的,因为 cv.split 会被多次调用。然而,在单次调用 search.fit 内部,折叠间的比较是可能的,因为搜索估计器只调用 cv.split 一次。

为了在所有场景中获得可比较的折叠间结果,应该向 CV 分割器传递一个整数: cv = KFold(shuffle=True, random_state=0)

Note

虽然使用 RandomState 实例进行折叠间比较是不推荐的,但只要使用足够的折叠和数据,平均分数仍然可以得出一个估计器是否优于另一个的结论。

Note

在这个例子中重要的是传递给 KFold 的内容。无论我们传递一个 RandomState 实例还是一个整数给 make_classification 对于我们的说明目的都是不相关的。 此外,LinearDiscriminantAnalysisGaussianNB 都不是随机估计器。

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 变量声明为一个整数。然而,这可能会导致交叉验证结果不够稳健,我们将在下一节中看到。

Note

我们不推荐通过调用 np.random.seed(0) 来设置全局的 numpy 种子。有关讨论,请参见 这里

10.3.3.2. 交叉验证结果的稳健性#

当我们通过交叉验证评估随机化估计器的性能时,我们希望确保该估计器能够对新数据产生准确的预测,但我们也希望确保该估计器对其参数是稳健的。 随机初始化。例如,我们希望 SGDClassifier 的随机权重初始化在所有折叠中始终表现良好:否则,当我们在新数据上训练该估计器时,我们可能会运气不佳,随机初始化可能导致性能不佳。同样,我们希望随机森林对于每棵树将使用的随机选择的特征集合具有鲁棒性。

出于这些原因,最好通过让估计器在每个折叠上使用不同的随机数生成器(RNG)来评估交叉验证性能。这是通过将 RandomState 实例(或 None )传递给估计器初始化来完成的。

当我们传递一个整数时,估计器将在每个折叠上使用相同的 RNG:如果估计器在 CV 评估中表现良好(或不佳),可能仅仅是因为我们在这个特定种子上运气好(或不好)。传递实例会导致更稳健的 CV 结果,并使不同算法之间的比较更公平。它还有助于限制将估计器的 RNG 视为可以调整的超参数的诱惑。

无论我们向 CV 分割器传递 RandomState 实例还是整数,都不会影响鲁棒性,只要 split 只调用一次。当 split 被多次调用时,折叠之间的比较就不再可能了。因此,通常向 CV 分割器传递整数更安全,并且涵盖了大多数用例。