マルコフ連鎖で自動文章生成をする【Pythonによる自然言語処理超入門】

Python

15Stepで踏破自然言語処理アプリケーション開発入門 PythonとKerasで基礎から一巡/土屋祐一郎【3000円以上送料無料】

 

こんにちは、monachan_papa です!

今回はマルコフ連鎖を使った、文章自動生成をします。文章、連鎖という言葉から何となく、単語をつなげていくというのだけはイメージがつくかと思います。マルコフ連鎖を使うと、楽しいことが色々できます。とても面白い。

何が一番楽しいか?!

言葉あそびができます!

マルコフ連鎖とは

ざっくりとした定義

マルコフ連鎖は AI、機械学習分野においてよく使われる技術のひとつです。
自然言語処理超入門的に定義をめちゃくちゃ簡単に言えばこうなります。

過去は全く関係ねぇ!現在だけで未来は決まる!

何かすごいイイ響きです。ロック精神みたいだ。しかしながら、本当にそうなんです。
では、カッコいい言葉を使わず、もう少し詳しく説明します。
マルコフ連鎖は 確率を用いた状態遷移のアルゴリズムです。これを文章自動生成というテーマに即して言うと、「現在そこに存在する単語の中から、次に連鎖する単語を(未来)を、確率を使って決めていく」 となります。

イメージ

簡単な例文で、イメージさせてみます。以下のように分解された2つの文章があったとします。
これらを使って、新しい文章を作ってみます。

  • マスク / する / なぜなら / マスク警察 / うるさい
  • マスク / する / なぜなら / 花粉症 / 防止

表にするとこんな感じです。

原文No 単語1 単語2 単語3 単語4 単語5
1 マスク する なぜなら マスク警察 うるさい
2 マスク する なぜなら 花粉症 防止

このとき、単語1から単語5へ向かって(現在から未来へ向かう)、文を作ろうとすると、以下のような文ができそうです。表にするとこんな感じです。

作成No 単語1 単語2 単語3 単語4 単語5
1 マスク する なぜなら マスク警察 うるさい
2 マスク する なぜなら マスク警察 防止
3 マスク する なぜなら 花粉症 うるさい
4 マスク する なぜなら 花粉症 防止

ツリー形式で示すとこんな感じです。

マスク
└── する
    └── なぜなら
        ├── マスク警察
        │   ├── うるさい
        │   └── 防止
        └── 花粉症
            ├── うるさい
            └── 防止

さてここで!
「マスク する なぜなら 花粉症 うるさい」という文は、いかにもおかしな文章です。「花粉症」までは、まぁいいでしょう。しかし、「花粉症 うるさい」はもう錯乱していますよね、文学的です!
これはなぜか。定義通りに過去の状態を全く考慮していないし、「花粉症」に連鎖するのは、「うるさい」、「防止」という単語から2分の1の確率だけで決まるからです。

作成手順概要

さぁ、簡単なイメージがつかめたところで、Python を使って実装していきます。マルコフ連鎖自体はmarkovify という専用ライブラリがあるので、それを使えば簡単にできてしまいます。しかし、今回はマルコフ連鎖の仕組みを体感をするために、専用ライブラリを使わないで実装します。

そして、今回は3階マルコフ連鎖といって、「2単語で1つのペア」になっている単語に対し、連鎖する1単語を決めるスタイルで進めます。これはいわゆる、N階マルコフ連鎖と呼ばれていて、Nが大きいほど、まともな文章、お利口さんな文章になります。
反対にNが小さいほど、錯乱した文章、愉快な文章ができます。しかしながら、そもそも、モデルとなる文章の良し悪し、文章量にも左右されますがね。

作成について、大きく分けると以下の通りです。
ソースについては、Jupyter Lab などで逐次、動かしながらじっくり確認できる構成にしてあります。
最後のまとめでは、すべて関数化してあるので、サクッと全容だけを見たい御方はいきなり、まとめに飛んでもらうのも良いかと思います。

  • 各種モジュール
  • テストデータの わかち書き、加工
  • 単語とライグラム作成とその出現回数辞書作成
  • マルコフ連鎖用の辞書作成
  • 文章開始単リストとその重みリスト作成
  • 文章生成
  • まとめ -関数化、指定回数 文章生成-

また、これまでにこの自然言語処理超入門で扱ったテーマもいくつか登場するので、もし難しいと感じたら過去のテーマも参考にしてください。

自然言語処理とは?【Pythonによる自然言語処理超入門】

男は黙ってサッポロビールを形態素解析してみよう!【Pythonによる自然言語処理超入門】

