numpyで実装するフルスクラッチ・ニューラルネットワーク

# 初めに
機械学習・深層学習に関して、理論面で力不足を感じていたため、わかりやすいパターン認識という書籍でニューラルネットワークの理論について学習し、数式を元にニューラルネットワークを実装してみました。
以下のリポジトリが全体のプログラムです。

https://github.com/morikazu1119/full_scratch_nn

# 構成
- activation_function.py
- linear.py
- loss_function
- neural_network.py
- demo.ipynb

demo.ipynbを実行することで学習・推論を行うことができます。

## activation_function.py
```python
import numpy as np


class Sigmoid:
    def forward(self, x):
        self.out = 1 / (1 + np.exp(-x))
        return self.out

    def backward(self, grad_output, learning_rate):
        learning_rate = learning_rate
        return grad_output * self.out * (1 - self.out)


class ReLU:
    def forward(self, x):
        self.out = np.maximum(0, x)
        return self.out

    def backward(self, grad_output, learning_rate):
        learning_rate = learning_rate
        return grad_output * (self.out > 0)


class Softmax:
    def forward(self, x):
        self.out = np.exp(x) / np.sum(np.exp(x), axis=1, keepdims=True)
        return self.out

    def backward(self, grad_output, learning_rate):
        learning_rate = learning_rate
        return grad_output * self.out * (1 - self.out)
```

各クラスのforwardメソッドでは、入力データに対してそれぞれの活性化関数を適用し、変換後の出力を計算しています。一方、backwardメソッドは、逆伝播時に受け取った勾配（grad_output）を各活性化関数の微分値で乗算することで、誤差を前の層に伝える役割を果たしています。

### Sigmoid クラス

#### forward メソッド
入力に対してシグモイド関数を適用します。

```math
σ(x)=\frac{1}{1+e^{−x}}
```

この計算結果を self.out に保存しています。

#### backward メソッド
逆伝播では、受け取った勾配`grad_outputgrad_output` に対し、シグモイド関数の微分を乗算して誤差を前の層へ伝達します。
シグモイドの微分は次のようになります。

```math
\frac{dσ}{dx}=σ(x)(1−σ(x))
```

したがって戻り値は`grad_output × σ(x)(1−σ(x))`となります。

### ReLU

#### forward
入力に対し、ReLU関数

```math
max(0,x)
```

を適用し、負の値は$0$に、正の値はそのままにします。

#### backward

逆伝播では、入力が正の部分のみ$1$、それ以外は$0$となる微分を用いて勾配を伝えます。

$x>0$の場合は微分が$1$、$x≤0$の場合は$0$となります。

これを指示関数 $I(x>0)$を用いて表すと、

```math
\frac{d}{ReLUdx}=I(x>0)
```

よって、逆伝播時の勾配は

```math
δ=grad \, output×I(x>0)
```

となります。

### Softmax

#### forward
入力行列の各行に対して、指数関数を適用後、正規化を行い、確率分布としての出力を得ます。
Softmax 関数は

```math
Softmax(x_i​)=\frac{e^{x^i}}{∑{_j} e^{x^j}}
```

と定義されます。

#### backward

本実装では、逆伝播時に簡略化し

```math
δ=grad \, output×Softmax(x)(1−Softmax(x))
```

という式を用いています。

## linear.py

```python
import numpy as np

from scripts.neural_network import NeuralNetwork


# 活性化関数は変更可能にする
class Linear(NeuralNetwork):
    def __init__(self, input_size, output_size):
        self.weights = np.random.randn(input_size, output_size) * np.sqrt(
            1 / input_size
        )
        self.bias = np.zeros((1, output_size))

    def forward(self, x):
        """
        順伝播
        Parameters:
            x : 入力
        Returns:
            output : 出力
        """
        self.inputs = x
        # わかりやすいパターン認識 p36
        self.output = np.dot(x, self.weights) + self.bias
        return self.output

    def backward(self, output_error, learning_rate):
        """
        逆伝播
        Parameters:
            output_error : 出力誤差
            learning_rate : 学習率
        Returns:
            input_error : 入力誤差
        """
        input_error = np.dot(output_error, self.weights.T)
        # わかりやすいパターン認識 p62
        self.weights -= learning_rate * np.dot(self.inputs.T, output_error)
        # わかりやすいパターン認識 p67 図を参考
        self.bias -= learning_rate * np.sum(output_error, axis=0, keepdims=True)
        return input_error
```

### 初期化 (__init__ メソッド)

#### 重みの初期化
入力サイズ `input_size` と出力サイズ `output_size` を受け取り、重みは

```math
W∼N(0,\frac{1}{input \, size})
```

のように初期化されています。ここでは、入力次元に依存する正規化を行っており、勾配消失などの問題を軽減すしています。

#### バイアスの初期化

バイアスは出力サイズに合わせてゼロベクトルで初期化されます。

### 順伝播 (forward メソッド)

