MLエンジニアへの道 #13 - 人工ニューラルネットワーク

Last Edited: 8/20/2024

このブログ記事では、機械学習におけるフィードフォワードニューラルネットワークの基本を紹介します。

ML

ディープラーニング

機械学習の主な目的は変数間の複雑な関係を捉えることであり、それを達成するためにより知能の高いモデルを開発 することを命題としています。では現状現実世界で最も知能の高いとされるメカニズムは何でしょうか?それは 私たちの脳です。

脳がどのように機能しているかについては、まだ多くのことが不明ですが、脳が多数の相互に接続されたニューロンを含ん でいることはわかっています。これらのニューロンは、他のニューロンからの活性シグナルを受け取り、他のニューロンの活性化 に応じて活性化し、次のニューロンに活性シグナルを送ります。このニューロンのメカニズムを関数を使って真似すれば、 脳のような高性能なモデルを作れるのではないか、というのが人工ニューラルネットワークの試みです。

ディープラーニングは、この人工ニューラルネットワークを研究する機械学習の一分野であり、この分野の急速な進展と、 ハードウェアの革新によって可能になった驚異的なパフォーマンスが、最近のAIブームを引き起こしたと言っても過言ではありません。

フィードフォワードニューラルネットワーク

最も基本的な人工ニューラルネットワークは、フィードフォワードニューラルネットワーク(Feedfoward Neural Networks, FNN)です。 FNNは、入力層、隠れ層、および出力層から構成されています。入力層には、データの特徴に関連するニューロンが含まれています。 すべての入力ニューロンは、FNNの次の隠れ層にある各ニューロンに接続されており、隠れ層にある各ニューロンが 線形関数を入力ニューロンに適用します。

これらのニューロンは、通常シグモイド関数のような非線形活性化関数を適用して、ニューロンの活性化を模倣するため、 値の範囲を-\inftyから\inftyに変換して00から11に変換します。隠れ層のニューロンが、予測を行うために役立つ データの特徴を捉えることを期待しています。任意の数の隠れ層と任意の数のニューロンが接続され、同じ計算を行います。 これは次のように数学的に表すことができます:

zt=ϕ1ht1+ϕ2ht=σ(z) z_t = \phi_1 h_{t-1} + \phi_2 \\ h_{t} = \sigma(z)

ここで、hth_tは層ttのニューロンの活性化を表し、σ\sigmaは非線形活性化関数であり、ztz_tは層ϕ\phiの重みとバイアスを使用 して、前の層ht1h_{t-1}の活性化に線形関数を適用した結果です。隠れ層を重ねていくことで、モデルが様々なレベルの特徴を捉えることを期待します。

次に、出力層は最後の隠れ層の活性化を受け取り、タスクに応じた適切な活性化関数で同じ計算を行います。回帰の場合は出力値を無制限 に保つために活性化関数を使用しません。二値分類の場合は確率を出力するためにシグモイド関数を使用し、多クラス分類の場合は 複数のカテゴリの確率を出力するためにソフトマックス関数を使用できます。

理解を深めるために、アヤメ分類のためのフィードフォワードニューラルネットワークの例を見てみましょう。

ANN Example

入力層には、がく片の長さ、がく片の幅、花弁の長さ、花弁の幅に対応する4つのニューロンがあります。次に、5つのニューロンを含む1つの隠れ層があり、 各ニューロンは4つの入力ニューロンすべてに接続され、シグモイド関数を使用した上記の計算を実行して活性化を取得します。もう1つ同じ数のニューロンを持つ同じ隠れ層があります。

最後に、Setosa、Versicolor、またはVirginicaのクラス確率に対応する3つのニューロンを持つ出力層があり、隠れ層の5つのニューロンすべてに接続されています。 この出力層は、最終的な予測を得るためにソフトマックス関数を活性化関数として使用します。ネットワークを前方に伝播して予測を行うプロセスは、 順伝播と呼ばれ、次のような入れ子の関数として表すことができます:

