MLエンジニアへの道 #6 - 交差検証

Last Edited: 8/2/2024

この記事では、交差検証を紹介します。

ML

ありがちな誤解

多くの人が線形回帰を学ぶ際、線形関数をデータにフィットさせるから「線形」回帰と呼ばれていると誤解してしまいます。 しかし、これは「線形」の由来ではありません。実際、線形回帰を使って、二次関数や放射関数、フーリエ関数など の様々な非線形関数をフィットさせることができます。線形回帰の「線形」とは、入力ではなくパラメータにおいて線形である関数 をフィットさせることを指します。例えば、以下のような関数は線形回帰を使ってフィットさせることができます:

f(x)=w1x2+w2x+w3g(x,y)=w1x3+w2y2+w3h(x,y,z)=w1cos(x)+w2cos(y)+w3cos(z)+w4 f(x) = w_1 x^2 + w_2 x + w_3 \\ g(x,y) = w_1 x^3 + w_2 y^2 + w_3 \\ h(x, y, z) = w_1 cos(x) + w_2 cos(y) + w_3 cos(z) + w_4

一方、以下の関数は線形回帰を使ってフィットさせることはできません:

l(x)=w12x+w2k(x,y)=w13x2+w22y2+log(w3)m(x,y,z)=cos(w1)cos(x)+w2cos(y)+w3cos(z)+w4 l(x) = w_1^2 x + w_2 \\ k(x,y) = w_1^3 x^2 + w_2^2 y^2 + log(w_3) \\ m(x, y, z) = cos(w_1) cos(x) + w_2 cos(y) + w_3 cos(z) + w_4

線形回帰を使ってフィットさせることができるすべての関数は、線形の重み/パラメータ(w1w_1, w2w_2, w3w_3)を持っていますが、 線形回帰を使ってフィットさせることができないすべての関数は、非線形の重み/パラメータ(w12 w_1^2, log(w3)log(w_3), cos(w2)cos(w_2))を持っています。 データをフィットさせるために使用する関数は基底関数と呼ばれ、基底関数は上記のようにパラメータにおいて線形である必要があります。

線形回帰では、基底関数に基づいた損失関数の偏微分を、重み/パラメータに関して行います。これらのパラメータが基底関数において非線形である場合、 微分は線形である場合に比べてはるかに計算が難しくなります。また、非線形の重みを持つことは、計算を複雑にする以外に意味がないことにも気付く でしょう。なぜなら、単にw=w12w=w_1^2またはw=log(w3)w=log(w_3)と設定して、関数をパラメータにおいて線形にすることができるからです。したがって、 基底関数をパラメータにおいて線形にしたいと考えるのは理にかなっています。ロジスティック回帰やソフトマックス回帰は、 どちらも対数オッズに対する線形回帰であるため、パラメータにおいて線形であるいかなる基底関数を選ぶことができます。

バイアス-バリアンストレードオフ

任意の基底関数を選択できることを考慮して、以下の例を見てみましょう。

このデータセットを使用するとき、どの基底関数を選びますか?例えば、シンプルな線形関数(緑)、少し複雑な対数関数(青)、 さらに複雑な三角関数(紫)を次のようにフィットさせることができます。

この例から、関数が複雑になるほどデータにうまくフィットすることがわかります。実際、複雑な三角関数はデータに完全にフィットしています。 モデルがトレーニングデータにどれだけフィットするかはバイアスと呼ばれ、三角関数が最も低いバイアスを持ち、線形関数が最も高いバイアスを 持つと言えます。

どんなデータでも関数を非常に複雑にして、パラメータの数を訓練データの数と同じにすることで、関数を訓練データに完璧にフィットさせることができます。 それなら、なぜ線形関数や対数関数を使うのでしょうか?それは、テストデータに関連しています。

上記からもわかるように、三角関数はテストデータにうまくフィットしていませんが、より単純な線形関数や対数関数はうまくフィットしています。 モデルがテストデータにどれだけフィットするかはバリアンスと呼ばれ、三角関数は最も高いバリアンスを持つと言えます。訓練データに 対してあまりに少ないバイアスを持っていたため、テストデータに対して最も高いバリアンスを持ってしまうこの状況を、過学習 (訓練データに対する過剰な学習)と呼びます。

