2クラス分類問題を解くときに出力層のノードを増やしてみたお話

# 2値分類問題の概要
2値分類問題(バイナリ分類問題)は, あるデータを2つの異なるカテゴリに分類する問題である. 具体的な応用例としては, 以下のようなケースが挙げられる.
* スパムメールかどうかの判定（スパム／非スパム）
* 患者が病気かどうかの診断（病気／健康）
* 顧客が商品を購入するかしないかの予測（購入／非購入）

今回は, AとBに分ける必要があるものとする.

# 分類モデルの実装
今回, pytorchを用いて分類モデルを作成した. なお, データに欠損値は無く, すでにクレンジングを終えたものを使用している.
```python
# ライブラリのインストール
from sklearn.model_selection import train_test_split
import torch
from torch import nn
import pandas as pd

# データの読み込み
df = pd.read_csv("train.csv") # 訓練用のcsvファイル

# 学習モデルに使用するデータの整理
features = [] # 説明変数となるカラムのリストを定義
data = df[features].to_numpy(dtype='float64')
target = df['target'].to_numpy() #目的変数を代入, カラム名は適当

# データセットを分割する
x_tmp, xtest, y_tmp, ytest = train_test_split(data, target, test_size=0.2)
xtrain, xval, ytrain, yval = train_test_split(x_tmp, y_tmp, test_size=0.25)

# numpy配列をPyTorchテンソルに変換
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
xtrain = torch.from_numpy(xtrain).float().to(device)
ytrain = torch.from_numpy(ytrain).long().to(device)
xval = torch.from_numpy(xval).float().to(device)
yval = torch.from_numpy(yval).long().to(device)
xtest = torch.from_numpy(xtest).float().to(device)
ytest = torch.from_numpy(ytest).long().to(device)

# 分類モデルを作成する
class NeuralNetwork(nn.Module):
    def __init__(self, num_features):
        super(NeuralNetwork, self).__init__()
        self.linear_relu_stack = nn.Sequential(
            nn.Linear(num_features, 128),
            nn.ReLU(),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Linear(64, 32),
            nn.ReLU(),
            nn.Linear(32, 2),
            nn.Sigmoid()
        )

    def forward(self, x):
        logits = self.linear_relu_stack(x)
        return logits

# 学習環境の設定
num_features = xtrain.shape[1]  # 学習データの特徴量の数
model = NeuralNetwork(num_features)
loss_function = nn.CrossEntropyLoss()# 分類問題のため交差エントロピー誤差を使用
optimizer = torch.optim.Adam(model.parameters(), lr=0.01) # optimizerの設定, lrは学習率

n_epochs = 200  # epoch数

for epoch in range(n_epochs):
    # モデル学習を行う
    model.train()
    outputs = model(xtrain)
    loss = loss_function(outputs, ytrain)
    # 勾配のリセット
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    # 学習したモデルの評価
    model.eval()
    with torch.no_grad():
        val_outputs = model(xval)
        _, val_predicted = torch.max(val_outputs, 1)
        val_accuracy = (val_predicted == yval).float().mean().item()
    
    # 20epochごとに損失を表示
    if (epoch+1) % 20 == 0:
        print(f'Epoch [{epoch+1}/{n_epochs}], Loss: {loss.item():.4f}, Val Accuracy: {val_accuracy*100:.2f}%')

# モデルの評価
model.eval()
with torch.no_grad():
    test_outputs = model(xtest)
    _, test_predicted = torch.max(test_outputs, 1)
    test_accuracy = (test_predicted == ytest).float().mean().item()
    print(f'Test Accuracy: {test_accuracy*100:.2f}%')
```

今回注目するのは, 以下の機械学習モデルのノード数を定義している部分である.
```python
# 分類モデルを作成する
class NeuralNetwork(nn.Module):
    def __init__(self, num_features):
        super(NeuralNetwork, self).__init__()
        self.linear_relu_stack = nn.Sequential(
            nn.Linear(num_features, 128),
            nn.ReLU(),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Linear(64, 32),
            nn.ReLU(),
            nn.Linear(32, 2), #出力層
            #nn.Sigmoid()
        )

    def forward(self, x):
        logits = self.linear_relu_stack(x)
        return logits
```
出力層におけるノード数は2となっており, これは2値分類問題であることから2としている.
そのため, (当たり前ではあるが)モデルからの出力結果も`バッチサイズ × 2`の行列となっている. 

続いて, 実際に学習したモデルを用いて, 予測値を出力し制度評価をするコードの部分に注目する.
```python
    # 学習したモデルの評価
    model.eval() 
    with torch.no_grad():
        val_outputs = model(xval) 
        _, val_predicted = torch.max(val_outputs, 1) 
        val_accuracy = (val_predicted == yval).float().mean().item()
```

```
val_outputs = model(xval) 
```
では, 学習したモデルを用いて, バッチサイズ×2の出力を算出している.
よって, `val_outputs`の片方の列は2値分類のAである確率を, もう片方の列はBである確率を表していることとなる.