川端康成『雪国』の冒頭を形態素解析してみよう!【Pythonによる自然言語処理超入門】

MeCab辞書カスタムで『恋の鶴舞線』の歌詞を、でらええ感じに形態素解析する【Pythonによる自然言語処理超入門】

betenya駒子さん作詞『恋の鶴舞線』を共起ネットワークで可視化してみた【Pythonによる自然言語処理超入門】

単語N-gram の基礎理解【Pythonによる自然言語処理超入門】

各種モジュール

機能は上から順に、以下の通りです。インポートします!

  • わかち書き用
  • 乱数生成用
  • 単語トライグラム作成用
  • 辞書操作を便利にする用
import MeCab
import random
from nltk import ngrams
from collections import Counter, defaultdict

テストデータの わかち書き、加工

テストデータは、私が適当に作った文章と、美空ひばりさんの『悲しい酒』の歌詞冒頭を連結した文章を使います。
osake.txt に以下が記述してあります。


酒を1本飲む
2本も飲むと楽しい
5本も飲むと悲しい
酒は楽しい
酒は悲しい
悲しい酒は美空ひばり

ひとり酒場で飲む酒は
別れ涙の味がする
飲んで棄てたい面影が
飲めばグラスにまた浮かぶ


 

では、とにもかくにも、わかち書き!行単位ごとに、わかち書きします。
ポイントとなるのは、わかち書きの後、各行の先頭と末尾に目印を付与することです。目印は何でも良いですが、分かりやすいように、先頭は__BEGIN__、末尾は__END__としました。
この後、現在から未来に向かうにあたり、この目印があると色々、便利なのです。

# テストデータ読み込み
with open('osake.txt', 'r') as f:
    lines = f.readlines()

# わかち書き
t = MeCab.Tagger('-Owakati')
datas = []
for line in lines:
    data = t.parse(line).strip()
    datas.append(data)

datas
['酒 を 1 本 飲む 。',
 '2 本 も 飲む と 楽しい 。',
 '5 本 も 飲む と 悲しい 。',
 '酒 は 楽しい 。',
 '酒 は 悲しい 。',
 '悲しい 酒 は 美空 ひばり 。']
# 各行の先頭と末尾に目印を付与
datas = [f'__BEGIN__ {data} __END__' for data in datas]
datas = [data.split() for data in datas]
datas
[['__BEGIN__', '酒', 'を', '1', '本', '飲む', '。', '__END__'],
 ['__BEGIN__', '2', '本', 'も', '飲む', 'と', '楽しい', '。', '__END__'],
 ['__BEGIN__', '5', '本', 'も', '飲む', 'と', '悲しい', '。', '__END__'],
 ['__BEGIN__', '酒', 'は', '楽しい', '。', '__END__'],
 ['__BEGIN__', '酒', 'は', '悲しい', '。', '__END__'],
 ['__BEGIN__', '悲しい', '酒', 'は', '美空', 'ひばり', '。', '__END__']]

単語トライグラム作成、3単語とその出現回数辞書作成

概要のところで、3階マルコフ連鎖は「2単語で1つのペア」になっている単語に対し、連鎖する1単語を決めていくこと、だと述べました。そのための事前準備として、まずは文章を単語トライグラム化していきます。(単語トライグラムについては、過去に 単語N-gramの基礎理解 で紹介しました。)

行ごとにトライグラム作成し、最終的に1つに連結します。
3単語とその出現回数辞書作成は、collectionモジュールのCouterクラスを使えば、瞬殺です!
そして、この出現回数というのが、現在から未来の単語を決めていくことに、この後重要な役割を果たします。

# 行ごとにトライグラム作成し、1つに連結
words = []
for data in datas:
    words.extend(list(ngrams(data, 3)))

# トライグラム
print(words)

# 3単語とその出現回数辞書
words_cnt_dic = Counter(words)
words_cnt_dic
[('__BEGIN__', '酒', 'を'), ('酒', 'を', '1'), ('を', '1', '本'), ('1', '本', '飲む'), ('本', '飲む', '。'), ('飲む', '。', '__END__'), ('__BEGIN__', '2', '本'), ('2', '本', 'も'), ('本', 'も', '飲む'), ('も', '飲む', 'と'), ('飲む', 'と', '楽しい'), ('と', '楽しい', '。'), ('楽しい', '。', '__END__'), ('__BEGIN__', '5', '本'), ('5', '本', 'も'), ('本', 'も', '飲む'), ('も', '飲む', 'と'), ('飲む', 'と', '悲しい'), ('と', '悲しい', '。'), ('悲しい', '。', '__END__'), ('__BEGIN__', '酒', 'は'), ('酒', 'は', '楽しい'), ('は', '楽しい', '。'), ('楽しい', '。', '__END__'), ('__BEGIN__', '酒', 'は'), ('酒', 'は', '悲しい'), ('は', '悲しい', '。'), ('悲しい', '。', '__END__'), ('__BEGIN__', '悲しい', '酒'), ('悲しい', '酒', 'は'), ('酒', 'は', '美空'), ('は', '美空', 'ひばり'), ('美空', 'ひばり', '。'), ('ひばり', '。', '__END__')]


