CaboChaでランダムに文章を生成する【Python, 自然言語処理】

142, 2020-12-24

目次

CaboChaで文章を生成する

人間の話す言語を「自然言語」と言います。
自然言語を解析する処理を「自然言語処理」と言います。

スポンサーリンク

自然言語処理は↓のような工程に分かれています。

  • 字句解析
  • 構文解析
  • 意味解析
  • 文脈解析

↑のうち構文解析までをやってくれるPythonのライブラリに「CaboCha(カボチャ)」があります。
今回はこのCaboChaを使って夏目漱石の「こころ」の文章を元にしてランダムに文章を生成するスクリプトを作ってみました。
このスクリプトを使うと↓のような文章を生成することが出来ます。

筆を呼んでいた。
本名は打ち明けない。
方が打ち明けない。
心持はいいたくなる。
本名はいいたくなる。
記憶を事である。
世間をいいたくなる。

生成する文章の精度はそれほど高くありませんが、かなり単純な方法で文章を生成することが可能です。

文章生成の仕組み

日本語の文章の特徴として、「を」や「が」や「は」などの付く単語は主語として成立しやすく、「ない」や「なる」や「いた」などで終わる単語は述語として成立しやすい特徴があります。
CaboChaを使ってこれらの単語を文章から抽出し、そしてそれらの単語を適当に並べることでランダムな文章を生成します。

たとえば↓のような日本語の文章があるとします。

今日は一日中呆けていた。
猫が犬になる。

↑の文章から「は」「が」の付く単語、それから「いた」「なる」の付く単語を抽出すると↓のようなリストになります。

主語: 今日は / 猫が
述語: 呆けていた / 犬になる

そしてこれらの主語と述語をランダムに組み合わせると↓のような文章を新たに生成することが出来ます。

猫が呆けていた。
今日は犬になる。

仕組みとしてはこれだけです。
形容詞などを省いて再構築しているので、生成される文章はひじょうに簡素化されます。
また、組み合わせによっては意味の通らない文章にもなります。

しかし仕組みが単純なため実装がしやすいというメリットがあります。

コード全文

スクリプトのコード全文は↓になります。
ライセンスはMITです。

# coding: utf-8
import CaboCha
import random
import unittest


def read_chunk_tokens(tree, chunk):
    """
    チャンクに所属しているトークン列を取得する
    """
    toks = []
    beg = chunk.token_pos
    end = chunk.token_pos + chunk.token_size

    for i in range(beg, end):
        tok = tree.token(i)
        toks.append(tok)

    return toks


def read_subjects_and_predicates(tree):
    """
    主語と述語のリストを読み込む
    """
    subjects = []
    predicates = []

    def backtrack(i):
        """
        チャンクを持っているトークンまで後方検索する
        """
        while i >= 0:
            tok = tree.token(i)
            if tok.chunk:
                return tok
            i -= 1

    for i in range(tree.size()):
        tok = tree.token(i)
        if tok.surface in ('が', 'は', 'を'):
            bef = backtrack(i)
            toks = read_chunk_tokens(tree, bef.chunk)
            subjects.append(toks)
        elif tok.surface in ('た', 'なる', 'ある', 'ない'):
            bef = backtrack(i)
            toks = read_chunk_tokens(tree, bef.chunk)
            predicates.append(toks)

    return subjects, predicates


def merge_surface(toks):
    """
    トークン列の表層形をマージする
    """
    s = ''
    for tok in toks:
        s += tok.surface
    return s


def gen_sentence(subjects, predicates):
    """
    主語のリストと述語のリストからランダムに文章を生成する
    """
    stoks = random.choice(subjects)
    ptoks = random.choice(predicates)
    s1 = merge_surface(stoks)
    s2 = merge_surface(ptoks)
    return s1 + s2


def main():
    sentence = '私が先生と知り合いになったのは鎌倉である。その時私はまだ若々しい書生であった。暑中休暇を利用して海水浴に行った友達からぜひ来いという端書を受け取ったので、私は多少の金を工面して、出掛ける事にした。私は金の工面に二、三日を費やした。ところが私が鎌倉に着いて三日と経たないうちに、私を呼び寄せた友達は、急に国元から帰れという電報を受け取った。電報には母が病気だからと断ってあったけれども友達はそれを信じなかった。友達はかねてから国元にいる親たちに勧まない結婚を強いられていた。彼は現代の習慣からいうと結婚するにはあまり年が若過ぎた。それに肝心の当人が気に入らなかった。それで夏休みに当然帰るべきところを、わざと避けて東京の近くで遊んでいたのである。彼は電報を私に見せてどうしようと相談をした。私にはどうしていいか分らなかった。けれども実際彼の母が病気であるとすれば彼は固より帰るべきはずであった。それで彼はとうとう帰る事になった。せっかく来た私は一人取り残された。'
    cp = CaboCha.Parser()
    tree = cp.parse(sentence)
    subjects, predicates = read_subjects_and_predicates(tree)

    for _ in range(20):
        result = gen_sentence(subjects, predicates)
        print(result)