ho(x)=σo(zo(x))zo(x)=wohh2(x)+bohhi(x)=σhi(zhi(x))zhi(x)=whix+bhiho(x)=σo(wo(σh2(wh2(σh1(wh1x+bh1))+bh2))+bo) h_{o}(x) = \sigma_o(z_o(x)) \\ z_o(x) = w_{o} h_{h2}(x) + b_{o} \\ h_{hi}(x) = \sigma_{hi}(z_{hi}(x)) \\ z_{hi}(x) = w_{hi} x + b_{hi} \\ h_o(x) = \sigma_o(w_{o} (\sigma_{h2}(w_{h2} (\sigma_{h1}(w_{h1}x + b_{h1})) + b_{h2})) + b_{o})

ここで、ho(x)h_o(x)は出力層の活性化または予測であり、hh(x)h_h(x)は入力xxに対する隠れ層の活性化です。上記の方程式は、FNNで非線形活性化関数を使用することは、 それはニューロンを模倣するためだけでなく、実際的な理由からも重要であることを示しています。。全てのσ(x)\sigma(x)axaxと設定してみましょう:

ho(x)=ao(wo(ah2(wh2(ah1(wh1x+bh1))+bh2))+bo)=ao(wo(ah2(wh2(ah1wh1x+ah1bh1)+bh2))+bo)=ao(wo(ah2(wh2ah1wh1x+wh2ah1bh1+bh2))+bo)=ao(wo(ah2wh2ah1wh1x+ah2wh2ah1bh1+ah2bh2)+bo)=ao(woah2wh2ah1wh1x+woah2wh2ah1bh1+woah2bh2+bo)=aowoah2wh2ah1wh1x+aowoah2wh2ah1bh1+aowoah2bh2+aobo=(aowoah2wh2ah1wh1)x+(aowoah2wh2ah1bh1+aowoah2bh2+aobo) h_o(x) = a_o(w_{o} (a_{h2}(w_{h2} (a_{h1}(w_{h1}x + b_{h1})) + b_{h2})) + b_{o}) \\ = a_o(w_{o} (a_{h2}(w_{h2} (a_{h1}w_{h1}x + a_{h1}b_{h1}) + b_{h2})) + b_{o}) \\ = a_o(w_{o} (a_{h2}(w_{h2}a_{h1}w_{h1}x + w_{h2}a_{h1}b_{h1} + b_{h2})) + b_{o}) \\ = a_o(w_{o} (a_{h2}w_{h2}a_{h1}w_{h1}x + a_{h2}w_{h2}a_{h1}b_{h1} + a_{h2}b_{h2}) + b_{o}) \\ = a_o( w_{o}a_{h2}w_{h2}a_{h1}w_{h1}x + w_{o}a_{h2}w_{h2}a_{h1}b_{h1} + w_{o}a_{h2}b_{h2} + b_{o}) \\ = a_ow_{o}a_{h2}w_{h2}a_{h1}w_{h1}x + a_ow_{o}a_{h2}w_{h2}a_{h1}b_{h1} + a_ow_{o}a_{h2}b_{h2} + a_ob_{o} \\ = (a_ow_{o}a_{h2}w_{h2}a_{h1}w_{h1})x + (a_ow_{o}a_{h2}w_{h2}a_{h1}b_{h1} + a_ow_{o}a_{h2}b_{h2} + a_ob_{o})

ご覧のとおり、活性化関数に線形関数を使用すると、モデルが線形回帰モデルに変わり、複雑な非線形データに適合できなくなります。モデルが複雑なデータに適合できる状態を保つためには、 非線形活性化関数を使用することが重要です。

逆伝播

上記で説明したモデルをトレーニングして、コストを最小化するためには、重みとバイアス(wwbb)を調整する必要があります。これは、コスト関数の重みとバイアスに対する 偏微分を計算し、パラメータを徐々に調整する、勾配降下法を使用して実行できます。したがって、まずwoL(y,ho(x))\frac{\partial}{\partial w_o} L(y, h_o(x))および boL(y,ho(x))\frac{\partial}{\partial b_o} L(y, h_o(x))を計算します。これらは、チェインルール(連鎖律)を使用して次のように書き換えることができます:

