scikit-learnのPipelineを理解する――fitとtransformはどう連携しているのか

AIスペシャリストを目指して、機械学習の学習を進めている。今回は、これまでTitanicデータセットを使って学んできたロジスティック回帰に、Pipelineを導入した。

最初にPipelineのコードを見たとき、少し不思議に感じた。

model.fit(X_train, y_train)と書いているだけなのに、なぜその前にStandardScalerfit_transform()が実行されるのか。

「Pipelineだから自動的にやってくれる」と覚えてしまえば使うことはできる。しかし、今回はそこを一段深く理解することにした。

今回使ったコード

Titanicのデータから、以下の5つの特徴量を使ってロジスティック回帰モデルを作った。

  • Pclass
  • Sex
  • Age
  • Fare
  • FamilySize

前処理としてAgeの欠損値を中央値で補完し、Sexを0/1に変換している。

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

df = pd.read_csv("train.csv")

df["FamilySize"] = df["SibSp"] + df["Parch"] + 1
df["Age"] = df["Age"].fillna(df["Age"].median())

df["Sex"] = df["Sex"].map({
    "male": 0,
    "female": 1
})

features = [
    "Pclass",
    "Sex",
    "Age",
    "Fare",
    "FamilySize"
]

X = df[features]
y = df["Survived"]

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

model = Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression())
])

model.fit(X_train, y_train)

y_pred = model.predict(X_test)

print(accuracy_score(y_test, y_pred))

実行結果は以下だった。

0.8044692737430168

これまで作ってきたロジスティック回帰モデルと同じ結果になった。Pipelineを使ったことで、モデルの精度そのものが変わったわけではない。

Pipelineによって、前処理とモデルの学習・予測を一つの流れとして扱えるようになったのである。

そもそもPipelineとは何なのか

Pipelineは、複数の処理を順番につないで、一つのオブジェクトとして扱うための仕組みである。

今回のコードでは、次の2つを登録している。

model = Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression())
])

これを図にすると、次のようになる。

X
 ↓
StandardScaler
 ↓
LogisticRegression

ここで重要なのは、modelという変数がLogisticRegressionそのものではないという点である。

modelPipelineオブジェクトであり、その中にStandardScalerLogisticRegressionが登録されている。

では、なぜmodel.fit()でScalerまで動くのか

今回、一番気になったのがここである。

model.fit(X_train, y_train)

この1行を見ると、普通は「LogisticRegressionを学習させている」と考えるかもしれない。

しかし、実際にはmodelはPipelineオブジェクトである。

したがって呼び出されているのは、概念的には次のような処理である。

Pipeline.fit(X_train, y_train)

Pipelineの中には、

[
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression())
]

という順番で処理が登録されている。

そのため、Pipelineのfit()は、登録された処理を順番に実行する。

X_train
   ↓
StandardScaler.fit_transform()
   ↓
スケーリングされたX_train
   ↓
LogisticRegression.fit()

つまり、Pipelineが魔法のようにStandardScalerを見つけているわけではない。

Pipelineクラス自身が、「自分に対してfit()が呼ばれたら、登録されている処理をこの順番で実行する」という仕組みを持っているのである。

PipelineはPythonの特別な文法ではない

ここは初学者が勘違いしやすいところだと思う。

以下のコードはPythonの特別な構文ではない。

Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression())
])

Pipelineというクラスをインスタンス化しているだけである。

つまり、オブジェクト指向の観点から考えると理解しやすい。

概念的には次のようなイメージである。

model
 ├─ scaler      → StandardScalerのオブジェクト
 └─ classifier  → LogisticRegressionのオブジェクト

そしてPipelineオブジェクト自身がfit()predict()を持っている。

そのメソッドの内部で、登録されたオブジェクトのメソッドを呼び出しているのである。

fitとtransformの役割を整理する

ここまでの学習で、fittransformの違いも重要だと分かった。

fit

fitは、データから必要な情報を計算して「覚える」処理である。

StandardScalerの場合、訓練データから各特徴量の平均と標準偏差を計算する。

scaler.fit(X_train)

例えばAgeについて、訓練データから次のような値を計算したとする。

平均       30
標準偏差   10

この情報をScalerが保持する。

transform

transformは、fitで覚えた情報を使ってデータを変換する処理である。

標準化では、おおむね次の計算を行う。

(元の値 - 平均) / 標準偏差

例えばAgeが22なら、平均30、標準偏差10の場合は次のようになる。

(22 - 30) / 10 = -0.8

つまり、Scalerは「データを見て変換方法を覚える」のがfitで、「覚えた方法でデータを変換する」のがtransformである。

fit_transform

fit_transformは、この2つを続けて行う便利なメソッドである。

scaler.fit_transform(X_train)

これは概念的には次と同じである。

scaler.fit(X_train)
X_train_scaled = scaler.transform(X_train)

なぜテストデータではfitしないのか

機械学習では、訓練データとテストデータを分ける。

訓練データからモデルを作り、最後にテストデータを使って、未知のデータに対してどの程度うまく予測できるかを評価するためである。

