機器學習學習筆記之——演算法鏈與管道

前丨塵憶·夢發表於2020-12-27

演算法鏈與管道

對於許多機器學習演算法,你提供的特定資料表示非常重要首先對資料進行縮放,然後手動合併特徵,再利用無監督機器學習來學習特徵。因此,大多數機器學習應用不僅需要應用單個演算法,而且還需要將許多不同的處理步驟和機器學習模型連結在一起。本章將介紹如何使用 Pipeline 類來簡化構建變換和模型鏈的過程。我們將重點介紹如何將 PipelineGridSearchCV 結合起來,從而同時搜尋所有處理步驟中的引數。

舉一個例子來說明模型鏈的重要性。我們知道,可以通過使用 MinMaxScaler 進行預處理來大大提高核 SVMcancer 資料集上的效能。下面這些程式碼實現了劃分資料、計算最小值和最大值、縮放資料與訓練 SVM

from sklearn.svm import SVC
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler

# 載入並劃分資料
cancer = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    cancer.data, cancer.target, random_state=0)

# 計算訓練資料的最小值和最大值
scaler = MinMaxScaler().fit(X_train)

# 對訓練資料進行縮放
X_train_scaled = scaler.transform(X_train)

svm = SVC()
# 在縮放後的訓練資料上學習SVM
svm.fit(X_train_scaled, y_train)
# 對測試資料進行縮放,並計算縮放後的資料的分數
X_test_scaled = scaler.transform(X_test)
print("Test score: {:.2f}".format(svm.score(X_test_scaled, y_test)))
# Test score: 0.97

1、用預處理進行引數選擇

現在,假設我們希望利用 GridSearchCV 找到更好的 SVC 引數。 我們應該怎麼做?一種簡單的方法可能如下所示:

from sklearn.model_selection import GridSearchCV
# 只是為了說明,不要在實踐中使用這些程式碼!
param_grid = {'C': [0.001, 0.01, 0.1, 1, 10, 100],
              'gamma': [0.001, 0.01, 0.1, 1, 10, 100]}
grid = GridSearchCV(SVC(), param_grid=param_grid, cv=5)
grid.fit(X_train_scaled, y_train)
print("Best cross-validation accuracy: {:.2f}".format(grid.best_score_))
# Best cross-validation accuracy: 0.98
print("Best parameters: ", grid.best_params_)
# Best parameters:  {'C': 1, 'gamma': 1}
print("Test set accuracy: {:.2f}".format(grid.score(X_test_scaled, y_test)))
# Test set accuracy: 0.97

這裡我們利用縮放後的資料對 SVC 引數進行網格搜尋。但是,上面的程式碼中有一個不易察覺的陷阱在縮放資料時,我們使用了訓練集中的所有資料來找到訓練的方法。然後,我們使用縮放後的訓練資料來執行帶交叉驗證的網格搜尋。對於交叉驗證中的每次劃分,原始訓練集的一部分被劃分為訓練部分,另一部分被劃分為測試部分。測試部分用於度量在訓練部分上所訓練的模型在新資料上的表現。但是,我們在縮放資料時已經使用過測試部分中所包含的資訊。請記住,交叉驗證每次劃分的測試部分都是訓練集的一部分,我們使用整個訓練集的資訊來找到資料的正確縮放

對於模型來說,這些資料與新資料看起來截然不同。如果我們觀察新資料(比如測試集中的資料),那麼這些資料並沒有用於對訓練資料進行縮放,其最大值和最小值也可能與訓練資料不同。下面這個例子顯示了交叉驗證與最終評估這兩個過程中資料處理的不同之處

mglearn.plots.plot_improper_processing()

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-3jWR20S0-1609069765904)(素材/在交叉驗證迴圈之外進行預處理時的資料使用情況.png)]

