ナード戦隊データマン

機械学習, 自然言語処理, データサイエンスについてのブログ

ルールベースの言い換え: 同義語辞書を使う

ルールベースの言い換えは、if-then形式のルールによってパラフレーズへ変換する方法です。

github.com

概要

言い換え(パラフレーズ)の手法が、抽出、変換、フィルタリングのどれかになることが多そうだ、ということを前回書きましたが、これまで試してきたのが主に抽出・フィルタリングの手法でした。

ここでは、変換の手法を考えるために、最も単純なものの一つと考えられる「同義語」の置き換えによる変換を試します。

同義語の変換において、対象の語が別の語と辞書的に入れ替えられるケースを考えます。例えば、「コーヒー」は「珈琲」と置き換えられますが、これは文脈に依存しない置き換えの一種です。

X0はY0をZ0した。

という文があり、X0の同義語にX1,X2、Y0にはY1,Y2、Z0にはZ1,Z2があるばあい、すべてのパラフレーズの組み合わせは、 3^3-1 通りあります。

今回試すのは、単にこのすべての組み合わせからなるパラフレーズを出力するだけです。

事前準備

以下のモジュールを用意します。

モジュール: https://github.com/sugiyamath/text_commonality_experiments/blob/master/wntools/wn.py

wordnetの日本語版をダウンロードします。

wordnet: http://compling.hss.ntu.edu.sg/wnja/index.ja.html

jupyterで実行

import MeCab
import wn
from tqdm import tqdm
import networkx as nx


tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")


def load_corpus(corpus_file, min_length=10, max_length=25):
    with open(corpus_file) as f:
        for line in tqdm(f):
            sent = line.split("\t")[1]
            if min_length < len(sent) and len(sent) < max_length:
                yield sent


def rep_noun(text,
             noun_prefix="名詞",
             ignore="abcdefghijklmnopqrstuvwxyz0123456789-"):
    start_token = "<S>"
    path = [[start_token]]
    edges = []
    for i, line in enumerate(tagger.parse(text).split("\n")):
        wn_dict = None
        if "\t" in line and "," in line:
            try:
                token, pos = line.strip().split("\t")
            except Exception:
                break
            suftok = "_{}".format(i)
            for p in path[i]:
                edges.append((p, token+suftok))
            path.append([token+suftok])
            if pos.startswith(noun_prefix):
                if pos.startswith("名詞,代名詞") or \
                   pos.startswith("名詞,非自立") or \
                   pos.startswith("名詞,接尾"):
                    continue
                if len(token) < 2:
                    continue
                try:
                    wn_dict = dict(list(wn.wn(token)))
                except TypeError:
                    wn_dict = None
                except Exception as e:
                    wn_dict = None
            if wn_dict is not None:
                for j, (key, tokens) in enumerate(wn_dict.items()):
                    if key[0] in ["same"]:
                        for k, tok_rep in enumerate(tokens):
                            if tok_rep[0].lower() in ignore:
                                pass
                            elif token == tok_rep:
                                pass
                            else:
                                suffix = "_{}_{}_{}".format(i, j, k)
                                path[i + 1].append(tok_rep+suffix)
                                for p in path[i]:
                                    edges.append(
                                        (p, tok_rep+suffix))
    for p in path[-1]:
        edges.append((p, "</S>"))
    end_token = "</S>"
    return path, edges, start_token, end_token

def generate_candidates(text):
    G = nx.DiGraph()
    path, edges, snode, enode = rep_noun(text)
    G.add_edges_from(edges)
    yield G
    for text2 in nx.all_shortest_paths(G, snode, enode):
        text2 = ''.join(x.split("_")[0] for x in text2[1:-1])
        yield text2
gen = generate_candidates("私は喫茶店のコーヒーを飲み、ついでにステーキを食べた")
G = next(gen)

for cand_text in gen:
    print(cand_text)

[出力]

