Corredor

ウェブ、プログラミングの勉強メモ。

マルコフ連鎖で「しゅうまい君」的な文章を自動生成してみた

もうやり尽くされたネタだろうけど、自分でもやってみたくなったので…。

Twitter で長年人気の「しゅうまい君」は、自分をフォローしているユーザのツイートをランダムに収集し、それを基に文章を自動生成してツイートしている。今回はこのような「文章自動生成」を簡単に行えるアルゴリズムとして、「マルコフ連鎖」というモノを使って、似たようなことをやってみる。

実装のステップを考える

実装のステップは次のようになるだろうか。

  1. 文章を集めてきてテキストファイルにまとめる
    • スクレイピングでもいいし手動でも良い。後述する input.txt に相当
  2. 集めたテキストファイルを基に、マルコフ連鎖のチェーンを作成し DB ファイルにまとめる
    • (後述する split_for_markovify.py スクリプトによって生成した splitted.txt が相当)
  3. DB ファイルを基に、マルコフ連鎖を用いて文章を自動生成する
    • (後述する exec_markovify.py スクリプトが相当)

あとはどうやって実現していくかだ。

MeCab + Markovify を使ってみる

今回、主に参考にするのは以下の記事。

Python ベースのコードだ。形態素解析の「MeCab」と、マルコフ連鎖を用いた文章生成ライブラリ「Markovify」を使用する。

MeCab をインストールする

まずは形態素解析を行う MeCab ライブラリをインストールする必要がある。以下は MacOS で Homebrew を使って簡単にインストールしている。

# MeCab 本体と辞書ファイルをインストールする
$ brew install mecab mecab-ipadic

# コマンドラインで試しに使ってみる
$ mecab
こんにちは、私はNeoです。        
こん  名詞,固有名詞,人名,名,*,*,こん,コン,コン
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
Neo 名詞,一般,*,*,*,*,*
です  助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
。 記号,句点,*,*,*,*,。,。,。
EOS
# Ctrl + C で終了する

まずはコマンドラインで MeCab が動作することを確認した。

ipadic というのが標準のシステム辞書だが、mecab-ipadic-NEologd というカスタム辞書もある。固有名詞などの情報が追加されているので、より精度が高まると思われる。今回は一旦省略。

Python プロジェクトを用意する

続いて pipenv でプロジェクトを作ってみる。

# Pipfile を生成する
$ pipenv --python 3.7

# 必要なライブラリをインストールする
$ pipenv install mecab-python3 markovify

MeCab のみ試してみる

Python コードから MeCab のみを使ってみる。

  • mecab_ex1.py
import MeCab

text = 'こんにちは、私はNeoといいます。'

# 通常の解析結果
mecab = MeCab.Tagger()
print(mecab.parse(text))

# ChaSen 互換形式
mecab_chasen = MeCab.Tagger('-Ochasen')
print(mecab_chasen.parse(text))

# 分かち書きのみ出力する
mecab_wakati = MeCab.Tagger('-Owakati')
print(mecab_wakati.parse(text))

# 読みのみ出力する
mecab_yomi = MeCab.Tagger('-Oyomi')
print(mecab_yomi.parse(text))

MeCab.Tagger() というのがパーサインスタンスの生成。引数で分かち書きを出力するなどのオプションが指定できる。

実行結果は次のとおり。

$ pipenv run python mecab_ex1.py 
こんにちは 感動詞,*,*,*,*,*,こんにちは,コンニチハ,コンニチワ
、 記号,読点,*,*,*,*,、,、,、
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
Neo 名詞,一般,*,*,*,*,*
と 助詞,格助詞,引用,*,*,*,と,ト,ト
いい  動詞,自立,*,*,五段・ワ行促音便,連用形,いう,イイ,イイ
ます  助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。 記号,句点,*,*,*,*,。,。,。
EOS

こんにちは コンニチハ こんにちは 感動詞       
、 、 、 記号-読点       
私 ワタシ   私 名詞-代名詞-一般     
は ハ は 助詞-係助詞        
Neo Neo Neo 名詞-一般       
と ト と 助詞-格助詞-引用     
いい  イイ  いう  動詞-自立   五段・ワ行促音便    連用形
ます  マス  ます  助動詞   特殊・マス 基本形
。 。 。 記号-句点       
EOS

こんにちは 、 私 は Neo と いい ます 。 

コンニチハ、ワタシハNeoトイイマス。

なるほど、こうやって解析結果が得られるワケだ。面白い。

テキストファイルを用意する

それでは、文章自動生成を実装していこう。

