Janomeで親父ギャグメーカーを作る【Python, 自然言語処理】

147, 2020-12-30

目次

Janomeで親父ギャグメーカーを作る

人間の話す言語はなんというのでしょうか? これは「自然言語」と呼ばれています。
この自然言語をプログラム的に解析する処理を「自然言語処理」と言います。
自然言語処理には色々な工程がありますが、その中に「形態素解析」という工程があります。
Pythonにはこの形態素解析を行うライブラリ「Janome」があります。

今回はこのJanomeを使って日本語の文章から親父ギャグのリストを表示するというスクリプトを作ってみました。
精度的にはけっこうゆるい作りになってますがあらかじめご了承ください。

今回はこの親父ギャグメーカーについて↓を見ていきます。

  • 形態素解析とは?
  • Janomeとは?
  • 親父ギャグの定義
  • 親父ギャグメーカーを作る
  • 親父ギャグメーカーを使う

形態素解析とは?

自然言語処理の工程の1つである「形態素解析」とはいったいどんな処理なのでしょうか?
これは日本語の文章を単語のリストに分割する処理です。

自然言語処理では基本となる単位が文章の「単語」です。
この単語を基本的に1単位として解析します。
そのため文章を単語のリストに変換するというのは、さまざまな工程で必要となる工程になります。

たとえば英語は、単語がスペースで区切られています。そのためスペースでチョップすれば文章を単語のリストに変換することが出来ます。
これは↓のような文章を

I have a pen

↓のような単語のリストに変換するということです。

I / have / a / pen

英語はこのように簡単に単語に変換できますが、日本語の文章の場合はそうもいきません。
たとえば「太郎は学校に行った」という文章の単語はスペースなどで区切られていないため、これを単語のリストに変換するには別の方法が必要です。

形態素解析はこのような日本語の文章を辞書を使って解析します。
単語と辞書を照らし合わせながら解析して行って、単語を保存していきます。
こうすることで例えば↓のような日本語の文章も

太郎は学校に行った

↓のように単語のリストに変換することが出来ます。

太郎 / は / 学校 / に / 行った

Janomeとは?

この形態素解析を行えるPythonのライブラリが「Janome(ジャノメ)」です。

形態素解析を行うライブラリには他にも「MeCab(メカブ)」などが有名です。
JanomeはMeCabの辞書を使っています。
速度的にはJanomeはMeCabに劣りますが、MeCabと同精度の解析を行えると言われています。
そのため自然言語処理の学習用途などで最近人気が高まってきているライブラリです。

Janomeを使うにはpipなどで環境にJanomeをインストールしておく必要があります。
インストールは↓のように行います。

> pip install Janome

親父ギャグの定義

今回スクリプトで扱う親父ギャグの定義ですが、↓のように定義したいと思います。

  • Aの単語の末尾の読み方が、Bの単語の先頭の読み方に一致していたら親父ギャグ

これはつまり、「階段」という単語があるとします。
この単語の読み方は「カイダン」です。
もう1つの単語は「ダンス」です。

「ダンス」の先頭の「ダン」は「階段」の末尾の「ダン」に含まれています。
そのため「階段」と「ダンス」は親父ギャグの関係と言えます。

これを親父ギャグ表記で表すと「カイダンス」という表記になります。
今回のスクリプトではこのような出力を親父ギャグと言い張りたいと思います。

リンゴリラとかね

親父ギャグというか語呂合わせ?

親父ギャグの世界は広い

高度な親父ギャグについては今回のスクリプトでは出力できません。
たとえば「トンネルで豚が寝てるよ → 豚寝る」など、こういった高度な表現はいまのところ不可能です。
これを実現するにはおそらく機械学習が必要と思われます。

親父ギャグメーカーを作る

それでは親父ギャグメーカーを作ります。
コード全文は↓になります。

# coding: utf-8
from janome.analyzer import Analyzer
from janome.tokenfilter import POSKeepFilter


def is_in(toks, tok):
    """
    toksの中にtokが含まれていたらTrue, でなければFalse
    """
    for t in toks:
        if t.surface == tok.surface:
            return True
    return False


def unique(toks):
    """
    toksから重複したトークンを除外する
    """
    dst = []
    for tok in toks:
        if not is_in(dst, tok):
            dst.append(tok)
    return dst


def analyze(text):
    """
    textを解析してトークン列にする
    """
    token_filters = [POSKeepFilter(['名詞'])]
    a = Analyzer(token_filters=token_filters)
    toks = a.analyze(text)
    return unique(toks)


