MLエンジニアへの道 #14 - 最適化アルゴリズム

Last Edited: 8/23/2024

このブログ記事では、ディープラーニングに用いられるいくつかの最適化アルゴリズムを紹介します。

ML

勾配降下法

私たちはこれまで、勾配降下法をなんの疑いもなく最適化アルゴリズムとして使用してきましたが、勾配降下法には重大な欠点があり、そ れが前回のFNN(フィードフォワードニューラルネットワーク)の学習が上手くいっていなかった大きな原因となっています。 その問題は、局所的な最小値から抜け出して、グローバルな最小値を探索することができないという点です。

ML Loss Contour

上のコンタープロットは、学習可能なパラメータである xxyy に関する コスト関数を示しています。右上半分から始めるか左下 半分から始めるかに応じて、勾配降下法は局所的な最小値またはグローバルな最小値に収束し、両方の場合で勾配がゼロに近くなるため、 そこに停滞してしまいます。前回のアヤメデータセットの例でも、損失が約1.1のポイントでモデルが停滞し、そこから抜け出すのに 時間がかかっているように見えました。実際に異なるハイパーパラメータのセットを試すと、モデルがそのポイントから学習を停止して しまうことがよくあります。

確率的勾配降下法 (SGD)

この問題を解決するための簡単な方法は、アルゴリズムにランダム性を導入し、ランダムに選ばれた1つのデータポイントだけを使って 勾配を計算することです。これを確率的勾配降下法(SGD)と呼びます。SGDは、場合によっては非常にうまく機能し、コードに 実装するのも簡単です。では、以下のようにFNNClassifier.fit関数に実装してみましょう。

def fit(self, X, y, epochs=100, verbose=True):
    for epoch in range(epochs):
 
      if self.optimizer == "SGD":
          randId = np.random.choice(X.shape[0]-1)
          X_batch = X[randId].reshape(1, X.shape[1])
          y_batch = y[randId]
          if y.shape[0] != 1:
            y_batch = y_batch.reshape(1, y.shape[1])
      else:
        X_batch = X
        y_batch = y
    
      pred, activations = self.predict(X_batch, train=True)
      self.history.append(log_loss(y_batch, pred))
      if verbose:
        print(f"Epoch: {epoch}, Loss: {self.history[-1]}")
 
      delta = pred - y_batch
 
    ...

しかし、SGDには重大な欠点があります。それは勾配を1つずつ計算する必要があるため、通常の勾配降下法のように並列化する ことができないという点です。さらに、勾配が不安定になることがあり、学習が不安定になり、時間が大幅にかかることがあります。 以下は、アヤメデータセットでSGDを使用した結果です。

ML SGD Loss

学習曲線はかなり不安定ですが、全体的にはグローバルな最小値に収束しています。

ミニバッチ勾配降下法

上の二つの方法の良いとこ取りとして、ミニバッチ勾配降下法を使用することができます。これは、16または32のデータポイント を含むランダムなミニバッチを作成し、それを使って勾配を計算する方法です。これにより、複数のデータポイントの勾配を並列に 計算し、比較的安定した勾配を得ながらグローバルな最小値を探索することができます。以下は、FNNClassifier.fit関数内での ミニバッチGDの実装です。

def fit(self, X, y, epochs=100, verbose=True):
    for epoch in range(epochs):
 
      if self.optimizer == "SGD":
          randId = np.random.choice(X.shape[0]-1)
          X_batch = X[randId].reshape(1, X.shape[1])
          y_batch = y[randId]
          if y.shape[0] != 1:
            y_batch = y_batch.reshape(1, y.shape[1])
      elif self.optimizer == "MiniBatchGD":
          randId = np.random.choice(X.shape[0]-1, self.batch_size, replace=False)
          X_batch = X[randId]
          y_batch = y[randId]
      else:
        X_batch = X
        y_batch = y
    
      ...

