算数ができるAIを作ろう！～RNNと足し算～

### はじめに

算数、特に計算といえば、当然AIの得意分野だ、というのがおそらく世間一般の認識でしょう。ですが、計算という課題においてAI（というかPC）は人間の与えたアルゴリズムに従って処理を行っているだけで、「画像判定」や「音声認識」のようにAIが自ら学習して得た方法によって行っているのではありません。
そこで今回のテーマは、算数レベルの簡単な計算の問題と答えのデータを大量に与えることでAIに計算というものを学習してもらおう、というものです。
元データを大量に用意するのが極めて容易であり一つ一つのデータが小さいので学習にかかる時間も小さく、また結果を確認するのも簡単なので、機械学習初心者向けの例とその解説のようなものだと思ってもらえれば幸いです。



#### 対象者
MNISTなどで最低限機械学習に触れたことがある人。

#### 注意
この記事は、機械学習素人の大学生がやってみたことを記録した記事です。コード、説明等に誤りや不十分な点がある可能性がありますが、予めご了承ください。誤り等の指摘は大歓迎です。

#### 実行環境
Google Colaboratory
Windows 11

#### バックナンバー
[初心者向け機械学習その１　全結合NNについて](https://qiita.com/kagamidokei/items/cf595854099613e9e950)
[初心者向け機械学習その２　GANについて](https://qiita.com/kagamidokei/items/77c87862c88bc067f373)

#### 今回の目標
繰り上がりのある複数桁の足し算ができるニューラルネットワークモデルを作成し、モデルの質を検証、改善すること。




***
# 1.全結合NN
機械学習で最もわかりやすいものと言えば全結合ニューラルネットワークです。
全結合NNをそもそも知らない作ったことがない、という方は[前々回の記事](https://qiita.com/kagamidokei/items/cf595854099613e9e950)などを参照してそちらに先に挑戦してみることをお勧めします。

今回は全結合NNは使えません。理由は２つあります。
１つ目に、全結合NNでは入力が固定であることです。例えば３桁の足し算を学習させたければ長さ３（実際はone-hotベクトルに変換するので3×10）の配列を入力として与えることになりますが、これで学習してしまうと桁数が増えたときに応用が利きません。
２つ目に、正解ラベルの問題があります。全結合NNでは正解の種類の数だけ正解ラベルを用意する必要があります。ですが例えば３桁同士の足し算の正解は0から1998まで約2000種類存在します。2000個のラベルを用意するのは非現実的ですし、学習もうまくいきません。
そこで今回はRNN（再帰的ニューラルネットワーク）を用いて学習を行ってみたいと思います。

***
# 2. RNN①
#### RNNとは？
RNNとは、現在の内部状態が次の入力になるようなニューラルネットワークです。詳細な解説は他の記事に譲りますが、動画や音楽のような時系列的なデータを扱うことができるのが特徴です。今回はRNNに、足し算の対象となる２つの数を下の桁からそれぞれ与えて、桁ごとの計算結果を出力として得ます。
例えば、247+935の場合以下のようになります。

| 時刻         | 0   | 1   | 2   | 3   | 
| ------------ | --- | --- | --- | --- | 
| 入力ｘ       | 7   | 4   | 2   | 0   | 
| 入力ｙ       | 5   | 3   | 9   | 0   | 
| 正解ラベルｚ | 2   | 8   | 1   | 1   | 

#### 実装

以下、実装です。今回は４桁同士の足し算を学習データとして与えています。



```Python:ライブラリのインポート
import tensorflow as tf 
import numpy as np 
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Activation
from tensorflow.keras.layers import LSTM
from tensorflow.keras.optimizers import Adam
```

```Python:学習データ作成のための諸関数
def makedata(l):#桁ごとに分割
  res=[]
  for num in l:
    data=[]
    val=num
    for i in range(5):
      data.append(val%10)
      val=val//10
    res.append(data)
  return res

def bound(a,b):#被加数（足される数）のデータ列と加数（足す数）のデータ列をくっつける
  res=[]
  for i,j in zip(a,b):
    res.append([i,j])
  return res
```

```Python:学習データ作成
tf.random.set_seed(111)
np.random.seed(111)

n=100000
x = np.random.randint(0,10000,n)#被加数
y = np.random.randint(0,10000,n)#加数
partx=makedata(x)
party=makedata(y)
train=np.array(bound(partx,party))
ans=x+y#足し算の答え
z=makedata(ans)
label=np.array(z)
label=label[:, :, np.newaxis]#次元を追加
train=train.transpose(0, 2, 1)#軸の入れ替え
```

最後の２行が何をやっているのかがわかりづらいですが、以下のような形で変形しているだけです。

```Python:
x=[4820 4182 7443]
y=[2996 7037 6833]
ans=[ 7816 11219 14276]

#最後の２行がないと
train=[[[0 2 8 4 0][6 9 9 2 0]]
       [[2 8 1 4 0][7 3 0 7 0]]
       [[3 4 4 7 0][3 3 8 6 0]]]
label=[[6 1 8 7 0]
       [9 1 2 1 1]
       [6 7 2 4 1]]
#最後の２行があると
train=[[[0 6]
        [2 9]
        [8 9]
        [4 2]
        [0 0]]
       [[2 7]
        [8 3]
        [1 0]
        [4 7]
        [0 0]]
       [[3 3]
        [4 3]
        [4 8]
        [7 6]
        [0 0]]]
label=[[[6][1][8][7][0]]
       [[9][1][2][1][1]]
       [[6][7][2][4][1]]]

```

このとき、下の桁のほうが前に来ていることに注意してください。
これによって、次のコードを実行すると

```Python:確認
print(train.shape)
print(label.shape)
```
以下の出力が得られるはずです。

```:出力
(100000, 5, 2)
(100000, 5, 1)
```
次はモデルの構築です。今回、各時刻における出力は単一の値なのでoutshape=1としておきます。hiddenは隠れ層の数です。大きすぎると学習に時間がかかり、小さすぎると質が下がります。100~300くらいに設定するのが無難です。Adam(lr=0.001)のlrとは学習率のことです。0.001など小さな値に設定しておきましょう。

```Python:モデルの構築
outshape = 1
hidden = 200
model = Sequential()
model.add(LSTM(hidden, activation=None, input_shape=(None,2), return_sequences=True))
model.add(Dense(outshape))
model.add(Activation("linear"))
optimizer = Adam(lr=0.001)
model.compile(loss="mean_squared_error", optimizer=optimizer)
```
いよいよ学習です。
この設定だと筆者の環境で約20分ほどかかりました。気長に待ちましょう。

```Python:学習
history=model.fit(train, label,
          batch_size=300,
          epochs=1,
          validation_split=0.1,
          )
```
出力のところにあるval_lossとは評価データに対する損失関数の値です。基本的にはこの値が0.1などのように小さくなっていれば学習がうまくいっているといってよいでしょう。

```Python:出力
Epoch 1/100
300/300 [==============================] - 12s 37ms/step - loss: 8.1342 - val_loss: 6.6363
#中略
Epoch 100/100
300/300 [==============================] - 17s 56ms/step - loss: 0.3470 - val_loss: 0.3495
```
少しval_lossが大きいのが気になりますが、とりあえず学習はできていそうです。
では早速実際にこのモデルに計算問題のテストを解いてもらいましょう。
まずテストを作ります。

```Python:テスト作成
def maketest(n):
  x = np.random.randint(0,10000,n)
  y = np.random.randint(0,10000,n)
  partx=makedata(x)
  party=makedata(y)
  train=np.array(bound(partx,party))
  ans=x+y
  z=makedata(ans)
  label=np.array(z)
  train=train.transpose(0, 2, 1)
  return train,label
```

次に採点です。


```Python:採点
def scoring(TFlist,n):
  score = 0
  for i in range(n):
    TF=True
    for j in range(5):
      if not TFlist[i*5+j]:
        TF=False
    if TF:
      score+=1
  return score

def exam(n):
  problem,answer=maketest(n)
  result=model.predict(problem)
  result = np.round(result)
  answer=answer.flatten().astype(np.uint8)
  result=result.flatten().astype(np.uint8)
  TFlist=(answer == result)
  score=np.count_nonzero(TFlist)
  generalscore=scoring((TFlist),n)
  print(f"桁ごと正解率：{score}/{n*5}")
  print(f"採点結果：{generalscore}/{n}, 正解率：{100*generalscore/n}%")
```

RNNの推定結果は小数の値で得られるので、四捨五入してこれを整数値に直します。たとえば推定値が4.8だった場合、このモデルの回答は5であるとして考えます。
answer（正解）の配列とresult（回答）の配列でそのままいくつ一致しているかを計算したのがscoreです。
しかし一般に算数テストでは１つの問題（４桁同士の足し算）に対して回答した５桁の数字がすべて合っていて初めて点数がもらえるものです。
なのでscoring関数を実装し、5問ごとに採点します。
さて、結果やいかに。

```:テスト結果
桁ごと正解率：4455/5000
採点結果：512/1000, 正解率：51.2%
```

#### 考察

あれ・・・？

駄目ですね。51.2点、これでは落第です。しかし、回答した数字を一つ一つ分けて桁ごとに見たときの正解率はそれほど悪くないようです。
では、一体何がダメなのでしょう。
ここで、一の位、十の位といった位ごとの結果を見るために、少しコードを書き換えてみましょう。

```Python:採点２
def scoring(TFlist,n):
  score = 0
  part_score=np.zeros(5)
  for i in range(n):
    TF=True
    for j in range(5):
      if TFlist[i*5+j]:
        part_score[j]+=1
      else:
        TF=False
    if TF:
      score+=1
  return score,part_score

def exam(n):
  problem,answer=maketest(n)
  result=model.predict(problem)
  result = np.round(result)
  answer=answer.flatten().astype(np.uint8)
  result=result.flatten().astype(np.uint8)
  TFlist=(answer == result)
  score=np.count_nonzero(TFlist)
  generalscore,part_score=scoring((TFlist),n)
  print(f"桁ごと正解率：{score}/{n*5}, {part_score}")
  print(f"採点結果：{generalscore}/{n}, 正解率：{100*generalscore/n}%")
```
すると結果はこうなります。

```:テスト結果２
桁ごと正解率：4455/5000, [ 522.  940.  994.  999. 1000.]
採点結果：512/1000, 正解率：51.2%
```
これを見ると、時刻0の出力、すなわち一の位の計算において誤りが多くなっていることがわかります。
RNNでは前の時刻の処理の結果として中の状態が変化しており、その状態に基づいて次の時刻の処理が行われます。つまり時刻0では内部の状態という「ヒント」がないままに予測を行うことになり、言うなれば「下の桁からの繰り上がりがあるのかないのかわからない」というような状況になってしまったため、正解率が低くなったと考えられます。
そうと分かれば解決策は難しくありません。すなわちRNNに「最初は繰り上がりがない」ということを教えればよいのです。方法はシンプルで、小数第一位を新たに情報として付け足すことになります。


例えば、247+935の場合以下のようになります。

| 時刻         | 0   | 1   | 2   | 3   | 4  |
| ------------ | --- | --- | --- | --- | ---|
| 入力ｘ       | 0   | 7   | 4   | 2   | 0   | 
| 入力ｙ       | 0   |  5  | 3   | 9   | 0   | 
| 正解ラベルｚ  | 0   |  2  | 8   | 1   | 1   | 

では実際にこの実装を見ていきましょう。

# 3.RNN②

#### 修正

makedata関数に若干の変更を加えます。具体的にはリストdataに最初から0を入れた状態で準備します。bound関数はそのままです。

```Python:makedata関数の変更
def makedata(l):
  res=[]
  for num in l:
    data=[0]
    val=num
    for i in range(5):
      data.append(val%10)
      val=val//10
    res.append(data)
  return res

def bound(a,b):
  res=[]
  for i,j in zip(a,b):
    res.append([i,j])
  return res
```
学習データ作成は全く同様です。
当然のことですが、以下のコードを実行すると

```Python:確認
print(train.shape)
print(label.shape)
```
得られるべき出力は次のようになります。

```:出力
(100000, 6, 2)
(100000, 6, 1)
```
モデルの構築と学習のコードについても変更の必要はありません。
学習には私の環境で約28分かかりました。
ではまずは学習の出力を見てみましょう。

```Python:出力
Epoch 1/100
300/300 [==============================] - 17s 54ms/step - loss: 6.5585 - val_loss: 5.5240
#中略
Epoch 100/100
300/300 [==============================] - 17s 56ms/step - loss: 0.0167 - val_loss: 0.0313
```
val_lossの値が先ほどの10分の1ほどにまで小さくなっているのがわかります。
なお以下のコードを実行すると、損失関数の値が減少していく様子をグラフで見ることができます。

```Python:グラフの生成
import matplotlib.pyplot as plt
plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.title('Model loss')
plt.ylabel('Loss')
plt.xlabel('Epoch')
plt.legend(['Train', 'Test'], loc='upper left')
plt.show()
```
![改善版history.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/881013/b22aa419-4768-52a1-b294-dc0039ed8584.png)
確かに減衰していますね。


では実際にテストしてみましょう。
テスト作成のコードは変更の必要ありません。
採点については、若干の変更（定数で5だった部分を6に変更）が生じます。

```Python:scoring関数、exam関数の変更
def scoring(TFlist,n):
  score = 0
  part_score=np.zeros(6)
  for i in range(n):
    TF=True
    for j in range(6):
      if TFlist[i*6+j]:
        part_score[j]+=1
      elif j>0:
        TF=False
    if TF:
      score+=1
  return score,part_score
def exam(n):
  problem,answer=maketest(n)
  result=model.predict(problem)
  result = np.round(result)
  answer=answer.flatten().astype(np.uint8)
  result=result.flatten().astype(np.uint8)
  TFlist=(answer == result)
  score=np.count_nonzero(TFlist)
  generalscore,part_score=scoring((TFlist),n)
  print(f"桁ごと正解率：{score}/{n*6}, {part_score}")
  print(f"採点結果：{generalscore}/{n}, 正解率：{100*generalscore/n}%")
exam(1000)
```
さて、今度こそRNNは正しくテストを解くことができたでしょうか。
結果は次のようになりました。

```:追試結果
桁ごと正解率：5858/6000, [1000. 1000. 1000.  927.  931. 1000.]
採点結果：865/1000, 正解率：86.5%
```

見事、86.5点と高得点を獲得することができました。AIなのでもっと高い点数を目指してもらいたいところではありますが、前章と比べると飛躍的に結果がよくなっているといえます。

# 4.まとめ

ということで今回は以上です。
「計算」という与えられるデータが可変長であり、また１桁ずつ順に見ていくことが効果的な課題においてはRNNが有効であること、kerasを利用することで簡単にRNNが使えること、さらにRNNにおいては時刻0の入出力に注意する必要があることがわかりました。
これの応用として様々な課題やモデルが考えられると思いますので、興味が出た方はぜひチャレンジしてみてください。