このメソッドでは、入力に対して線形変換を行います。具体的には、以下の計算を行っています。

```math
output=xW+b
```

#### 変数

`self.inputs`: 逆伝播の際に、入力 xx を利用するために保持しています。

`self.output`: 線形変換の結果を保持しています。

この順伝播処理は、ニューラルネットワーク内の各層で特徴量の抽出を行います。

### 逆伝播 (backward メソッド)

逆伝播では、出力側から受け取った誤差 `output_error` を元に、前の層に伝える勾配（入力誤差）を計算し、さらに重みとバイアスの更新を行います。

#### 入力誤差の計算
入力誤差 `input_error` は、出力誤差を重み行列の転置と掛け合わせることで計算されます。

```math
input \, error=output \, error×WT
```

これにより、出力層で計算された誤差が、入力層へ逆伝播されます。

#### 重みの更新
重みは、学習率 learning_rate を用いて、勾配降下法により更新されます。
勾配は、入力の転置と出力誤差の積で求められます。

```math
W−=learning \, rate×xT⋅output \, error
```

ここで $xT$ は入力 $x$ の転置です。
この式は、「わかりやすいパターン認識 p62」の内容に基づいています。

#### バイアスの更新
バイアスも同様に、学習率をかけた出力誤差の和で更新されます。
出力誤差を各サンプルごとに合計し、次のように更新します。

```math
    b−=learning \, rate×∑output \, error
```

バイアスの更新は「わかりやすいパターン認識 p67」の図を参考にしています。

#### 戻り値
最終的に、計算した入力誤差 input_error を返し、これが前の層へ伝播されます。

## loss_function.py
```python
import numpy as np


# Mean Squared Error (MSE)
def mse(y_true, y_pred):
    """
    平均二乗誤差
    Parameters:
        y_true : 真の値
        y_pred : 予測値
    Returns:
        mse : 平均二乗誤差
    """
    if y_true.shape != y_pred.shape:
        raise ValueError("Shapes of y_true and y_pred must be the same.")
    return np.mean((y_true - y_pred) ** 2)


# Cross Entropy Loss
def cross_entropy(y_true, y_pred):
    """
    クロスエントロピー誤差
    Parameters:
        y_true : 真の値
        y_pred : 予測値
    Returns:
        cross_entropy : クロスエントロピー誤差
    """
    if y_true.shape != y_pred.shape:
        raise ValueError("Shapes of y_true and y_pred must be the same.")
    return -np.sum(y_true * np.log(y_pred)) / y_true.shape[0]


# Binary Cross Entropy Loss
def binary_cross_entropy(y_true, y_pred):
    """
    二値クロスエントロピー誤差
    Parameters:
        y_true : 真の値
        y_pred : 予測値
    Returns:
        binary_cross_entropy : 二値クロスエントロピー誤差
    """
    if y_true.shape != y_pred.shape:
        raise ValueError("Shapes of y_true and y_pred must be the same.")
    return (
        -np.sum(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
        / y_true.shape[0]
    )


def return_loss_function(loss):
    """
    損失関数を返す
    Parameters:
        loss : 損失関数の名前
    Returns:
        loss function : 損失関数
    """
    if loss == "mse":
        return mse
    elif loss == "cross_entropy":
        return cross_entropy
    elif loss == "binary_cross_entropy":
        return binary_cross_entropy
    else:
        raise ValueError("Invalid loss function.")
```

### Mean Squared Error (MSE)
予測値と真の値との誤差の二乗平均を計算します。
特に回帰問題でよく用いられ、誤差が大きいほどペナルティを大きく与える性質があります。

```math
MSE=\frac{1}{N}∑_i=\frac{1}{N}(∑_{i=1}^{N}y_{true}^{(i)}−y_{pred}^{(i)})^2
```

ここで $N$ はデータサンプル数です。

### Cross Entropy Loss

分類問題（特に多クラス分類）で、予測確率と真のクラスラベルとの誤差を測定します。
確率分布間の距離を評価するため、確率が小さいほど大きなペナルティが課されます。 
損失は全サンプルに対する平均の形で計算されます。

```math
CrossEntropy=−\frac{1}{N}∑_{i=1}^{N}∑_jy_{true_j}^{(i)}log⁡(y_{pred_j}^{(i)})
```

ここで、$j$ はクラスのインデックスです。

### Binary Cross Entropy Loss
2値分類（バイナリ分類）において、各サンプルごとの損失を計算します。
正例と負例それぞれの確率に対してペナルティを課す形となっています。
損失は正例 $y=1$ の場合と負例 $y=0$ の場合で別々に考え、平均値を求めます。

```math
BinaryCrossEntropy=−\frac{1}{N}∑_i=\frac{1}{N}[y_{true}^{(i)}log⁡(y_{pred}^{(i)})+(1−y_{true}^{(i)})log⁡(1−y_{pred}^{(i)})]
```