def find_matchs(toks, tok, min_match_len=0):
    """
    tokにマッチするトークンをtoksから探す
    """
    dst = []

    for t in toks:
        if id(tok) == id(t):
            continue
        if tok.reading == t.reading:
            continue

        i = len(tok.reading) - 1
        found = False
        while i >= 0:
            if tok.reading[i] == t.reading[0]:
                found = True
                break
            i -= 1
        if not found:
            continue

        j = 0
        while i + j < len(tok.reading) and j < len(t.reading):
            if tok.reading[i + j] != t.reading[j]:
                break
            j += 1
        if j < min_match_len:
            continue
        if i + j >= len(tok.reading):
            dst.append(t)

    return dst


def build(toks, min_reading=0, min_match_len=0):
    """
    データを構築する

    @param {int} min_reading 読み方の最小文字数
    @param {int} min_match_len マッチする最小文字数
    """
    data = []

    for tok in toks:
        m = {'tok': None, 'matchs': None}

        if len(tok.reading) <= min_reading:
            continue

        matchs = find_matchs(toks, tok, min_match_len)
        if not len(matchs):
            continue

        m['tok'] = tok
        m['matchs'] = matchs
        data.append(m)

    return data


def show(tok, match):
    """
    トークンとマッチしたトークンを親父ギャグで表示する
    """
    i = len(tok.reading) - 1
    while i >= 0:
        if tok.reading[i] == match.reading[0]:
            break
        i -= 1

    j = 0
    while j < i:
        print(tok.reading[j], end='')
        j += 1

    print(match.reading)



def show_all(data):
    """
    すべてのデータを表示する
    """
    for m in data:
        tok = m['tok']
        matchs = m['matchs']
        print(tok.surface)
        for match in matchs:
            show(tok, match)
        print()


def main():
    text = '''
    吾輩は猫である。名前はまだ無い。
    どこで生れたかとんと見当がつかぬ。...(省略)
    '''
    toks = list(analyze(text))
    data = build(toks, min_reading=1, min_match_len=2)
    show_all(data)


main()

コードの解説は↓になります。

スポンサーリンク

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

今回はJanomeのAnalyzerPOSKeepFilterのみを使います。
↓のようにインポートします。

from janome.analyzer import Analyzer
from janome.tokenfilter import POSKeepFilter

Analyzerは形態素解析を行うパーサーです。
POSKeepFilterAnalyzerに指定できるフィルターです。これに名詞などを指定すると、名詞のみを抽出するAnalyzerを作ることが出来ます。

main関数

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

def main():
    text = '''
    吾輩は猫である。名前はまだ無い。
    どこで生れたかとんと見当がつかぬ。...(省略)
    '''
    toks = list(analyze(text))
    data = build(toks, min_reading=1, min_match_len=2)
    show_all(data)

今回のスクリプトは日本語の文章を元に親父ギャグの一覧を生成します。
その元になる日本語の文章はtext変数に保存しておきます。
今回は夏目漱石の「吾輩は猫である」を使っていますが、↑のコードでは省略しています。

textanalyze()関数に渡して形態素解析を行いトークン列に変換します。
そしてbuild()関数にトークン列を渡して出力データをビルドします。
最後にshow_all()関数でデータを表示して終わりです。

analyze関数

analyze()関数では引数のtextを形態素解析してトークン列に変換します。

def analyze(text):
    """
    textを解析してトークン列にする
    """
    token_filters = [POSKeepFilter(['名詞'])]
    a = Analyzer(token_filters=token_filters)
    toks = a.analyze(text)
    return unique(toks)

token_filtersにリストを保存しておきます。このリストにはPOSKeepFilterのオブジェクトを入れています。
POSKeepFilter()の引数にはリストで文字列の名詞を入れておきます。こうすると名詞のみを抽出するフィルターになります。
あとはこのtoken_filtersAnalyzer()に渡し、Analyzerをオブジェクトにします。
このオブジェクトのanalyze()メソッドにtextを渡し、形態素解析を行います。
そして最後に形態素解析で得たトークン列をunique()関数に渡し、重複したトークンを除去します。
重複したトークンが除かれたトークン列をreturnで返して終わりです。

unique関数

unique()関数は引数toksから重複したトークンを取り除きます。
内部ではis_in()関数を使っていて、これがFalseになるトークンをdstリストに追加していきます。

def unique(toks):
    """
    toksから重複したトークンを除外する
    """
    dst = []
    for tok in toks:
        if not is_in(dst, tok):
            dst.append(tok)
    return dst

is_in関数

is_in()関数は引数toksの中に引数tokが含まれていたらTrueを返し、含まれていなければFalseを返します。
トークンが含まれているかどうかの判定にはトークンの持つsurface属性を使います。
surfaceは表層形と言う元の文章そのままの表記の文字列です。

def is_in(toks, tok):
    """
    toksの中にtokが含まれていたらTrue, でなければFalse
    """
    for t in toks:
        if t.surface == tok.surface:
            return True
    return False

build関数

build関数は引数toksから表示データを構築します。