woL(y,ho(x))=dLdhowoho(x)boL(y,ho(x))=dLdhoboho(x) \frac{\partial}{\partial w_o} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{\partial}{\partial w_o} h_o(x) \\ \frac{\partial}{\partial b_o} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{\partial}{\partial b_o} h_o(x)

チェインルールを使用して、この偏微分をさらに分解できます:

woL(y,ho(x))=dLdhodhodzowozo(x)boL(y,ho(x))=dLdhodhodzobozo(x) \frac{\partial}{\partial w_o} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{\partial}{\partial w_o} z_o(x) \\ \frac{\partial}{\partial b_o} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{\partial}{\partial b_o} z_o(x)

fracdLdhofracdhodzo\\frac{d L}{d h_o} frac{d h_o}{d z_o}は、ロジスティック回帰やソフトマックス回帰で示した通り容易に計算できます。 したがって、wow_oおよびbob_oに対するコスト関数の勾配は次のようになります:

woL(y,ho(x))=dLdhodhodzohh2(x)boL(y,ho(x))=dLdhodhodzo \frac{\partial}{\partial w_{o}} L(y, h_o(x)) = \frac{dL}{d h_o} \frac{d h_o}{d z_o} h_{h2}(x) \\ \frac{\partial}{\partial b_{o}} L(y, h_o(x)) = \frac{dL}{d h_o} \frac{d h_o}{d z_o}

上記の方程式に含まれるすべての要素は、出力値と前の隠れ層の活性化を保存することで容易に得られます。 wow_oおよびbob_oに対する勾配を計算した後、次にwh2w_{h2}およびbh2b_{h2}に対する勾配を求める必要があります:

wh2L(y,ho(x))=dLdhowh2ho(x)=dLdhodhodzowh2zo(x)=dLdhodhodzodzodhh2wh2hh2(x)bh2L(y,ho(x))=dLdhodhodzodzodhh2bh2hh2(x) \frac{\partial}{\partial w_{h2}} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{\partial}{\partial w_{h2}} h_o(x) \\ = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{\partial}{\partial w_{h2}} z_o(x) \\ = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{d z_o}{d h_{h2}} \frac{\partial}{\partial w_{h2}} h_{h2}(x) \\ \frac{\partial}{\partial b_{h2}} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{d z_o}{d h_{h2}} \frac{\partial}{\partial b_{h2}} h_{h2}(x)

ここで注目すべきは、最初の2つの項がすでにbob_oの勾配として計算されており、3番目の項dzodhh2\frac{d z_o}{d h_{h2}}が単にwow_oであることです。 したがって、最後の項を追加で計算するだけで、wh2w_{h2}およびbh2b_{h2}に対する偏勾配を得ることができます。これをチェインルールを使用してさらに 次のように分解できます:

wh2L(y,ho(x))=dLdhodhodzodzodhh2dhh2dzh2wh2zh2(x)bh2L(y,ho(x))=dLdhodhodzodzodhh2dhh2dzh2bh2zh2(x) \frac{\partial}{\partial w_{h2}} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{d z_o}{d h_{h2}} \frac{d h_{h2}}{d z_{h2}}\frac{\partial}{\partial w_{h2}} z_{h2}(x) \\ \frac{\partial}{\partial b_{h2}} L(y, h_o(x)) = \frac{d L}{d h_o} \frac{d h_o}{d z_o} \frac{d z_o}{d h_{h2}} \frac{d h_{h2}}{d z_{h2}}\frac{\partial}{\partial b_{h2}} z_{h2}(x)

上記を考慮すると、wh2w_{h2}およびbh2b_{h2}に対するコスト関数の偏微分は次のようになります:

wh2L(y,ho(x))=dLdhodhodzodzodhh2dhh2dzh2hh1(x)bh2L(y,ho(x))=dLdhodhodzodzodhh2dhh2dzh2 \frac{\partial}{\partial w_{h2}} L(y, h_o(x)) = \frac{dL}{d h_o} \frac{d h_o}{d z_o}\frac{d z_o}{d h_{h2}} \frac{d h_{h2}}{d z_{h2}} h_{h1}(x) \\ \frac{\partial}{\partial b_{h2}} L(y, h_o(x)) = \frac{dL}{d h_o} \frac{d h_o}{d z_o}\frac{d z_o}{d h_{h2}} \frac{d h_{h2}}{d z_{h2}}

同様の方法で、wh1w_{h1}およびbh1b_{h1}に対してもチェインルールを適用することができます:

wh2L(y,ho(x))=dLdhodhodzodzodhh2dhh2dzh2dzh2dhh1dhh1dzh1xbh2L(y,ho(x))=dLdhodhodzodzodhh2dhh2dzh2dzh2dhh1dhh1dzh1 \frac{\partial}{\partial w_{h2}} L(y, h_o(x)) = \frac{dL}{d h_o} \frac{d h_o}{d z_o}\frac{d z_o}{d h_{h2}} \frac{d h_{h2}}{d z_{h2}} \frac{d z_{h2}}{d h_{h1}} \frac{d h_{h1}}{d z_{h1}} x \\ \frac{\partial}{\partial b_{h2}} L(y, h_o(x)) = \frac{dL}{d h_o} \frac{d h_o}{d z_o}\frac{d z_o}{d h_{h2}} \frac{d h_{h2}}{d z_{h2}} \frac{d z_{h2}}{d h_{h1}} \frac{d h_{h1}}{d z_{h1}} \\

そろそろパターンが見えてきたでしょうか?すべての隠れ層に対して、チェインルールを使用し、dzhdhh1dhh1dzh1\frac{d z_h}{d h_{h-1}} \frac{d h_{h-1}}{d z_{h-1}}を掛けていくことで、 偏微分が計算できます。隠れ層がいくつあっても、チェインルールを利用して勾配を計算し、それに応じて重みを更新することができます。 勾配を出力から計算し、ネットワークを逆方向に伝播させてチェインルールを適用するため、この仕組みは逆伝播と呼ばれ、 人工ニューラルネットワークを学習させる配降下法やその他の類似の学習メカニズムを適用するために不可欠です。

コードの実装

前述のコンセプトを使用して、シンプルなフィードフォワードニューラルネットワークをゼロから実装してみましょう。ここでは、 Irisデータセットを使用して、Irisの種を分類するタスクを実行します(ステップ1と2は過去の記事でカバーしたため省略します)。

ステップ3. モデル

まず、以下のように必要なパラメータと活性化関数を初期化します:

class FNNClassifier():
  def __init__(self, input_dim, hidden_dims, output_dim, lr=0.001):
    self.lr = lr
    self.history = []
    self.W = []
    self.b = []
    for i in range(len(hidden_dims)):
      if (i == 0):
        self.W.append(np.random.rand(input_dim, hidden_dims[i]))
      else:
        self.W.append(np.random.rand(hidden_dims[i-1], hidden_dims[i]))
      self.b.append(np.random.rand(hidden_dims[i]))
    
    self.W.append(np.random.rand(hidden_dims[len(hidden_dims)-1], output_dim))
    self.b.append(np.random.rand(output_dim))
 
  def sigmoid(self, X):
    return 1 / (1 + np.exp(-X))
 
  def softmax(self, X):
    # log-sum-exp trick
    max_logits = np.max(X, axis=1, keepdims=True)
    stabilized_logits = X - max_logits
 
    odds = np.exp(stabilized_logits)
    total_odds = np.sum(odds, axis=1, keepdims=True)
    return odds / total_odds