私は喫茶店のコーヒーを飲み、ついでにステーキを食べた
私はティーハウスのコーヒーを飲み、ついでにステーキを食べた
私はティールームのコーヒーを飲み、ついでにステーキを食べた
私は喫茶店のカフエを飲み、ついでにステーキを食べた
私はティーハウスのカフエを飲み、ついでにステーキを食べた
私はティールームのカフエを飲み、ついでにステーキを食べた
私は喫茶店のカフェーを飲み、ついでにステーキを食べた
私はティーハウスのカフェーを飲み、ついでにステーキを食べた
私はティールームのカフェーを飲み、ついでにステーキを食べた
私は喫茶店のカフェを飲み、ついでにステーキを食べた
私はティーハウスのカフェを飲み、ついでにステーキを食べた
私はティールームのカフェを飲み、ついでにステーキを食べた
私は喫茶店の珈琲を飲み、ついでにステーキを食べた
私はティーハウスの珈琲を飲み、ついでにステーキを食べた
私はティールームの珈琲を飲み、ついでにステーキを食べた
私は喫茶店のキャフェを飲み、ついでにステーキを食べた
私はティーハウスのキャフェを飲み、ついでにステーキを食べた
私はティールームのキャフェを飲み、ついでにステーキを食べた
私は喫茶店のコーヒーを飲み、ついでにビステキを食べた
私はティーハウスのコーヒーを飲み、ついでにビステキを食べた
私はティールームのコーヒーを飲み、ついでにビステキを食べた
私は喫茶店のカフエを飲み、ついでにビステキを食べた
私はティーハウスのカフエを飲み、ついでにビステキを食べた
私はティールームのカフエを飲み、ついでにビステキを食べた
私は喫茶店のカフェーを飲み、ついでにビステキを食べた
私はティーハウスのカフェーを飲み、ついでにビステキを食べた
私はティールームのカフェーを飲み、ついでにビステキを食べた
私は喫茶店のカフェを飲み、ついでにビステキを食べた
私はティーハウスのカフェを飲み、ついでにビステキを食べた
私はティールームのカフェを飲み、ついでにビステキを食べた
私は喫茶店の珈琲を飲み、ついでにビステキを食べた
私はティーハウスの珈琲を飲み、ついでにビステキを食べた
私はティールームの珈琲を飲み、ついでにビステキを食べた
私は喫茶店のキャフェを飲み、ついでにビステキを食べた
私はティーハウスのキャフェを飲み、ついでにビステキを食べた
私はティールームのキャフェを飲み、ついでにビステキを食べた
私は喫茶店のコーヒーを飲み、ついでにテキを食べた
私はティーハウスのコーヒーを飲み、ついでにテキを食べた
私はティールームのコーヒーを飲み、ついでにテキを食べた
私は喫茶店のカフエを飲み、ついでにテキを食べた
私はティーハウスのカフエを飲み、ついでにテキを食べた
私はティールームのカフエを飲み、ついでにテキを食べた
私は喫茶店のカフェーを飲み、ついでにテキを食べた
私はティーハウスのカフェーを飲み、ついでにテキを食べた
私はティールームのカフェーを飲み、ついでにテキを食べた
私は喫茶店のカフェを飲み、ついでにテキを食べた
私はティーハウスのカフェを飲み、ついでにテキを食べた
私はティールームのカフェを飲み、ついでにテキを食べた
私は喫茶店の珈琲を飲み、ついでにテキを食べた
私はティーハウスの珈琲を飲み、ついでにテキを食べた
私はティールームの珈琲を飲み、ついでにテキを食べた
私は喫茶店のキャフェを飲み、ついでにテキを食べた
私はティーハウスのキャフェを飲み、ついでにテキを食べた
私はティールームのキャフェを飲み、ついでにテキを食べた
私は喫茶店のコーヒーを飲み、ついでにビーフステーキを食べた
私はティーハウスのコーヒーを飲み、ついでにビーフステーキを食べた
私はティールームのコーヒーを飲み、ついでにビーフステーキを食べた
私は喫茶店のカフエを飲み、ついでにビーフステーキを食べた
私はティーハウスのカフエを飲み、ついでにビーフステーキを食べた
私はティールームのカフエを飲み、ついでにビーフステーキを食べた
私は喫茶店のカフェーを飲み、ついでにビーフステーキを食べた
私はティーハウスのカフェーを飲み、ついでにビーフステーキを食べた
私はティールームのカフェーを飲み、ついでにビーフステーキを食べた
私は喫茶店のカフェを飲み、ついでにビーフステーキを食べた
私はティーハウスのカフェを飲み、ついでにビーフステーキを食べた
私はティールームのカフェを飲み、ついでにビーフステーキを食べた
私は喫茶店の珈琲を飲み、ついでにビーフステーキを食べた
私はティーハウスの珈琲を飲み、ついでにビーフステーキを食べた
私はティールームの珈琲を飲み、ついでにビーフステーキを食べた
私は喫茶店のキャフェを飲み、ついでにビーフステーキを食べた
私はティーハウスのキャフェを飲み、ついでにビーフステーキを食べた
私はティールームのキャフェを飲み、ついでにビーフステーキを食べた
私は喫茶店のコーヒーを飲み、ついでにビフテキを食べた
私はティーハウスのコーヒーを飲み、ついでにビフテキを食べた
私はティールームのコーヒーを飲み、ついでにビフテキを食べた
私は喫茶店のカフエを飲み、ついでにビフテキを食べた
私はティーハウスのカフエを飲み、ついでにビフテキを食べた
私はティールームのカフエを飲み、ついでにビフテキを食べた
私は喫茶店のカフェーを飲み、ついでにビフテキを食べた
私はティーハウスのカフェーを飲み、ついでにビフテキを食べた
私はティールームのカフェーを飲み、ついでにビフテキを食べた
私は喫茶店のカフェを飲み、ついでにビフテキを食べた
私はティーハウスのカフェを飲み、ついでにビフテキを食べた
私はティールームのカフェを飲み、ついでにビフテキを食べた
私は喫茶店の珈琲を飲み、ついでにビフテキを食べた
私はティーハウスの珈琲を飲み、ついでにビフテキを食べた
私はティールームの珈琲を飲み、ついでにビフテキを食べた
私は喫茶店のキャフェを飲み、ついでにビフテキを食べた
私はティーハウスのキャフェを飲み、ついでにビフテキを食べた
私はティールームのキャフェを飲み、ついでにビフテキを食べた
%matplotlib inline
import matplotlib as mpl
import matplotlib.pyplot as plt
from networkx.drawing.nx_agraph import graphviz_layout
from matplotlib import pylab
import networkx as nx