Counter({('__BEGIN__', '酒', 'を'): 1,
         ('酒', 'を', '1'): 1,
         ('を', '1', '本'): 1,
         ('1', '本', '飲む'): 1,
         ('本', '飲む', '。'): 1,
         ('飲む', '。', '__END__'): 1,
         ('__BEGIN__', '2', '本'): 1,
         ('2', '本', 'も'): 1,
         ('本', 'も', '飲む'): 2,
         ('も', '飲む', 'と'): 2,
         ('飲む', 'と', '楽しい'): 1,
         ('と', '楽しい', '。'): 1,
         ('楽しい', '。', '__END__'): 2,
         ('__BEGIN__', '5', '本'): 1,
         ('5', '本', 'も'): 1,
         ('飲む', 'と', '悲しい'): 1,
         ('と', '悲しい', '。'): 1,
         ('悲しい', '。', '__END__'): 2,
         ('__BEGIN__', '酒', 'は'): 2,
         ('酒', 'は', '楽しい'): 1,
         ('は', '楽しい', '。'): 1,
         ('酒', 'は', '悲しい'): 1,
         ('は', '悲しい', '。'): 1,
         ('__BEGIN__', '悲しい', '酒'): 1,
         ('悲しい', '酒', 'は'): 1,
         ('酒', 'は', '美空'): 1,
         ('は', '美空', 'ひばり'): 1,
         ('美空', 'ひばり', '。'): 1,
         ('ひばり', '。', '__END__'): 1})

マルコフ連鎖用の辞書作成

ここからマルコフ連鎖の要となる辞書を作っていきます。

key

2単語で1つペアの単語(タプル)を設定します。

value

連鎖して次に来る1単語と、その重みをそれぞれ key とした辞書で設定します。

辞書の設定例

{(‘M’, ‘A’): {‘words’: [‘C’, ‘D’], ‘weights’: [2, 1]}}

重みについて

重み というのは、重要度のことです。上記の設定例の場合、「MA」という単語に連鎖するのは「C」の方が重要度が高いということになります。
つまり、確率上「MAC」という単語が決定されやすくなるというわけです。

# 空のマルコフ辞書
m_dic = {}
for k, v in words_cnt_dic.items():
    # 先頭2単語、その次の単語
    two_words, next_word = k[:2], k[2]

    # 存在しなければ作る
    if two_words not in m_dic:
        m_dic[two_words] = {'words': [], 'weights': []}

    # 先頭2単語に対し、次に来る単語とその重み
    m_dic[two_words]['words'].append(next_word)
    m_dic[two_words]['weights'].append(v)

m_dic    
{('__BEGIN__', '酒'): {'words': ['を', 'は'], 'weights': [1, 2]},
 ('酒', 'を'): {'words': ['1'], 'weights': [1]},
 ('を', '1'): {'words': ['本'], 'weights': [1]},
 ('1', '本'): {'words': ['飲む'], 'weights': [1]},
 ('本', '飲む'): {'words': ['。'], 'weights': [1]},
 ('飲む', '。'): {'words': ['__END__'], 'weights': [1]},
 ('__BEGIN__', '2'): {'words': ['本'], 'weights': [1]},
 ('2', '本'): {'words': ['も'], 'weights': [1]},
 ('本', 'も'): {'words': ['飲む'], 'weights': [2]},
 ('も', '飲む'): {'words': ['と'], 'weights': [2]},
 ('飲む', 'と'): {'words': ['楽しい', '悲しい'], 'weights': [1, 1]},
 ('と', '楽しい'): {'words': ['。'], 'weights': [1]},
 ('楽しい', '。'): {'words': ['__END__'], 'weights': [2]},
 ('__BEGIN__', '5'): {'words': ['本'], 'weights': [1]},
 ('5', '本'): {'words': ['も'], 'weights': [1]},
 ('と', '悲しい'): {'words': ['。'], 'weights': [1]},
 ('悲しい', '。'): {'words': ['__END__'], 'weights': [2]},
 ('酒', 'は'): {'words': ['楽しい', '悲しい', '美空'], 'weights': [1, 1, 1]},
 ('は', '楽しい'): {'words': ['。'], 'weights': [1]},
 ('は', '悲しい'): {'words': ['。'], 'weights': [1]},
 ('__BEGIN__', '悲しい'): {'words': ['酒'], 'weights': [1]},
 ('悲しい', '酒'): {'words': ['は'], 'weights': [1]},
 ('は', '美空'): {'words': ['ひばり'], 'weights': [1]},
 ('美空', 'ひばり'): {'words': ['。'], 'weights': [1]},
 ('ひばり', '。'): {'words': ['__END__'], 'weights': [1]}}