因此,對於建模過程,交叉驗證中的劃分無法正確地反映新資料的特徵。我們已經將這部分資料的資訊洩露(leak)給建模過程。這將導致在交叉驗證過程中得到過於樂觀的結果, 並可能會導致選擇次優的引數。

為了解決這個問題,在交叉驗證的過程中,應該在進行任何預處理之前完成資料集的劃分。任何從資料集中提取資訊的處理過程都應該僅應用於資料集的訓練部分,因此,任何交叉驗證都應該位於處理過程的 “最外層迴圈”。

scikit-learn 中,要想使用 cross_val_score 函式和 GridSearchCV 函式實現這一點,可以使用 Pipeline 類。Pipeline 類可以將多個處理步驟合併(glue)為單個 scikit-learn 估計器Pipeline 類本身具有 fitpredictscore 方法,其行為與 scikit-learn 中的其他模型相同。Pipeline 類最常見的用例是將預處理步驟(比如資料縮放)與一個監督模型 (比如分類器)連結在一起


2、構建管道

我們來看一下如何使用 Pipeline 類來表示在使用 MinMaxScaler 縮放資料之後再訓練一個 SVM 的工作流程(暫時不用網格搜尋)。首先,我們構建一個由步驟列表組成的管道物件。 每個步驟都是一個元組,其中包含一個名稱(你選定的任意字串)和一個估計器的例項:

from sklearn.pipeline import Pipeline
pipe = Pipeline([("scaler", MinMaxScaler()), ("svm", SVC())])

這裡我們建立了兩個步驟:第一個叫作 "scaler",是 MinMaxScaler 的例項;第二個叫作 "svm",是 SVC 的例項。現在我們可以像任何其他 scikit-learn 估計器一樣來擬合這個管道:

pipe.fit(X_train, y_train)

這裡 pipe.fit 首先對第一個步驟(縮放器)呼叫 fit,然後使用該縮放器對訓練資料進行變換,最後用縮放後的資料來擬合 SVM。要想在測試資料上進行評估,我們只需呼叫 pipe.score

print("Test score: {:.2f}".format(pipe.score(X_test, y_test)))
# Test score: 0.97

如果對管道呼叫 score 方法,則首先使用縮放器對測試資料進行變換,然後利用縮放後的測試資料對 SVM 呼叫 score 方法。如你所見,這個結果與我們從開頭的程式碼得到的結果(手動進行資料變換)是相同的。利用管道,我們減少了 “預處理 + 分類” 過程 所需要的程式碼量。但是,使用管道的主要優點在於,現在我們可以在 cross_val_scoreGridSearchCV 中使用這個估計器


3、在網格搜尋中使用管道

在網格搜尋中使用管道的工作原理與使用任何其他估計器都相同。我們定義一個需要搜尋的引數網格,並利用管道和引數網格構建一個 GridSearchCV。不過在指定引數網格時存在一處細微的變化。我們需要為每個引數指定它在管道中所屬的步驟。我們要調節的兩個引數 C 和 gamma 都是 SVC 的引數,屬於第二個步驟。我們給這個步驟的名稱是 "svm"為管道定義引數網格的語法是為每個引數指定步驟名稱,後面加上 __(雙下劃線),然後是引數名稱。因此,要想搜尋 SVC 的 C 引數,必須使用 "svm__C" 作為引數網格字典的鍵,對 gamma 引數也是同理:

param_grid = {'svm__C': [0.001, 0.01, 0.1, 1, 10, 100],
              'svm__gamma': [0.001, 0.01, 0.1, 1, 10, 100]}

有了這個引數網格,我們可以像平常一樣使用 GridSearchCV

grid = GridSearchCV(pipe, param_grid=param_grid, cv=5)
grid.fit(X_train, y_train)
print("Best cross-validation accuracy: {:.2f}".format(grid.best_score_))
# Best cross-validation accuracy: 0.98
print("Test set score: {:.2f}".format(grid.score(X_test, y_test)))
# Test set score: 0.97
print("Best parameters: {}".format(grid.best_params_))
# Best parameters: {'svm__C': 1, 'svm__gamma': 1}

