単純パーセプトロンの学習をPythonで実装する

## はじめに
先日、自己学習のために単純パーセプトロンの学習をPythonを用いて実装したので、その備忘録として記事に残します。今回は「2クラス分類において、線形分離可能かを判定するコードの実装」をゴールとしました。

## パーセプトロンの学習方法
はじめに単純パーセプトロンの学習について簡単におさらいします。
まず、任意の入力パターン$x_i (i=1,2, \cdots ,n)$と、重み$w_i (i=0,1, \cdots ,m)$を用いて以下の識別関数$g(x)$を用意します。
```math
g(x) = w_0 + w_1x_1 + w_2x_2 +  \cdots + w_{m-1}x_{n-1} + w_mx_n 
```
これを使って入力パターン$x$のクラス分類を行います。「ある入力に対して出力される$g(x)$の値が一定以上ならクラスAに、そうでなければクラスBに属す」などといった振る舞いをします。例えば２クラス分類の場合、関数$g(x)$の出力によって次のようにクラスを決定できます（分類クラスは$c_0, c_1$）。
```math
\left\{
    \begin{array}{ll}
    g(x) > 0 & \Rightarrow  x \in c_0\\
    g(x) < 0 & \Rightarrow  x \in c_1\\
    \end{array}
\right.
```
$0$を決定境界として、それ以上であればクラス$c_0$に、そうでなければクラス$c_1$に属すると判断できます。これを実現するために、入力データすべてにおいて、識別関数の出力が正しくなるように重み$w_i$を決定することが、パーセプトロンの学習です。

## 実装
先んじて、今回実装したコードを記載します。簡単な解説は後述。
### 環境
実装環境は以下の通りです。データセットはsk-learnのirisデータセットを使用しました。
・Windows 11 Pro
・Python 3.12.3
・irisデータセット

### 実装コード
```TrainPerceptron.py
# ===== import =====
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris # irisデータ読み込み

# ===== 変数定義 =====
iris = load_iris()  # irisデータ
epochs = 100        # エポック数
LearningRate = 0.5  # 学習率
dim = 5             # 入力次元数
weights = [0]*dim   # 重みベクトル


# ===== irisデータを整形する関数 =====
# --- 出力：irisデータのデータフレーム（df） ---
def DataFormat():
    df = pd.DataFrame(iris.data, columns=iris.feature_names)
    df['target'] = iris.target  # taeget列を生成
    df['dim'] = 1               # 次元を合わせる
    return df

# ===== 重みベクトルを初期化（-1から1のランダム）する関数 =====
def WeightsIni(weights):
    for i in range(len(weights)):
        weights[i] = np.random.default_rng().uniform(-1, 1)
    print('weights_ini:', weights, '\n')
    return weights

# ===== パーセプトロンの学習を行う関数 =====
# --- 入力：2つのクラス, df ---
def TrainPerceptron(DataA, DataB, df):
    print('START:', DataA, ',', DataB)
    # --- 変数定義 ---
    dfX = pd.concat([df[df['target'] == DataA], df[df['target'] == DataB]])
    dfX.loc[df['target'] == DataA, 'target'] = 1   # ダミー変数
    dfX.loc[df['target'] == DataB, 'target'] = -1  # ダミー変数
    dfX = dfX.reset_index(drop=True)               # インデックスを振り直す

    # --- 重みの初期化 ---
    global weights  # 重みベクトル
    weights = WeightsIni(weights)    

    # --- 学習の実行 ---
    for epoch in range(epochs):
        MissCount = 0 # 誤識別した回数
        for i in range(len(dfX)):
            x = dfX.iloc[i].drop('target').values # 説明変数
            target = dfX.at[i, 'target']          # 目的変数

            # 識別関数による計算
            ans = DiscriminationFunc(x, weights, target)
            
            # 重みベクトルの更新
            if ans < 0:
                MissCount += 1
                for j in range(dim):
                    weights[j] += LearningRate * x[j] * target
        
        # 正しく識別できたら終了
        if MissCount == 0:
            break
        
    # --- 結果出力 ---
    if MissCount == 0:
        print('DONE: linear separable')
    else:
        print('DONE: NOT linear separable')
    print('weights_end', weights)
    print('miss', MissCount)
    print('epoch:', epoch)
    return 0

# ===== 識別関数 =====
# --- 入力：特徴ベクトルx, 重みベクトル, 学習パターンの属するクラス ---
# --- 出力：正負 ---
def DiscriminationFunc(x、weights, target):
    ans = 0
    for i in range(len(x)):
        ans += weights[i]*x[i]
    if ans*target > 0:
        return 1  # 正識別した
    else:
        return -1 # 誤識別した  

# ===== main関数 =====
def main():
    # データ整形
    df = DataFormat() 

    # パーセプトロンの学習
    print('0: setosa, 1: versicolor, 2: virginica\n')
    TrainPerceptron(0, 1, df)

if __name__ == '__main__':
    main()
```

