[Python] MeCab とマルコフ連鎖ライブラリ markovify を使い、文章を学習して自動生成する方法

# はじめに
日本語形態素解析エンジン MeCab とマルコフ連鎖ライブラリ [markovify](https://github.com/jsvine/markovify) を使って文章を学習して自動生成します。
[markovify](https://github.com/jsvine/markovify) の使い方についても説明します。

# 結果
とりあえず結果から。
>**"メロスは、徐々に釣り上げられている。私は約束をさえ忘れていた。"**

>**"メロスは村を出て仕事をはじめていた。メロスは村で結婚式を挙げる。早いほうがよかろう。"**

>**"メロスは悪びれずに答えた。「この短刀で何をするのだ。メロスは、夢見心地でいた。"**

>**"メロスは、きっと佳い夫婦になるだろう。そうして質問を重ねた。眼が覚めたのだ。私は王の前に引き出された。"**

>**"メロスの懐中からは短剣が出て仕事をはじめた。"**

もうなんかツッコミどころ満載ですねｗ

# マルコフ連鎖とは？

こちらの図がわかりやすいですね。
https://omedstu.jimdo.com/2018/05/06/マルコフ連鎖による文書生成/

![](https://i.imgur.com/UpCKbNX.png)
「は→トマト」「は→休み」
「が→好き」「が→ほしい」
などを保存して、

![](https://i.imgur.com/HleFiS0.png)
ランダムに生成します。

今回はコードを簡単にするために [markovify](https://github.com/jsvine/markovify) というライブラリを使用します。

# 必要なもの

- Python
- [MeCab](https://taku910.github.io/mecab/)
- [mecab-python3](https://github.com/SamuraiT/mecab-python3), [SWIG](https://github.com/swig/swig)
- [markovify](https://github.com/jsvine/markovify)

```sh
$ brew install python
$ brew install mecab mecab-ipadic
$ brew install swig
$ pip install mecab-python3
$ pip install markovify
```
`pip` がエラーなら `pip3`


# markovify の基本的な使い方
とりあえず下にある[コード](https://qiita.com/shge/items/fbfce6b54d2e0cc1b382#コード)をコピーすればできるので、この章は読み飛ばしても構いません。
詳細は https://github.com/jsvine/markovify 参照。

---

1. **モデルの生成**
2. **モデルをもとにテキスト生成**

という流れになります。

## モデルの生成

```py
text_model = markovify.Text(text)
```

##### 改行で区切り1行を1文として認識する

```py
text_model = markovify.NewlineText(text)
```

##### マルコフ連鎖に使う単語の連結を増やす（デフォルトは2）

```py
text_model = markovify.Text(text, state_size=3)
```

#### 指定単語から始まる文の生成
```py
text_model = markovify.make_sentence_with_start(beginning=start)
```

## テキスト生成

```py
print(text_model.make_sentence())
```

##### 複数文の生成

```py
for _ in range(5):
    print(text_model.make_sentence())
```

##### 140字を超えない文の生成

```py
print(text_model.make_short_sentence(140))
```

##### 文章が短かったりしてエラーが出るとき

```py
print(text_model.make_short_sentence(tries=100))
```


## モデルの合成

```py
model_a = markovify.Text(text_a)
model_b = markovify.Text(text_b)

model_combo = markovify.combine([model_a, model_b], [1.5, 1])
```

`[1.5, 1]` は各モデルの比重。デフォルトはすべて等しい（`[1, 1, ...]`）


# コード

```app.py
import markovify
import MeCab

# Load file
text_file = open("input.txt", "r")
text = text_file.read()

# Parse text using MeCab
parsed_text = MeCab.Tagger('-Owakati').parse(text)

# Build model
text_model = markovify.Text(parsed_text, state_size=2)

# Output
for _ in range(10):
    sentence = text_model.make_short_sentence(100, 20, tries=100).replace(' ', '')
    print(sentence)
```

# 学習データ

上のファイルを保存したのと同じディレクトリに `input.txt` という名前で保存します。

今回は、青空文庫のみんな大好き『走れメロス』を使用しました。
https://www.aozora.gr.jp/cards/000035/files/1567_14913.html
スクレイピングするのも面倒だったので手動でコピー&ペーストしました。

その時にルビ(ふりがな)が邪魔だったのでブラウザのコンソールでこれを実行：

```js
Array.from(document.querySelectorAll('rt')).forEach(el => el.remove());
```

# 結果

1番上の結果。

>**"メロスは、徐々に釣り上げられている。私は約束をさえ忘れていた。"**

>**"メロスは村を出て仕事をはじめていた。メロスは村で結婚式を挙げる。早いほうがよかろう。"**

>**"メロスは悪びれずに答えた。「この短刀で何をするのだ。メロスは、夢見心地でいた。"**

>**"メロスは、きっと佳い夫婦になるだろう。そうして質問を重ねた。眼が覚めたのだ。私は王の前に引き出された。"**

>**"メロスの懐中からは短剣が出て仕事をはじめた。"**

---

あれ、これすべての文が「メロス」から始まっている…？

これは文章全体が1文として認識されているからですね。
英語は `.` を文の切れ目と認識しますが日本語の `。` は認識されないからのようです。

# 修正

```markov.py
import logging
import markovify
import MeCab
import re

logger = logging.getLogger(__name__)
fmt = "%(asctime)s %(levelname)s %(name)s :%(message)s"
logging.basicConfig(level=logging.DEBUG, format=fmt)

# Toggle test_sentence_input
test_sentence_input = markovify.Text.test_sentence_input  # Stash
def disable_test_sentence_input():
    def do_nothing(self, sentence):
        return True
    markovify.Text.test_sentence_input = do_nothing
def enable_test_sentence_input():
    markovify.Text.test_sentence_input = test_sentence_input

def format_text(t):
    t = t.replace('　', ' ')  # Full width spaces
    # t = re.sub(r'([。．！？…]+)', r'\1\n', t)  # \n after ！？
    t = re.sub(r'(.+。) (.+。)', r'\1 \2\n', t)
    t = re.sub(r'\n +', '\n', t)  # Spaces
    t = re.sub(r'([。．！？…])\n」', r'\1」 \n', t)  # \n before 」
    t = re.sub(r'\n +', '\n', t)  # Spaces
    t = re.sub(r'\n+', r'\n', t).rstrip('\n')  # Empty lines
    t = re.sub(r'\n +', '\n', t)  # Spaces
    return t

def parse_text(filepath):
    file = open(filepath, 'r').read()

    parsed_text = ''
    for line in file.split("\n"):
        parsed_text = parsed_text + MeCab.Tagger('-Owakati').parse(line)
    return parsed_text

def build_model(text, format=True, state_size=2):
    """
    format=True: Fast.
    format=False: Slow. Funnier(?)
    """
    if format is True:
        logger.info('Format: True')
        return markovify.NewlineText(format_text(text), state_size)
    else:
        logger.info('Format: False')
        disable_test_sentence_input()
        text = markovify.Text(text, state_size)
        enable_test_sentence_input()
        return text

def make_sentences(text, start=None, max=300, min=1, tries=100):
    if start is (None or ''):   # If start is not specified
        for _ in range(tries):
            sentence = str(text.make_sentence()).replace(' ', '')
            if sentence and len(sentence) <= max and len(sentence) >= min:
                return sentence
    else:  # If start is specified
        for _ in range(tries):
            sentence = str(text.make_sentence_with_start(beginning=start)).replace(' ', '')
            if sentence and len(sentence) <= max and len(sentence) >= min:
                return sentence
```

```learn.py
"""
python learn.py <filename> [format] [max_chars] [min_chars]
filename: Do not include .txt or .json etc.
"""

import markov
import logging
import sys
from distutils.util import strtobool

logger = logging.getLogger(__name__)
fmt = "%(asctime)s %(levelname)s %(name)s :%(message)s"
logging.basicConfig(level=logging.DEBUG, format=fmt)

args = sys.argv

print('Usage: python learn.py <filename> [format] [max_chars] [min_chars]')

try:
    filename = args[1]
except IndexError:
    print('ERROR: filename is required. (e.g. "sample")')
    sys.exit()

format = bool(strtobool(args[2])) if args[2:3] else True
max_chars = int(args[3]) if args[3:4] else 70
min_chars = int(args[4]) if args[4:5] else 25


"""
1. Load text -> Parse text using MeCab
"""
parsed_text = markov.parse_text('data/' + filename + '.txt')
logger.info('Parsed text.')


"""
2. Build model
"""
text_model = markov.build_model(parsed_text, format=format, state_size=2)
logger.info('Built text model.')

json = text_model.to_json()
open('data/' + filename + '.json', 'w').write(json)

# Load from JSON
# json = open('input.json').read()
# text_model = markovify.Text.from_json(json)


"""
3. Make sentences
"""
try:
    for _ in range(10):
        sentence = markov.make_sentences(text_model, start='', max=max_chars, min=min_chars)
        logger.info(sentence)
except KeyError:
    logger.error('KeyError: No sentence starts with "start".')
    logger.info('If you set format=True, please change "start" to another word.')
    logger.info('If you set format=False, you cannot specify "start".')
```

めっちゃ長くなってしまいましたね…
`markov.py`, `learn.py` を保存し、`filename.txt` にテキストデータを入れて、

```sh
$ python learn.py filename
```

で実行できます。
これで「メロス」から始まる文以外も生成できるようになりました。

`learn.py` 下から6行目の `start=''` の `''` に単語を入れると、その単語から始まる文章を生成できます。
試しに「私」を入れてみた結果：

>**"私は、人質としてここにいる！」と、岩の裂目から滾々と、わしの孤独がわからぬ。"**

>**"私は精一ぱいに鳴り響くほど音高くメロスの懐中からは短剣が出て仕事をはじめた。"**

>**"私は、人を殺して、真紅の心臓をお目に殺して、はじめて君を、信ずる事が出来ぬのだ。"**

# あとがき

LINE Bot と組み合わせると自動生成した文章で返信してくれる Bot も作れます。
https://qiita.com/shge/items/06169220a8f55e2ed861

このマルコフ連鎖は文章をつなぎ合わせていっているだけで、本当に学習しているかというと微妙なので、今度はディープラーニングとかやってみたいですね。

# 参考
https://qiita.com/kakakaya/items/38042e807f3410b88b2d
https://stackoverflow.com/questions/13125817/how-to-remove-elements-that-were-fetched-using-queryselectorall