Help us understand the problem. What is going on with this article?

LSTMでブリーチ風ポエムを作ってみたった

More than 1 year has passed since last update.

改良版 [ https://qiita.com/nagayosi/items/79916363a9a5a36137bc

私の胸に深く突き刺さるその声は 鳴り止まぬ歓声に似ている

ブリーチと言えば死神が戦うバトル漫画だが、漫画のストーリーよりもそのオサレなポエムの方が有名ではないでしょうか。
だけど連載が終わってしまい、もう(おしゃれではなく)オサレなポエムがもう聞けないので、自動生成するLSTM、いわゆるOsarePoemGeneratorを作ってみました。

実装はKerasでtutorial(examples?)を参考に作ってみました。
仕組みは直前3文字を入力して次の1文字を作るtri-gram modelです。

実装

keras実装
https://github.com/nagayosi/OsarePoemGenerator_keras_v1

N-gram model

”私は男”という文字の列を入力して、次の文字を出力させます。
"私は男" -- LSTM --> ”で”
つまり、$p(w_{t}|w_{t-3}, w_{t-2}, w_{t-1})$を求めることになります。
N-gramは何文字から予測するかを表します。上の場合は3文字使うので3gramです。

一般式はこれ。$p(w_{t}|w_{t-n}, w_{t-n+1}, ... , w_{t-1})$

単語の表現方法

”私は男”をどう表現させるかだけど、今回は1-hotにしてみました。
1-hotはボキャブラの分だけ0の配列を確保して、対応するインデックスの部分だけ1にするembeddingです。
つまり、”私は男”だったらボキャブラは3なので、[0,0,0]を用意します。
そんで、
私 --> [1,0,0]
は --> [0,1,0]
男 --> [0,0,1]
ってなります。
0配列は使用する単語の長さになるので、配列が大きくなりすぎることになります。
なんで、気が向いたらWord2Vecとかでやってみよう(やるとはいってない)(´;ω;`)

import

1番上のはKerasでGPUを使う時に必要(これがないとGPUメモリを全部確保しちゃう)

import os
os.environ["CUDA_DEVICE_ORDER"] = 'PCI_BUS_ID'
os.environ["CUDA_VISIBLE_DEVICES"] = '0'

from keras.models import Sequential
from keras.layers import Dense, Activation
from keras.layers import LSTM, SimpleRNN
from keras.optimizers import Nadam
from keras.utils.data_utils import get_file
import numpy as np
import random
import sys
import re

学習データの用意

bleach_poem.txt ファイルをこんな感じで作りました。
1巻から最終巻(74巻)までの巻頭ポエムを1行ずつ書き込みました。
最初に@@@、最後に@を付けてるのは後で説明します。

@@@我等は 姿無きが故に それを畏れ@
@@@人が希望を持ちえるのは 死が目に見えぬものであるからだ@
@@@もし わたしが雨だったなら それが永遠に交わることのない 空と大地を繋ぎ留めるように 誰かの心を繋ぎ留めることができただろうか@
...

文字を分解する

テキストファイルを読み込んで、1文字に分解する。んで、それをソートします。

TRAIN_PATH = 'Dataset/bleach_poem_original.txt'

def get_chars():
    path = TRAIN_PATH
    print('get chars from <-- {}'.format(path))
    ## テキストファイルを読み込む
    text = open(path, encoding='utf-8').read()
    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))

    print('\tget chars successed!')
    return chars, char_indices, indices_char, text

LSTMの定義

今回はLSTMを3段にしてやってみました。
LSTMをつなげる時は注意なんだけども、LSTM(1) -> LSTM(2)にする時、1の引数のreturn_sequences=Trueをしなければならない。逆に LSTM -> Dense にする時はreturn_sequences=False にしなきゃいけません。
今回はGPUを使えるので、ユニット数をめちゃくちゃ増やしてみました。
出力層のDense(chars)は学習で用意した文字の種類(ボキャブラ数)にしてます。

def my_model(chars):
    model = Sequential()
    model.add(LSTM(2048, activation='relu', input_shape=(maxlen, chars), return_sequences=True))
    model.add(LSTM(2048, activation='relu', return_sequences=True))
    model.add(LSTM(2048, activation='relu', return_sequences=False))
    model.add(Dense(chars))
    model.add(Activation('softmax'))

    return model

学習してみたった

こっからいよいよ学習させます。

準備

まずは全テキストから3文字と次の文字をそれぞれsentencesとnext_charsに入れます。

maxlen = 3

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))

1hotにエンコード

これだとまだ、文字として分けたにすぎないので、これを1-hotベクトルに変換します。
Xは[sentencesの長さ、入力の大きさ、文字の種類]の3次元の要素0ベクトルになっています(ここでの入力の大きさはn-gramのnと同じ大きさ、文字の種類はボキャブラ数)。
yは[sentencesの長さ、文字の種類]の2次元の要素0ベクトルになってます。

これに文字を1-hotにエンコードしてきます。

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

モデルの準備

んで、学習するモデルを用意します。
上のmy_modelで定義したので、それを使います。
今回はNadamで最適化します。

model = my_model(len(chars))
optimizer = Nadam()
model.compile(loss='categorical_crossentropy', optimizer=optimizer)

学習するお

以下で学習して保存しました。

MODEL_PATH = 'osarePoem.hdf5'
model.fit(X, y, batch_size=128, epochs=100)
model.save_weights(MODEL_PATH)

結果

学習も終わったので、こっから結果を見てみましょう!!

準備

モデル定義とか諸々の準備。
sentenceは最初に入力する文字で@@@に固定します(学習データの各行を@@@にしたのは、ここで最初の文字もランダムに生成させるため)。
text_lengthは文章の最大長です。

sentence = '@@@'
generated = sentence

diversity = .3
text_length = 1000

model = my_model(len(chars))
model.load_weights(MODEL_PATH)

文章を生成

いよいよ文字を生成します!
xに入力する3文字の1-hotをembedして、model.predictで出力をゲットします。
ただし出力はあくまで次の1文字の確率分布なので(出力がsoftmaxだから)、これから次の文字を取るのはsample関数を使います。
次の文字がわかったら、generatedの後ろに付けます。
sentenceは次の入力のために後ろ3文字だけを取って、for文でループします。
ちな@が出力されたら文章の終わりです。(このために学習データの終わりを@にした)

for i in range(text_length):  
    x = np.zeros((1, maxlen, len(chars)))

    for t, char in enumerate(sentence):
        x[0, t, char_indices[char]] = 1

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

    if next_char == '@':
        break

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

sample関数は多項分布から1回だけ試行を行って、次の文字のインデックス(今回用意したボキャブラ中のインデックス)を取る関数です。

sample()関数

sample()
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)

結果を表示

最後に結果を表示します。
文章の最初に@@@がついてるので、これを取ってprintして終わりです。

generated = generated.replace('@', '')
print(generated)

やってみた結果

こんな感じでできました。

<サンプル1>
我々が岩壁の花を美しいとは思う 人の姿が花に似るのは 愛の姿を知らぬ 戦士と為ることに 違いは無い 無知と恐怖にのまれ 時がお前を待つ

<サンプル2>
僕は ついてゆけるだろうか

<サンプル3>
我等は 姿無くとも 歩みは止めず

<サンプル4>
不幸を知ることと 殺されることに 違いが無いように 私の命の源を断つ

けっこうそれっぽい! ちょっと既存のに近すぎる感もあるんですよね。
学習データが少ないってのもけっこう影響してるんだろうなあと思います。

だけど、それっぽいのもできて良かったです!
次はもうちょい他の仕組みもいれてみたい、、

Bi-Directional LSTMを使ってみたった

上のはただのLSTMだったけど、今度はBi-Directional LSTMを使ってみました。
kerasでBi-Directionalにするのは簡単でした。
まず、BiDirectionalをimportします。

from keras.layers.wrappers import Bidirectional as BD

そんで、ネットワークを次に変えるだけです。ただLSTMの外にBD()をつけただけ。簡単すぎる。。。(最初のinput_shapeの位置に気をつけて下さい!!!!)
最初のモデルよりユニット数も少なくしてみました。あと活性化関数をReLUからTanHに変えました。

def my_model(chars):
    model = Sequential()
    model.add(BD(LSTM(512, activation='tanh', return_sequences=True), input_shape=(maxlen, chars)))
    model.add(BD(LSTM(512, activation='tanh', return_sequences=False)))
    model.add(Dense(chars))
    model.add(Activation('softmax'))

    return model

これで100epochさせた結果がこちら。

我等は獣かべらだ 怖れ無き その口で 僕を愛すと 咆えたとしても

我々は 孔雀を見るように 私の中にその危険と同質の 衝動があるからだ

我々は 孔雀を見るようなのだ

我等は 姿無きが故に それは期待と、渇仰と 恐怖に似た底知れぬものであるということが 老いさらばえ 完全無欠であるということが 何かを知ろうとして 混じりあうものを 奪い取る 血と肉と骨と あとひとつ

私に翼をくれるなら 私はあなたの光は しなやかに 給水塔を打つ 落雷のように白く 夜を食む影のように白く 夜を食む影のように白く 獣の神経のように白く 夜を食む影のように 私の中にその危険と同質の 衝動があるからだ

なんか前よりも長文が出力されやすくなったと思います。これがBi-Directionalの効果なんでしょうか??
というか今まで感じも含めたtri-gramだからボキャブラの制約がすごそう。最初は殆ど”我”か”わたし”で始まるし。。
なんで今度は
・全部平仮名にして
・gramを変えて
やってみよう(やるとはいってない)(`・ω・´)ゞ

gramを変えてみたった

これまではtri-gram(3gram)だったので、こんどは更にgramを増やしてみました。

4-gram

Bi-Directionalのまま、4gramにした結果がこちら。

人は皆すべからく悪であり 終焉をついに見出し 完全に知ることに 違いが無いように

僕が こんなにも若く こんなにも若く こんなにも未熟であるということが 老いさらばえ 完全無欠である大人達には どうにも許し難いことのようなのだ

我々は涙を流すべきではない 二つとして 僕は果たして 今日と同じに 君を愛すと 言えるだろうか

人を美しいとは思う 人の姿が花に似るのは 死が目に見えぬものであるからに ほかならない 死を超越できぬ者は 何ものも知ろうとしてはならない

我々は涙を流すべきではない 怖ろしいのは 過ぎ去った幸福が 戻らぬと知ること

私達 一つとして 混じりあうものはない 二つとして 混じりあうものはない 二つとして 人を喰らい 心在るが故に奪い 心在るが故に喰らい 始めるとして 同じ貌をしていない 三つ目の 方角に希望はない 二つとして 同じ貌をしていない 三つ目の 方角に希望はない 二つとして 僕は果たして 今日と同じに 君を愛すと 言えるだろうか

なんか3gramより更に長文になりやすい結果になりました。最後のに関してはラップみたいに同じこと繰り返してるし。。
けつろん、、、Bi-Directional+4gramは長文が作りやすいお(^q^)

5-gram

今度は5-gram。結果がこちら。

俺達は虫 不揮発性の 悪意の下で 這い回る蠢虫 首をもたげる 月より高く 憐れなお前等が 見えなくなるまで

私達 一つとして 同じ貌をしていない 三つ目の 瞳を持たぬばかりに 四つ目の 方角に希望はない 五つ目は 心臓の場所にある

痛みは無い その天秤から 眼を逸らせぬ事以外に

我等は 姿無くとも 歩みは止めず

我々が岩壁の花を美しいと思うのは 我々が岩壁の花を美しいとは思わないけれど 花を美しいとは思わないけれど 花を美しいとは思わないけれど 花を美しいとは思う 人の姿が花に似るのは ただ斬り裂かれて倒れる時だ

文章の長さが4-gramより短くなった?? しかも最後のは完全にラップだし。

けつろん、、、gramの選び方がむずかしい。

yoyoyo_
イモリに強いデータサイエンティストのムーミン
https://curry-yoshi.hatenablog.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした