LSTMで桃華ちゃまと会話する

[雪美](https://qiita.com/bluepost59/items/4195d3c146538236d9e8)、[響子さん](https://qiita.com/bluepost59/items/027ae9f60f59d05bc7fc)に引きつづき、アイマス駆動開発第3弾です。今回は桃華ちゃまを実装します。

# LSTM
よい説明がたくさんあると思うので、ここでは簡単に。

時系列データをニューラルネットワークで扱うにあたって、過去のデータを保持するために、出力をフィードバックする**RNN**というものがあった。ただしこれは無限に過去の情報まで持とうとするので、長期依存性や勾配消失などの問題があった。

そこで忘却ゲートなどの改良を加えたものが**LSTM**である。ただし内部的な構造が異なるだけで、入出力はRNN同様に扱える。

# データセット
* 「アイドルマスターシンデレラガールズスターライトステージ」（以下デレステ）の全カードの全セリフ<br>https://imascg-slstage-wiki.gamerch.com/%E6%AB%BB%E4%BA%95%E6%A1%83%E8%8F%AF
* 「アイドルマスターシンデレラガールズ」（以下モバマス）の全カードのプロフィール<br>http://imas.gamedbs.jp/cg/idol/detail/15?h=1cefdef7df7c14eca803910b67e61dfc
* モバマスの手元にあったカード（11枚）の全セリフ
* シンデレラガールズ劇場の全吹き出しのセリフ

# 実行環境
Google ColaboratoryでGPUを使った。

# 文字単位
とりあえずお試しとして、文字単位でやってみる。

## 実装
骨組みは参考サイト（http://cedro3.com/ai/keras-lstm-text/ ）からもらって、タブや改行を削除する処理を追加した。また表示する際に適宜改行を入れるようにした。

<details><summary>コード</summary><div>

```python
# 文字単位
# http://cedro3.com/ai/keras-lstm-text/

from __future__ import print_function
from keras.callbacks import LambdaCallback
from keras.models import Sequential
from keras.layers import Dense, Activation
from keras.layers import LSTM
from keras.optimizers import RMSprop
from keras.utils.data_utils import get_file
import matplotlib.pyplot as plt  # 追加
import numpy as np
import random
import sys
import io
 
path = './momoka.txt'
with io.open(path, encoding='utf-8') as f:
    text = f.read().lower()
    text = text.replace("\n","").replace("　","").replace(" ","").replace("\t","")
print('corpus length:', len(text))
 
chars = sorted(list(set(text)))
print('total chars:', len(chars))
char_indices = dict((c, i) for i, c in enumerate(chars))
indices_char = dict((i, c) for i, c in enumerate(chars))
 
# cut the text in semi-redundant sequences of maxlen characters
maxlen = 8
step = 1
sentences = []
next_chars = []
for i in range(0, len(text) - maxlen, step):
    sentences.append(text[i: i + maxlen])
    next_chars.append(text[i + maxlen])
print('nb sequences:', len(sentences))
 
print('Vectorization...')
x = np.zeros((len(sentences), maxlen, len(chars)), dtype=np.bool)
y = np.zeros((len(sentences), len(chars)), dtype=np.bool)
for i, sentence in enumerate(sentences):
    for t, char in enumerate(sentence):
        x[i, t, char_indices[char]] = 1
    y[i, char_indices[next_chars[i]]] = 1
 
 
# build the model: a single LSTM
print('Build model...')
model = Sequential()
model.add(LSTM(128, input_shape=(maxlen, len(chars))))
model.add(Dense(len(chars)))
model.add(Activation('softmax'))
 
optimizer = RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)
 
 
def sample(preds, temperature=1.0):
    # helper function to sample an index from a probability array
    preds = np.asarray(preds).astype('float64')
    preds = np.log(preds) / temperature
    exp_preds = np.exp(preds)
    preds = exp_preds / np.sum(exp_preds)
    probas = np.random.multinomial(1, preds, 1)
    return np.argmax(probas)
 
 
def on_epoch_end(epoch, logs):
    if (epoch+1) % 10 == 0:
      # Function invoked at end of each epoch. Prints generated text.
      print()
      print('----- Generating text after Epoch: %d' % epoch)

      start_index = random.randint(0, len(text) - maxlen - 1)
      start_index = 0  
      for diversity in [0.2,0.6,1.0]:  # diversity = 0.2 のみとする
          print('----- diversity:', diversity)

          generated = ''
          sentence = text[start_index: start_index + maxlen]
          generated += sentence
          print('----- Generating with seed: "' + sentence + '"')
          sys.stdout.write(generated)

          for i in range(400):
              x_pred = np.zeros((1, maxlen, len(chars)))
              for t, char in enumerate(sentence):
                  x_pred[0, t, char_indices[char]] = 1.

              preds = model.predict(x_pred, verbose=0)[0]
              next_index = sample(preds, diversity)
              next_char = indices_char[next_index]

              generated += next_char
              sentence = sentence[1:] + next_char

              sys.stdout.write(next_char)
              sys.stdout.flush()
              
              if i % 30 == 0:
                print()
              
          print()
    print()
 
print_callback = LambdaCallback(on_epoch_end=on_epoch_end)
 
history = model.fit(x, y,
                    batch_size=128,
                    epochs=100,
                    callbacks=[print_callback])
 
# Plot Training loss & Validation Loss
loss = history.history["loss"]
epochs = range(1, len(loss) + 1)
plt.plot(epochs, loss, "bo", label = "Training loss" )
plt.title("Training loss")
plt.legend()
plt.savefig("loss.png")
plt.close()
```
</div>

## 結果
diversity=0.6の場合を書く。

### 10エポック

```
ふふっ、今のわたく
しのちゃんと見ていただきましょう！それにはきってくださいませ
、このわたくしの家にはいっぱいと…それは…わからも、とても人
形で！びをさんってご緒に、まるかわないですわ！pちゃま、わた
くしでちょっかりして？pちゃま、いつもおと……今のでき子ーも
もつじてきたんですの？ドールですわよね？pちゃま、わたくしの
そばにいてくださいませ♪わたくしのちゃととてもろしくて！あの
…と。でも、みなさんにと楽しんではありませんのよ？それに、桜
になったかしらあち、pちゃまのお顔、ごついなおかしないですわ
。pちゃま、わたくしのそばにいえば、いのですも、どうかしまし
たの？レディのそうに、届きましたのよつリードイドの香り…わた
くしがちゃまっ！パリランで……そうですわね。うふふ、うふふ、
桃華のきのでしょね！わたくしがちゃまってください、プロデュー
サーちゃまとも、やりかせなさな、けにないように…このは、ち｡
んですもの…やしく
```
雰囲気はすでに桃華ちゃまだが、だいぶ意味不明な単語だらけになっている。

「*pちゃまのお顔、ごついなおかしないですわ。*」
猛虎魂を感じる。

### 100エポック

```
ふふっ、今のわたく
し、ちゃんと見ていまして？もう……pちゃまが誰よりもおちろん
せんのですかおわたもしがましたわですわ、pちゃま！pちゃまの
やりとげる力、どうか、くださいませ♪お迎えがくるまで、シャボ
ン玉を…。ふふっ、キラキラ光ってきれいですわね。pちゃまとわ
はよりませ！このよのせーでも…いえば、マいスティーの疲れよれ
で…わたくし、心のからいただいましたわ♪ーちゃんともうのあら
、｡ドールわ心のも、pちゃまにもられか…わたくしのことが…ま
るから？テージに桜。みせさんれると…このセー手をわたくしも、
が気になせて。pちゃまも視線をのおかかし！このステージで思い
きり遊んじゃいますけれど、みなさんもご一緒に楽しんでください
ませ♪わたくしが楽してをこまリんですのがらかるかし……まあ、
こまにならっておけれせいっわイがアイドルとできましたわ！そし
てうからしいませ♪わたくしが楽しめば楽しむほど、見てくださる
ファンの方からの感
```
ちょっとずつ単語がまともになってきた。

### 200エポック

```
ふふっ、今のわたく
し、ちゃんと見ていらして？皆さんの視線、独り眠りなっこもへの
、でくだけれて…なわがみなさんと一緒なら、ドキドキですわす、
好きな人なんて。こんなお話、誰にもしたことありませんわ。でも
わたくし、もう、もらうだけでは物足りませんの。もらった分は必
ずそれ以上にして、お返ししますわ。これは、レディとしての礼儀
。いいえ、好意ですわ♪明日も、明後日も、そのつぎも会ってくだ
さる？わたくしにとっても、今宵は特別な夜…パーティーは終わる
もの…。でも、わたくしたちの関係は、ずっと続きますの。pちゃ
ま、わたくしだけを見てください…。pちゃまがわたくしをそう…
わたねしたべ、でくさせしpちゃまとの思い出がまた増えましたわ
♪どちらかと言えば世話好きかもしれませんわ。苦には思いません
ものどんな時ものありたいですわ♪みなさん、ごきげんよう。わた
くしという華が咲く花園へようこそ。この舞台で一番咲き誇るべき
はわたくしなのだか
```
完璧とは言い難いが、ところどころ文が読み取れる。

# 単語単位
文字単位でやると断末魔の叫びみたいなのが入るので、もう少し意味が通るように、単語単位でやる。

## 実装
分かち書きにはjanomeを使った。

<details><summary>コード</summary><div>

```python
# 単語単位

from __future__ import print_function
from keras.callbacks import LambdaCallback
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.optimizers import RMSprop
from keras.utils.data_utils import get_file
import numpy as np
import random
import sys
import io

import codecs
from janome.tokenizer import Tokenizer

# path = get_file(
#     'momoka.txt',
#     origin='./momoka.txt')
with io.open("momoka_small.txt", encoding='utf-8') as f:
    text = f.read().lower()
    text = text.replace("　","").replace(" ","").replace("\t","")
#     text = text.replace("\n","").replace("　","").replace(" ","").replace("\t","")
    # print(text)

words =Tokenizer().tokenize(text, wakati=True)  # 分かち書きする
count = 0
char_indices = {}  # 辞書初期化
indices_char = {}  # 逆引き辞書初期化
 
for word in words:
    if not word in char_indices:  # 未登録なら
       char_indices[word] = count  # 登録する      
       count +=1
    #    print(count,word)  # 登録した単語を表示
# 逆引き辞書を辞書から作成する
indices_char = dict([(value, key) for (key, value) in char_indices.items()])
print(len(indices_char))
# print(indices_char)

print('corpus length:', len(text))

# chars = sorted(list(set(text)))
# print('total chars:', len(chars))
# char_indices = dict((c, i) for i, c in enumerate(chars))
# indices_char = dict((i, c) for i, c in enumerate(chars))

# cut the text in semi-redundant sequences of maxlen characters
maxlen = 5
step = 1
sentences = []
next_words = []
for i in range(0, len(words) - maxlen, step):
    sentences.append(words[i: i + maxlen])
    next_words.append(words[i + maxlen])
print('nb sequences:', len(sentences))

print('Vectorization...')
x = np.zeros((len(sentences), maxlen, len(words)), dtype=np.bool)
y = np.zeros((len(sentences), len(words)), dtype=np.bool)
for i, sentence in enumerate(sentences):
    for t, myword in enumerate(sentence):
        x[i, t, char_indices[myword]] = 1
    y[i, char_indices[next_words[i]]] = 1


# build the model: a single LSTM
print('Build model...')
model = Sequential()
model.add(LSTM(128, input_shape=(maxlen, len(words))))
model.add(Dense(len(words), activation='softmax'))

optimizer = RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)


def sample(preds, temperature=1.0):
    # helper function to sample an index from a probability array
    preds = np.asarray(preds).astype('float64')
    preds = np.log(preds) / temperature
    exp_preds = np.exp(preds)
    preds = exp_preds / np.sum(exp_preds)
    probas = np.random.multinomial(1, preds, 1)
    return np.argmax(probas)


def on_epoch_end(epoch, _):
    if (epoch+1) % 10 == 0:
      # Function invoked at end of each epoch. Prints generated text.
      print()
      print('----- Generating text after Epoch: %d' % epoch)

      start_index = random.randint(0, len(words) - maxlen - 1)
      for diversity in [0.2]:
          print('----- diversity:', diversity)

          generated = []
          sentence = words[start_index: start_index + maxlen]
          generated += sentence
  #         print('----- Generating with seed: "' + sentence + '"')
  #         sys.stdout.write(generated)

          generated_text = ""
          for i in range(400):
              x_pred = np.zeros((1, maxlen, len(words)))
              for t, char in enumerate(sentence):
                  x_pred[0, t, char_indices[char]] = 1.

              preds = model.predict(x_pred, verbose=0)[0]
              next_index = sample(preds, diversity)
              next_word = indices_char[next_index]

              sentence = sentence[1:]
              sentence.append(next_word)
              generated_text += next_word

              sys.stdout.write(next_word)
              sys.stdout.flush()
#               if i % 10 == 0:
#                 print()
                #                   f.write(generated_text)

  #             print()
      else:
          print()

print_callback = LambdaCallback(on_epoch_end=on_epoch_end)

model.fit(x, y,
          batch_size=128,
          epochs=50,
callbacks=[print_callback])
```
</div></details>

コーパスが10万を超えると落ちるようになったので、データを少し（モバマスのカードセリフとしんげき）削った。diversityも0.2より上げるとout of indexで落ちるので、どこかにバグがあると思う。

## 結果
あまりに横長なので手動で適宜改行を入れた。

### 10エポック目

```
う｡pちゃまの､弁護士の光と｡れ､も…｡pちゃまの､弁護士の鍵を｡
ませ､pちゃまのお顔が終わっている？…？
わたくしのことを、楽しくお話てしたなら、のことはありますわ。
でも、この白薔薇のように、わたくしを！
みなさんの愛なら、少しですの
ふふっ、わたくしの気持ちが、ファンの方に｡学びからがののなら、ここの全ても…
pちゃま、お花見のあり方のです。ね。
今のわたくし、ちゃんと見ていまして？
ふふっ、pちゃまのご一緒に！
pちゃま、お顔がすることかん…。んののは…､んののは､方ですわ…｡んでも､pちゃまと一緒なら、もっと！！楽しい！
さぁ､pちゃまも…
何がよろしくて？ふふ♪
わたくしを事務所にお届けが気の♪
お仕事が終わったらわたくしと…いえ、何でもないのですけれどもご一緒とありたいと。何がありますの。
でも、この衣装、は日の光ですものね､プロデューサーのまも…くらい
家がたくさんましたの。それは、pちゃまに、何がのんですの。
わたくし、もっともっと！！…！
これはpちゃまの心の顧問弁護士ですからね♪
pちゃま、プロデューサーちゃまのご一緒に！
pちゃまのことを知るのなら、何もないんですの
お仕事が終わったらわたくしと…いえ、何でもないのですけれど？
んですの？
お仕事は終わったらわたくしと…いえ、何でもないのですけれどもいいものですのね♪
お
```
文字単位のときよりは意味がわかる。

「*pちゃまのお顔が終わっている？*」
悲しい。
「*お仕事は終わったらわたくしと…いえ、何でもないのですけれどもいいものですのね♪*」
意味深。

### 30エポック

```
全国のファン達がいるのですから､それにこたえてあげるのがアイドルの役目！
わたくしの愛を皆にプレゼントですわ！
フリフリな衣装はたくさん持っていますの！普段､桃華が着ているのはそのひとつ｡
pちゃまだけに秘蔵のコレクションを見せて差し上げますわ！おどう､では、ありませんわねね
pちゃまのお顔が浮かんでくるんでしょう
みりあさんって、本当に巻き込むのがお上手…。お付き合いいたしますわ
夢中になっていたらお部屋が大変…。明日、自分たちでお片付けしなきゃ
疲れているはずなのに、心は躍って…。ドキドキが止まりませんの
いつもなら何でもないことも、一緒だと大盛り上がり。楽しいですね
pちゃまの視線を一人占めして、心を奪ってしまう桜に。
pちゃま、桜ばっかり見ていてはいけませんのね。
今日はお礼に、この舞台を雑巾がけして帰りますわ。
舞台さん、今後もよろしくお願いします。今、ピカピカのレディにして差し上げますから。
拭き方がわかりませんわ…。押し出すように？それとも手前へ…
モップは使いませんのね。
今日はお礼に、この舞台を雑巾がけして帰りますわ。
舞台さん、今後もよろしくお願いします。今、ピカピカのレディにして差し上げますから。
拭き方がわかりませんわ…。押し出すように？それとも手前へ…
モップは使いませんのね。
今日はお礼に、この舞台を雑巾がけして帰りますわ。
舞台さん、今後もよろしくお願いします。今、ピカピカのレディにして差し上げますから。
拭き方がわかりませんわ…。押し出すように？それとも手前へ
```

既にだいぶ元データの一部をひっぱってきてしまっている。また、たまにループしているのは、おそらく「。」の後がループポイントになってしまっている。

「*舞台さん、今後もよろしくお願いします。今、ピカピカのレディにして差し上げますから。*」
舞台さんもレディにしてくれる桃華ちゃまの愛。

# 感想
* データ数が少ないので、割と早い段階で過学習になる。
    * 特に単語となると1単語の出現回数が少ないので、元データを引っ張ってきてしまう。
* データ集めは大変。やっぱりデータは重要。
* 半角カナは悪い文明。
* Google Colaboratoryは神。

# 参考
* Keras LSTMでサクッと文章生成をしてみる<br>http://cedro3.com/ai/keras-lstm-text/
* https://github.com/keras-team/keras/blob/master/examples/lstm_text_generation.py