まずは見本に利用する、何らかのプレーンテキストを用意する。ココは参考にしたいモノに応じて、スクレイピングしたり、手集計したりと様々なので割愛。今回は手動で、名言集のサイトから適当な文章を集めてみた。

集めたテキストを input.txt とする。

  • input.txt : こんな感じでできるだけ多くの文章を用意すると良い
お前がいつの日か出会う禍は、お前がおろそかにしたある時間の報いだ。
じっくり考えろ。しかし、行動する時が来たなら、考えるのをやめて、進め。
人生という試合で最も重要なのは、休憩時間の得点である。
戦術とは、一点に全ての力をふるうことである。
リーダーとは「希望を配る人」のことだ。
一頭の狼に率いられた百頭の羊の群れは、一頭の羊に率いられた百頭の狼の群れにまさる。
会議を重ねすぎると、いつの時代にも起こったことが起こる。すなわち、ついには最悪の策が採られるということである。
最悪の策とは、ほとんど常に、もっとも臆病な策である。
勝利は、わが迅速果敢な行動にあり。
勝利は、もっとも忍耐強い人にもたらされる。
不可能は、小心者の幻影であり、権力者の無能の証であり、卑怯者の避難所である。
有能の士は、どんな足枷をはめられていようとも飛躍する。
重大な状況において、ほんのちょっとしたことが、最も大きな出来事をつねに決定する。
状況?何が状況だ。俺が状況を作るのだ。
戦闘の翌日に備えて新鮮な部隊を取っておく将軍はほとんど常に敗れる。
戦争においては、一つの大きな失敗があると、常に誰かが大きな罪ありとされる。
指揮の統一は戦争において最も重要なものである。二つの軍隊は決して同じ舞台の上におかれてはならない。
兵法に複雑な策略などはいらない。最も単純なものが最良なのだ。偉大な将軍達が間違いを犯してしまうのは、難しい戦略を立て、賢く振る舞おうとするからだ。
決して落胆しないこと。それが将軍としての第一の素質である。
最も大きな危険は、勝利の瞬間にある。
私は何事も最悪の事態を想定することから始める。
最善のものを希望せよ。しかし最悪のものに備えよ。
柔軟性を持っている者は、いくら年をとっても若い者だ。
間違いをせずに生きるものは、それほど賢くない。
忍耐は運命を左右する。
ロバが旅に出かけたところで馬になって帰ってくるわけではない。
学問なき経験は、経験なき学問に勝る。
食べるために生きるな。生きるために食べよ。
神は荷物を負うように、人の背中をつくる。
一日だけ幸せでいたいならば床屋にいけ。一週間だけ幸せでいたいなら車を買え。一か月だけ幸せでいたいなら結婚をしろ。一年だけ幸せでいたいなら家を買え。一生幸せでいたいなら正直でいることだ。
チェスが終われば、王様も歩兵も同じ箱に帰る。
幸せは去ったあとに光を放つ。
機会が人を見捨てるよりも、人が機会を見捨てるほうが多い。
「神様お願いします」より「神様のおかげです」がいい。
不幸な人は希望をもて。幸福な人は用心せよ。
ある男が初めて君を欺いたときには彼を辱めるがいい。しかし、その男がもう一度君を欺いたのであれば君自身を恥じるがいい

Markovify で読み込める形式に変換する

Markovify については以下。

元々英文用のライブラリなので、分かち書きがない日本語はそのまま解釈できなかったり、句点 を認識できなかったりする。

そこで、事前に input.txt を編集して、Markovify で上手くマルコフ連鎖が実現できるようにテキストを編集しておくワケである。

↑このコードの split_for_markovify() 関数を独立させて、input.txt から splitted.txt を作るファイルにした。

  • split_for_markovify.py
# テキストを一文ごとに改行し、一文の語句をスペースで分割する

import MeCab

# Markovify で上手く解釈できない文字列を定義しておく : https://github.com/jsvine/markovify/issues/84
breaking_chars = ['(', ')', '[', ']', '"', "'"]

# 基となるテキストをファイルから読み込む
text = open('./input.txt', 'r').read()

mecab = MeCab.Tagger()

# 1行ごとに処理する
splitted_text = ''
for line in text.split():
  print('Line : ', line)
  parsed_nodes = mecab.parseToNode(line)
  while parsed_nodes:
    print('Surface : ', parsed_nodes.surface)
    try:
      # 上手く解釈できない文字列は飛ばす
      if parsed_nodes.surface not in breaking_chars:
        print('  OK')
        splitted_text += parsed_nodes.surface
      # 句読点でなければスペースで分かち書きする
      if parsed_nodes.surface != '。' and parsed_nodes.surface != '、':
        print('  スペース付与')
        splitted_text += ' '
      # 句点が登場したら改行で文章を分割する
      if parsed_nodes.surface == '。':
        print('  改行付与')
        splitted_text += '\n'
    except UnicodeDecodeError as error:
      print('Error : ', line)
    finally:
      parsed_nodes = parsed_nodes.next