一方、線形関数と対数関数は訓練データに対し対しフィットし過ぎていないため、バリアンスが低いです。そんな中でも、今回の場合、 対数関数が線形関数が捉えられない一般的な非線形関係を捉えているため、より良く機能しているように見えます。線形関数では訓練データ に対するバイアスが高すぎたため、データの複雑な関係を捉えることができず、バリアンスが最適ではなくなってしまっています。これを 過小適合(データに対する過少なフィット)と呼びます。

このように、多くの場合バイアスとバリアンはストレードオフの関係にあります。最適なモデルを見つけるためには、 このバイアス-バリアンストレードオフの中上手くバランスを取り、適切な複雑さを持つモデルを選ぶことで、 過学習と過小適合を避ける必要があります。この作業の難しい点は、テストデータに触れられないモデルをステップ3:モデル の時点で適切なモデルを選ぶ必要があるという点です。

交差検証

適切なバイアスと高いバリアンスをもつモデルを選択するという課題は、機械学習にとって大きなテーマです。 機械学習の研究者たちはこの課題に対処するためにさまざまな方法を考案しています。その方法の1つが交差検証です。

モデルを選択するために、訓練データの一部を取り分け、擬似テストデータとして設定することができます。 その後、取り分けた検証データにおける指標を用いてモデルのバリアンスを推定し、推定されたバリアンスが最も低いもの を選択します。しかし、検証データは訓練データの一部に過ぎないため、検証データセットが全体のデータの傾向を上手く反映している と仮定するのは難しいです。

したがって、1つの検証データを使用する代わりに、訓練データを k 個の小さなグループに分け、それぞれを検証データとして使用して指標 を計算し、指標の平均を取ります。この方法により、訓練データをフルに活用し、モデルのバリアンスをより正確に推定することができます。 この方法をk-分割交差検証と呼びます。

分割交差検証は非常に便利ですが、データセットが不均衡な場合、グループにクラスが1つだけしかないグループが見られることもあります。 そのような場合には、層化k分割交差検証を使用できます。これは、各グループに全てのクラスを入れる方法です。また、 トレーニングデータセットが非常に小さい場合でも交差検証を実行できるように、同じデータが複数のグループでランダムに選ばれることを 許可するシャッフル分割交差検証を使用することもできます。

コード実装

交差検証を実際に見てみましょう。ここでは、Irisデータセットを使用し、アヤメの種類を予測するタスクを行います。 選択肢は以下の3つのモデルです。

log(odds)=w1pl+w2log(odds)=w1sl+w2sw+w3pl+w4pw+w5log(odds)=w1pl2+w2pl+w3 log(odds) = w_1 pl + w_2 \\ log(odds) = w_1 sl + w_2 sw + w_3 pl + w_4 pw + w_5 \\ log(odds) = w_1 pl^2 + w_2 pl + w_3

これらのモデルに対しては、いくつかの変更を加えたSoftmaxRegressionGDを使用できます。

class SoftmaxRegressionGD():
  def __init__(self, lr=0.001, basis="model_1"):
    self.lr = lr # Learning rate
    self.history = [] # History of loss
    self.basis = basis
    shapes = {"model_1": 1, "model_2": 4, "model_3": 2}
    self.W = np.ones((shapes[self.basis], y.shape[1]))
    self.b = np.ones(y.shape[1])
 
  def transform(self, X):
    if self.basis == "model_1":
        return X[:,0][:, np.newaxis]
    elif self.basis == "model_3":
        return np.concatenate(([X[:,0]**2], [X[:,0]])).T
    else:
        return X
 
  def predict(self, X):
    X = self.transform(X)
    
    # ソフトマックス計算を安定化するためにlog-sum-expトリックを使用
    logits = np.matmul(X, self.W) + self.b
    max_logits = np.max(logits, axis=1, keepdims=True)
    
    stabilized_logits = logits - max_logits  # オーバーフローを防止
    odds = np.exp(stabilized_logits)
    total_odds = np.sum(odds, axis=1, keepdims=True)
 
    return odds / total_odds
 
  def fit(self, X, y, epochs=100):
    X = self.transform(X)
    for i in range(epochs):
      pred = self.predict(X)
 
      self.history.append(log_loss(y, pred))
 
      diff = 1 - pred
      grad_W = np.matmul(X.T, diff*y)
      grad_b = np.sum(diff*y, axis=0)
 
      self.W += self.lr * grad_W
      self.b += self.lr * grad_b
    return self.history

