Chapter 04

怖くない最初の Deep Learning

ろぐみ
ろぐみ
2023.02.05に更新

怖くない、信じて

この章ではついに Deep Learning を実際にやってみて、どんなものなのかを体感していきます。
怖くないよ!

解く問題

Deep Learning の Hello World と言われる MNIST という手書き数字のデータセットがあるのですが、実はこれ Hello World にしては難しいです。

というわけで今回は排他的論理和(exclusive or, xor) を学習させてみます。

なぜ xor なのか

xor は、2つの入力(x1, x2)を受け取ったとき、以下のような出力(y)を返す関数だとここでは考えてください。

x1 x2 y
0 0 0
0 1 1
1 0 1
1 1 0

入力値が同じなら出力は 0 になり、入力値が異なるなら出力は 1 になります。
これをグラフにしてみます。

このように○の領域と×の領域が交差しており、線形の関数では表現できません。
つまり、xor は非線形の関数です。

Deep Learning は非線形な問題も精度が出せる、ということを実感していただくために xor を Deep Learning で実装してみましょう。

xor のデータセット

Deep Learning でいわゆる教師あり学習を行うには、教師データが必要です。
教師あり学習とは、入力と出力のペアを与えて学習させることです。

xor は当然、答えがわかるので、教師データを作るのは簡単です。
xor の教師データは以下のようになります。(再掲)

x1 x2 y
0 0 0
0 1 1
1 0 1
1 1 0

では、これを Deep Learning で学習させてみましょう。

Deep Learning で xor を解く

データセットの準備

まずは、xor の教師データを準備します。

import torch

# xor の教師データ
xor_input = torch.tensor([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=torch.float32)
xor_answer = torch.tensor([[0], [1], [1], [0]], dtype=torch.float32)

# データセットの作成
dataset = torch.utils.data.TensorDataset(xor_input, xor_answer)

# データローダーの作成
dataloader = torch.utils.data.DataLoader(dataset, batch_size=4, shuffle=True)

# データの確認
for inputs, labels in dataloader:
    print(inputs)
    print(labels)

PyTorch の基本の章でもやったように、torch.tensor を用いてデータを作成します。
xor_input は入力値、xor_answer は答えのラベルです。インデックスがそれぞれ対応するようにすることで、この入力の答えはこの出力だという組が作れます。

次に、torch.utils.data.TensorDataset を用いてデータセットを作成します。
TensorDataset は、データとラベルをペアにしてくれる便利なクラスです。

最後に、torch.utils.data.DataLoader を用いてデータローダーを作成します。
データローダーは、データセットからバッチサイズ分のデータを取り出してくれる便利なクラスです。
shuffle=True とすることで、データをシャッフルしながら取り出してくれます。

バッチサイズは、今回は4としました。
つまり一気に4つのデータを取り出してくれるということです。(今回の場合、つまり全部ですね)

実際に Colab でデータを確認してみると以下のようになると思います。

tensor([[1., 1.],
        [1., 0.],
        [0., 0.],
        [0., 1.]])
tensor([[0.],
        [1.],
        [0.],
        [1.]])

ランダムにシャッフルされつつ、インデックスに紐づく答えも一致しているはずです。

モデルの作成

次に、モデルを作成します。
今回は3層のニューラルネットワークを作成します。

import torch.nn as nn

# 3層のニューラルネットワーク
model = nn.Sequential(
    nn.Linear(2, 2),
    nn.Sigmoid(),
    nn.Linear(2, 2),
    nn.Sigmoid(),
    nn.Linear(2, 1),
    nn.Sigmoid(),
)

図にするとこんな感じです。

\sigma はシグモイド関数です。
最後もシグモイド関数を通すことで、出力を0から1の間に収めることができます。

nn.Sequential は、モデルを順番に積み重ねていくときに便利なクラスです。
nn.Linear は、全結合層を表します。
nn.Sigmoid は、シグモイド関数を表します。

あ、そろそろニューラルネットワークの説明もしましょうか!

ニューラルネットワーク

ニューラルネットワークは、人間の脳の構造をモデル化したものと言われています。
脳神経細胞は、入力を受け取って、それに応じて出力を返すという機能を持っています。
これに対応するのが、ニューラルネットワークのニューロンです。

ニューラルネットワークも、入力を受け取って、内部のパラメータである重みを用いて、出力を計算します。

実際にはこれに加えて b というバイアス項があります。
これは、ニューロンが発火するかどうかのしきい値を表します。

ニューロンの出力は、次のニューロンの入力になります。

1つの層の入出力(全結合層)は、以下のように表されます。

y = f(\sum_{i=1}^{n} w_ix_i + b)

手が滑ってうっかり数式を出してしまいましたが、大丈夫、上の図をそのまま式にしただけで、雰囲気は図でつかめば大丈夫です。

そして、式がわからなくても大丈夫です。
なぜなら上のことをやってくれているのが nn.Linear なのですから!

ちなみに nn.Linear の引数は、入力の次元数と出力の次元数です。
先程の図の縦に並ぶ○の数ですね。

nn.Sigmoid

nn.Sigmoid はシグモイド関数という活性化関数です。

先程の nn.Linear はその名前の通り、線形な関数です。

ところで今回やりたい xor は非線形なものです。
そこで、活性化関数を用いて、線形な関数を非線形に変換します。

f(x) = \frac{1}{1 + e^{-x}}

数式出したけど逃げないで!

こんなグラフになります。

シグモイド関数は、入力が大きくなると出力が1に近づき、入力が小さくなると出力が0に近づく関数です。
そして、この関数は見事に非線形なものです。

活性化関数には他にも nn.ReLUnn.Tanh などがあります。
線形な関数ではなく、非線形な関数を用いる、というのが大事です。

モデルの学習

では、モデルを学習させてみましょう。
今回は用意したデータを用いて1000エポック学習させます。
エポックとはデータセット1周分の学習を1エポックと言います。

モデルを学習させる上でのひとまずの目標は正解データとの損失(Loss)を小さくすることです。

# モデルの学習

# 損失関数の定義
criterion = nn.BCELoss()

# 最適化手法の定義
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)