上記の変更に伴い、FNNClassifierクラスにバッチサイズ(batch_size)のイパーパラメータを追加必要があります。 バッチサイズが16の場合のミニバッチGDの学習曲線は以下のようになります。

MBGD Loss

この曲線は、より安定しており、グローバルな最小値により迅速に収束します。しかし、それでも曲線はやや不安定です。 この不安定さは、オーバーシュートが原因である可能性があります。オーバーシュートとは、最適化アルゴリズムが過剰に 降下して曲線の反対側に移動してしまうことです。例を見てみましょう。

ML MiniBatch GD Path

上の図は、狭い谷を持つコスト関数とミニバッチ勾配降下法の辿った道を示しています。学習率と勾配の値が高いため、アルゴリズムは最初、 谷の両側の間を行き来してしまっています。理想的には、アルゴリズムがこの振動を最小限に抑え、より早く最小値に収束して欲しいです。

モーメンタム

この問題に対処する一つのアプローチは、アルゴリズムの以前の動きのモーメンタムを追跡し、それを考慮して勾配を計算することで す。これにより、アルゴリズムが過度に振動するのを防ぐことができます。この概念は、以下の方程式で実装できます。

νt=βνt1+(1β)Lwwt=wt1ανt \nu_t = \beta \nu_{t-1} + (1 - \beta) \frac{\partial L}{\partial w} \\ w_t = w_{t-1} - \alpha \nu_t

ここで、β\beta は前の勾配をどれだけ考慮するかを制御するハイパーパラメータです。このアプローチにより、アルゴリズムが 方向を変える際に勾配を減少させるだけでなく、同じ方向に動いている場合には勾配を増加させて学習を加速させることができます。 適切な β\betaα\alpha の値を選ぶことで、スムーズで効率的な学習を目指します。

RMSProp

別のアプローチとして、モーメンタムを使用して学習率を調整する方法があります。この手法は RMSProp と呼ばれます。 RMSPropの方程式は以下の通りです。

νt=βνt1+(1β)Lw2wt=wt1ανt+ϵLw \nu_t = \beta \nu_{t-1} + (1 - \beta) \frac{\partial L}{\partial w}^2 \\ w_t = w_{t-1} - \frac{\alpha}{\sqrt{\nu_t + \epsilon}} \frac{\partial L}{\partial w}

RMSProp はモーメンタムのアプローチと似ていますが、一つの大きな違いがあります。それは、勾配が ν\nu を計算するために 二乗され、その結果が学習率に適用されることです。勾配を二乗してから平方根を取ることで、値が正のままになり、勾配の方向を 変えずに学習率のスケールを調整することができます。ϵ\epsilon は、分母がゼロになるのを防ぐための小さな値です。

Adam

モーメンタムとRMSPropの両方が効果的な手法であるため、これらを組み合わせて勾配と学習率の両方を調整することができます。 この組み合わせは、以下の方程式で実現されます。

νt=βνt1+(1β)Lwst=βst1+(1β)Lw2wt=wt1αst+ϵνt \nu_t = \beta \nu_{t-1} + (1 - \beta) \frac{\partial L}{\partial w} \\ s_t = \beta s_{t-1} + (1 - \beta) \frac{\partial L}{\partial w}^2 \\ w_t = w_{t-1} - \frac{\alpha}{\sqrt{s_t + \epsilon}} \nu_t

上記の式は一見正常に動作するように見えますが(実際動作はします)、最初は勾配が0から動きにくく、学習が遅くなります。 そのため、バイアス補正を使用します。

νt=βνt1+(1β)Lwst=βst1+(1β)Lw2νt^=νt1βtst^=st1βtwt=wt1αst^+ϵνt^ \nu_t = \beta \nu_{t-1} + (1 - \beta) \frac{\partial L}{\partial w} \\ s_t = \beta s_{t-1} + (1 - \beta) \frac{\partial L}{\partial w}^2 \\ \hat{\nu_t} = \frac{\nu_t}{1-\beta^t} \\ \hat{s_t} = \frac{s_t}{1-\beta^t} \\ w_t = w_{t-1} - \frac{\alpha}{\sqrt{\hat{s_t} + \epsilon}} \hat{\nu_t}