### return_loss_function
与えられた文字列（例："mse", "cross_entropy", "binary_cross_entropy"）に対応する損失関数を返すためのヘルパー関数です。
条件分岐により、対応する損失関数を返し、未知の名前の場合はエラーを発生させます。

## neural_network.py
```python
from scripts.loss_function import return_loss_function


class NeuralNetwork:
    """
    ニューラルネットワーク
    Parameters:
        layers : レイヤーのリスト
        loss : 損失関数
        learning_rate : 学習率
        debug_loss_interval : デバッグ用の損失関数の表示間隔

    Functions:
        forward : 順伝播
        backward : 逆伝播
        train : 学習

    """

    def __init__(self, layers, loss, learning_rate, debug_loss_interval=100):
        self.layers = layers
        self.loss_func = return_loss_function(loss)
        self.learning_rate = learning_rate
        self.debug_loss_interval = debug_loss_interval

    def forward(self, x):
        for layer in self.layers:
            x = layer.forward(x)
        return x

    def backward(self, y, output):
        error = (output - y) / y.shape[0]
        for layer in reversed(self.layers):
            error = layer.backward(error, self.learning_rate)

    def train(self, X, y, epochs):
        for epoch in range(epochs):
            output = self.forward(X)
            self.backward(y, output)
            if epoch % self.debug_loss_interval == 0 or epoch == 0:
                loss = self.loss_func(y, output)
                print(f"Epoch {epoch}, Loss: {loss}")
```

### __init__ メソッド

- レイヤーのリスト:
`layers` にはネットワーク内の各層のインスタンスが格納されます

- 損失関数:
`loss` で指定された損失関数の名前を`return_loss_function` を使って関数オブジェクトに変換し、`self.loss_func` に保存します
たとえば、"mse" と指定すれば、平均二乗誤差が用いられます

- 学習率:
`learning_rate` はパラメータ更新時のステップサイズとして利用され、各層の `backward` メソッドに渡されます。

- デバッグ用設定:
`debug_loss_interval` は学習中に損失値を表示する間隔（エポック数）を指定します。

### forward メソッド
入力データ $x$ を各層を通して順に処理し、最終的な出力を計算します。
各層の forward メソッドを順番に呼び出し、次の層の入力として渡します。
数式で表すと、入力 $x$ に対して各層 $f_1,f_2,…,f_L​$ を適用し、

```math
    output=f_L(⋯f_2(f_1(x))⋯ )
```

のように表現されます。

### backward メソッド

#### 誤差の初期化
順伝播で得た出力と正解ラベル yy との誤差を各層に逆伝播させ、パラメータの更新を行うための勾配を計算します。
ネットワーク全体の出力と正解との差を正規化して初期誤差を計算します。

```math
        error=\frac{output−y}{N}
```

ここで $N$ はサンプル数`y.shape[0]`です。

#### 層ごとの逆伝播
各層の backward メソッドを逆順に呼び出し、誤差を前の層に伝えます。
各層では、受け取った勾配に基づいてパラメータ（重みやバイアス）の更新と、次の層に渡す入力誤差の計算が行われます。

### train メソッド

ネットワークを指定されたエポック数だけ訓練します。各エポックで、順伝播・逆伝播によるパラメータの更新を行います。
#### 順伝搬
入力 $X$ を用いて forward メソッドを呼び出し、出力を計算します。
#### 逆伝搬
得られた出力と正解 $y$ をもとに、backward メソッドで勾配計算とパラメータ更新を実施します。
#### 損失の表示
指定されたエポック（`debug_loss_interval` の間隔または最初のエポック）ごとに、現在の損失値を計算し表示します。
損失値は、前述の損失関数（例：MSE やクロスエントロピー）を用いて、

```math
        Loss=loss \, func(y,output)
```
として求められます。

## demo.ipynb
```python
import sys
from pathlib import Path
current_dir = Path().resolve().parent
sys.path.append(str(current_dir))

import numpy as np
from scripts.neural_network import NeuralNetwork
from scripts.linear import Linear
from scripts.activation_function import Sigmoid, ReLU, Softmax
np.random.seed(1)
```


```python
X = np.array([[0, 0],
              [0, 1],
              [1, 0],
              [1, 1]])
y = np.array([[0],
              [1],
              [1],
              [0]])

Layers = [Linear(2, 4), 
          Sigmoid(), 
          Linear(4, 1)]

nn = NeuralNetwork(Layers, loss="mse", learning_rate=0.4, debug_loss_interval=100)

nn.train(X, y, epochs=1000)

pred = nn.forward(X)
print("Predictions:")
print(pred)
```

必要なライブラリをimportし、パスを通し、作成したメソッドを利用することで、学習・推論を行うことができます。

# 最後に
理論を実装してみると、Pytorchなどのフレームワークに関する知識がつくのでおすすめです。
CNNなどもフルスクラッチで作成したら面白いかな？とか思っています。