12. 常见陷阱与推荐做法#
本章旨在说明在使用 scikit-learn 时出现的一些常见陷阱和反模式。它提供了不该做什么的示例,以及相应的正确示例。
12.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...
流水线 (Pipeline) 还有助于避免另一个常见陷阱:将测试数据泄露到训练数据中。
12.2. 数据泄露#
数据泄露发生在构建模型时使用了预测时不可用的信息。这会导致性能估计过于乐观(例如通过 交叉验证),从而导致模型在实际新数据上(例如在生产期间)表现较差。
一个常见原因是未将测试和训练数据子集分开。测试数据永远不应用于对模型做出选择。通用的规则是永远不要在测试数据上调用 fit。虽然这听起来显而易见,但在某些情况下(例如应用某些预处理步骤时)很容易被忽略。
虽然训练和测试数据子集都应接受相同的预处理转换(如上一节所述),但重要的是,这些转换仅从训练数据中学习。例如,如果您有一个除以平均值的归一化步骤,则该平均值应为训练子集的平均值,而不是所有数据的平均值。如果测试子集包含在平均值计算中,测试子集的信息就会影响模型。
12.2.1. 如何避免数据泄露#
以下是一些避免数据泄露的提示
务必先将数据划分为训练和测试子集,尤其是在执行任何预处理步骤之前。
使用
fit和fit_transform方法时,永远不要包含测试数据。使用所有数据(例如fit(X))会导致分数过于乐观。相反,
transform方法应该在训练和测试子集上都使用,因为相同的预处理应该应用于所有数据。这可以通过在训练子集上使用fit_transform并在测试子集上使用transform来实现。scikit-learn 流水线 (Pipeline) 是防止数据泄露的好方法,因为它确保在正确的子集上执行适当的方法。流水线是用于交叉验证和超参数调整函数的理想选择。
下面详述了预处理期间数据泄露的一个示例。
12.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 HistGradientBoostingClassifier
>>> 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 = HistGradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train, y_train)
HistGradientBoostingClassifier(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 = HistGradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train_selected, y_train)
HistGradientBoostingClassifier(random_state=1)
>>> X_test_selected = select.transform(X_test)
>>> y_pred = gbc.predict(X_test_selected)
>>> accuracy_score(y_test, y_pred)
0.5
在这里,我们再次建议使用 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),
... HistGradientBoostingClassifier(random_state=1))
>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('selectkbest', SelectKBest(k=25)),
('histgradientboostingclassifier',
HistGradientBoostingClassifier(random_state=1))])
>>> y_pred = pipeline.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.5
流水线也可以输入到交叉验证函数中,例如 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.43+/-0.05
12.3. 控制随机性#
有些 scikit-learn 对象本质上是随机的。这些通常是评估器(例如 RandomForestClassifier)和交叉验证拆分器(例如 KFold)。这些对象的随机性通过其 random_state 参数进行控制,如 术语表 中所述。本节扩展了术语表条目,并描述了有关这个微妙参数的良好实践和常见陷阱。
注意
建议摘要
为了获得最佳的交叉验证 (CV) 结果稳健性,在创建评估器时传递 RandomState 实例,或者将 random_state 设置为 None。向 CV 拆分器传递整数通常是最安全的选择,也是首选方案;向拆分器传递 RandomState 实例有时对于实现非常特定的用例可能很有用。对于评估器和拆分器,传递整数与传递实例(或 None)会导致细微但显著的差异,尤其是对于 CV 流程。在报告结果时,理解这些差异非常重要。
为了在多次执行中获得可重复的结果,请移除所有对 random_state=None 的使用。
12.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。
12.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 参数传递了一个整数,我们将每次都获得相同的模型,从而获得相同的分数。当我们传递整数时,所有 fit 调用都使用同一个 RNG。内部发生的是,即使 RNG 在调用 fit 时被消耗,它在 fit 开始时始终会重置为其初始状态。
12.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 来比较多个评估器的性能,这可能会导致意外结果,我们将在下一节中看到。
12.3.2. 常见陷阱与微妙之处#
虽然管理 random_state 参数的规则看似简单,但它们确实具有一些微妙的影响。在某些情况下,这甚至会导致错误的结论。
12.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 个折叠 (folds) 中都是相同的。特别是,评估器的(随机选择的)特征子集在所有折叠中将是相同的。由于为
rf_inst传递了一个RandomState实例,每次调用fit时都从不同的 RNG 开始。因此,每个折叠的随机特征子集都会不同。
虽然各折叠之间具有恒定的评估器 RNG 本身并没有错,但我们通常希望 CV 结果对于评估器的随机性是稳健的。因此,传递实例而不是整数可能更可取,因为这将允许评估器的 RNG 随每个折叠而变化。
注意
在这里,cross_val_score 将使用非随机的 CV 拆分器(默认设置),因此两个评估器都将在相同的拆分上进行评估。本节不是关于拆分的变异性。此外,无论我们向 make_classification 传递整数还是实例,对于我们的说明目的而言并不重要:重要的是我们向 RandomForestClassifier 评估器传递了什么。
克隆 (Cloning)#
传递 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 实例,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 等)。
12.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 传递了什么。对于我们的说明目的而言,我们向 make_classification 传递 RandomState 实例还是整数并不相关。此外,LinearDiscriminantAnalysis 和 GaussianNB 都不是随机评估器。
12.3.3. 一般建议#
12.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 种子。有关讨论,请参阅 此处。
12.3.3.2. 交叉验证结果的稳健性#
当我们通过交叉验证评估随机评估器的性能时,我们希望确保评估器能够为新数据产生准确的预测,但我们也希望确保评估器对于其随机初始化是稳健的。例如,我们希望 SGDClassifier 的随机权重初始化在所有折叠中始终保持良好性能:否则,当我们在新数据上训练该评估器时,我们可能会不走运,随机初始化可能会导致较差的性能。同样,我们希望随机森林对于每棵树将使用的随机选择特征集是稳健的。
由于这些原因,通过让评估器在每个折叠上使用不同的 RNG 来评估交叉验证性能是更可取的。这通过在评估器初始化时传递 RandomState 实例(或 None)来实现。
当我们传递整数时,评估器将在每个折叠上使用相同的 RNG:如果评估器表现良好(或差),正如 CV 评估的那样,那可能只是因为我们在该特定种子上运气好(或运气不好)。传递实例会导致更稳健的 CV 结果,并使各种算法之间的比较更公平。这也有助于限制将评估器的 RNG 视为可以调优的超参数的诱惑。
只要 split 仅被调用一次,向 CV 拆分器传递 RandomState 实例还是整数对稳健性没有影响。当 split 被多次调用时,折叠间比较就不再可能。因此,向 CV 拆分器传递整数通常更安全,并涵盖了大多数用例。