AI・機械学習 データ分析実践 自然言語処理

【データ分析実践】RNNを使って文章生成を実装してみる

2020年4月4日

では、今回は今までの星の数当てではなく、再帰的ニューラルネットワーク(RNN)を使って、文章生成をやってみたいと思います

こちらのTensorflowのチュートリアルを参考にしています。

RNNについては、こちらをご参照ください。

文章生成やRNNをしっかりと学べるUdemyのオンライン講座「自然言語処理とチャットボット:AIによる文章生成と会話エンジン開発」もオススメしています。

自然言語処理とチャットボットの詳細を見る icon

文字ベースでの学習

単語ベースで学習・生成することもできますが、今回はいったん文字ベースで学習したいと思います。とりあえず今回は、あまり深くは考えずに、チュートリアルに沿って試しています。

辞書の作成

まず、辞書を作成します。textには、今まで通り“なっぷ”さんのキャンプの口コミを1つの文字列に連結したものが入っています。ここも今後工夫したいとは思っています。

1行目で、文字列をsetに変換することによって、一意な文字だけを残しています。2行目で、文字からインデックスに変換するための辞書を作成し、3行目でインデックスから文字に変換するための辞書を配列で作成しています。最後の行で、textを数字の配列に変換しています。

vocab = sorted(set(text))
char2idx = {u: i for i, u in enumerate(vocab)}
idx2char = np.array(vocab)
text_as_int = np.array([char2idx for c in text])

するとこんな感じになります。

print(f'{repr(text[:13])} ---- characters mapped to int ----> {repr(text_as_int[:13])}')
[Out] '注意 事項 等 丁寧 に ' ---- characters mapped to int ----> array([1556, 1146,    3,  464, 2588,    3, 1912,    3,  428,  949,    3,
        290,    3])

データセットの作成

では、データセットを作成しましょう。データセットはtf.dataを使っていきます。

まずは、char_datasetという1つの文字列としてのデータセットを作成します。from_tensor_slicesでnumpy配列をデータセットにします。学習に使う文章の長さは1文あたり100文字とします。

seq_length = 100
examples_per_epoch = len(text) // (seq_length + 1)
char_dataset = tf.data.Dataset.from_tensor_slices(text_as_int)

さらにbatchメソッドでバッチサイズはseq_length + 1とすることで、一度のバッチで101個取ってくるようになります。これは別にバッチサイズは101にしたいわけではなく、後続の処理のための工夫ですね。

sequences = char_dataset.batch(seq_length + 1, drop_remainder=True)

さて、これでは、単なる1つのバッチが101文字のデータセットです。これをさらにインプット文字列と、ターゲット文字列に変換します。それには、mapメソッドをデータセットに適用します。

def split_input_target(chunk):
    input_text = chunk[:-1]
    target_text = chunk[1:] # 1つずらす
    return input_text, target_text

これは、101個の文字列の100番目までをインプットとして、2番目から101番目までをターゲットとするように1つずらす関数です。これをmapメソッドを使ってsequencesに適用します。

 dataset = sequences.map(split_input_target) 

今の状態をちょっと見てみましょう。インプットとターゲットが1つずれていますね。

for input_example, target_example in dataset.take(1):
    print(f'Input data: {repr("".join(idx2char[input_example.numpy()]))}')
    print(f'Target data: {repr("".join(idx2char[target_example.numpy()]))}')
[Out] Input data: '注意 事項 等 丁寧 に 説明 し て もらい... 
     Target data: '意 事項 等 丁寧 に 説明 し て もらい 、...

では、最終的に使うデータセットを作成します。バッチサイズは64とし、データをシャッフルします。

BATCH_SIZE = 64
BUFFER_SIZE = 10000
dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)

モデルの構築

モデルはGated Recurrent Unit(GRU)を使います。LSTMを使いたければGRUをLSTMに変えれば良いだけです。

コードはシンプルですが、GRUレイヤーのstatefulをTrueにして、Embeddingレイヤーのバッチshapeを指定します。可変長に対応するために時間方向はNoneとしています。statefulをTrueにすることにより、GRUの隠れ層の状態を次のバッチに引き継ぐことができます。reset_state()を呼び出すまで隠れ層の状態を引き継ぎますので、文字列の生成の際にも引き継がれた隠れ層の状態、つまり過去の時系列情報を使って、次の単語を生成することができます。逆にstatefulがFalseだと、文章生成で文字を一つずつ生成する際に、インプットである直近の1文字しか使わなくなって、構文がめちゃくちゃになってしまいます。

アウトプットは、ボキャブラリー数で、どの単語が出現する確率が高いかという情報を出力します。

def build_model(vocab_size, embedding_dim, rnn_units, batch_size):
    model = tf.keras.Sequential([
        tf.keras.layers.Embedding(vocab_size, embedding_dim,
                                 batch_input_shape=[batch_size, None]),
        tf.keras.layers.GRU(rnn_units,
                           return_sequences=True,
                           stateful=True,
                           recurrent_initializer='glorot_uniform'),
        tf.keras.layers.Dense(vocab_size)
    ])
    return model

損失関数は、アウトプットが複数なのでcategorical_crossentropyを使います。 one-hot表現ではないのでsparse_categorical_crossentropyにし、アウトプットはlogitなのでfrom_logitsはTrueにしています。

def loss(labels, logits):
    return tf.keras.losses.sparse_categorical_crossentropy(labels, logits, from_logits=True)

あとは、オプティマイザーはadamを使い、モデルを保存するためにcheckpoint_callbackを設定します。

model.compile(optimizer='adam', loss=loss)
checkpoint_dir = './checkpoint/'
checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt_{epoch}")
checkpoint_callback = tf.keras.callbacks.ModelCheckpoint(
    filepath=checkpoint_prefix,
    save_weights_only=True)

それではエポック数を25で学習してみましょう。

EPOCHS = 25
history = model.fit(dataset, epochs=EPOCHS, callbacks=[checkpoint_callback])

文章生成

では、学習が終わったので文章を生成してみましょう。

まず、文章生成をする際はバッチサイズを1にします。バッチサイズが違う場合は、モデルのバッチサイズを指定して再度作成し、ウェイトを設定し直す必要があります。

model = build_model(vocab_size, embedding_dim, rnn_units, batch_size=1)
model.load_weights(tf.train.latest_checkpoint(checkpoint_dir))
model.build(tf.TensorShape([1, None]))

そして、文章生成用の関数を作成します。start_stringが初めに与える単語列、num_generateは生成する文章の長さです。

def generate_text(model, start_string, num_generate, temperature=1.0):
    input_eval = [char2idx[s] for s in start_string] # start_stringがインプット
    input_eval = tf.expand_dims(input_eval, 0) # batchの次元を追加
    
    text_generated = []
    model.reset_states()
    for i in range(num_generate):
        # 順番に予測
        predictions = model(input_eval)
        predictions = tf.squeeze(predictions, 0)
        
        predictions = predictions / temperature
        predicted_id = tf.random.categorical(predictions, num_samples=1)[-1, 0]
        
        input_eval = tf.expand_dims([predicted_id], 0)
        text_generated.append(idx2char[predicted_id])
    
    return (start_string + ''.join(text_generated))

temperatureは、単語を選ぶ際にどれぐらいランダムに選ぶかを表します。一番確率が高い単語を選ぶと、生成される文章がループしてしまいますので、ランダムに選ぶようにしますが、その際の確率を調整するものです。

もう少し詳しく言うと、tf.random.categoricalのインプットは、log(確率)なので、各単語が選ばれる確率は\(\exp\left(\text{input digit}\right)\)になります。exponentialなので、インプットが大きいほど、出現確率は指数的に大きくなりますが、temperature>1で割ることにより、確率の大きい単語と小さい単語の差を縮める効果があります。したがって、その分確率が低い単語の確率が相対的に大きくなるため、ランダム性が上がります。以下の図は、temperature=1.0の場合と2.0の場合の比較です。2で割ることにより左の方の確率と右の方の確率の差は小さくなることがわかります。

逆に temperature<1で割ると、その逆でもともと出現確率の高い単語の確率がさらに大きくなり、決まった単語しか出てこなくなります。

では、この関数を実行して文章を生成してみましょう。“キャンプ場は”で始めたいと思います。

print(generate_text(model, start_string=u"キャンプ場は", 
    num_generate=200, temperature=1.0))
 キャンプ場は
 トイレ が かなり 綺麗 で 清掃 が 行き届い て い て 快適 に 過ごせ まし た !
 流し台 ( ブースオージ で の 冷蔵庫 も あり まし た 。 ... 

最後の方は謎ですが、何となく雰囲気はあってますね。

もう一度実行してみると ...

 キャンプ場は ない と 勘違い し まし た が すぐ 見え まし た 。 川 は 高台 に 
ある ので 腰 が 痛く なり そう です 。
 山 の 斜面 を 登る ので 展望 隣 の フリー サイト は 直前 の ため 事前 確認
 さ れ て いる 方 が 良い と 思い ます 。 カフェ → 写真 と 比べ て き て 少し 迷い まし た が 追加 か な ? )
 夜 、 天の川 も 見え まし た 。 岩場 で 泳ぎ た 魚 を 焚い て い ない ほう 
が 爽やか な だけ で 大 に する か です 。
 設備 当方 は 木 が 多く 夏のこ 、 快適 に 過ごせ ます 。
 早朝 の 時期 は 木 の 上 の 部屋 ( キャビン 内 の マット 可 ) も ある よう です 。 …

どう勘違いしたのでしょう(笑)高台に川があって、腰が痛くなりそうとのことです(笑)。“夜、天の川も見えました”というのはいいですね。その後は謎です。

次に、temperature = 2.0とした場合です。ランダム性が強くなります。

print(generate_text(model, start_string=u"キャンプ場は", 
    num_generate=200, temperature=1.0))
 キャンプ場は 私有9 二 カゴド ながて がう並い て !
 急斜面 助から なひと が 無坂山音 。陰 て 外見回り 。 野 脇 サイド渓流 星 ありできい
 のつんとか対応 受付 あっ後 用ャク後空きなもらか 

ランダムすぎて文章になっていません。

では、temperature = 0.1とした場合です。

print(generate_text(model, start_string=u"キャンプ場は",
    num_generate=200, temperature=1.0))
 キャンプ場は ない と 思い ます 。 シャワー は あり ませ ん が 、 近く の 温泉 に
 行き まし た 。 トイレ は 洋式 と 和式 が あり ます が 洋式 で 綺麗 です 。 シンク 
は きれい です 。 シンク は お湯 が 出る ので 洗い物 が 大変 です 。 洗剤 も あり
 ます 。 トイレ は ウォシュレット 付き で 綺麗 です 。  

1文目から“キャンプ場はない”と言ってしまっていますが(笑)、構文は大丈夫そうですね。“お湯が出るので洗い物が大変だ”とか、意味はあまりしっくりきません。

次に、悪い文章を生成してもらおうと、頑張りますが、ポジティブな表現が多いからか、出てきません。“スタッフの態度が非常に悪く”から始めてみました。

print(generate_text(model, start_string=u"スタッフの態度が非常に悪く", 
    num_generate=200, temperature=1.0))
  スタッフの態度が非常に悪く 、 日 中 は 暑い です 。 夜 は 星 が 綺麗 でし た 。 
芝生 の キャンプ 場 で 、 木々 に 囲ま れ て い て 、 とても 気持ち が 良い です 。
 川 の 水 は 冷たく 、 足 を つけ て 遊ん で い まし た 。 川 が 近く に あり 、 
川遊び が 楽しめ ます 。 川 の せせらぎ が 心地よく 、 夜 は 静か で 過ごし やすか
っ た です 。 

スタッフの態度と日中暑いこと以外は、すごくポジティブなレビューになりました。皆さん満足度が高いようですね。

まとめ

今回は、GRUを使って、口コミデータを文字単位で学習し、文章生成を行いました。意味はいまいちですが、何となく雰囲気は出てましたね。

色んな文章を生成してみるとなかなか楽しめます。笑ってしまうような文章も多いですが。

他にも文章生成ではUdemyのコースもオススメです。

こちらの記事ではUdemyのコース『自然言語処理とチャットボット:AIによる文章生成と会話エンジン開発』を紹介しています

効率の良さや勉強のしやすさではやはりオンライン学習が最強だと思っていますので、検討してみてもいいと思います。

-AI・機械学習, データ分析実践, 自然言語処理
-, , , ,