與前面所做的網格搜尋不同,現在對於交叉驗證的每次劃分來說,僅使用訓練部分對 MinMaxScaler 進行擬合,測試部分的資訊沒有洩露到引數搜尋中。

mglearn.plots.plot_proper_processing()

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-fVTbiBZg-1609069765905)(素材/使用管道在交叉驗證迴圈內部進行預處理時的資料使用情況.png)]

在交叉驗證中,資訊洩露的影響大小取決於預處理步驟的性質使用測試部分來估計資料的範圍,通常不會產生可怕的影響,但在特徵提取和特徵選擇中使用測試部分,則會導致結果的顯著差異


4、通用的管道介面

Pipeline 類不但可用於預處理和分類,實際上還可以將任意數量的估計器連線在一起。例如,你可以構建一個包含特徵提取、特徵選擇、縮放和分類的管道,總共有 4 個步驟。同樣,最後一步可以用迴歸或聚類代替分類。

對於管道中估計器的唯一要求就是,除了最後一步之外的所有步驟都需要具有 transform 方法,這樣它們可以生成新的資料表示,以供下一個步驟使用。

在呼叫 Pipeline.fit 的過程中,管道內部依次對每個步驟呼叫 fittransform,其輸入是前一個步驟中 transform 方法的輸出。對於管道中的最後一步,則僅呼叫 fit

忽略某些細枝末節,其實現方法如下所示。請記住,pipeline.steps 是由元組組成的列表, 所以 pipeline.steps[0][1] 是第一個估計器,pipeline.steps[1][1] 是第二個估計器,以此類推:

def fit(self, X, y):
    X_transformed = X
    for name, estimator in self.steps[:-1]:
        # 遍歷除最後一步之外的所有步驟
        # 對資料進行擬合和變換
        X_transformed = estimator.fit_transform(X_transformed, y)
    # 對最後一步進行擬合
    self.steps[-1][1].fit(X_transformed, y)
    return self

使用 Pipeline 進行預測時,我們同樣利用除最後一步之外的所有步驟對資料進行變換 (transform),然後對最後一步呼叫 predict

def predict(self, X):
    X_transformed = X
    for step in self.steps[:-1]:
        # 遍歷除最後一步之外的所有步驟
        # 對資料進行變換
        X_transformed = step[1].transform(X_transformed)
    # 利用最後一步進行預測
    return self.steps[-1][1].predict(X_transformed)

整個過程如下圖所示,其中包含兩個變換器(transformer)T1 和 T2,還有一個分類器 (叫作 Classifier)。

管道實際上比上圖更加通用。管道的最後一步不需要具有 predict 函式,比如說,我們可以建立一個只包含一個縮放器和一個 PCA 的管道。由於最後一步(PCA)具有 transform 方 法,所以我們可以對管道呼叫 transform,以得到將 PCA.transform 應用於前一個步驟處理過的資料後得到的輸出。管道的最後一步只需要具有 fit 方法。

4.1、用 make_pipeline 方便地建立管道

利用上述語法建立管道有時有點麻煩,我們通常不需要為每一個步驟提供使用者指定的名稱。有一個很方便的函式 make_pipeline,可以為我們建立管道並根據每個步驟所屬的類為其自動命名。make_pipeline 的語法如下所示:

from sklearn.pipeline import make_pipeline
# 標準語法
pipe_long = Pipeline([("scaler", MinMaxScaler()), ("svm", SVC(C=100))])
# 縮寫語法
pipe_short = make_pipeline(MinMaxScaler(), SVC(C=100))

管道物件 pipe_longpipe_short 的作用完全相同,但 pipe_short 的步驟是自動命名的。 我們可以通過檢視 steps 屬性來檢視步驟的名稱:

print("Pipeline steps:\n{}".format(pipe_short.steps))
'''
Pipeline steps:
[('minmaxscaler', MinMaxScaler()), ('svc', SVC(C=100))]
'''

