十種數據預處理中的數據泄露模式解析:識別與避免策略
在機器學習教學實踐中,我們常會遇到這樣一個問題:"模型表現非常出色,準確率超過90%!但當將其提交到隱藏數據集進行測試時,效果卻大打折扣。問題出在哪里?"這種情況幾乎總是與數據泄露有關。
當測試數據在數據準備階段無意中泄露(滲透)到訓練數據時,就會發生數據泄露。這種情況經常出現在常規數據處理任務中,而你可能并未察覺。當泄露發生時,模型會從本不應看到的測試數據中學習,導致測試結果失真。
數據泄露的定義
數據泄露是機器學習中的一個常見問題,發生在不應被模型看到的數據(如測試數據或未來數據)意外地被用于訓練模型時。這可能導致模型過擬合,并在新的、未見數據上表現不佳。
我們將聚焦以下數據預處理步驟中的數據泄露問題。并將結合scikit-learn中的具體預處理方法,并在文章末尾給出代碼示例。
缺失值填充
在處理真實數據時,經常會遇到缺失值。與其刪除這些不完整的數據點,不如用合理的估計值填充它們。這有助于我們保留更多的數據用于分析。
填充缺失值的簡單方法包括:
- 使用SimpleImputer(strategy='mean')或SimpleImputer(strategy='median')將缺失值填充為該列的平均值或中位數
- 使用KNNImputer()查看相似的數據點并使用它們的值
- 使用SimpleImputer(strategy='ffill')或SimpleImputer(strategy='bfill')將缺失值填充為數據中前一個或后一個值
- 使用SimpleImputer(strategy='constant', fill_value=value)將所有缺失值替換為相同的數字或文本
這個過程被稱為填充,雖然很有用,但我們需要謹慎計算這些替換值,以避免數據泄露。
數據泄露案例:簡單填充(平均值)
當你使用所有數據的平均值填充缺失值時,平均值本身包含了訓練集和測試集的信息。這個合并的平均值與你僅使用訓練數據計算所得不同。由于這個不同的平均值會進入你的訓練數據,你的模型實際上從本不應看到的測試數據信息中學習。
- 問題所在使用完整數據集計算平均值
- 錯誤做法使用訓練集和測試集的統計數據計算填充值
- 后果訓練數據包含受測試數據影響的平均值
當使用所有數據行計算的平均值(4)填充缺失值,而非正確地僅使用訓練數據的平均值(3)時,就會發生平均值填充泄露,導致錯誤的填充值。
數據泄露案例:KNN填充
當在所有數據上使用KNN填充缺失值時,該算法會從訓練集和測試集中找到相似的數據點。它創建的替換值基于這些鄰近點,這意味著測試集值直接影響了訓練數據。由于KNN查看實際的鄰近值,相比簡單的平均值填充,這種訓練和測試信息的混合更加直接。
- 問題所在在完整數據集中尋找鄰居
- 錯誤做法 使用測試集樣本作為填充的潛在鄰居
- 后果使用直接的測試集信息填充缺失值
當使用訓練數據和測試數據找到最近鄰(得到值3.5和4.5)時,就會發生KNN填充泄露;而正確的做法是僅使用訓練數據模式填充缺失值(得到值6和6)。
分類編碼
有些數據以類別而非數字的形式呈現,如顏色、名稱或類型。由于模型只能處理數字,我們需要將這些類別轉換為數值。
常見的類別轉換方法包括:
- 使用OneHotEncoder()為每個類別創建單獨的由1和0組成的列(也稱為虛擬變量)
- 使用OrdinalEncoder()或LabelEncoder()為每個類別分配一個數字(如1、2、3)
- 使用OrdinalEncoder(categories=[ordered_list])自定義類別順序,以反映自然層次結構(如small=1, medium=2, large=3)
- 使用TargetEncoder()根據類別與我們試圖預測的目標變量之間的關系將類別轉換為數字
轉換類別的方式會影響模型的學習效果,在此過程中需要注意不要使用來自測試數據的信息。
數據泄露案例:目標編碼
當使用所有數據的目標編碼轉換分類值時,編碼值是使用來自訓練集和測試集的目標信息計算的。替換每個類別的數字是目標值的平均值,其中包括測試數據。這意味著訓練數據被分配的值已經包含了本不應知道的測試集目標值信息。
- 問題所在使用完整數據集計算類別平均值
- 錯誤做法使用所有目標值計算類別替換
- 后果訓練特征包含未來目標信息
當使用所有數據替換類別的平均目標值(A=3, B=4, C=2)時,就會發生目標編碼泄露;而正確的做法是僅使用訓練數據的平均值(A=2, B=5, C=1),否則會導致錯誤的類別值。
數據泄露案例:One-Hot編碼
當使用所有數據將類別轉換為二進制列,然后選擇要保留的列時,選擇是基于在訓練集和測試集中發現的模式。保留或刪除某些二進制列的決定受到它們在測試數據中預測目標效果的影響,而不僅僅是訓練數據。這意味著選擇的列部分取決于本不應使用的測試集關系。
- 問題所在從完整數據集確定類別
- 錯誤做法基于所有唯一值創建二進制列
- 后果 特征選擇受測試集模式影響
當使用完整數據集的所有唯一值(A,B,C,D)創建類別列時,就會發生One-Hot編碼泄露;而正確的做法是僅使用訓練數據中存在的類別(A,B,C),否則會導致錯誤的編碼模式。
數據縮放
數據中不同特征的取值范圍差異通常很大,有些可能是幾千,有些則是微小的小數。調整這些范圍,使所有特征具有相似的尺度,以幫助模型更好地工作。
常見的尺度調整方法包括:
- 使用StandardScaler()使值以0為中心,大多數值落在-1和1之間(均值=0,方差=1)
- 使用MinMaxScaler()將所有值壓縮在0和1之間,或使用MinMaxScaler(feature_range=(min, max))自定義范圍
- 使用FunctionTransformer(np.log1p)或PowerTransformer(method='box-cox')處理非常大的數字,使分布更正態
- 使用RobustScaler()采用不受異常值影響的統計數據調整尺度(使用四分位數而非均值/方差)
雖然縮放有助于模型公平地比較不同特征,但我們需要僅使用訓練數據計算這些調整,以避免泄露。
數據泄露案例:標準縮放
當使用所有數據對特征進行標準化時,計算中使用的平均值和分布值來自訓練集和測試集。這些值與僅使用訓練數據所得不同。這意味著訓練數據中的每個標準化值都使用了測試集中值的分布信息進行了調整。
- 問題所在 使用完整數據集計算統計數據
- 錯誤做法使用所有值計算均值和標準差
- 后果使用測試集分布縮放訓練特征
當使用完整數據集的平均值(μ=0)和分布(σ=3)對數據進行歸一化時,就會發生標準縮放泄露;而正確的做法是僅使用訓練數據的統計數據(μ=2,σ=2),否則會導致錯誤的標準化值。
數據泄露案例:最小-最大縮放
當使用所有數據的最小值和最大值縮放特征時,這些邊界值可能來自測試集。訓練數據中的縮放值是使用這些邊界計算的,這可能與僅使用訓練數據所得結果不同。這意味著你訓練數據中的每個縮放值都使用了測試集中值的完整范圍進行了調整。
- 問題所在使用完整數據集找到邊界
- 錯誤做法從所有數據點確定最小/最大值
- 后果使用測試集范圍歸一化訓練特征
當使用完整數據集的最小值(-5)和最大值(5)縮放數據時,就會發生最小-最大縮放泄露;而正確的做法是僅使用訓練數據的范圍(最小值=-1,最大值=5),否則會導致值的錯誤縮放。
離散化
有時將數字分組為類別比使用精確值更有利。這有助于機器學習模型更輕松地處理和分析數據。
創建這些組的常見方法包括:
- 使用KBinsDiscretizer(strategy='uniform')使每個組覆蓋相同大小范圍的值
- 使用KBinsDiscretizer(strategy='quantile')使每個組包含相同數量的數據點
- 使用KBinsDiscretizer(strategy='kmeans')通過聚類找到數據中的自然分組
- 使用QuantileTransformer(n_quantiles=n, output_distributinotallow='uniform')根據數據中的百分位數創建組
雖然對值進行分組可以幫助模型更好地找到模式,但我們決定組邊界的方式需要僅使用訓練數據,以避免泄露。
數據泄露案例:等頻分箱
當使用所有數據創建具有相等數量數據點的箱時,箱之間的切割點是使用訓練集和測試集確定的。這些切割值與僅使用訓練數據所得結果不同。這意味著當你將訓練數據中的數據點分配到箱中時,使用的分割點受到了測試集值的影響。
- 問題所在使用完整數據集設置閾值
- 錯誤做法使用所有數據點確定箱邊界
- 后果使用測試集分布對訓練數據分箱
當使用所有數據設置箱切割點(-0.5,2.5)時,就會發生等頻分箱泄露;而正確的做法是僅使用訓練數據設置邊界(-0.5,2.0),否則會導致值的錯誤分組。
數據泄露案例:等寬分箱
當使用所有數據創建相等大小的箱時,用于確定箱寬度的范圍來自訓練集和測試集。這個總范圍可能比僅使用訓練數據所得范圍更寬或更窄。這意味著當你將訓練數據中的數據點分配到箱中時,你使用的是基于測試集值的完整分布計算得到的箱邊界。
- 問題所在 使用完整數據集計算范圍
- 錯誤做法基于完整數據分布設置箱寬度
- 后果使用測試集邊界對訓練數據分箱
圖片
當使用完整數據集的范圍(-3到6)將數據拆分為大小相等的組時,就會發生等寬分箱泄露;而正確的做法是僅使用訓練數據的范圍(-3到3),否則會導致錯誤的分組。
重采樣
當數據中某些類別的樣本數量遠多于其他類別時,我們可以使用imblearn中的重采樣技術通過創建新樣本或移除現有樣本來平衡它們。這有助于模型公平地學習所有類別。
添加樣本的常見方法(過采樣):
- 使用RandomOverSampler()復制較小類別中的現有樣本
- 使用SMOTE()使用插值為較小類別創建新的合成樣本
- 使用ADASYN()在模型最難處理的區域創建更多樣本,重點關注決策邊界 移除樣本的常見方法(欠采樣):
- 使用RandomUnderSampler()從較大類別中隨機移除樣本
- 使用NearMiss(versinotallow=1)或NearMiss(versinotallow=2)根據它們與較小類別的距離從較大類別中移除樣本
- 使用TomekLinks()或EditedNearestNeighbours()根據它們與其他類別的相似性仔細選擇要移除的樣本
雖然平衡數據有助于模型學習,但創建或移除樣本的過程應僅使用訓練數據的信息,以避免泄露。
數據泄露案例:過采樣(SMOTE)
當使用所有數據上的SMOTE創建合成數據點時,該算法會從訓練集和測試集中選取附近的點來創建新樣本。這些新點是通過將測試集樣本的值與訓練數據混合創建的。這意味著你的訓練數據獲得了直接使用測試集值信息創建的新樣本。
- 問題所在使用完整數據集生成樣本
- 錯誤做法使用測試集鄰居創建合成點
- 后果 訓練數據被測試集影響的樣本增強
當根據整個數據集的類別計數復制數據點(A×4, B×3, C×2)時,就會發生過采樣泄露;而正確的做法是僅使用訓練數據(A×1, B×2, C×2)來決定每個類別要復制的次數。
數據泄露案例:欠采樣(TomekLinks)
當使用所有數據上的Tomek Links移除數據點時,該算法會從訓練集和測試集中找到最接近但標簽不同的點對。從訓練數據中移除點的決定基于它們與測試集點的接近程度。這意味著你的最終訓練數據是由其與測試集值的關系塑造的。
- 問題所在使用完整數據集移除樣本
- 錯誤做法使用測試集關系識別點對
- 后果 基于測試集模式減少訓練數據
當根據整個數據集的類別比例移除數據點(A×4, B×3, C×2)時,就會發生欠采樣泄露;而正確的做法是僅使用訓練數據(A×1, B×2, C×2)來決定每個類別要保留的樣本數量。
最后總結
在預處理數據時,需要將訓練數據和測試數據完全分開。任何時候使用來自所有數據的信息來轉換值-無論是填充缺失值,將類別轉換為數字,縮放特征,分箱還是平衡類-都有可能將測試數據信息混合到訓練數據中。這使得模型的測試結果不可靠,因為模型已經從它不應該看到的模式中學習了。
解決方案很簡單:始終首先轉換訓練數據,保存這些計算,然后將其應用于測試數據。
數據預處理+分類(帶泄漏)代碼
讓我們看看在預測一個簡單的高爾夫比賽數據集時,泄漏是如何發生的。
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%}")
與泄漏版本的關鍵區別在于:
在進行任何處理之前,先拆分數據所有轉換(預處理、SMOTE),預處理僅從訓練數據中學習的參數,SMOTE僅適用于訓練數據。在預測之前,測試數據完全不可見
這種方法提供了更現實的性能估計,因為它在訓練和測試數據之間保持了適當的分離。