このアプローチは、モーメンタムとRMSPropをバイアス補正と組み合わせたもので、Adaptive Momentum Estimation、 (訳:適応モーメンタム)略して Adam と呼ばれています。Adam は、ディープラーニングで最も広く使われている最適化アルゴリズム の一つです。これを FNNClassifier に実装してみましょう。

def __init__(self, input_dim, hidden_dims, output_dim, lr=0.001, batch_size=16, beta_1=0.9, beta_2=0.99, optimizer="SGD"):
        self.lr = lr
        self.history = []
        self.optimizer = optimizer
        self.batch_size = batch_size
        self.beta_1 = beta_1
        self.beta_2 = beta_2
        self.W = []
        self.b = []
        
        ...
 
        # Initialize Adam variables
        if self.optimizer == "Adam":
            self.epsilon = 1e-8
            self.m_W = [np.zeros_like(w) for w in self.W]
            self.m_b = [np.zeros_like(b) for b in self.b]
            self.s_W = [np.zeros_like(w) for w in self.W]
            self.s_b = [np.zeros_like(b) for b in self.b]

まず、Adam用のハイパーパラメータを追加し、重みとバイアスのためのモーメンタム mm とセカンドモーメンタム ss を初期化する必要があります。 その後、上記の方程式を使用して fit 関数を更新します。

    def fit(self, X, y, epochs=100, verbose=True):
        for epoch in range(epochs):
            if self.optimizer == "SGD":
                randId = np.random.choice(X.shape[0], self.batch_size, replace=False)
                X_batch = X[randId]
                y_batch = y[randId]
            elif self.optimizer == "MiniBatchGD" or self.optimizer == "Adam":
                randId = np.random.choice(X.shape[0], self.batch_size, replace=False)
                X_batch = X[randId]
                y_batch = y[randId]
            else:
                X_batch = X
                y_batch = y
            
            ...
 
                # Adam-specific parameter updates
                if self.optimizer == "Adam":
                    t = epoch + 1
                    self.m_W[i] = self.beta_1 * self.m_W[i] + (1 - self.beta_1) * grad_W
                    self.s_W[i] = self.beta_2 * self.s_W[i] + (1 - self.beta_2) * (grad_W ** 2)
                    self.m_b[i] = self.beta_1 * self.m_b[i] + (1 - self.beta_1) * grad_b
                    self.s_b[i] = self.beta_2 * self.s_b[i] + (1 - self.beta_2) * (grad_b ** 2)
 
                    m_W_hat = self.m_W[i] / (1 - self.beta_1 ** t)
                    s_W_hat = self.s_W[i] / (1 - self.beta_2 ** t)
                    m_b_hat = self.m_b[i] / (1 - self.beta_1 ** t)
                    s_b_hat = self.s_b[i] / (1 - self.beta_2 ** t)
 
                    self.W[i] -= self.lr * m_W_hat / (np.sqrt(s_W_hat) + self.epsilon)
                    self.b[i] -= self.lr * m_b_hat / (np.sqrt(s_b_hat) + self.epsilon)
                else:
                    self.W[i] -= self.lr * grad_W
                    self.b[i] -= self.lr * grad_b
 
        return self.history

Adamはミニバッチに対して動作するという点で、ミニバッチ勾配降下法に似ています。アヤメデータセットにモデルを適合させたとき の学習曲線は以下のようになります。

Adam Loss

残念ながら、この特定のケースでは、ミニバッチGDと比べて大きな違いが観察できないかもしれませんが、Adam は一般的にモデルをより堅牢にするはずです。 今後の記事では、FNNClassifier をさらに改善し、より良い結果を達成することを目指して行きます。