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的管道是防止数据泄露的好方法,因为它确保在正确的数据子集上执行适当的方法。管道非常适合用于交叉验证和超参数调整功能。
下面详细介绍了预处理期间数据泄露的一个例子。
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
在这里,我们再次建议使用管道
将特征选择和模型估计器链接在一起。管道确保在执行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"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
参数的默认值。
我们在这里说明了估计器和 CV 拆分器的这些规则。
注意
由于传递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
时被消耗(即被修改),并且这个修改后的 RNG 将在后续调用fit
时使用。此外,rng
对象在所有使用它的对象之间共享,因此,这些对象变得有点相互依赖。例如,两个共享相同RandomState
实例的估计器会相互影响,正如我们将在后面讨论克隆时看到的那样。在调试时,这一点很重要。
如果我们将一个整数传递给SGDClassifier
的random_state
参数,我们将获得相同的模型,因此每次都会获得相同的得分。当我们传递一个整数时,相同的 RNG 将在所有对fit
的调用中使用。内部发生的事情是,即使 RNG 在调用fit
时被消耗,它也会在fit
开始时始终重置到其原始状态。
10.3.1.2. CV 拆分器#
当传递一个 RandomState
实例时,随机 CV 分割器具有类似的行为;多次调用 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:这意味着随机森林估计器的所有随机特征在 CV 过程的 5 个折叠中都将相同。特别是,估计器的(随机选择的)特征子集在所有折叠中都将相同。由于
rf_inst
传递了一个RandomState
实例,因此每次调用fit
都从不同的 RNG 开始。因此,随机特征子集在每个折叠中都将不同。
虽然在折叠之间保持恒定的估计器 RNG 本身并不错误,但我们通常希望 CV 结果对估计器的随机性具有鲁棒性。因此,传递一个实例而不是一个整数可能更可取,因为它将允许估计器 RNG 在每个折叠中发生变化。
注意
在这里,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)
由于 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. 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
将在内部对同一个 KFold
实例调用 cv.split
,但分割每次都会不同。对于任何通过交叉验证执行模型选择的工具,例如 GridSearchCV
和 RandomizedSearchCV
,情况也是如此:分数在不同调用 search.fit
时,无法在折叠之间进行比较,因为 cv.split
会被多次调用。然而,在单个调用 search.fit
内,折叠之间的比较是可能的,因为搜索估计器只调用 cv.split
一次。
为了在所有情况下获得可比较的折叠间结果,应该将一个整数传递给 CV 分割器:cv = KFold(shuffle=True, random_state=0)
。
注意
虽然不建议使用 RandomState
实例进行折叠间比较,但只要使用足够的折叠和数据,就可以预期平均分数能够得出结论,即一个估计器是否优于另一个。
注意
在这个例子中,重要的是传递给 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
的随机权重初始化在所有折叠中始终保持良好:否则,当我们在新数据上训练该估计器时,我们可能会遇到运气不好,随机初始化可能会导致糟糕的性能。同样,我们希望随机森林对其随机选择的特征集(每棵树将使用这些特征集)具有稳健性。
出于这些原因,最好通过让估计器在每个折叠上使用不同的 RNG 来评估交叉验证性能。这是通过将 RandomState
实例(或 None
)传递给估计器初始化来完成的。
当我们传递一个整数时,估计器将在每个折叠上使用相同的 RNG:如果估计器表现良好(或糟糕),如 CV 所评估的那样,这可能仅仅是因为我们对该特定种子很幸运(或不幸)。传递实例会导致更稳健的 CV 结果,并使各种算法之间的比较更加公平。它还有助于限制将估计器的 RNG 视为可以调整的超参数的诱惑。
无论我们传递 RandomState
实例还是整数到 CV 分割器,只要 split
只调用一次,都不会影响稳健性。当 split
被多次调用时,折叠之间的比较就不再可能。因此,将整数传递给 CV 分割器通常更安全,并且涵盖了大多数用例。