transformメソッドでは、入力と重みの形状を方程式に応じて設定します。別の変更点はpredictメソッドにおける log-sum-expトリックで、大きすぎるexpを持たないように計算を安定化します。(ソフトマックス関数は冗長なパラメータを持つことから、最大のロジットを毎回引くことができる。) 次に、交差検証を実行するための関数を定義します。

from sklearn.model_selection import StratifiedKFold
 
def sm_cross_validation(X, y, models, score, epochs=500, n_splits=10, shuffle=True, verbose=True):
  scores = {}
  kf = StratifiedKFold(n_splits=n_splits, shuffle=shuffle)
  
  for i, (train_index, test_index) in enumerate(kf.split(X, np.argmax(y, axis=1))):
    print(f"{i+1}th Fold") if verbose else None
    for m in models:
      sm = SoftmaxRegressionGD(basis=m)
      sm.fit(X[train_index], y[train_index], epochs=epochs)
      pred = sm.predict(X[test_index])
      pred, y_val = np.argmax(pred, axis=1), np.argmax(y[test_index], axis=1)
      scores[m] = scores.get(m, 0) + (score(y_val, pred) / n_splits)
      print(f"{m}: {scores[m]}") if verbose else None
 
  return scores

上記では、StratifiedKFoldを用いて層化k分割のインデックスを取得し、各ぐるーぷに異なる基底関数を持つモデルをフィットさせて、 指標の平均値を比較します。ここで使用する指標は重み付き平均F1スコアです。

from sklearn.metrics import f1_score
 
def f1_weighted(y_true, pred):
  return f1_score(y_true, pred, average="weighted")

上記を用いて、交差検証を実行してみましょう!

sm_cross_validation(X_train, y_train, ["model_1", "model_2", "model_3"], f1_weighted, verbose=False)
 
# {'model_1': 0.32254578754578755,
#  'model_2': 0.8637539682539682,
#  'model_3': 0.1835164835164835}

モデル2は、以前のソフトマックス回帰に関する記事で訓練したのと同じモデルで、最良の結果を達成しました。したがって、モデル2を選び、 訓練データをフルに使って学習させることができます。

ハイパーパラメータチューニング

上記で示したように、交差検証を使用して、最適な基底関数またはハイパーパラメータを持つモデルを選択できます。ハイパーパラメータとは、 トレーニング中に最適化されないモデルのパラメータであり、最適なハイパーパラメータの組み合わせを見つけるプロセスを ハイパーパラメータチューニングと呼びます。

基底関数以外にも、SoftmaxRegressionGDにはもう一つ学習率というハイパーパラメータがあり、これについては最適化されていません。実際、 上記の交差検証では、学習率がデフォルト値の0.001に設定されている場合に、最良の基底関数をチェックしただけです。では、どのように してハイパーパラメータチューニングを行い、基底関数と学習率の最適な組み合わせを得るのでしょうか?これには、グリッドサーチランダムサーチの2つの方法があります。

グリッドサーチ交差検証は、実質的には総当たりのアプローチで、ネストされたループを使用してハイパーパラメータのすべての組み合わせをテストします。 しかし、場合によってはトレーニングの完了に時間がかかったり、ハイパーパラメータが非常に多かったりするため、すべての組み合わせをテストするのが 現実的に不可能なことがあります。代わりに、ランダムサーチ交差検証では、ハイパーパラメータの分布を取り、その中からいくつかの組み合わせを ランダムに選んでテストすることで、時間を節約しつつ、ハイパーパラメータに関して知見を得ることができます。

ハイパーパラメータチューニングは、バイアス-バリアンストレードオフに対処するだけでなく、勾配降下法のような学習メカニズムの性能に 関して最適なハイパーパラメータを見つけることにも寄与します。したがって、モデルのハイパーパラメータが多すぎない 場合に使用するのに適した方法です。しかし、機械学習におけるバイアス-バリアンストレードオフの解決策について話す際、多くの場合 交差検証については触れず、正則化やバギング、ブースティングなどの他の解決策が持ち出されます。これについては、今後の記事で 取り上げたいと思います。