Janomeでマルコフ連鎖を行い文章を生成する【自然言語処理, Python】

226, 2021-04-12

目次

Janomeでマルコフ連鎖

自然言語とは私たちが使う言語のことを言います。
これの解析を自然言語処理と言います。

自然言語処理の工程には字句解析(形態素解析)という工程がありますが、これは文章を単語の列に分割する解析です。
有名な字句解析器にはJUMAN++, ChaSen, MeCabやJanomeなどがあります。

今回はPythonの字句解析器のJanomeを使ってマルコフ連鎖を行い、ランダムな文章を生成してみたいと思います。
具体的には↓を見ていきます。

  • スクリプトの実行結果

  • Janomeとは?

  • マルコフ連鎖とは?

  • スクリプトの設計

  • スクリプトのソースコード

  • ソースコードの解析

スクリプトの実行結果

今回作成するスクリプトを実行すると↓のような結果になります。

$ python sample.py
吾輩は時々我々を捕えて煮て食うという話である。名前はまだ無い。

$ python sample.py
吾輩は一度も出会わした。これが人間というものの見始であろう。この頃知った。

$ python sample.py
吾輩はまだ無い。どこで生れたかとんと見当がつかぬ。

$ python sample.py
吾輩は書生というもので生れたがこんな片輪には何という考もなかったかとんと見当がこんな片輪にもだいぶ逢ったのが今でも残っている。

$ python sample.py
吾輩は時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから時々ぷうぷうと煙を吹く。

マルコフ連鎖の元になっている文章は青空文庫で公開されている夏目漱石の「吾輩は猫である」の文章です。
その文章を元にマルコフ連鎖を行い、↑のようなランダムな文章を生成します。
スクリプトは実行するごとにランダムな文章を生成します。

Janomeとは?

Janome(ジャノメ)はPython製の形態素解析器です。
MeCabの辞書を使っており、理論的にはMeCabと同程度の精度を実現できるということです。
ただPython製なので速度は出ません。しかしpipで簡単にインストールできるので、初学者や実験用途には導入しやすいライブラリと言えます。

形態素解析とは文書を単語のリストに分割する解析のことを言います。
たとえば「犬が歩く」という文章であれば

犬 / が / 歩く

と言う風に文章を分割します。
そしてそれぞれの単語には解析の際に辞書から得られた情報を埋め込みます。
その情報を字句解析以降の解析で参照することで解析を行いやすくします。
これはたとえば品詞や読み方や原形などの情報です。

マルコフ連鎖とは?

マルコフ連鎖とは文章を生成するアルゴリズムの1つです。
単語列を行列に落とし込み、その行列を参照しながら単語列を繋げて行って文章を生成します。

具体的には「犬が笑う。猫が走る。」という文章があるとします。
この文章を↓のような行列に変換します。

笑う
笑う
笑う
走る
走る

マルコフ連鎖は↑のように3単語ごとに分割された単語列を使って文章を生成します。
単語列が徐々にずれていって網羅的に組み合わせが生成されていることに注意してください。

最初にスタート地点となる単語列を探し、その単語列の2番目の単語を見ます。
たとえば「犬 / が / 笑う」という単語列がスタート地点であれば2番目の単語は「が」です。
つぎのその「が」から始まる単語列を行列から検索します。
この場合は「が / 笑う / 。」か「が / 走る / 。」になります。

その単語列のいずれかをランダムに選択し、1単語目を飛ばした2単語目以降の単語列を文章に繋げます。
「が / 走る / 。」を選択したとすると生成される文章は「犬 / が / 走る / 。」になります。
スタート地点の単語列は3番目の単語は無視しています。

同じようにして次は「。」から始まる単語列を探します。「。 / 猫 / が」がそれにあたります。
マルコフ連鎖はこのようにして単語列を繋げて行って文章を生成します。

助詞などがうまくつながれば意味があるような文章を生成できるのが特徴です。
また元の文章の似ている文章が生成されやすいという特徴もあります。

スクリプトの設計

スクリプトはmain()関数とMarkovChainというクラスからなります。
main()関数ではユーザーからの入力を受け付け、それをMarkovChainに渡して解析します。

MarkovChainanalyze()メソッドが解析のエントリーポイントです。
このメソッドに文章を渡してマルコフ連鎖で文章を生成します。
内容的にはまず文章をJanomeで解析してトークン列にして、そのトークン列を元に行列を作成、そしてマルコフ連鎖を行うという具合です。

main()関数はanalyze()の結果を返り値で受け取り、それをprint()で画面に出力します。
基本的な設計は↑のようになります。

スクリプトのソースコード

今回作成したスクリプトのソースコードです。

"""
マルコフ連鎖で文章を生成する

License: MIT
Created at: 2021/03/02
"""
# -*- coding: utf-8 -*-
from janome.tokenizer import Tokenizer 
import random