## 各関数のふるまい

### データ整形
```TrainPerceptron.py
# ===== import =====
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris # irisデータ読み込み

# ===== irisデータを整形する関数 =====
# --- 出力：irisデータのデータフレーム（df） ---
def DataFormat():
    df = pd.DataFrame(iris.data, columns=iris.feature_names)
    df['target'] = iris.target  # taeget列を生成
    df['dim'] = 1               # 次元を合わせる(*1)
    return df
```
sk-learnのirisデータをデータフレームに格納しています。重みベクトルとデータの要素数を合わせるために、(*1)の行を追加しました。以下の式変形において$x_0$に該当します。
```math
\begin{align}
g(x) = w_0 + w_1x_1 + w_2x_2 +  \cdots + w_{m-1}x_{n-1} + w_mx_n  \\
    = w_0x_0 + w_1x_1 + w_2x_2 +  \cdots + w_{n-1}x_{n-1} + w_nx_n
\end{align}
```

### 識別関数
```TrainPerceptron.py
# ===== 識別関数 =====
# --- 入力：特徴ベクトルx, 重みベクトル, 学習パターンの属するクラス ---
# --- 出力：正負 ---
def DiscriminationFunc(x, weights, target):
    ans = 0
    for i in range(len(x)):
        ans += weights[i]*x[i]
    if ans*target > 0:
        return 1  # 正識別した
    else:
        return -1 # 誤識別した 
```

先述した通りの識別関数を、for文を回して計算し、変数ansに格納しています。
ここで、分類クラスを表す変数targetは1 or -1を取る質的な変数です。ansの正負がtargetの正負と一致していれば正しく識別したと判断します。


### 学習
```TrainPerceptron.py
# ===== パーセプトロンの学習を行う関数 =====
# --- 入力：2つのクラス, df ---
def TrainPerceptron(DataA, DataB, df):
    '''
    省略
    '''
    # --- 学習の実行 ---
    for epoch in range(epochs):
        MissCount = 0 # 誤識別した回数
        for i in range(len(dfX)):
            x = dfX.iloc[i].drop('target').values # 説明変数
            target = dfX.at[i, 'target']          # 目的変数

            # 識別関数による計算
            ans = DiscriminationFunc(x, target, weights)
            
            # 重みベクトルの更新
            if ans < 0:
                MissCount += 1
                for j in range(dim):
                    weights[j] += LearningRate * x[j] * target
        
        # 正しく識別できたら終了
        if MissCount == 0:
            break
        
    # --- 結果出力 ---
    '''
    省略
    '''
    return 0
```
対象の学習データを格納したデータフレームを作成し、その学習データの数だけfor文を回して、少しづつ識別関数の重みを修正します。データに対して誤識別しなくなった場合、その時点で学習を終了します。
識別関数によって-1が返ってきた（誤識別した）ときに重みベクトルを更新します。更新するときの式は以下のようになります。
```math
\left\{
    \begin{array}{ll}
    w' = w + η*x （クラスc_0をc_1と識別したとき）\\
    w' = w - η*x （クラスc_1をc_0と識別したとき）\\
    \end{array}
\right.
```
正負が異なるだけなので、1か-1を取る変数targetを計算に使用しました。もとより多クラスへの拡張は考えていないから、これが楽かなあ。

## 結果
```
0: setosa, 1: versicolor, 2: virginica

START: 0 , 1
weights_ini: [0.9538111946133632, 0.3363357385833583, 0.39864598153337094, -0.115127847711751, -0.012107471414915416]

DONE: linear separable
weights_end [0.5538111946133628, 2.286335738583358, -3.851354018466629, -1.8151278477117507, 0.4878925285850846]
miss 0
epoch: 5
```

ためしにsetosaとversicolorとで分類を行ったら、線形分離可能と判定されて終了しました。ちゃんと重みが更新されていますね。

## まとめ
今回はじめての記事執筆でした。何かしらの参考になれば幸いです。