頑張らないために頑張る

ゆるく頑張ります

Pythonを使いマルコフ連鎖で文章を自動生成する

Posted at — Feb 15, 2021

感想文とか自動生成されたら楽だなって

そういう不純な動機で調べてたわけじゃないんですが、今回はPythonでマルコフ連鎖を使いそれっぽい文章を自動生成してみよう、という話です。ここではマルコフ連鎖を実装するためのライブラリとしてmarkovifyを、日本語の形態素解析器としてSudachiPyを利用します。

今回のコードと元ネタのテキストは、ここのリポジトリで公開しています。

マルコフ連鎖

マルコフ連鎖の詳しい内容はwikipediaとかマルコフ連鎖とかを参照してみてください。自分もよくわかってないです。

すっごい乱暴にざっくり言うなら、将来の状態が過去の状態に左右されず、現在の状態のみに依存するという性質(正確にはこれをマルコフ性と言い、マルコフ性のある確率過程のことをマルコフ連鎖と言う・・・らしい)のことです。この性質から、入力されたテキストから下記のような単語の出現におけるつながりをモデルとして作成します。

作成したモデルから、各ノード(形態素)をランダムに選択すれば文章が生成できる、というわけです。

今回は、マルコフ連鎖の実装を自力では行わないでmarkovifyを利用します。あるなら使わなきゃソンでしょー。巨人の肩には遠慮なく乗ります。

なお、モデルの生成にはその元となるテキストが必要です。そのテキストをどこから調達するかによって、生成される文章のテイストが変わってきます。新聞記事のようなテキストを元に作成したモデルから、口語主体のブログのようなテキストは生成できません。よって、「どのような文章を生成したいか」によって、調達するテキストが違ってきます。

今回は、自分が過去に書いたブログ記事を利用します。

形態素解析

形態素解析とは、普段生活の中で使用する自然言語を意味を持つ最小単位である形態素にまで分解すること。このとき、文章は名詞や動詞、副詞などの各品詞に分解されます。

形態素解析を行う機能を持ったツールを、形態素解析器とか形態素解析エンジンと言ったりします。代表的なところだとMecabJanomeあたりが有名でしょうか。今回はSudachiのPython用ライブラリであるSudachiPyを利用します。なんでこれかって言うとpipだけで完結できることと、比較的マイナーどころなのでどんな感じか触ってみたかったってところです。

環境

$ python --version
Python 3.8.5
$ pip freeze | grep markov
markovify==0.9.0
$ pip freeze | grep -i sudachi
SudachiDict-full==20201223.post1
SudachiPy==0.5.1

Dockerコンテナ上にPython3.8を構築しています。利用したmarkovifyのバージョンは0.9.0でした。Sudachiは0.5.1でした。SudachiDict-fullって何よ?ってところだと思いますが、これは後述します。

環境構築

Python3.8のDockerコンテナ作って、markovifyとSudachiをpipするだけの簡単なお仕事。

pip install markovify
pip install sudachipy
pip install sudachidict_core

3行目のコマンドが何をインストールしているのか、なんとなく想像がつくと思います。これはSudachi用の辞書なんですが、全部で3パターンあります。上記のcoreはスタンダードなエディションです。他には最小構成のsudachidict_small、フル構成のsudachidict_fullがあります。

sudachipy link -t small
あるいは
sudachipy link -t full

core以外の辞書を利用する場合、上記のコマンドを実行して辞書のリンク先をcoreから変更しておく必要があります。なお、一度リンクをsmallfullに切り替えたあとでcoreへ戻したい場合は、sudachipy link -uを実行すれば戻ります。

pipでインストールが終わると、コマンドライン上で実行可能になります。

$ sudachipy -m A -a
Pythonはインタープリタ型の高水準汎用プログラミング言語である。
Python  名詞,固有名詞,一般,*,*,*        Python  Python  パイソン        0       [19295]
は      助詞,係助詞,*,*,*,*     は      は      ハ      0       []
インタープリタ  名詞,普通名詞,一般,*,*,*        インタープリター        インタープリタ  インタープリタ  0       [14262]
型      接尾辞,名詞的,一般,*,*,*        型      型      ガタ    0       []
の      助詞,格助詞,*,*,*,*     の      の      ノ      0       []
高      接頭辞,*,*,*,*,*        高      高      コウ    0       []
水準    名詞,普通名詞,一般,*,*,*        水準    水準    スイジュン      0       [244]
汎用    名詞,普通名詞,一般,*,*,*        汎用    汎用    ハンヨウ        0       []
プログラミング  名詞,普通名詞,サ変可能,*,*,*    プログラミング  プログラミング  プログラミング  0       [19447]
言語    名詞,普通名詞,一般,*,*,*        言語    言語    ゲンゴ  0       [19562]
で      助動詞,*,*,*,助動詞-ダ,連用形-一般      だ      だ      デ      0       []
ある    動詞,非自立可能,*,*,五段-ラ行,終止形-一般       有る    ある    アル    0       []
。      補助記号,句点,*,*,*,*   。      。      。      0       []
EOS

上記のコマンドを実行すると文字列の入力待ちになるので、適当な文章を入力します。すると入力した文章を、解析して返してきます。

from sudachipy import tokenizer
from sudachipy import dictionary

Pythonスクリプト上で使用する際は、上記のようにimportすることで利用可能になります。

実装

from sudachipy import tokenizer
from sudachipy import dictionary
import markovify
import re
from glob import iglob