def save_graph(graph, file_name):
    #initialze Figure
    plt.figure(num=None, figsize=(10, 10))
    plt.axis('off')
    fig = plt.figure(1)
    pos = graphviz_layout(G, prog="dot")
    nx.draw_networkx_nodes(graph,pos,font_family='IPAGothic', font_size=12)
    nx.draw_networkx_edges(graph,pos,font_family='IPAGothic', font_size=12)
    nx.draw_networkx_labels(graph,pos,font_family='IPAGothic', font_size=12)

    cut = 1.1
    xmax = cut * max(xx for xx, yy in pos.values())
    ymax = cut * max(yy for xx, yy in pos.values())
    plt.xlim(0, xmax)
    plt.ylim(0, ymax)

    plt.savefig(file_name)
    pylab.close()
    del fig

save_graph(G,"example.pdf")

[出力] f:id:mathgeekjp:20200206135031g:plain

考察

この方法は最も単純なタイプの言い換えの一つですが、

  • 直接置換可能な語でなければならない。
  • すべての組み合わせを列挙すると組み合わせが膨大になる。
  • 同義語として登録されている語が複数の意味をもつ場合、文脈を考慮する必要がある。
  • 同義語辞書に一般的ではない語が含まれることがある。

のような課題があります。言い換えの文法ルールの一般化を行いたいとすれば、このような仮説駆動のアプローチよりも、むしろ抽出系手法のようなデータ駆動のアプローチのほうがエレガントな気は(個人的に)しています。

ルールベースの生成系の手法のメリットは、すでに高い精度が得られることがわかっているタイプの言い換えルールであれば、生成されたパラフレーズが信頼できるということです。一方で抽出系の手法の場合は、膨大なルールを構築しなくとも、実世界のデータから多様な言い換えの言語現象を発見できるようなメリットがあります。

このトレードオフはprecisionとrecallのトレードオフに似ていますが、最終的に人間の手でアノテーションしてテストデータを構築することが目的なら、ルールベースの生成手法よりもアラインメントのような抽出手法のほうが理にかなっている気がします。一方で、パラフレーズ検出やパラフレーズ生成のための訓練データとして利用する場合は、各々の手法で得たデータを使って訓練されたモデルの精度によって判断するべきだと考えられます。

参考

  1. 言い換えのあれこれ / A classification of paraphrases
  2. 日本語 Wordnet
  3. https://www.mitpressjournals.org/doi/pdf/10.1162/coli_a_00002