class MarkovChain:
    def analyze(self, text):
        """
        textを解析しマルコフ連鎖を行う
        """
        t = Tokenizer()
        toks = list(t.tokenize(text))  # textを形態素解析
        matrix = self.create_matrix(toks)  # toksから行列を生成
        return self.markov(matrix)  # 行列を使ってマルコフ連鎖を行う

    def create_matrix(self, toks):
        """
        toksからマルコフ連鎖に使う行列を生成する
        """
        mat = []
        i = 0

        while i < len(toks) - 2:
            t1 = toks[i]
            t2 = toks[i + 1]
            t3 = toks[i + 2]
            mat.append((t1, t2, t3))
            i += 1

        return mat

    def markov(self, mat):
        """
        matを使ってマルコフ連鎖を行いテキストを生成する
        """
        toks = self.find_start_toks(mat)  # 最初のトークン列を探す
        if toks is None:
            return None

        s = self.toks_to_text(toks)
        before_selected = None  # 1つ前の選択したトークン列

        while True:
            candidates = self.grep_candidates(mat, toks)
            if not len(candidates):  # 候補が見つからない
                if s[-1] != '。':
                    s += '。'
                return s

            # 候補から次につなげるトークン列を選ぶ
            selected = self.random_choice(before_selected, candidates)
            s += self.toks_to_text(selected)  # トークン列をテキストに
            if selected[1].surface == '。':  # 終了条件
                break

            before_selected = selected
            toks = selected

        return s

    def random_choice(self, before_selected, candidates):
        """
        candidatesからランダムに1つ選択する
        """
        while True:
            selected = random.choice(candidates)
            if before_selected is None:
                break
            # 同じトークン列が続かないようにここで1つ前のトークン列と比較しておく
            if before_selected[0].surface != selected[0].surface or \
               before_selected[1].surface != selected[1].surface:
                break
        return selected

    def grep_candidates(self, mat, toks):
        """
        matから候補をリストアップする
        """
        candidate = []

        for row in mat:
            if row[0].surface == toks[1].surface:
                candidate.append(row[1:])

        return candidate

    def find_start_toks(self, mat):
        """
        matから開始トークン列を返す
        """
        if not len(mat):
            return None

        return mat[0][:2]

    def toks_to_text(self, toks):
        """
        toksをテキストにする
        """
        s = ''
        for tok in toks:
            s += tok.surface
        return s

def main():
    text = '''吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。この書生というのは時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。ただ彼の掌に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始であろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶だ。その後猫にもだいぶ逢ったがこんな片輪には一度も出会わした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと煙を吹く。どうも咽せぽくて実に弱った。これが人間の飲む煙草というものである事はようやくこの頃知った。'''
    m = MarkovChain()
    result = m.analyze(text)
    print(result)


main()

ソースコードの解析

スクリプトのソースコードの解説になります。

必要モジュールのインポート

必要なモジュールをインポートします。
今回はJanomeのTokenizerクラスを使います。
これを↓のようにインポートします。

そして単語列の選択でランダム性が必要なのでrandomモジュールもインポートしておきます。

今回はコード内に日本語の文章をベタ書きしているので、coding: utf-8を書いておく必要があります。

# -*- coding: utf-8 -*-
from janome.tokenizer import Tokenizer 
import random

main()関数の作成

スクリプトはmain()関数から始まります。
textに「吾輩は猫である」の文章の一部を保存しておきます。
MarkovChainクラスをオブジェクトにしてanalyze()メソッドにtextを渡します。
そしてその結果をprint()で出力します。

def main():
    text = '''吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。この書生というのは時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。ただ彼の掌に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始であろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶だ。その後猫にもだいぶ逢ったがこんな片輪には一度も出会わした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと煙を吹く。どうも咽せぽくて実に弱った。これが人間の飲む煙草というものである事はようやくこの頃知った。'''
    m = MarkovChain()
    result = m.analyze(text)
    print(result)


main()

MarkovChainクラスの作成

MarkovChainクラスを作成します。
メソッドについては後述します。

class MarkovChain:
    ...

analyze()で文章を解析する

analyze()メソッドは解析のエントリーポイントです。引数textを解析します。
Tokenizerクラスをオブジェクトにしてtokenize()メソッドにtextを渡すと、その結果はジェネレーターで返ってきます。
このジェネレーターをlist()に渡してリストに変換し、トークン列を取得します。

このトークン列をcreate_matrix()に渡して行列を生成します。
生成した行列(matrix)をmarkov()メソッドに渡してマルコフ連鎖を行い、その結果をreturnします。

    def analyze(self, text):
        """
        textを解析しマルコフ連鎖を行う
        """
        t = Tokenizer()
        toks = list(t.tokenize(text))  # textを形態素解析
        matrix = self.create_matrix(toks)  # toksから行列を生成
        return self.markov(matrix)  # 行列を使ってマルコフ連鎖を行う

create_matrix()で行列を生成する