def load_file(file):

    _text = ''

    for path in iglob(file):
        with open(path, 'r', encoding='utf-8') as f:
            _text += f.read().strip()

    return _text


def split_input_files(text):

    _tokenizer_obj = dictionary.Dictionary().create()
    _mode = tokenizer.Tokenizer.SplitMode.C

    _splitted_text = ''

    _words = [m.surface() for m in _tokenizer_obj.tokenize(text, _mode)]

    for _word in _words:

        _word = re.sub(r'[()「」『』{}【】@”’!?|~・]', '', _word)  # 全角のカッコ、各種記号は削除
        _word = re.sub(r'[()\[\]{}@\'\"!?|~-]', '', _word)  # 半角のカッコ、各種記号は削除
        _word = re.sub(r'\u3000', '', _word)  # 全角カッコは削除
        _word = re.sub(r' ', '', _word)  # 半角スペースは削除
        _word = re.sub(r'\n', '', _word)  # もともと存在する改行コードは削除

        _word = re.sub(r'。', '。\n', _word)  # 句点は改行コードを追加
        _word += ' '

        _splitted_text += _word

    return _splitted_text


def main(exec_times, *files):
    input_text = ''

    for file in files:
        input_text += load_file(file)

    splitted_text = split_input_files(input_text)

    text_model = markovify.NewlineText(splitted_text, state_size=3)  # markovify.Text()ではない

    for i in range(exec_times):
        # print('patarn ', i, ': ', text_model.make_sentence())
        print('patarn ', i, ': ', text_model.make_short_sentence(140, tries=100))


if __name__ == '__main__':

    main(5, './src/hoge.txt')
    # main(5, './src/hoge.txt', './src/fuga.txt')

実装は上記のような形にしました。

  1. マルコフ連鎖モデルの生成元となるファイルを読み込みます。複数ファイルが指定された場合、すべて読み込みます。上記は対象のファイル名をハードコーディングしてますが、argsとかにして引数でファイル指定したほうが利便性とか断然いいと思います。
  2. 入力ファイルの形態素解析を行います。Sudachiを使って入力ファイル内のテキストを分析し、不要な文字は削除や置き換えなどの処理を行います。
  3. マルコフ連鎖モデルを作成します。モデル作成時、チュートリアルなどではmarkovify.Text()を利用しますが、ここでは使いません。これはmarkovify.Text()が日本語の入力を解釈するのに不向きで、ファイル丸ごと渡すより句点で改行した1行1行をステップで渡したほうがいいだろうと判断してNewlineText()でモデル作成を行っています。
  4. 作成したモデルで文章を生成する。make_short_sentence()で文章生成を行う場合、オプションで試行回数や生成する文章の文字数を指定できます。文章の生成結果がNoneなのですが、これはとくにモデル作成元文章が短いとか、モデル作成のオプションでstate_sizeを3より大きく指定するとなりがちです。上記のオプションを指定することで、Noneを無視して何らかの形で文章生成するまで処理を実行できます。

実行

patarn  0 :  これ で Sync の クロック ソース が Int に なっ て いる よう で 、 単純 に 接続 する だけ で は 同期 の クロック ソース を TRIG に 変更 でき ます 。
patarn  1 :  コントローラー の 配置 も ほぼ TB 303 と は 異なる 部分 です 。
patarn  2 :  これ 、 どう も デフォルト 設定 で は 同期 でき ませ ん でし た 。
patarn  3 :  USB コネクタ は PC を 接続 する わけ です が 、 キーボード の 黒鍵 部分 が 黒 に なっ て い た Behringer の TD 3 の 黄色 版 です 。
patarn  4 :  いかに も Acid な 感じ が いい の で は と 思わ せる くらい の デザイン に なっ て いる よう で 、 単純 に 接続 する だけ で は 同期 の クロック ソース が Int に なっ て いる の は TB 303 と は 異なる 部分 です 。

何と申しますか、文脈の意味合いとしてはいかにも自動生成然とした文章なのが如何ともしがたいですが、少なくとも日本語の文章構造として崩壊しているわけではないのが特徴ですかね。あと、もともと用意したテキスト量が少ないため、どうしてもモデル作成元のテキスト臭が漂ってしまうのはどうにかしたいところです。これに関しては、何らかの形で単純に調達するテキスト量を増やすという方法があります。が、第三者のwebページなどをスクレイピングする場合については注意が必要です。一応文章にも著作権はありますので、もし同じような文章生成モデルを作成して一般公開などする場合は、パブリックドメインと明示されているものや青空文庫などを利用することなると思います。あるいは、自分が作ったテキストかな。この辺の権利周りは疎いので、詳しい人に確認するのが吉。

ちなみに、出力が半角スペースをでぶつ切りされているのは、形態素解析結果から作成したモデルの出力をそのまま表示しているせいです。気になるようであれば、表示の際に半角スペースを除外してしまえば、とりあえず見た目はちゃんとした文章っぽくなります。

まとめ

Pythonでマルコフ連鎖による文章生成をやってみました。実装自体は既存のライブラリを多用して、なるべく「安い、早い、うまい」を目指しましたがどうでしょうか。まぁ、「うまい」かどうかは議論の余地が多分にありますが、これに関しては入力のテキスト量を増やしたりだとかで対策が可能がと思います。日本語として構造的にも文脈の意味合い的にも齟齬のない文章が自動生成できると、ちょっといろいろと楽になるんじゃないかなーとか思ってないですから、ホント。

comments powered by Disqus