总会提出这样的问题:“我的模型之前表现很好——准确率超过90%!但当我提交到隐藏数据集进行测试时,现在表现却没那么好了。是哪里出了问题?”这种情况几乎总是指向数据泄露。
数据泄露指的是在数据准备阶段,测试数据中的信息悄悄(或泄露)进入了训练数据中。这通常发生在常规数据处理任务中,而你可能没有注意到。当这种情况发生时,模型学习到了它本不应该看到的测试数据,导致测试结果具有误导性。
让我们来看看常见的预处理步骤,并深入了解当数据泄露时到底会发生什么——希望你在自己的项目中能够避免这些“流程问题”。
定义
数据泄露是机器学习中的一个常见问题,它发生在模型意外地使用了本不应被模型看到的数据(如测试数据或未来数据)来训练时。这可能导致模型过拟合,并且在新数据、未见过的数据上表现不佳。
现在,让我们关注以下数据预处理步骤中的数据泄露问题。此外,我们还将看到这些步骤与具体的scikit-learn预处理方法名称相对应,并且我们将在本文的最后看到代码示例。
缺失值填充
在处理真实数据时,经常会遇到缺失值。我们可以使用合理的估计值来填充这些缺失值,而不是删除这些不完整的数据点。这有助于我们保留更多的数据进行分析。
填充缺失值的简单方法包括:
这个过程被称为填充(imputation),虽然它很有用,但我们需要小心计算这些替代值,以避免数据泄露。
数据泄露案例:简单填充(平均值)
当你使用全部数据的平均值来填充缺失值时,这个平均值本身就包含了训练集和测试集的信息。这个综合的平均值与你仅使用训练数据得到的平均值是不同的。由于这个不同的平均值进入了你的训练数据,你的模型就学习到了它本不应该看到的测试数据信息。总结来说:
? 问题
使用完整数据集计算平均值
❌ 我们的错误做法
使用训练集和测试集的统计信息来计算填充值
? 后果
训练数据包含了受测试数据影响的平均值
数据泄露案例:KNN填充
当你对所有数据使用KNN(K-最近邻)算法来填充缺失值时,该算法会从训练集和测试集中找到相似的数据点。它创建的替代值是基于这些附近点的,这意味着测试集的值会直接影响你的训练数据中的内容。由于KNN查看的是实际的附近值,因此这种训练信息和测试信息的混合甚至比使用简单的平均值填充更加直接。总结来说:
? 问题
在整个数据集中寻找邻居
❌ 我们的错误做法
使用测试集样本作为填充时的潜在邻居
? 后果
使用直接的测试集信息来填充缺失值
分类编码
有些数据是以类别而非数字的形式出现的,比如颜色、名称或类型。由于模型只能处理数字,因此我们需要将这些类别转换为数值。
常见的转换类别的方法包括:
我们转换这些类别的方式会影响模型的学习效果,并且在此过程中,我们需要小心不要使用测试集中的信息。
数据泄露案例:目标编码
当你在全部数据上使用目标编码来转换分类值时,编码值是根据训练集和测试集中的目标信息计算出来的。替换每个类别的数字是包含测试数据的目标值的平均值。这意味着你的训练数据被赋予了已经包含它本不应该知道的测试集中目标值信息的数值。总结来说:
? 问题
使用完整数据集计算类别平均值
❌ 我们的错误做法
使用所有目标值来计算类别替换
? 后果
训练特征包含未来的目标信息
数据泄露案例:独热编码
当你在使用全部数据将类别转换为二进制列后,再选择保留哪些列时,这个选择是基于在训练集和测试集中发现的模式。决定保留或删除某些二进制列会受到它们在测试数据中预测目标的效果的影响,而不仅仅是训练数据。这意味着你选择的列集合部分地由你不应该使用的测试集关系所决定。总结来说:
? 问题
从完整数据集中确定类别
❌ 我们的错误做法
基于所有唯一值创建二进制列
? 后果
特征选择受到测试集模式的影响
数据缩放
数据中的不同特征往往具有截然不同的范围——有些可能是数千,而有些则是微小的小数。我们调整这些范围,使所有特征具有相似的尺度,这有助于模型更好地工作。
调整尺度的常见方法包括:
虽然缩放有助于模型公平地比较不同的特征,但我们需要仅使用训练数据来计算这些调整,以避免数据泄露。
数据泄露案例:标准缩放
当你使用全部数据对特征进行标准化时,计算中使用的平均值和分布值来自训练集和测试集。这些值与仅使用训练数据得到的结果不同。这意味着你的训练数据中的每个标准化值都是根据测试集中值的分布信息进行调整的。总结来说:
? 问题
使用完整数据集计算统计量
❌ 我们的错误做法
使用所有值计算均值和标准差
? 后果
使用测试集分布对训练特征进行缩放
数据泄露案例:最小-最大缩放
当你使用全部数据的最小值和最大值来缩放特征时,这些边界值可能来自你的测试集。训练数据中的缩放值是根据这些边界计算出来的,这可能与仅使用训练数据得到的结果不同。这意味着你的训练数据中的每个缩放值都是根据测试集中的全部值范围进行调整的。总结来说:
? 问题
使用完整数据集确定边界
❌ 我们的错误做法
从所有数据点中确定最小/最大值
? 后果
使用测试集范围对训练特征进行归一化
离散化
有时候,将数字分组为类别而不是使用精确值会更好。这有助于机器学习模型更容易地处理和分析数据。
创建这些分组的常见方法包括:
虽然对值进行分组可以帮助模型更好地找到模式,但我们决定分组边界的方式需要仅使用训练数据来避免泄露。
数据泄露案例:等频分箱
当你使用全部数据创建具有相同数量数据点的分箱时,分箱之间的界点是根据训练集和测试集共同确定的。这些界点与仅使用训练数据得到的界点不同。这意味着当你在训练数据中将数据点分配到分箱中时,你使用的是受到测试集值影响的分界点。总结来说:
? 问题
使用完整数据集设置阈值
❌ 我们的错误做法
使用所有数据点确定分箱边界
? 后果
使用测试集分布对训练数据进行分箱
数据泄露案例:等宽分箱
当你使用全部数据创建大小相等的分箱时,用于确定分箱宽度的范围来自于训练集和测试集的总和。这个总范围可能比仅使用训练数据得到的范围更宽或更窄。这意味着当你在训练数据中将数据点分配到分箱中时,你使用的是基于测试集值全范围计算出来的分箱边界。总结来说:
? 问题
使用完整数据集计算范围
❌ 我们的错误做法
基于完整数据范围设置分箱宽度
? 后果
使用测试集边界对训练数据进行分箱
重采样
当数据中的某些类别拥有的样本数量远多于其他类别时,我们可以使用imblearn中的重采样技术来平衡它们,方法是创建新样本或移除现有样本。这有助于模型公平地学习所有类别。
增加样本的常见方法(过采样):
移除样本的常见方法(欠采样):
虽然平衡数据有助于模型更好地学习,但创建或移除样本的过程应仅使用训练数据中的信息,以避免数据泄露。
数据泄露案例:过采样(SMOTE)
当你在全部数据上使用SMOTE创建合成数据点时,算法会从训练集和测试集中选择附近的点来创建新样本。这些新点是通过混合测试集样本和训练数据的值来创建的。这意味着你的训练数据获得了直接使用测试集值信息创建的新样本。总结来说:
? 问题
使用完整数据集生成样本
❌ 我们的错误做法
使用测试集邻居创建合成点
? 后果
训练数据被添加了受测试集影响的样本
数据泄露案例:欠采样(Tomek链接)
当你在全部数据上使用Tomek链接移除数据点时,算法会找到训练集和测试集中彼此最接近但标签不同的点对。是否从训练数据中移除点的决定是基于它们与测试集点的接近程度。这意味着你的最终训练数据会受到与测试集值关系的影响。总结来说:
? 问题
使用完整数据集移除样本
❌ 我们的错误做法
利用测试集关系识别点对
? 后果
基于测试集模式减少训练数据
总结
在数据预处理阶段,你必须确保训练数据和测试数据完全分离。任何时候,如果你使用全部数据的信息来转换值——无论是填充缺失值、将类别转换为数字、缩放特征、创建分箱还是平衡类别——你都有可能将测试数据的信息混入训练数据中。这会导致模型的测试结果不可靠,因为模型已经学习到了它本不应该看到的模式。
解决方法很简单:始终先转换你的训练数据,保存这些计算,然后再应用到你的测试数据上。
数据预处理 + 分类(存在泄露)代码总结
让我们来看看在预测一个简单的高尔夫球比赛数据集时,数据泄露是如何发生的。这是一个不好的例子,不应该被效仿。仅用于演示和教育目的。
import pandas as pd
import numpy as np
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OrdinalEncoder, KBinsDiscretizer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
# Create dataset
dataset_dict = {
'Outlook': ['sunny', 'sunny', 'overcast', 'rain', 'rain', 'rain', 'overcast', 'sunny', 'sunny', 'rain', 'sunny', 'overcast', 'overcast', 'rain', 'sunny', 'overcast', 'rain', 'sunny', 'sunny', 'rain', 'overcast', 'rain', 'sunny', 'overcast', 'sunny', 'overcast', 'rain', 'overcast'],
'Temperature': [85.0, 80.0, 83.0, 70.0, 68.0, 65.0, 64.0, 72.0, 69.0, 75.0, 75.0, 72.0, 81.0, 71.0, 81.0, 74.0, 76.0, 78.0, 82.0, 67.0, 85.0, 73.0, 88.0, 77.0, 79.0, 80.0, 66.0, 84.0],
'Humidity': [85.0, 90.0, 78.0, 96.0, 80.0, 70.0, 65.0, 95.0, 70.0, 80.0, 70.0, 90.0, 75.0, 80.0, 88.0, 92.0, 85.0, 75.0, 92.0, 90.0, 85.0, 88.0, 65.0, 70.0, 60.0, 95.0, 70.0, 78.0],
'Wind': [False, True, False, False, False, True, True, False, False, False, True, True, False, True, True, False, False, True, False, True, True, False, True, False, False, True, False, False],
'Play': ['No', 'No', 'Yes', 'Yes', 'Yes', 'No', 'Yes', 'No', 'Yes', 'Yes', 'Yes', 'Yes', 'Yes', 'No', 'No', 'Yes', 'Yes', 'No', 'No', 'No', 'Yes', 'Yes', 'Yes', 'Yes', 'Yes', 'Yes', 'No', 'Yes']
}
df = pd.DataFrame(dataset_dict)
X, y = df.drop('Play', axis=1), df['Play']
# Preprocess AND apply SMOTE to ALL data first (causing leakage)
preprocessor = ColumnTransformer(transformers=[
('temp_transform', Pipeline([
('imputer', SimpleImputer(strategy='mean')),
('scaler', StandardScaler()),
('discretizer', KBinsDiscretizer(n_bins=4, encode='ordinal'))
]), ['Temperature']),
('humid_transform', Pipeline([
('imputer', SimpleImputer(strategy='mean')),
('scaler', StandardScaler()),
('discretizer', KBinsDiscretizer(n_bins=4, encode='ordinal'))
]), ['Humidity']),
('outlook_transform', OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1),
['Outlook']),
('wind_transform', Pipeline([
('imputer', SimpleImputer(strategy='constant', fill_value=False)),
('scaler', StandardScaler())
]), ['Wind'])
])
# Transform all data and apply SMOTE before splitting (leakage!)
X_transformed = preprocessor.fit_transform(X)
smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_transformed, y)
# Split the already transformed and resampled data
X_train, X_test, y_train, y_test = train_test_split(X_resampled, y_resampled, test_size=0.5, shuffle=False)
# Train a classifier
clf = DecisionTreeClassifier(random_state=42)
clf.fit(X_train, y_train)
print(f"Testing Accuracy (with leakage): {accuracy_score(y_test, clf.predict(X_test)):.2%}")
上面的代码使用了ColumnTransformer,这是scikit-learn中的一个实用工具,允许我们对数据集中的不同列应用不同的预处理步骤。
无数据泄露的数据预处理 + 分类代码总结
以下是无数据泄露的版本:
import pandas as pd
import numpy as np
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OrdinalEncoder, KBinsDiscretizer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
# Create dataset
dataset_dict = {
'Outlook': ['sunny', 'sunny', 'overcast', 'rain', 'rain', 'rain', 'overcast', 'sunny', 'sunny', 'rain', 'sunny', 'overcast', 'overcast', 'rain', 'sunny', 'overcast', 'rain', 'sunny', 'sunny', 'rain', 'overcast', 'rain', 'sunny', 'overcast', 'sunny', 'overcast', 'rain', 'overcast'],
'Temperature': [85.0, 80.0, 83.0, 70.0, 68.0, 65.0, 64.0, 72.0, 69.0, 75.0, 75.0, 72.0, 81.0, 71.0, 81.0, 74.0, 76.0, 78.0, 82.0, 67.0, 85.0, 73.0, 88.0, 77.0, 79.0, 80.0, 66.0, 84.0],
'Humidity': [85.0, 90.0, 78.0, 96.0, 80.0, 70.0, 65.0, 95.0, 70.0, 80.0, 70.0, 90.0, 75.0, 80.0, 88.0, 92.0, 85.0, 75.0, 92.0, 90.0, 85.0, 88.0, 65.0, 70.0, 60.0, 95.0, 70.0, 78.0],
'Wind': [False, True, False, False, False, True, True, False, False, False, True, True, False, True, True, False, False, True, False, True, True, False, True, False, False, True, False, False],
'Play': ['No', 'No', 'Yes', 'Yes', 'Yes', 'No', 'Yes', 'No', 'Yes', 'Yes', 'Yes', 'Yes', 'Yes', 'No', 'No', 'Yes', 'Yes', 'No', 'No', 'No', 'Yes', 'Yes', 'Yes', 'Yes', 'Yes', 'Yes', 'No', 'Yes']
}
df = pd.DataFrame(dataset_dict)
X, y = df.drop('Play', axis=1), df['Play']
# Split first (before any processing)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, shuffle=False)
# Create pipeline with preprocessing, SMOTE, and classifier
pipeline = Pipeline([
('preprocessor', ColumnTransformer(transformers=[
('temp_transform', Pipeline([
('imputer', SimpleImputer(strategy='mean')),
('scaler', StandardScaler()),
('discretizer', KBinsDiscretizer(n_bins=4, encode='ordinal'))
]), ['Temperature']),
('humid_transform', Pipeline([
('imputer', SimpleImputer(strategy='mean')),
('scaler', StandardScaler()),
('discretizer', KBinsDiscretizer(n_bins=4, encode='ordinal'))
]), ['Humidity']),
('outlook_transform', OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1),
['Outlook']),
('wind_transform', Pipeline([
('imputer', SimpleImputer(strategy='constant', fill_value=False)),
('scaler', StandardScaler())
]), ['Wind'])
])),
('smote', SMOTE(random_state=42)),
('classifier', DecisionTreeClassifier(random_state=42))
])
# Fit pipeline on training data only
pipeline.fit(X_train, y_train)
print(f"Training Accuracy: {accuracy_score(y_train, pipeline.predict(X_train)):.2%}")
print(f"Testing Accuracy: {accuracy_score(y_test, pipeline.predict(X_test)):.2%}")