class Test(unittest.TestCase):
    def eq(self, a, b, c):
        cp = CaboCha.Parser()
        tree = cp.parse(a)
        subjects, predicates = read_subjects_and_predicates(tree)
        for i in range(len(subjects)):
            s = merge_surface(subjects[i])
            self.assertEqual(s, b[i])
        for i in range(len(predicates)):
            s = merge_surface(predicates[i])
            self.assertEqual(s, c[i])

    def test_method(self):
        self.eq('猫は犬である', ['猫は'], ['犬である'])
        self.eq('可愛い猫は怖い犬である', ['猫は'], ['犬である'])
        self.eq('猫が話した', ['猫が'], ['話した'])
        self.eq('猫が大いに話した', ['猫が'], ['話した'])
        self.eq('猫を可愛がらない', ['猫を'], ['可愛がらない'])
        self.eq('猫が鳥になる', ['猫が'], ['なる'])
        self.eq('猫は眠らない', ['猫は'], ['眠らない'])


main()

コードの解説

コードの解説です。

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

まずコードの先頭で必要モジュールをインポートしておきます。
今回はCaboCharandomモジュールを使います。
それからテスト用にunittestもインポートしておきます。

import CaboCha
import random
import unittest

スポンサーリンク

main関数

まずスクリプトはmain関数から始まります。

def main():
    sentence = '私が先生と知り合いになったのは鎌倉である。その時私はまだ若々しい書生であった。暑中休暇を利用して海水浴に行った友達からぜひ来いという端書を受け取ったので、私は多少の金を工面して、出掛ける事にした。私は金の工面に二、三日を費やした。ところが私が鎌倉に着いて三日と経たないうちに、私を呼び寄せた友達は、急に国元から帰れという電報を受け取った。電報には母が病気だからと断ってあったけれども友達はそれを信じなかった。友達はかねてから国元にいる親たちに勧まない結婚を強いられていた。彼は現代の習慣からいうと結婚するにはあまり年が若過ぎた。それに肝心の当人が気に入らなかった。それで夏休みに当然帰るべきところを、わざと避けて東京の近くで遊んでいたのである。彼は電報を私に見せてどうしようと相談をした。私にはどうしていいか分らなかった。けれども実際彼の母が病気であるとすれば彼は固より帰るべきはずであった。それで彼はとうとう帰る事になった。せっかく来た私は一人取り残された。'
    cp = CaboCha.Parser()
    tree = cp.parse(sentence)
    subjects, predicates = read_subjects_and_predicates(tree)
    result = gen_sentence(subjects, predicates)
    print(result)

main関数内では最初にsentenceに生成する文章のもとになる文章を保存しておきます。これは「こころ」の文章の一文です。
それからCaboCha.Parser()でパーサーを作ります。
そしてパーサーのparse()メソッドに文章を渡し、構文木(tree)を構築します。

構文木を構築したら、その構文木を元に主語のリストと述語のリストを読み込みます。
これはread_subjects_and_predicates()関数で行います。
そして主語と述語のリストから文章を生成します。これにはgen_sentence()関数を使います。
最後にget_sentence()が生成した文章をprint()で出力しておわりです。

read_subjects_and_predicates関数

read_subjects_and_predicates()関数は構文木から主語と述語のリストを生成する関数です。

def read_subjects_and_predicates(tree):
    """
    主語と述語のリストを読み込む
    """
    subjects = []
    predicates = []

    def backtrack(i):
        """
        チャンクを持っているトークンまで後方検索する
        """
        while i >= 0:
            tok = tree.token(i)
            if tok.chunk:
                return tok
            i -= 1

    for i in range(tree.size()):
        tok = tree.token(i)
        if tok.surface in ('が', 'は', 'を'):
            bef = backtrack(i)
            toks = read_chunk_tokens(tree, bef.chunk)
            subjects.append(toks)
        elif tok.surface in ('た', 'なる', 'ある', 'ない'):
            bef = backtrack(i)
            toks = read_chunk_tokens(tree, bef.chunk)
            predicates.append(toks)

    return subjects, predicates

tree.size()でツリーの持つトークン列のサイズを取得できるので、これの数だけfor文を回します。
そしてtree.token(i)でトークンを順に取得していきます。
そのトークンのsurface属性を参照し、「が」「は」「を」だったら主語のリストにトークンを追加し、「た」「なる」「ある」「ない」だったら述語のリストにトークンを追加しています。
surfaceはトークンの表層形を表す属性です。表層形とは元の文章のそのままの表記の文字列です。