文章開始単語リストとその重みリスト作成

文章を自動生成するといっても、意味をもった文章を生成するためには、モデルとなる文章の開始単語がしっかりと分かっていないといけません。例えば、助詞から始まってしまう文章を作ってしまったら、どうなるでしょうか?どう考えてもおかしいですよね。
開始単語は3単語とその出現回数辞書である 変数words_cnt_dic から、その情報を得ることができます。
開始単語の目印をもとに、リストを作っていきます。

collectionモジュールのdefaultdictクラスが使われていますが、このクラスを使うと事前にキーが存在するかチェックするコードを省くことができます。

# 文章開始単語リストとその重みリスト作成
begin_words_dic = defaultdict(int) 
for k, v in words_cnt_dic.items():
    if k[0] == '__BEGIN__':
        next_word = k[1]
        begin_words_dic[next_word] = v

begin_words = [k for k in begin_words_dic.keys()]
begin_weights = [v for v in begin_words_dic.values()]

print(begin_words)
print(begin_weights)
['酒', '2', '5', '悲しい']
[2, 1, 1, 1]

 


15Stepで踏破自然言語処理アプリケーション開発入門 PythonとKerasで基礎から一巡/土屋祐一郎【3000円以上送料無料】

 

文章生成

やっと、最後まで来ました!ここまで来たら、あとは気合いのみです。

文章生成に必要な準備が整ったので、あとは生成するだけになりました。
基本的な流れは以下の通りです。

  1. random.choices関数で開始単語を抽選する(開始単語リストとその重みリストから)
  2. 作成文章格納用のリスト変数にセット(初期値は[開始目印, 取得した開始単語])
  3. ↓↓↓↓↓↓↓↓↓↓ 以降を終了目印が出るまで行う ↓↓↓↓↓↓↓↓↓↓
  4. マルコフ辞書の key に作成文章格納リスト変数の後方2単語を指定し、value 取得
  5. 次の単語を random.choices関数で、上記の取得値を使って抽選する
  6. 取得単語を作成文章格納用リスト変数に追加

randomモジュールのchoices関数について

random.chioces(シーケンス, weights=重みリスト, k=抽選数)

第1引数にシーケンス、第2引数に重みリスト、第3引数に抽選数を指定します。
この関数の戻り値はリストになります。よって、インデックス0を指定することで、単なる文字列として結果を得ることができます。

# 開始単語の抽選
begin_word = random.choices(begin_words, weights=begin_weights, k=1)[0]
begin_word
'酒'
# 作成文章の格納
sentences = ['__BEGIN__', begin_word]
sentences
['__BEGIN__', '酒']
# 作成文章の後方2単語をもとに、次の単語を抽選する
while True:
    # 後方2単語
    back_words = tuple(sentences[-2:])

    # マルコフ用辞書から抽選
    words, weights = m_dic[back_words]['words'], m_dic[back_words]['weights']
    next_word = random.choices(words, weights=weights, k=1)[0]

    # 終了の目印が出たら抜ける
    if next_word == '__END__':
        break

    # 取得単語を追加
    sentences.append(next_word)

# 開始マークより後ろを連結
''.join(sentences[1:])
'酒は悲しい。'

まとめ -関数化、指定回数 文章生成-

これですべて実装が終わりました。
以降は、これまでの処理をすべて関数化して且つ、文章生成を指定回数実行できるようにしたものです。

# 各種モジュール
import MeCab
import random
from nltk import ngrams
from collections import Counter, defaultdict
# テストデータのわかち書き、加工
def parse_words(test_data):

    # テストデータ読み込み
    with open(test_data, 'r') as f:
        lines = f.readlines()

    # わかち書き
    t = MeCab.Tagger('-Owakati')
    datas = []
    for line in lines:
        data = t.parse(line).strip()
        datas.append(data)

    # 各行の先頭と末尾に目印を付与
    datas = [f'__BEGIN__ {data} __END__' for data in datas]
    datas = [data.split() for data in datas]    

    return datas
