Ko!交叉验证还有陷阱?

教育   2024-09-23 11:30   四川  


交叉验证是数据科学家必不可少的技术,但很容易被误用。

图解机器学习中的 12 种交叉验证技术

我们在在构建机器学习模型时,特别注意需要避免的错误,今天我们一起看看~

交叉验证的意义是什么?

机器学习的基本思想是:在“训练”数据集上拟合模型,并在单独的“测试”数据上评估其性能(该数据应该模拟模型在现实世界中的表现):

from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.datasets import make_classification

# 示例数据集
X, y = make_classification(n_samples= 1000 , n_features= 20 , random_state= 42 ) 

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size= 0.2 ) 

clf = LogisticRegression() 
clf.fit(X_train, y_train) 
y_pred = clf.predict(X_test) 

rocauc = roc_auc_score(y_test, y_pred)

但是简单的训练测试分割方法存在一个问题。

当使用单个训练测试拆分X_testy_test时,拆分可能无法代表模型在生产中会遇到的数据类型。这是一个问题,因为这意味着模型在测试拆分中的表现可能无法可靠地估计生产中的表现。

交叉验证是训练稳健模型的好方法

解决方案是使用交叉验证,其中包括:

  1. 创建多个训练测试分组,
  2. 分别对每个分割点进行训练和评估你的模型,并且
  3. 计算所有测试部分的平均性能。

这将为您提供模型在实际世界中表现的更可靠估计。(更确切地说,交叉验证可以帮助您“估计底层模型的泛化误差”。)

以下是简单评估与 k 倍(5 倍)交叉验证的视觉比较:

如图所示,交叉验证过程总是从创建不同的训练测试分组开始。该例子中,我们使用 5 倍交叉验证,因此我们将创建 5 个版本的训练和测试数据。

接下来,对于每个Train分割,我们都会在该分割上训练模型并计算其在该Test分割上的表现。最后,我们计算所有Test分割的平均分数。这样,我们能够对模型的能力有了更可靠/更真实的了解,不太可能受到我们使用的特定分割策略的影响。

Scikit-learn 的cross_val_score函数是一种巧妙的方法,只需一行代码即可实现此目的:

from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.linear_model import LogisticRegression

clf = LogisticRegression() 
scores = cross_val_score(clf, X, y, cv= 5 , metric= 'roc_auc' )

那么...问题是什么?