# 学習
for epoch in range(1000):
    for inputs, labels in dataloader:
        # 勾配の初期化
        optimizer.zero_grad()
        # 順伝播
        outputs = model(inputs)
        # 損失の計算
        loss = criterion(outputs, labels)
        # 逆伝播
        loss.backward()
        # パラメータの更新
        optimizer.step()
    # 100エポックごとに損失を表示
    if (epoch + 1) % 100 == 0:
        print('Epoch: {}, Loss: {:.4f}'.format(epoch + 1, loss.item()))

このようなコードでモデルのトレーニングをすることができます。
学習の流れは以下の通りです。

  1. 勾配の初期化
  2. モデルにデータを流し込んで予測する(順伝播)
  3. 予測結果と正解の誤差を計算する(損失の計算)
  4. 誤差を逆伝播させて勾配を計算する
  5. 最適化アルゴリズムを用いてパラメータを更新する
  6. 1.に戻る

損失関数(Loss Function)

criterion は損失関数を定義しています。
BCELoss の BCE は Binary Cross Entropy の略です。
クロスエントロピーは、分類問題の損失関数としてよく用いられます。
今回は 0 or 1 を出力するので、二値分類問題として扱っています。
そのため BinaryCross Entropy である BCELoss を用いています。

最適化アルゴリズム(Optimizer)

optimizer は最適化アルゴリズムを定義しています。
今回は Adam を用いています。
Adam は、勾配の二乗平均を用いてパラメータを更新するアルゴリズムです。
勾配とは、損失関数をパラメータで偏微分したものになります。
??となるかもしれませんが、要は、勾配とは、損失関数を小さくするためにどのようにパラメータを更新すれば良いかを計算したものです。

lrLearning Rate の略で、学習率を表します。
学習率は、パラメータをどれだけ更新するかを決めるハイパーパラメータです。
パラメータはモデルが内部に持っている学習によって変化、更新する値のことです。
ハイパーパラメータは学習によって変わることはなく、人間が手動で決める値になります。
今回のモデルだと学習率もハイパーパラメータですが、学習を回すエポック数もハイパーパラメータになります。
もし興味があれば最適な学習率を探ってみるのも良いでしょう。

Adam の他にも、SGD などがあります。
どのアルゴリズムを用いるかは、結構難しく、実験をしながら決める必要があります。
が、一旦は Adam を使ってみると良いでしょう。
もし、興味があれば他のアルゴリズムも実際に使ってみて試してみてください。

モデルの評価

では、モデルの評価をしてみましょう。
今回は入力のパターンが4つしかないので、全てのパターンに対して予測を行い、正解率を計算します。

# モデルの評価
test_inputs = torch.tensor([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=torch.float32)
test_labels = torch.tensor([[0], [1], [1], [0]], dtype=torch.float32)

with torch.no_grad():
    test_outputs = model(test_inputs)
    predicted = (test_outputs > 0.5).float()
    accuracy = (predicted == test_labels).float().mean()
    print('Accuracy: {:.2f}'.format(accuracy.item()))

torch.no_grad() は、勾配を計算しないようにするためのコンテキストマネージャです。
モデルの評価を行う際は、学習時とは異なり、パタメータを更新する必要がありません。
すなわち、勾配を計算する必要もありません。
よって、torch.no_grad() で勾配計算を無効にし、高速化を図っています。

あとは出力が 0.5 より大きければ 1 とし、そうでなければ 0 としています。
そして、正解率を計算しています。

もし、うまく学習ができるハイパーパラメータを選択できていた場合、今回は非常にかんたんな問題であるため、100%の正解率が出るはずです。

Accuracy: 1.00

もし、正解率が 1.00 にならない場合は、ハイパーパラメータを調整してみてください。
私の場合は モデルの学習 でやったように学習率を 0.05 にしたところ、正解率が 1.00 になりました。
ハイパーパラメータの調整は結構難しいので色々と実際に動かしてみて試してみてください。