隠れ層の数や各層のニューロン数は、入力パラメータ input_dimhidden_dims、および output_dim によって指定できます。 上の活性化関数についてよく分からない場合は、ロジスティック回帰やソフトマックス回帰に関する過去の記事を参照してください。 次に、行列の積を使用して前向き伝播を行う predict 関数を定義します。

def predict(self, X, train=False):
    # 勾配計算のために活性化関数の出力を記録
    activations = [X]
    for i in range(len(self.W)):
        X = np.matmul(X, self.W[i]) + self.b[i]
        if i == len(self.W) - 1:
            # クラス数に応じてソフトマックスまたはシグモイド関数を使用
            if self.b[i].shape[0] > 1:
                X = self.softmax(X)
            else:
                X = self.sigmoid(X)
                X = X.flatten()
            activations.append(X) if train else None
        else:
            # 隠れ層にはシグモイド活性化関数を使用
            X = self.sigmoid(X)
        activations.append(X) if train else None
    if not train:
        return X
    else:
        return X, activations
 
FNNClassifer.predict = predict

タスクが多クラス分類か二値分類かに応じて、出力層の活性化関数を変更する必要があります。隠れ層については、 シグモイド関数を使用します。次に、逆伝播と勾配降下法を使用してモデルを訓練するための fit 関数を定義します。

def fit(self, X, y, epochs=100, verbose=True):
    for epoch in range(epochs):
        pred, activations = self.predict(X, train=True)
        self.history.append(log_loss(y, pred))
        if verbose:
            print(f"Epoch: {epoch}, Loss: {self.history[-1]}")
 
        # 最後の隠れ層に対するコストの勾配
        # ソフトマックスとシグモイドの両方に対応
        delta = pred - y
 
        for i in range(len(self.W)-1, -1, -1):
            grad_W = np.matmul(activations[i].T, delta)
            grad_b = np.sum(delta, axis=0)
 
            # 逆伝播
            if i > 0:
                # dz_t/dh_t-1 = self.W[i].T
                # dh_t-1/dz_t-1 = activations[i] * (1 - activations[i]) for h = sigmoid
                delta = np.matmul(delta, self.W[i].T) * activations[i] * (1 - activations[i])
 
            # 勾配降下
            self.W[i] -= self.lr * grad_W
            self.b[i] -= self.lr * grad_b
 
    return self.history
 
FNNClassifer.fit = fit

上記の定義を使用して、以下のようにフィードフォワードニューラルネットワークを初期化し、トレーニングデータに適合させることができます:

fnn = FNNClassifier(4, [32], 3, lr=0.001)
 
r = fnn.fit(X_train, y_train, epochs=1000, verbose=False)

トレーニング中のエポックごとのコストを以下のようにプロットできます:

import matplotlib.pyplot as plt
plt.plot(r)
plt.title("Loss vs Epoch")
plt.ylabel("Loss")
plt.xlabel("Epoch")
plt.show()
FNN Loss over Epoch

上記のプロットから、モデルがコストの削減に成功していることがわかりますが、途中で少し苦労していることも見て取れます。

ステップ4. モデル評価

先ほど訓練したニューラルネットワークを使用して、テストデータセットに対する予測を行いましょう。

pred = fnn.predict(X_test, train=False)
 
pred = np.argmax(pred, axis=1)
y_test = np.argmax(y_test, axis=1)

その後、混同行列を使用して予測結果を視覚化できます。

FNN Confusion Matrix

上記のように、モデルは完璧な予測を達成し、人工ニューラルネットワークの可能性を示しています。

ただし、あなたがノートブックでコードを実行しながらこの記事を読んでいる場合、結果がモデルのハイパーパラメータに非常に敏感であり、 モデルのトレーニングを行うたびに結果が異なることに気付くかもしれません。(実際、上記の実装は、 単純なソフトマックス回帰モデルよりも悪いパフォーマンスを示すことがよくあります。) これはいくつかの要因によるものであり、今後の記事でこれらの要因について詳しく説明し、対策を講じで行きます。