# 3単語とその出現回数辞書作成
def create_words_cnt_dic(datas):

    # 行ごとにトライグラム作成し、1つに連結
    words = []
    for data in datas:
        words.extend(list(ngrams(data, 3)))

    # 3単語とその出現回数辞書
    words_cnt_dic = Counter(words)

    return words_cnt_dic    
# マルコフ用辞書
def create_m_dic(words_cnt_dic):

    # 空のマルコフ辞書
    m_dic = {}
    for k, v in words_cnt_dic.items():
        # 先頭2単語、その次の単語
        two_words, next_word = k[:2], k[2]

        # 存在しなければ作る
        if two_words not in m_dic:
            m_dic[two_words] = {'words': [], 'weights': []}

        # 先頭2単語に対し、次に来る単語とその重み
        m_dic[two_words]['words'].append(next_word)
        m_dic[two_words]['weights'].append(v)

    return m_dic    
# 文章開始単語リストとその重みリスト作成
def create_begin_words_weights(words_cnt_dic):

    begin_words_dic = defaultdict(int) 
    for k, v in words_cnt_dic.items():
        if k[0] == '__BEGIN__':
            next_word = k[1]
            begin_words_dic[next_word] = v

    begin_words = [k for k in begin_words_dic.keys()]
    begin_weights = [v for v in begin_words_dic.values()]

    return begin_words, begin_weights
# 文章生成
def create_sentences(m_dic, begin_words, begin_weights):

    # 開始単語の抽選
    begin_word = random.choices(begin_words, weights=begin_weights, k=1)[0]

    # 作成文章の格納
    sentences = ['__BEGIN__', begin_word]

    # 作成文章の後方2単語をもとに、次の単語を抽選する
    while True:
        # 後方2単語
        back_words = tuple(sentences[-2:])

        # マルコフ用辞書から抽選
        words, weights = m_dic[back_words]['words'], m_dic[back_words]['weights']
        next_word = random.choices(words, weights=weights, k=1)[0]

        # 終了の目印が出たら抜ける
        if next_word == '__END__':
            break

        # 取得単語を追加
        sentences.append(next_word)

    # 開始マークより後ろを連結
    return ''.join(sentences[1:])    
# テストデータのわかち書き、加工
datas = parse_words('osake.txt')

# 3単語の出現回数辞書作成
words_cnt_dic = create_words_cnt_dic(datas)

# マルコフ用辞書作成
m_dic = create_m_dic(words_cnt_dic)

# 開始単語とその重みリスト作成
begin_words, begin_weights = create_begin_words_weights(words_cnt_dic)

# 20回 文章生成
for i in range(20):
    text = create_sentences(m_dic, begin_words, begin_weights)
    print(str(i).zfill(2), text, sep=': ')
00: 別れ涙の味がする
01: 5本も飲むと悲しい
02: 酒を1本飲む
03: 酒は美空ひばり
04: 飲めばグラスにまた浮かぶ
05: 2本も飲むと楽しい
06: 悲しい酒は
07: 飲んで棄てたい面影が
08: 飲めばグラスにまた浮かぶ
09: 2本も飲むと楽しい
10: 5本も飲むと楽しい
11: 悲しい酒は
12: 別れ涙の味がする
13: 飲めばグラスにまた浮かぶ
14: ひとり酒場で飲む酒は
15: 悲しい酒は美空ひばり
16: 悲しい酒は楽しい
17: 悲しい酒は美空ひばり
18: 別れ涙の味がする
19: ひとり酒場で飲む酒は楽しい

 

3階マルコフ連鎖で、20回文章生成しました。いかがでしたでしょうか?わりかし、まともな文章です。
モデルとなる文章をもっとたくさん使うと楽しくなりますよ!やはり、楽しく学ぶというのはとても大切です。今回は文章生成でしたが、楽しみがもっと欲しい御方には、15Stepで踏破自然言語処理アプリケーション開発入門 PythonとKerasで基礎から一巡/土屋祐一郎【3000円以上送料無料】が大変おすすめです。対話エージェントといういわゆる作品を作りながら自然言語処理が学べて、一石二鳥です。


15Stepで踏破自然言語処理アプリケーション開発入門 PythonとKerasで基礎から一巡/土屋祐一郎【3000円以上送料無料】

 

また、爆速で 自然言語処理 を学びたい御方にはプログラミングスクールを考えるのも、ひとつの手です。独学よりも効果が出やすいですが、いかんせん投資がけっこうかかります。しかし、techgymというスクールは通うか通わないかは別として、無料のサンプルテキスト&解説動画がもらえます。これをとりあえず getしてまずは試しに体験学習するのもありでしょう。

コメント

タイトルとURLをコピーしました