這兩個步驟被命名為 minmaxscalersvc。一般來說,步驟名稱只是類名稱的小寫版本。 如果多個步驟屬於同一個類,則會附加一個數字:

from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

pipe = make_pipeline(StandardScaler(), PCA(n_components=2), StandardScaler())
print("Pipeline steps:\n{}".format(pipe.steps))
'''
Pipeline steps:
[('standardscaler-1', StandardScaler()), ('pca', PCA(n_components=2)), ('standardscaler-2', StandardScaler())]
'''

如你所見,第一個 StandardScaler 步驟被命名為 standardscaler-1,而第二個被命名為 standardscaler-2。但在這種情況下,使用具有明確名稱的 Pipeline 構建可能更好,以便 為每個步驟提供更具語義的名稱。

4.2、訪問步驟屬性

通常來說,你希望檢查管道中某一步驟的屬性——比如線性模型的係數或 PCA 提取的成分。要想訪問管道中的步驟,最簡單的方法是通過 named_steps 屬性,它是一個字典,將步驟名稱對映為估計器:

# 用前面定義的管道對cancer資料集進行擬合
pipe.fit(cancer.data)
# 從"pca"步驟中提取前兩個主成分
components = pipe.named_steps["pca"].components_
print("components.shape: {}".format(components.shape))
# components.shape: (2, 30)

4.3、訪問網格搜尋管道中的屬性

本章前面說過,使用管道的主要原因之一就是進行網格搜尋。一個常見的任務是在網格搜尋內訪問管道的某些步驟。我們對 cancer 資料集上的 LogisticRegression 分類器進行網格搜尋,在將資料傳入 LogisticRegression 分類器之前,先用 PipelineStandardScaler 對資料進行縮放。首先,我們用 make_pipeline 函式建立一個管道:

from sklearn.linear_model import LogisticRegression

pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))

接下來,我們建立一個引數網格。LogisticRegression 需要調節的正則化引數是引數 C。我們對這個引數使用對數網格,在 0.01 和 100 之間進行搜尋。由於我們使用了 make_pipeline 函式,所以管道中 LogisticRegression 步驟的名稱是小寫的類 名稱 logisticregression。因此,為了調節引數 C,我們必須指定 logisticregression__C 的引數網格:

param_grid = {'logisticregression__C': [0.01, 0.1, 1, 10, 100]}

像往常一樣,我們將 cancer 資料集劃分為訓練集和測試集,並對網格搜尋進行擬合:

X_train, X_test, y_train, y_test = train_test_split(cancer.data, cancer.target, random_state=4)
grid = GridSearchCV(pipe, param_grid, cv=5)
grid.fit(X_train, y_train)

那麼我們如何訪問 GridSearchCV 找到的最佳 LogisticRegression 模型的係數呢?我們知道,GridSearchCV 找到的最佳模型(在所有訓練資料上訓練得到的模型)儲存在 grid.best_estimator_ 中:

print("Best estimator:\n{}".format(grid.best_estimator_))
'''
Best estimator:
Pipeline(steps=[('standardscaler', StandardScaler()),
                ('logisticregression', LogisticRegression(C=1, max_iter=1000))])
'''

在我們的例子中,best_estimator_ 是一個管道, 它包含兩個步驟:standardscalerlogisticregression。 如前所述, 我們可以使用管道的 named_steps 屬性來訪問 logisticregression 步驟:

print("Logistic regression step:\n{}".format(
      grid.best_estimator_.named_steps["logisticregression"]))
'''
Logistic regression step:
LogisticRegression(C=1, max_iter=1000)
'''

現在我們得到了訓練過的 LogisticRegression 例項,下面我們可以訪問與每個輸入特徵相關的係數(權重):

print("Logistic regression coefficients:\n{}".format(
      grid.best_estimator_.named_steps["logisticregression"].coef_))