def build(toks, min_reading=0, min_match_len=0):
    """
    データを構築する

    @param {int} min_reading 読み方の最小文字数
    @param {int} min_match_len マッチする最小文字数
    """
    data = []

    for tok in toks:
        m = {'tok': None, 'matchs': None}

        if len(tok.reading) <= min_reading:
            continue

        matchs = find_matchs(toks, tok, min_match_len)
        if not len(matchs):
            continue

        m['tok'] = tok
        m['matchs'] = matchs
        data.append(m)

    return data

min_reading引数は読み方、つまりトークンのreadingの長さの最小値です。
この値を大きく設定するとreadingが長いトークンが保存されます。

min_match_len引数はマッチする最小文字数です。
この値が大きいほど、親父ギャグのレベル(?)が高くなります。

build関数内ではトークン列を回して1つずつ処理します。
内部ではfind_matchs()関数を使って、走査中のトークンにマッチするトークン列を抽出しています。

結果はtokmatchsに保存され、それが辞書としてdataに追加されます。

find_matchs関数

find_matchs関数は引数tokにマッチするトークンを引数toksから抽出します。

def find_matchs(toks, tok, min_match_len=0):
    """
    tokにマッチするトークンをtoksから探す
    """
    dst = []

    for t in toks:
        if id(tok) == id(t):
            continue
        if tok.reading == t.reading:
            continue

        i = len(tok.reading) - 1
        found = False
        while i >= 0:
            if tok.reading[i] == t.reading[0]:
                found = True
                break
            i -= 1
        if not found:
            continue

        j = 0
        while i + j < len(tok.reading) and j < len(t.reading):
            if tok.reading[i + j] != t.reading[j]:
                break
            j += 1
        if j < min_match_len:
            continue
        if i + j >= len(tok.reading):
            dst.append(t)

    return dst

マッチの条件は親父ギャグの定義によるものです。
↑の処理では最初に走査中のトークン(t)のreadingの先頭文字がtokにマッチするか調べています。
tokreadingを末尾からチェックして、1文字マッチしていたらfoundフラグをTrueにします。
foundTrueだったらtok.readingの末尾とt.readingの先頭部分を比較します。
比較した結果(比較した長さ)がtok.readingの長さ以上だったらマッチしたと見なしてdstに走査中のトークンtを追加します。

この関数がこのスクリプトの中核部分です。
ちょっと複雑になってしまいました。書きようによってはもっとシンプルになるかもしれません。

show_all関数

show_all関数は表示データを表示します。
データ内の辞書(m)をfor文で走査して、それをprint()show()関数などに渡します。

def show_all(data):
    """
    すべてのデータを表示する
    """
    for m in data:
        tok = m['tok']
        matchs = m['matchs']
        print(tok.surface)
        for match in matchs:
            show(tok, match)
        print()

show関数

show関数は引数tokmatchを使って親父ギャグ表記でトークンを表示します。
これはたとえば「階段」と「ダンス」というトークンであれば「カイダンス」と表示するというものです。

def show(tok, match):
    """
    トークンとマッチしたトークンを親父ギャグで表示する
    """
    i = len(tok.reading) - 1
    while i >= 0:
        if tok.reading[i] == match.reading[0]:
            break
        i -= 1

    j = 0
    while j < i:
        print(tok.reading[j], end='')
        j += 1

    print(match.reading)

親父ギャグメーカーを使う

今回作った親父ギャグメーカーを実行すると↓のような出力になります。
(出力結果はtextの値によって変わります)

名前
ナマエ

見当
ケントウジ

所
トコロ

いた事
イタコト

記憶
キオク

そう
ソウショク
ソウグウ

感じ
カンジン

一
イチバン
イチジュ

装飾
ソウショクモツ

薬缶
ヤカンジ
ヤカンジン

煙
ケムリヤリ

運転
ウンテン

たくさん
タクサン
タクサン

兄弟
キョウダイドコロ

上今
カミイマ

笹原
ササハラ

向う
ムコウ

我慢
ガマンナカ

一樹
イチジュウ

今日
キョウダイ

方
ホウモン

運
ウンテン

おさん
オサン
オサン

台所
ダイドコロ

遍
ヘンポウ

この間
コノカンジ
コノカンジン

御台
ミダイドコロ

鼻
ハナシ

「兄弟」が「台所」とくっついて「キョウダイドコロ」という親父ギャグになっているのがわかります。
ほかにも「我慢」と「真ん中」がくっついて「ガマンナカ」という意味不明な親父ギャグになっているのもわかります。
入力を工夫したり、名詞のみの抽出をやめて範囲をもっと広くすればもっとバリエーションのある出力が得られるかと思います。

おわりに

今回は形態素解析ライブラリであるJanomeを使って親父ギャグメーカーを作ってみました。
このように形態素解析を行うと日本語の文章を使ったプログラムを作ることが出来ます。

自然言語処理はおもしろい

スポンサーリンク

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

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

投稿する内容です。

スポンサーリンク

スポンサーリンク