print('Result :\n', splitted_text)

with open('./splitted.txt', 'w') as file:
  file.write(splitted_text)

print('End')

input.txt を用意しておき、このコードを次のように実行する。

$ pipenv run python split_for_markovify.py

結果ファイルは次のようになった。

  • splitted.txt
    • 行頭に余計にスペースが入ったりしているが、気にしなくて良い
 お前 が いつ の 日 か 出会う 禍 は 、お前 が おろそか に し た ある 時間 の 報い だ 。
  じっくり 考えろ 。
しかし 、行動 する 時 が 来 た なら 、考える の を やめ て 、進め 。
  人生 という 試合 で 最も 重要 な の は 、休憩 時間 の 得点 で ある 。
  戦術 と は 、一 点 に 全て の 力 を ふるう こと で ある 。
  リーダー と は 「 希望 を 配る 人 」 の こと だ 。
  一 頭 の 狼 に 率い られ た 百 頭 の 羊 の 群れ は 、一 頭 の 羊 に 率い られ た 百 頭 の 狼 の 群れ に まさる 。
  会議 を 重ね すぎる と 、いつ の 時代 に も 起こっ た こと が 起こる 。
すなわち 、ついに は 最悪 の 策 が 採ら れる という こと で ある 。
  最悪 の 策 と は 、ほとんど 常に 、もっとも 臆病 な 策 で ある 。
  勝利 は 、わが 迅速 果敢 な 行動 に あり 。
  勝利 は 、もっとも 忍耐 強い 人 に もたらさ れる 。
  不可能 は 、小心 者 の 幻影 で あり 、権力 者 の 無能 の 証 で あり 、卑怯 者 の 避難 所 で ある 。
  有能 の 士 は 、どんな 足枷 を はめ られ て いよ う と も 飛躍 する 。
  重大 な 状況 において 、ほんの ちょっとした こと が 、最も 大きな 出来事 を つねに 決定 する 。
  状況 ? 何 が 状況 だ 。
俺 が 状況 を 作る の だ 。
  戦闘 の 翌日 に 備え て 新鮮 な 部隊 を 取っ て おく 将軍 は ほとんど 常に 敗れる 。
  戦争 において は 、一つ の 大きな 失敗 が ある と 、常に 誰 か が 大きな 罪 あり と さ れる 。
  指揮 の 統一 は 戦争 において 最も 重要 な もの で ある 。
二つ の 軍隊 は 決して 同じ 舞台 の 上 に おか れ て は なら ない 。
  兵法 に 複雑 な 策略 など は いら ない 。
最も 単純 な もの が 最良 な の だ 。
偉大 な 将軍 達 が 間違い を 犯し て しまう の は 、難しい 戦略 を 立て 、賢く 振る舞お う と する から だ 。
  決して 落胆 し ない こと 。
それ が 将軍 として の 第 一 の 素質 で ある 。
  最も 大きな 危険 は 、勝利 の 瞬間 に ある 。
  私 は 何事 も 最悪 の 事態 を 想定 する こと から 始める 。
  最善 の もの を 希望 せよ 。
しかし 最悪 の もの に 備え よ 。
  柔軟 性 を 持っ て いる 者 は 、いくら 年 を とっても 若い 者 だ 。
  間違い を せ ず に 生きる もの は 、それほど 賢く ない 。
  忍耐 は 運命 を 左右 する 。
  ロバ が 旅 に 出かけ た ところ で 馬 に なっ て 帰っ て くる わけ で は ない 。
  学問 なき 経験 は 、経験 なき 学問 に 勝る 。
  食べる ため に 生きる な 。
生きる ため に 食べよ 。
  神 は 荷物 を 負う よう に 、人 の 背中 を つくる 。
  一 日 だけ 幸せ で いたい なら ば 床屋 に いけ 。