'''
Logistic regression coefficients:
[[-0.43570655 -0.34266946 -0.40809443 -0.5344574  -0.14971847  0.61034122
  -0.72634347 -0.78538827  0.03886087  0.27497198 -1.29780109  0.04926005
  -0.67336941 -0.93447426 -0.13939555  0.45032641 -0.13009864 -0.10144273
   0.43432027  0.71596578 -1.09068862 -1.09463976 -0.85183755 -1.06406198
  -0.74316099  0.07252425 -0.82323903 -0.65321239 -0.64379499 -0.42026013]]
'''

這個係數列表可能有點長,但它通常有助於理解你的模型。


5、網格搜尋預處理步驟與模型引數

我們可以利用管道將機器學習工作流程中的所有處理步驟封裝成一個 scikit-learn 估計 器。這麼做的另一個好處在於,現在我們可以使用監督任務(比如迴歸或分類)的輸出來調節預處理引數。在前幾章裡,我們在應用嶺迴歸之前使用了 boston 資料集的多項式特徵。下面我們用一個管道來重複這個建模過程。管道包含 3 個步驟:縮放資料、計算多項式特徵與嶺迴歸

from sklearn.datasets import load_boston
boston = load_boston()
X_train, X_test, y_train, y_test = train_test_split(boston.data, boston.target, random_state=0)

from sklearn.preprocessing import PolynomialFeatures
pipe = make_pipeline(
    StandardScaler(),
    PolynomialFeatures(),
    Ridge())

我們怎麼知道選擇幾次多項式,或者是否選擇多項式或互動項呢?理想情況下,我們希望根據分類結果來選擇 degree 引數。我們可以利用管道搜尋 degree 引數以及 Ridgealpha 引數。為了做到這一點,我們要定義一個包含這兩個引數的 param_grid,並用步驟名稱作為字首:

param_grid = {'polynomialfeatures__degree': [1, 2, 3],
              'ridge__alpha': [0.001, 0.01, 0.1, 1, 10, 100]}

現在我們可以再次執行網格搜尋:

grid = GridSearchCV(pipe, param_grid=param_grid, cv=5, n_jobs=-1)
grid.fit(X_train, y_train)

我們可以用熱圖將交叉驗證的結果視覺化:

mglearn.tools.heatmap(grid.cv_results_['mean_test_score'].reshape(3, -1),
                      xlabel="ridge__alpha", ylabel="polynomialfeatures__degree",
                      xticklabels=param_grid['ridge__alpha'],
                      yticklabels=param_grid['polynomialfeatures__degree'], vmin=0)

從交叉驗證的結果中可以看出,使用二次多項式很有用,但三次多項式的效果比一次或二次都要差很多。從找到的最佳引數中也可以看出這一點:

print("Best parameters: {}".format(grid.best_params_))
# Best parameters: {'polynomialfeatures__degree': 2, 'ridge__alpha': 10}

這個最佳引數對應的分數如下:

print("Test-set score: {:.2f}".format(grid.score(X_test, y_test)))
# Test-set score: 0.77

為了對比,我們執行一個沒有多項式特徵的網格搜尋:

param_grid = {'ridge__alpha': [0.001, 0.01, 0.1, 1, 10, 100]}
pipe = make_pipeline(StandardScaler(), Ridge())
grid = GridSearchCV(pipe, param_grid, cv=5)
grid.fit(X_train, y_train)
print("Score without poly features: {:.2f}".format(grid.score(X_test, y_test)))
# Score without poly features: 0.63

正與我們觀察上圖中的網格搜尋結果所預料的那樣,不使用多項式特徵得到了明顯更差的結果。

同時搜尋預處理引數與模型引數是一個非常強大的策略。但是要記住,GridSearchCV 會嘗試指定引數的所有可能組合。因此,向網格中新增更多引數,需要構建的模型數量將呈指數增長。


6、網格搜尋選擇使用哪個模型

你甚至可以進一步將 GridSearchCVPipeline 結合起來:還可以搜尋管道中正在執行的實際步驟(比如用 StandardScaler 還是用 MinMaxScaler)。這樣會導致更大的搜尋空間, 應該予以仔細考慮。嘗試所有可能的解決方案,通常並不是一種可行的機器學習策略。但下面是一個例子:在 iris 資料集上比較 RandomForestClassifierSVC。我們知道,SVC 可能需要對資料進行縮放,所以我們還需要搜尋是使用 StandardScaler 還是不使用預處理。我們知道,RandomForestClassifier 不需要預處理。我們先定義管道。這裡我們顯式地對步驟命名。我們需要兩個步驟,一個用於預處理,然後是一個分類器。我們可以用 SVCStandardScaler 來將其例項化:

pipe = Pipeline([('preprocessing', StandardScaler()), ('classifier', SVC())])

現在我們可以定義需要搜尋的 parameter_grid。我們希望 classifierRandomForestClassifierSVC。由於這兩種分類器需要調節不同的引數,並且需要不同的預處理,所以我們可以使用 “在非網格的空間中搜尋” 中所講的搜尋網格列表。為了將一個估計器分配 給一個步驟,我們使用步驟名稱作為引數名稱。如果我們想跳過管道中的某個步驟(例如,RandomForest 不需要預處理),則可以將該步驟設定為 None

from sklearn.ensemble import RandomForestClassifier

param_grid = [
    {'classifier': [SVC()], 'preprocessing': [StandardScaler(), None],
     'classifier__gamma': [0.001, 0.01, 0.1, 1, 10, 100],
     'classifier__C': [0.001, 0.01, 0.1, 1, 10, 100]},
    {'classifier': [RandomForestClassifier(n_estimators=100)],
     'preprocessing': [None], 'classifier__max_features': [1, 2, 3]}]

現在,我們可以像前面一樣將網格搜尋例項化並在 cancer 資料集上執行:

X_train, X_test, y_train, y_test = train_test_split(
    cancer.data, cancer.target, random_state=0)

grid = GridSearchCV(pipe, param_grid, cv=5)
grid.fit(X_train, y_train)

print("Best params:\n{}\n".format(grid.best_params_))
'''
Best params:
{'classifier': SVC(C=10, gamma=0.01), 'classifier__C': 10, 'classifier__gamma': 0.01, 'preprocessing': StandardScaler()}
'''
print("Best cross-validation score: {:.2f}".format(grid.best_score_))
# Best cross-validation score: 0.99
print("Test-set score: {:.2f}".format(grid.score(X_test, y_test)))
# Test-set score: 0.98

網格搜尋的結果是 SVCStandardScaler 預處理,在 C=10gamma=0.01 時給出最佳結果。


7、小結與展望

本章介紹了 Pipeline 類,這是一種通用工具,可以將機器學習工作流程中的多個處理步驟連結在一起。現實世界中的機器學習應用很少僅涉及模型的單獨使用,而是需要一系列處理步驟。使用管道可以將多個步驟封裝為單個 Python 物件,這個物件具有我們熟悉的 scikit-learn 介面 fitpredicttransform。特別是使用交叉驗證進行模型評估與使用網格搜尋進行引數選擇時,使用 Pipeline 類來包括所有處理步驟對正確的評估至關重要。 利用 Pipeline 類還可以讓程式碼更加簡潔,並減少不用 pipeline 類構建處理鏈時可能會犯的錯誤(比如忘記將所有變換器應用於測試集,或者應用順序錯誤)的可能性。選擇特徵提取、預處理和模型的正確組合,這在某種程度上是一門藝術,通常需要一些試錯。但是有了管道,這種 “嘗試” 多個不同的處理步驟是非常簡單的。在進行試驗時,要小心不要將處理過程複雜化,並且一定要評估一下模型中的每個元件是否必要。

相關文章