Scalerについても同じ考え方をする。

訓練データに対しては、

X_train
   ↓
StandardScaler.fit_transform()
   ↓
LogisticRegression.fit()

とする。

一方、テストデータに対しては、

X_test
   ↓
StandardScaler.transform()
   ↓
LogisticRegression.predict()

となる。

テストデータについては、Scalerのfitを実行しない。

訓練データから計算した平均や標準偏差を使って、テストデータを変換するだけである。

重要:テストデータから平均や標準偏差を計算してはいけない。テストデータの情報を前処理の学習に利用すると、データリークにつながる。

Pipelineのpredict()では何が起きるのか

fit()だけでなく、predict()も同じような仕組みになっている。

y_pred = model.predict(X_test)

この場合、概念的には次の処理が実行される。

X_test
   ↓
StandardScaler.transform()
   ↓
LogisticRegression.predict()
   ↓
y_pred

ここではScalerのfitは行われない。

訓練時に覚えた平均と標準偏差を使って、テストデータを変換するだけである。

Pipelineの途中には何を指定できるのか

今回、PipelineにはScalerとClassifierを指定した。

しかし、Pipelineは「ScalerとClassifierを組み合わせるためだけの機能」ではない。

基本的には、データを変換する処理を順番につなげることができる。

Pipeline([
    ("step1", 前処理1),
    ("step2", 前処理2),
    ("step3", 前処理3),
    ("model", モデル)
])

例えば、欠損値の補完、標準化、次元削減などを順番に実行する構成も考えられる。

データ
 ↓
欠損値処理
 ↓
標準化
 ↓
次元削減
 ↓
機械学習モデル

Pipelineの途中に置く処理は、基本的にfit()transform()を持つTransformerである。

一方、最後には分類や回帰を行うEstimatorを置く。

Pipelineの「途中」と「最後」の違い

ここは今回特に整理しておきたいポイントである。

位置 役割 fit時の処理
途中 データ変換 fit → transform
最後 モデル fit

例えば今回なら、

Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression())
])

なので、学習時は次のようになる。

StandardScaler
    ↓
fit_transform()

LogisticRegression
    ↓
fit()

予測時は、

StandardScaler
    ↓
transform()

LogisticRegression
    ↓
predict()

となる。

Pipelineを使わない場合との比較

Pipelineを使わなければ、同じ処理を自分で順番に書くことになる。

scaler = StandardScaler()

X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

classifier = LogisticRegression()

classifier.fit(X_train_scaled, y_train)

y_pred = classifier.predict(X_test_scaled)

Pipelineを使えば、次のようにまとめられる。

model = Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression())
])

model.fit(X_train, y_train)

y_pred = model.predict(X_test)

処理の内容そのものが消えたわけではない。

複数の処理を一つのPipelineにまとめて、学習や予測時の処理順序を一貫させているのである。

今回の学習で分かったこと

  • PipelineはPythonの特別な構文ではなく、scikit-learnのクラスである
  • Pipelineオブジェクト自身がfit()predict()を持っている
  • model.fit()を呼ぶと、Pipelineが登録された処理を順番に実行する
  • 途中のTransformerでは、訓練時にfit_transform()が実行される
  • 最後のモデルではfit()が実行される
  • 予測時には途中のTransformerでtransform()だけが実行される
  • テストデータからScalerをfitしてはいけない
  • Pipelineは前処理とモデルを一つの処理として扱える

今回、少し気になったこと

今回のコードには、Pipelineとは別に一つ気になる点が残っている。

df["Age"] = df["Age"].fillna(df["Age"].median())

これはtrain/test splitを行う前に、データセット全体からAgeの中央値を計算している。

つまり、厳密にはテストデータのAgeも中央値の計算に利用していることになる。

今回の学習ではPipelineそのものを理解することを優先したが、機械学習の実践では、このような前処理もPipelineの中に入れて、訓練データだけから必要な情報を学習する構成にした方がよい。

次はこの問題を解決するために、欠損値処理をPipelineに組み込む方法を学びたい。

次回に向けて

今回Pipelineの基本的な仕組みは理解できた。

次のステップでは、Titanicデータセットに含まれる「数値データ」と「カテゴリデータ」を別々に処理する方法を学ぶ。

例えば、AgeやFareは数値なので標準化したい。一方、SexやEmbarkedなどはカテゴリなので、One-Hot Encodingなど別の処理が必要になる。

そのような場合に登場するのがColumnTransformerである。

最終的には、

データ
   │
   ├── 数値列
   │      ↓
   │   欠損値補完
   │      ↓
   │   StandardScaler
   │
   └── カテゴリ列
          ↓
       欠損値補完
          ↓
       One-Hot Encoding
   │
   ↓
機械学習モデル

という構成を自分で書けるようになることを目指す。

今回は分からない部分をChatGPTに質問しながら、単にコードを動かすだけではなく、「なぜこの1行で次の処理まで実行されるのか」というところまで確認した。機械学習ではライブラリの抽象化が多いため、動作をブラックボックスのままにせず、少しずつ内部の考え方まで理解していきたい。

 

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA