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
再次強調,我們建議使用 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),
... 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
已產生不同的模型,即使資料相同。這是因為在呼叫 fit
時,會消耗(即變異)估計器的隨機數產生器 (RNG),並且這個變異的 RNG 將在後續的 fit
呼叫中使用。此外,rng
物件在所有使用它的物件之間共用,因此這些物件會變得有些相互依賴。例如,共享相同 RandomState
實例的兩個估計器會相互影響,這將在稍後討論複製時看到。這個重點在偵錯時要牢記在心。
如果我們將整數傳遞給 SGDClassifier
的 random_state
參數,我們每次都會獲得相同的模型,因此分數也會相同。當我們傳遞整數時,在所有 fit
呼叫中都會使用相同的 RNG。內部發生的是,即使在呼叫 fit
時會消耗 RNG,它始終會在 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
,情況也是如此:由於 cv.split
會被呼叫多次,因此不同 search.fit
呼叫之間的折疊對折疊分數是無法比較的。然而,在單次呼叫 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 分割器通常更安全,並且涵蓋了大多數使用案例。