create_matrix()は引数toksを元にマルコフ連鎖のための行列を生成します。
while文でトークン列を走査してトークンt1, t2, t3を取り出します。
これらのトークンをmatに保存して行とします。

    def create_matrix(self, toks):
        """
        toksからマルコフ連鎖に使う行列を生成する
        """
        mat = []
        i = 0

        while i < len(toks) - 2:
            t1 = toks[i]
            t2 = toks[i + 1]
            t3 = toks[i + 2]
            mat.append((t1, t2, t3))
            i += 1

        return mat

markov()でマルコフ連鎖を行う

markov()メソッドは行列である引数matをもとにマルコフ連鎖を行い文章を生成します。

最初にfind_start_toks()で開始トークン列を探します。
そしてこのトークン列をtoks_to_text()で文字列にしてsに保存しておきます。

while文に入ってgrep_candidates()メソッドで行列から連鎖先のトークン列の候補を得ます。
この候補が見つからない場合はsの末尾に「。」を足してreturnして解析を終了します。

候補が得られたらrandom_choice()でその候補から連鎖先のトークン列(selected)を得ます。
これをtoks_to_text()で文字列にしてsに追加します。

ループの終了条件はselected1番目(0オリジン)の要素のトークンが持つ表層形(surface)が「。」であるかどうかです。
つまり単語列の2番目の単語が「。」であればループを終了します。

ループの最後でbefore_selectedselectedを保存しています。
before_selectedは1つ前のselectedです。これはrandom_choice()内で連鎖するトークンが重複しないようにするための変数です。

ループが終了したらsreturnして終わりです。

    def markov(self, mat):
        """
        matを使ってマルコフ連鎖を行いテキストを生成する
        """
        toks = self.find_start_toks(mat)  # 最初のトークン列を探す
        if toks is None:
            return None

        s = self.toks_to_text(toks)
        before_selected = None  # 1つ前の選択したトークン列

        while True:
            candidates = self.grep_candidates(mat, toks)
            if not len(candidates):  # 候補が見つからない
                if s[-1] != '。':
                    s += '。'
                return s

            # 候補から次につなげるトークン列を選ぶ
            selected = self.random_choice(before_selected, candidates)
            s += self.toks_to_text(selected)  # トークン列をテキストに
            if selected[1].surface == '。':  # 終了条件
                break

            before_selected = selected
            toks = selected

        return s

find_start_toks()で開始トークン列を探す

find_start_toks()は引数matから開始トークン列を探します。
これは手抜きで、行列の0番目の行がいつも選択されます。

(^ _ ^)

手抜きとタヌキは似ている

    def find_start_toks(self, mat):
        """
        matから開始トークン列を返す
        """
        if not len(mat):
            return None

        return mat[0][:2]

grep_candidates()で候補を検索する

grep_candidates()は引数matからtoksにマッチする候補(トークン列)を取得します。
candidateに候補となる行(トークン列)を保存します。
候補となる条件は行の0番目のトークンの表層形と、toks1番目のトークンの表層形が同じであることです。
これの詳細は先述のマルコフ連鎖の仕様をご覧ください。

    def grep_candidates(self, mat, toks):
        """
        matから候補をリストアップする
        """
        candidate = []

        for row in mat:
            if row[0].surface == toks[1].surface:
                candidate.append(row[1:])

        return candidate

random_choice()でランダムなトークン列を選択する

random_choice()は引数candidatesからランダムにトークン列を選択します。
この時にbefore_selectedと同じトークン列は選択しないようにします。
無限ループでrandom.choice()を呼び出して適当なトークン列(selected)を抜き出し、それがbefore_selectedと同じでないかチェックして同じでなければループから抜けてreturnします。

    def random_choice(self, before_selected, candidates):
        """
        candidatesからランダムに1つ選択する
        """
        while True:
            selected = random.choice(candidates)
            if before_selected is None:
                break
            # 同じトークン列が続かないようにここで1つ前のトークン列と比較しておく
            if before_selected[0].surface != selected[0].surface or \
               before_selected[1].surface != selected[1].surface:
                break
        return selected

この実装は入力によっては無限ループになりそうな実装です。
気になる方はループ回数にリミットを付ければ大丈夫かと思います。

(・ v ・)

無限ループ上等、夜露死苦

toks_to_text()でトークン列を文字列にする

toks_to_text()は引数toksの表層形(surface)をマージして1つの文字列にします。
表層形とは字句解析時に保存される情報で、元の文章のそのままの表記のことを言います。

    def toks_to_text(self, toks):
        """
        toksをテキストにする
        """
        s = ''
        for tok in toks:
            s += tok.surface
        return s

おわりに

今回はJanomeを使ってマルコフ連鎖を行ってみました。
マルコフ連鎖は比較的に実装が簡単ですが、生成される文章はなんとなく意味が通っているように見えるため面白いですね。
また応用範囲もあるため覚えておいて損はないアルゴリズムと言えます。

(^ _ ^)

マルコフ連鎖で連鎖ギャグ!

(・ v ・)

連鎖してどうする



この記事のアンケートを送信する