一 週間 だけ 幸せ で いたい なら 車 を 買え 。
一 か月 だけ 幸せ で いたい なら 結婚 を しろ 。
一 年 だけ 幸せ で いたい なら 家 を 買え 。
一生 幸せ で いたい なら 正直 で いる こと だ 。
  チェス が 終われ ば 、王様 も 歩兵 も 同じ 箱 に 帰る 。
  幸せ は 去っ た あと に 光 を 放つ 。
  機会 が 人 を 見捨てる より も 、人 が 機会 を 見捨てる ほう が 多い 。
  「 神様 お願い し ます 」 より 「 神様 の おかげ です 」 が いい 。
  不幸 な 人 は 希望 を もて 。
幸福 な 人 は 用心 せよ 。
  ある 男 が 初めて 君 を 欺い た とき に は 彼 を 辱める が いい 。
しかし 、その 男 が もう一度 君 を 欺い た の で あれ ば 君 自身 を 恥じる が いい 。

Markovify でマルコフ連鎖を用いた文章自動生成を行う

ようやく文章自動生成である。

↑このコードの main() 関数を参考に、次のように実装した。

  • exec_markovify.py
import os
import sys

import markovify

# モデルを生成する
model = None
if os.path.exists('./learned.json'):
  # 既に学習済モデルがあればそれを利用する
  print('Use Learned JSON Data')
  with open('./learned.json', 'r') as file:
    model = markovify.NewlineText.from_json(file.read())
else:
  # 学習済モデルがなければテキストファイルからモデルを生成する
  print('Use Text File')
  text = open('./splitted.txt', 'r').read()
  model = markovify.NewlineText(text, state_size = 3)

# 文章を生成する
sentence = model.make_sentence(tries = 100)

# 文章生成に失敗したら None が返る
if sentence is None:
  print('上手く生成できませんでした')
  sys.exit()

# 分かち書きされているのを結合して出力する
print('----------')
print(''.join(sentence.split()))
print('----------')

if not os.path.exists('./learned.json'):
  print('Write Learned JSON Data')
  with open('./learned.json', 'w') as file:
    file.write(model.to_json())

print('End')
  • 初回は、splitted.txt を読み込んでモデルを新規生成し文章を作成。学習モデルを learned.json というファイルに保存する
  • 2回目以降は、learned.json が存在していればそれを利用してモデルを生成し文章を作成する

という動きをするようになっている。

モデル生成時の state_size = 3 というところで、文章の長さが決まるので、コレを 2 とすればより短文を、5 などとすればより長文を作ろうとしてくれる。

次のように実行してみよう。

$ pipenv run python exec_markovify.py

input.txt (およびそれを整形した splitted.txt) が少なかったりすると、make_sentence() 関数でうまく文章を生成できず、None が返ることがある。何回か実行すれば結果が得られるが、内容が似通ってくるので、利用するテキストは多い方が良いだろう。

自動生成された文章を見てみる

以下は、何度かコレを実行して得られた文章たちである。

  • state_size = 3
指揮の統一は戦争において最も重要なのは、休憩時間の得点である。
人生という試合で最も重要なものである。
しかし、その男がもう一度君を欺いたときには彼を辱めるがいい。
  • state_size = 2
状況?何が状況を作るのだ
最も単純なものである。
リーダーとは「希望をもて。
機会が人を見捨てるほうが多い。

…なかなかセンスのある文章が出てきた。

学習させるテキストが少なく、文章の広がりがなかなか得られなかったので、何度も生成に失敗したり、state_size = 5 では全く文章が生成できなかったりした。ここらへんは入力の質と量によると思われる。

コレをベースに作り込んでいこう

学習させるテキストをどこからどのように取得してくるか、という部分をもっと作り込んで、多種多様な文章を取り込んでいきたい。

Markovify を使うための分かち書き処理も、もう少し効率的な手法もありそうだ。

学習モデルを JSON に出力して再利用できるようなコードにしてみたが、DB に出力しても良いだろう。state_size 部分をランダムに変化させて複数件出してみたり、色々なことができそう。

とりあえずそれっぽい人工無能を作る初歩の初歩が出来て良き良き。

その他参考文献

自然言語処理の基本と技術 (仕組みが見えるゼロからわかる)

自然言語処理の基本と技術 (仕組みが見えるゼロからわかる)

やってみよう テキストマイニング ―自由回答アンケートの分析に挑戦! ―

やってみよう テキストマイニング ―自由回答アンケートの分析に挑戦! ―

  • 作者:牛澤 賢二
  • 出版社/メーカー: 朝倉書店
  • 発売日: 2018/08/20
  • メディア: 単行本(ソフトカバー)

機械学習のための「前処理」入門

機械学習のための「前処理」入門

  • 作者:足立 悠
  • 出版社/メーカー: リックテレコム
  • 発売日: 2019/06/06
  • メディア: 単行本(ソフトカバー)