キーワードにあてはまったら、backtrack()関数で後方検索します。この関数はトークンのchunk属性が無効なあいだ後方検索を続行します。chunkが見つかったらそのトークンを返します。
トークン列はチャンク(係り受けの関係を表す属性)を持つトークンと持たないトークンが混在しています。
そのためチャンクを持つトークンを取得できるようにしています。これはread_chunk_tokens()を実行するためです。

後方検索したトークンのチャンクをread_chunk_tokens()関数に渡し、そのチャンクが持っているトークン列をまとめて取得します。
そしてそのトークン列をリストに追加して終わりです。

関数の最後では構築したリストを返しています。

read_chunk_tokens関数

read_chunk_tokens()関数はチャンクに属しているトークン列をまとめて取得する関数です。
チャンクはtoken_postoken_size属性を持っています。それぞれツリーにおけるトークンの位置と、トークン列のサイズです。
これをfor文で回すことでツリーからその周辺のトークンを参照することが出来ます。

def read_chunk_tokens(tree, chunk):
    """
    チャンクに所属しているトークン列を取得する
    """
    toks = []
    beg = chunk.token_pos
    end = chunk.token_pos + chunk.token_size

    for i in range(beg, end):
        tok = tree.token(i)
        toks.append(tok)

    return toks

get_sentence関数

get_sentence()関数は引数の主語のリスト(subjects)と述語のリスト(predicates)から文章をランダムに生成する関数です。
random.choice()関数でリストからランダムなトークン列を取得します。
そのトークン列の表層形をmerge_surface()関数でマージして、最後に結合して返しています。

def gen_sentence(subjects, predicates):
    """
    主語のリストと述語のリストからランダムに文章を生成する
    """
    stoks = random.choice(subjects)
    ptoks = random.choice(predicates)
    s1 = merge_surface(stoks)
    s2 = merge_surface(ptoks)
    return s1 + s2

merge_surface関数

merge_surface関数はトークン列の表層形(surface)をマージして1つの文字列にします。

def merge_surface(toks):
    """
    トークン列の表層形をマージする
    """
    s = ''
    for tok in toks:
        s += tok.surface
    return s

テスト

簡易的ですがテストを書きます。
このテストは主語のリストと述語のリストのマージした表層形を比較するものです。

class Test(unittest.TestCase):
    def eq(self, a, b, c):
        cp = CaboCha.Parser()
        tree = cp.parse(a)
        subjects, predicates = read_subjects_and_predicates(tree)
        for i in range(len(subjects)):
            s = merge_surface(subjects[i])
            self.assertEqual(s, b[i])
        for i in range(len(predicates)):
            s = merge_surface(predicates[i])
            self.assertEqual(s, c[i])

    def test_method(self):
        self.eq('猫は犬である', ['猫は'], ['犬である'])
        self.eq('可愛い猫は怖い犬である', ['猫は'], ['犬である'])
        self.eq('猫が話した', ['猫が'], ['話した'])
        self.eq('猫が大いに話した', ['猫が'], ['話した'])
        self.eq('猫を可愛がらない', ['猫を'], ['可愛がらない'])
        self.eq('猫が鳥になる', ['猫が'], ['なる'])
        self.eq('猫は眠らない', ['猫は'], ['眠らない'])

実行結果

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

> python -m unittest myscript
彼は帰るべきはずであった。
友達は帰るべきはずであった。
なったのは信じなかった。
私が費やした。
彼は費やした。
友達は、遊んでいたのである。
彼は分らなかった。
私はなった。
年がした。
ところを、受け取ったので、
端書を受け取った。
母がなったのは
友達は、来た
私が気に入らなかった。
私が断ってあったけれども
三日を病気であると
金を気に入らなかった。
母が行った
私は行った
彼は取り残された。
.
----------------------------------------------------------------------
Ran 1 test in 0.024s

OK

結果を見ると言い切りの形でない文章も生成されています。
言い切りの形に整えたい場合は句読点の考慮などが必要になりそうです。
↓などは特に自然な文章になってますね。

彼は帰るべきはずであった。
友達は帰るべきはずであった。
友達は、遊んでいたのである。
彼は分らなかった。

キーワードを増やせばさらに生成する文章のバリエーションを増やせると思います。

おわりに

今回は構文解析器のCaboChaを使って文章の生成にチャレンジしてみました。
CaboChaを使うと比較的に簡単に単語間の関係を表現することが出来ます。
コードのライセンスはMITですので自由に改造などしてください。

文章の生成への飽くなきチャレンジ精神

スポンサーリンク

投稿者名です。64字以内で入力してください。

必要な場合はEメールアドレスを入力してください(全体に公開されます)。

投稿する内容です。