```
_, val_predicted = torch.max(val_outputs, 1) 
```
torch.max() には2つの引数を指定しており, 1つ目は対象のテンソル (val_outputs), 2つ目は次元である. ここでは次元 1 を指定しているため, `val_outputs`の行方向（各サンプル）に対して「最も高い値」を持つ要素とそのインデックスを取得している.

`val_outputs`の2つカラムは, それぞれAである確率/Bである確率を表していることと解釈できるので[^1] , そのデータがAであるかBであるかを結果的に判定することができるのである.

# やっと本題：出力層のノードを増やしてみた[^2]
ここで, 出力層のノードを増やした場合, そのノードは何を表すのだろうか.
結論を先に書くと, 「よくわからない」である.

出力層のノードを2つに絞ってしまえば, Aの特徴とBの特徴がきれいに2つのノードに反映させることができるが, 出力層のノードが増えてしまうと, Aの特徴とBの特徴が特定のノードに集中せず分散してしまうのである.
実際, 今回使用したモデルにおいて出力層のノードを`4`に変更する前と後の出力結果の比較は以下のとおりである.
```
# 出力層のノードが2つの時のval_outputs
tensor([[2.6271e-04, 9.9971e-01],
        [9.9658e-01, 3.5054e-03],
        [1.0000e+00, 9.1701e-07],
        ...,
        [1.0000e+00, 1.7433e-08],
        [4.5939e-11, 1.0000e+00],
        [9.9999e-01, 2.2113e-05]])
# 使用したデータセットにおける精度は94％くらいだった
```
```
# 出力層のノードが4つの時のval_outputs
tensor([[1.0000e+00, 1.0000e+00, 0.0000e+00, 0.0000e+00],
        [1.0000e+00, 1.0000e+00, 0.0000e+00, 0.0000e+00],
        [1.0000e+00, 1.0000e+00, 0.0000e+00, 0.0000e+00],
        ...,
        [1.0000e+00, 1.0000e+00, 2.3211e-36, 1.4173e-35],
        [1.0000e+00, 1.0000e+00, 0.0000e+00, 0.0000e+00],
        [1.0000e+00, 1.0000e+00, 0.0000e+00, 0.0000e+00]])
# 使用したデータセットにおける精度は56％くらいだった
# ちなみに計算速度もめっちゃ落ちる
```
出力層のノードが2つの時の出力結果と比較すると, 出力層のノードが4つの時の出力結果は分類できていないことがわかる. 

ただ, ここまで分類できていないのは出力直前の活性化関数`Sigmoid関数`の影響も含まれており, 実際, 活性化関数`Sigmoid関数`を除くと, 出力と分類精度は以下のようになる.
```
tensor([[  8.5494,   3.0569, -11.9324,  -9.4969],
        [  8.5694,   2.2341, -11.3464,  -8.9291],
        [  8.2721,   3.2728, -11.7632,  -9.3997],
        ...,
        [  0.7373,   7.2454, -14.6364, -14.1014],
        [ -0.5129,   8.4949, -16.3703, -16.1322],
        [  8.8616,   3.9732, -13.6231, -11.0658]])
# 使用したデータセットにおける精度は90％くらいだった, 意外と高くてびっくり
```
精度が回復した原因について, 損失関数を変更するなどして検証してみたが, いまいちよくわからなかった. 
Chat-GPTによると
> 出力層のノード数が4であっても、val_predicted に 0 と 1 しか含まれなかったのは、偶然にもインデックス2や3の確率が他と比べて高くならなかったためです。
> ## 理由の詳細
> ### 1. モデルの構造と学習の影響:
> 今回のデータやモデルの学習過程で、出力のノード2やノード3に大きな確率（もしくはロジット）が割り当てられることがなかった可能性があります。
> これは、モデルが「satisfaction」が2クラスであることを学習した結果、ノード0とノード1にだけ高い確率を出力するような重み調整が行われたためと考えられます。
> ### 2. 損失関数との不一致:
> 損失関数（CrossEntropyLoss）は2クラス分類を前提にしているため、モデルもそれに合わせて出力を調整しようとします。
> その結果、不要なクラス（ノード2やノード3）に高いスコアが割り当てられることが少なくなり、主にノード0とノード1に確率が集中したと考えられます。
> ### 3. 出力層の設計が合っていないため、偶然の産物:
> 出力層が4つのノードで設計されていることは2クラス分類に適していないため、これはあくまで偶然の産物です。
> モデルが別のデータで学習されたり、異なる重みで初期化された場合、ノード2やノード3の確率が高くなる可能性もあります。

とのこと. ちなみに2について検証するべく, 損失関数を他のもの（例えば`BCEWithLogitsLoss関数`にしてみたり, むりやり`MSELoss関数`にするなど）を使用してみたが, よくわからない上に精度が大幅に下がってしまったため, 力尽きて完全な検証を行うことはできなかった. 

[^1]: AとBに分類がきちんとできていれば, AとBの分類にしたがって0列目と1列目の数値(確率)にも差が出ているはずである. そしてその結果, 「0列目と1列目の数値(確率)の差≒AまたはBである確率の差」と見なすことができる, と筆者は解釈している. 
[^2]: 「2値分類問題なのに出力層のノードを4つにするのはおかしいのでは」というご指摘は大変ごもっともであるが, 本稿においては目をつむっていただきたい.