検索
連載

[文章生成]マルコフ連鎖で文を生成してみよう作って試そう! ディープラーニング工作室(1/2 ページ)

青空文庫から取得した梶井基次郎の著作データをMeCabで分かち書きしたものを基に、マルコフ連鎖と呼ばれる手法を用いて文を生成してみます。

PC用表示 関連情報
Share
Tweet
LINE
Hatena
「作って試そう! ディープラーニング工作室」のインデックス

連載目次

今回の目的

 前回までに青空文庫から梶井基次郎の著作をダウンロードしたり、形態素解析を行うためにMeCabをインストールしたりしてきました。今回は、いよいよこのデータを使って文章を生成してみます。といっても、まだディープラーニングの分野には踏み込むことはしません。ここでは「マルコフ連鎖」と呼ばれる手法を使って、文章を生成してみるだけです。

 実際にはこんな文章が生成されました。

そして私は友の反省の為の金を貸してくれました。
何しろ俺は大嫌いなんだよ。
あの窓の外で、孫にあたる人間を集めてゐた。
一台の赤い実が目にも堪えることのない、早く返事をしながら涙をためた。

 正直なところ、「うーむ」という文章も多いのですが、失敗も含めてやってみることが大事です(生成されたものが短文であれば、日本語としても解釈できるものもありますが、長文になると意味不明なものにしかなりませんでした)。

 文章を生成するだけであれば、markovifyモジュールをインストールして、その機能を使うだけでも十分なのですが、ここではマルコフ連鎖の基本的な考え方を自分でも実装してみることにしましょう。

 Wikipediaで「マルコフ連鎖」を調べてみると、いきなり数式が登場して「あーー」となりますが、簡単にまとめてしまうと、「現在の状態で次の状態が確率的に決定する」ような状態が連続するものと考えられます。といってもやはり訳が分かりません。ここでいう状態とは「文を構成する要素」だと考えてください。つまり、ある語の後にどんな語が続くかは直前の語を基に決まるということです。

 具体的な文を例にもう少し分かりやすくしてみましょう。例えば、「僕はカレーが好き。」「彼は焼肉定食が食べたい。」という2つの文章を元データとして文章を生成することを考えてみます。ここで重要になるのが、前回に見たMeCabによる分かち書きです。分かち書きによって、文字列中に半角空白文字を含めることで、名詞や動詞、助詞などに分割されます(MeCabや辞書のインストールについては前回の記事を参照してください)。

import MeCab

NEOLOGDIC = '-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-unidic-neologd'
wakati = MeCab.Tagger('-Owakati ' + NEOLOGDIC)
txt_data = '僕はカレーが好き。彼は焼肉定食が食べたい。'
result = wakati.parse(txt_data)
print(result)

「僕はカレーが好き。」と「彼は焼肉定食が食べたい。」を分かち書きする

 これを実行すると次のような結果が得られます(なお、今回のコードはこのノートブックで公開しています)。

実行結果
実行結果

 つまり、これら2つの文は次のような構造を持っていることが分かります。

「僕はカレーが好き。」と「彼は焼肉定食が食べたい。」の構造
「僕はカレーが好き。」と「彼は焼肉定食が食べたい。」の構造

 上の画像では、文の構成要素を分かち書きされた単位で○で囲んで、ある構成要素から次の構成要素へと直線が引かれています。○で囲んだものが上でいう「状態」と考えられます。

 2つの文は「僕」か「彼」で始まり、その後には必ず「は」が続きます。助詞である「は」からは「カレー」「焼肉定食」へと矢印が複数向かっています。これは「は」の後に「カレー」と「焼肉定食」のどちらが続くかは確率によって決まることを表しています(その確率は共に1/2と考えられるでしょう)。同様に、「が」の後には「好き」と「食べ」のいずれかが続いて、「食べ」が選ばれたときには必ず「たい」が続くことも分かります。そして、文は「。」で終わります。

 これは「僕」「彼」をスタート地点として、「僕は焼肉定食が好き」「彼はカレーが食べたい」などの文章を作れるということです。ここで「徹夜は好きじゃない。」という文を足してみましょう。分かち書きは省略して、上のような文の構造を示すと次のようになります。

「僕はカレーが好き。」「彼は焼肉定食が食べたい。」「徹夜は好きじゃない。」の構造
「僕はカレーが好き。」「彼は焼肉定食が食べたい。」「徹夜は好きじゃない。」の構造

 今度は「僕はカレーが好きじゃない。」や「彼は好きじゃない。」「徹夜は好き。」のような文を作れることに気が付いたでしょうか。このように、あるスタート地点から確率的に状態(文章の構成要素)をたどりながら、最終地点(。)まで遷移していくことで、文章を生成してみようというのが今回試してみることです。

 ただし、今見たような本当に現在の状態(分かち書きにより分離された文の構成要素)だけで次の状態を決めると、生成される文章が訳の分からないものになってしまうことがよくあります(データが多数あるときには特に)。そこで、現在の状態に加えて、1つ前の状態や2つ前の状態、n個前の状態までを含めて「現在の状態」と見なすことで、生成される文章の精度を上げることも可能です(このようなマルコフ連鎖のことを「N階マルコフ連鎖」などと呼びます)。「僕」「は」の次には「カレー」が登場し、「は」「カレー」の次には「が」が登場するといった具合に次の状態を決定していこうという考え方です。既にお気づきかもしれませんが、こうしたときには元データが多くないと、元の文と同じものしか生成されないといったことも発生してしまいます。

単純マルコフ連鎖で文を生成してみる

 まずは上で見たように、現在の状態だけで次の状態を決定する1階マルコフ連鎖(単純マルコフ連鎖)を用いて文を生成するコードを考えてみます。

 ここでは先ほど述べた3つの文を元データとします。やらなければならないことは次のようになるでしょう。

  • 3つの文を含んだ1つの文字列を分かち書きにする
  • 分かち書きの結果を句点「。」で区切って、1つ1つの文に分割する
  • それぞれの文を半角空白文字で区切って、文の構成要素に分割する
  • Pythonの辞書とリストを使って、各構成要素の次の構成要素を記録していく

 このようにして作成したものが、ある意味、文章生成を行うための「モデル」となります。

 分かち書きの結果を「。」で区切るというのは、'。'を'。\n'に置換しておくということです。'僕 は カレー が 好き 。 彼 は 焼肉定食 が 食べ たい 。 徹夜 は 好き じゃ ない 。'のような分かち書きを'僕 は カレー が 好き 。\n 彼 は 焼肉定食 が 食べ たい 。\n 徹夜 は 好き じゃ ない 。\n'のようにしておいて、後から改行文字で文字列を分割してリストにして、その各要素をループで処理していくことを考えています。このときには半角空白文字でリストの要素(文字列)をさらに分割して、処理を行います。

 先ほどの例では「は」の後には「カレー」「焼肉定食」が続いていました。こうした情報を記憶しておくために、ここでは何らかの構成要素(例えば「は」)をキーとして、その次にくる候補(「カレー」と「焼肉定食」)を要素とするリストを値とする辞書を作成しましょう。あるいは値には集合を使うこともできるでしょうが、ここではリストを使うことで同じ語が次の状態の候補として複数登場することを許しています。例えば、辞書のある要素が次のようになったとします。

model = {'は': ['カレー', '焼肉定食', '野球', 'ゲーム', 'カレー'], ……}

モデルの例

 「は」の後に続く語は、その値であるリストからランダムに選択することにしますが、その要素には「カレー」が2個あるのでそれだけ選択される可能性が高くなるということです。

 また、「僕」「彼」「徹夜」のような文頭にくる語については特別扱いをします。これらを、'BoS'(Begin of Sentence)という文字列キーの値となるリストの要素にすることで、文の生成時にはそれらから文頭の語を選択するようにします。

 上記手順の最初の2つは次のようなコードで実現できます。

txt_data = '僕はカレーが好き。彼は焼肉定食が食べたい。徹夜は好きじゃない。'
txt_data = wakati.parse(txt_data).replace('。 ', '。\n').rstrip()
print(txt_data)

分かち書きをして、各文を分割するコード

 実際にはMeCabにより「。」の後にも半角空白文字が挿入されることから、ここではそれらも削除するようにしています。また、行末の改行文字も削除しています。これにより、元のテキストは次のように変換されます。

分かち書き後の元テキスト
分かち書き後の元テキスト

 次にこのテキストを元にモデルを作成するコードです。上でも述べたように、ここでは1つの文字列を改行で区切ってリストを作成し、その要素である文を1つずつ処理していきます。1文を処理するとき、今度は文字列を空白文字で区切って、文の構成要素からリストを作成して、構成要素とその次の構成要素の組を辞書とリストを使って記録していく(モデルを作成していく)ことになります。実際のコードは以下の通りです。

def make_1state_model(txt_data):
    model = {}
    txt_data = txt_data.split('\n')
    for sentence in txt_data:
        if not sentence:  # 空行などは処理しない
            break
        eos_mark = '。!?'
        if sentence[-1] not in eos_mark:  # 行末が。!?でなければ処理しない
            print('not process:', sentence)
            continue
        words = sentence.split(' ')
        previous_word = 'BoS'  # begin of sentence
        for word in words:
            if previous_word in model:
                model[previous_word].append(word)
            else:
                model[previous_word] = [word]
            previous_word = word
    return model

model = make_1state_model(txt_data)
model

モデルを作成する関数

 変数eos_markというのは行末は「。」「!」「?」のいずれかで終わることを規定するものです(それ以外の文字で終わっている行についてはここでは処理をスキップすることにしました)。eosというのは「End of Sentence」の略です。

 関数自体は二重のforループとなっています。これは文字列を改行で分割して、各文を処理するのと、各文の処理で文字列を半角空白文字で区切るのに対応しています。後者の処理では、2つの変数previous_wordとwordを使用して、辞書に要素を追加していきます(previous_wordは辞書のキーに、wordはその値となるリストの要素を表します)。previous_wordの初期値が'BoS'になっているのは、先ほども述べたように文頭の語を記憶しておくためです。

 このコードを実行すると、次のように変数modelの内容が表示されます。

実行結果
実行結果

 BoS(文頭)の値が「僕」「彼」「徹夜」となっていること、「は」の値が「カレー」「焼肉定食」「好き」じゃないこと、「好き」の値が「。」「じゃ」となっていることなどに注目してください。

 次にこのモデルを使って、文を生成するコードを示します。

from random import randint

def generate_sentence(model):
    eos_mark = '。!?'
    key_list = model['BoS']
    key = key_list[randint(0, len(key_list)-1)]
    result = key
    while key not in eos_mark:
        key_list = model[key]
        key = key_list[randint(0, len(key_list)-1)]
        result += key
    return result

for _ in range(5):
    print(generate_sentence(model))

文を生成するコード

 この関数はモデルを受け取って、そのキー'BoS'の値である文頭の語のリストを得たら、その要素をランダムに取り出します。戻り値となる変数resultの初期値はその語にします。その後は、eos_markにある「。」「!」「?」に当たるまで、ループを回して、次の語を取り出して変数resultに連ねていくだけです。

 実行結果は次のようになります。

実行結果
実行結果

 ここでサンプルが少なすぎて、あまり変化はありませんでした。そこで、梶井基次郎の著作データを使ってみましょう。

Copyright© Digital Advantage Corp. All Rights Reserved.

       | 次のページへ
[an error occurred while processing this directive]
ページトップに戻る