交叉验证看起来很棒,但我发现使用简单的交叉验证策略(如 k-fold 或 leave-p-out 很少不足以确保我的模型是可靠的。

在本文的下一部分中,介绍三个常见错误以及解决这些错误所需的高级技巧。

错误#1:调整超参数时不使用嵌套交叉验证

在调整模型的超参数时,重要的是不要使用最终的测试集来重复评估不同的模型配置(因为这会导致一种微妙的泄漏)。

我这样说是什么意思呢?假设你将数据拆分为一个Train集合和一个Test集合:

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

首先使用默认超参数初始化模型,然后在你的Train集合上对其进行训练,并在你的Test集合上对其进行评估:

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score

# Initialise model with default hyperparameters
clf = RandomForestClassifier()

# Fit the model to your `Train` set
clf.fit(X_train, y_train)

# Generate predictions on `Test` and evaluate
y_pred = clf.predict(X_test)
print(roc_auc_score(y_test, y_pred))

# 0.73

0.73 AUC——还不错。

但是,假设在查看了这些结果之后,您决定尝试略微不同的超参数配置,将其设置n_estimators为 500。您在 上重新训练(新)模型Train,并再次在 上对其进行评估Test

# 使用新的 `n_estimators` 超参数值初始化模型
clf = RandomForestClassifier(n_estimators= 500 ) 

# 将模型拟合到你的 `Train` 集
clf.fit(X_train, y_train) 

# 在 `Test` 上生成预测并评估
y_pred = clf.predict(X_test) 
print (roc_auc_score(y_test, y_pred)) 

# 0.74

然后,在看到这些结果后,选择第三个超参数配置,在Train上重新训练,并在Test上进行评估:

# 使用新的 `n_estimators` 超参数值初始化模型
clf = RandomForestClassifier(n_estimators= 1000 ) 

# 将模型拟合到你的 `Train` 集
clf.fit(X_train, y_train) 

# 在 `Test` 上生成预测并评估
y_pred = clf.predict(X_test) 
print (roc_auc_score(y_test, y_pred)) 

# 0.75

我们已经达到 0.75 AUC — 这是个好消息!
或者是?
从表面上看,我们似乎改进了我们的模型。
但你看到问题了吗?

我们使用Test集合的分数来指导我们选择超参数。这是一种微妙的泄漏形式,这是一个问题,因为我们选择了针对特定Test集合定制的超参数,而不知道这些超参数是否是通用现实世界性能的最佳超参数。我们间接地将有关该Test集合的一些信息“泄露”给了我们的模型;这些信息在生产中不可用,因此不应该被使用。

增加验证集合,并保留测试集有助于防止出现这种情况:

对于每个超参数配置,我们通常都会在Train集上训练模型,并在Val集上对其进行评估,然后,一旦找到了最佳超参数,就可以使用这些超参数训练模型,并在最终的“保留”Test分割(模型尚未看到)上对其进行评估。这有助于确保训练过程的完整性并防止任何泄漏。

嵌套交叉验证的重要性

我们面临的问题是,我们选择的特定训练验证分割可能会影响我们选择的超参数

为了防止这种情况,我们可以使用交叉验证来找到最佳超参数:

*这只是从交叉验证到最终模型的一种方法。当然还有其他策略可以采用

但是,虽然这比简单的Train - Val - Test分割策略要好,但它仍然不是理想的,因为紫色Test分割可能无法代表我们在现实世界中遇到的数据。

我们又回到了简单(非交叉验证)评估方法中遇到的相同问题。

因此,我们可能想要使用嵌套交叉验证其中我们有一个交叉验证循环用于选择超参数,一个用于评估模型:

嵌套交叉验证

以下 scikit-learn 代码演示了这一点:

pipeline = Pipeline(steps=[
    ('scaler', StandardScaler()),
    ('pca', PCA(n_components=2)),
    ('classifier', RandomForestClassifier())
])

# 要调整的超参数
param_grid = { 
    'pca__n_components' : [ 2 , 5 , 10 ], 
    'classifier__n_estimators' : [ 50 , 100 ], 
    'classifier__max_depth' : [ None , 10 , 20 ], 


# 用于超参数调整的内部 CV
 grid_search = GridSearchCV(pipeline, param_grid, cv= 5 ) 

# 用于模型评估的外部 CV
 outer_cv = KFold(n_splits= 5 ) 

#嵌套 CV
 nested_score = cross_val_score(grid_search, X, y, cv=outer_cv) 

print ( "嵌套 CV 分数: " , nested_score.mean())

错误#2:时间序列数据分割不正确

时间序列数据需要特殊处理。

时间序列数据的一个定义特征是它们是自相关的,即时间序列与其自身的滞后版本呈线性相关。

这是一个问题,因为如果训练数据集包含晚于测试数据集的记录,那么模型获取到了生产中无法获得的有用信息。我们不希望我们的模型使用未来的信息进行学习;我们希望它使用过去的信息来学习趋势。

因此,我们在进行交叉验证时必须使用特殊的拆分策略。以下是我们的目标的快速可视化:

首先,我们定义保留Test集(在上图中,该Test集跨越四周,从 20224 年第 9 周到 2024 年第 13 周)。这是最终的“超出时间”划分,我们将使用它来估计模型的实际性能。

接下来,我们创建 k 个交叉验证拆分。每个Train拆分从 2023 年第 1 周开始,一直到Val拆分前一周。

Scikit-learn 提供了一个方便的TimeSeriesSplit类可以实现到这一点:

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits= 5 ) 

scores = [] 

for i, (train_index, val_index) in  enumerate (tscv.split(X)): 
    # 如果想要可视化折叠
    # print(f"Fold {i}:") 
    # print(f" Train: index={train_index}") 
    # print(f" Val: index={val_index}")

     train_X = X_train.loc[train_index] 
    train_y = y_train.loc[train_index] 
    val_X = X_train.loc[val_index] 
    val_y = y_train.loc[val_index] 

    clf = RandomForestClassifier() 
    
    # 将模型拟合到您的`Train`集
    clf.fit(train_X, train_y) 
    
    # 在`Test`上生成预测并评估
    y_pred = clf.predict(val_X)
    scores.append(roc_auc_score(val_y, y_pred))

print(np.mean(scores))

这里没有包含嵌套交叉验证 - 只有一个保留Test分割,其中包含 4 周的数据。这部分是为了简洁,部分是为了说明并不总是需要使用工具箱中的每个工具。适当的交叉验证策略将始终取决于情况。


🏴‍☠️宝藏级🏴‍☠️ 原创公众号『数据STUDIO』内容超级硬核。公众号以Python为核心语言,垂直于数据科学领域,包括可戳👉 PythonMySQL数据分析数据可视化机器学习与数据挖掘爬虫 等,从入门到进阶!

长按👇关注- 数据STUDIO -设为星标,干货速递

数据STUDIO
点击领取《Python学习手册》,后台回复「福利」获取。『数据STUDIO』专注于数据科学原创文章分享,内容以 Python 为核心语言,涵盖机器学习、数据分析、可视化、MySQL等领域干货知识总结及实战项目。
 最新文章