ナード戦隊データマン

機械学習と自然言語処理についてのブログ

KNPを使ったOpenIE

KNPとは、日本語文の構文・格・照応解析を行うすごいシステムです。今回は、このうち「構文解析」の結果を使ってSVOの抽出をできるか、ルールベースで試します。

事前準備

KNP, JUMAN, jumanppをインストール後、pyknpをインストールしてください。

[jumanppのインストール]

apt install cmake
wget https://github.com/ku-nlp/jumanpp/releases/download/v2.0.0-rc2/jumanpp-2.0.0-rc2.tar.xz
tar xf jumanpp-2.0.0-rc2.tar.xz 
mkdir build
cd build/
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local
make
make install

[JUMAN, KNPのインストール]

wget http://nlp.ist.i.kyoto-u.ac.jp/nl-resource/juman/juman-7.01.tar.bz2
wget http://nlp.ist.i.kyoto-u.ac.jp/nl-resource/knp/knp-4.19.tar.bz2
tar jxvf knp-4.19.tar.bz2 
tar jxvf juman-7.01.tar.bz2 
cd juman-7.01
./configure --prefix=/usr/local
make
make install
cd ..
cd knp-4.19
./configure --prefix=/usr/local/ --with-juman-prefix=/usr/local/
make
make install
pip install pyknp

入力文例の用意

入力文例は、ニュース記事の最初のパラグラフから抽出します。抽出には、domextractを用いています。

[example.txt]

自民・公明両党は14日午後、2019年度与党税制改正大綱を決定する。 大綱では、「消費税率10%への引き上げを19年10月に確実に実施する」と明記。 消費税増税に伴う消費下支えのため、自動車や住宅関連の減税を実施する。 政府が米軍普天間飛行場の移設のために約160ヘクタールを埋め立てようとしている。 土砂の投入によって豊かな自然環境が失われ、生態系に大きな影響を及ぼすことが懸念される。 北九州市は14日、特定危険指定暴力団工藤会」の本部事務所(同市小倉北区)について、差し押さえも含め撤去に向けた対応を検討していることを明らかにした。 【ワシントン中井正裕】英ヴァージン・グループ傘下の宇宙開発ベンチャーヴァージン・ギャラクティック社は13日、カリフォルニア州で宇宙船「スペースシップ2」の有人飛行試験を行った。 米国で宇宙空間とされる高度80キロに達した。 13日のニューヨーク外国為替市場の円相場は午後5時現在、前日比30銭円安・ドル高の1ドル=113円52~62銭をつけた。 米大リーグの球団幹部らが一堂に会するウインターミーティングは13日、ネバダ州ラスベガスで4日間の日程を終え、プロ野球西武からポスティングシステムでメジャー移籍を目指す菊池雄星投手(27)の所属先は決まらなかった。 銀座の洋食の歴史を物語る『煉瓦亭』は、ポークカツレツを日本で最初に出したことで知られる。 フランス東部ストラスブール中心部のクリスマスマーケット(市場)近くで11日起きた銃撃テロで、カスタネール内相は13日、逃走していたシェリフ・シェカット容疑者(29)を警察がストラスブールで射殺したと発表した。 【カトウィツェ(ポーランド)五十嵐和大】国連気候変動枠組み条約第24回締約国会議(COP24)は11、12の両日、各国の閣僚級演説が行われた。

 コード

SVO抽出をKNP出力からルールベースで定義します。

from pyknp import KNP
import networkx as nx
import re
import matplotlib as mpl

mpl.use("Agg")
font = {'family': 'IPAGothic'}
mpl.rc("font", **font)

import matplotlib.pyplot as plt
from networkx.drawing.nx_agraph import graphviz_layout


def sent_format(sent):
    reg_quote = re.compile(r"(「.+?」|『.+?』)")
    reg_rm = re.compile(r"(\(.+?\)|(.+?)|\[.+?\]|【.+?】)")
    i = 0
    cp_sent = sent[:]
    cp_sent = re.sub(reg_rm, "", cp_sent)
    cp_sent = ' '.join([x.capitalize() for x in cp_sent.split()])
    cp_sent = cp_sent.replace(" ", "")
    while True:
        old = cp_sent[:]
        cp_sent = re.sub(reg_quote, "[[quote{}]]".format(i), cp_sent, 1)
        i = i+1
        if old == cp_sent:
            break

    quotes = {"quote{}".format(i):x for i,x in enumerate(re.findall(reg_quote, sent))}
    return cp_sent, quotes


def extract(cp_sent, knp):
    result = knp.parse(cp_sent)
    ns = {int(x.bnst_id):{
        "parent":int(x.parent_id),
        "surface": x.midasi,
        "feature":x.fstring,
        "dpndtype":x.dpndtype,
        "tags":{ int(tag.tag_id): {
            'parent': int(tag.parent_id),
            'surface': tag.midasi,
            'feature': tag.fstring,
            'dpndtype': tag.dpndtype,
            "mrphs": { int(mrph.mrph_id): {
                'surface': mrph.midasi,
                'orig': mrph.genkei,
                'pos': mrph.hinsi,
                'feature': mrph.fstring
            } for mrph in tag.mrph_list()}
        } for tag in result.tag_list()}}
        for x in result.bnst_list()}
    es = [(k, v["parent"]) for k,v in ns.items() if k != -1 and v["parent"] != -1]
    return ns, es


def calc_paths(ns, es, outfile=None):
    G = nx.DiGraph()
    G.add_edges_from(es)
    if outfile is not None:
        labels = {k:v["surface"] for k,v in ns.items()}
        pos = graphviz_layout(G, prog='dot')
        nx.draw(G, pos, labels=labels, with_labels=True, font_size=16, font_family=font["family"], arrows=True)
        plt.savefig(outfile)
    paths = []
    for i in list({e[0] for e in es}):
        for j in list({e[1] for e in es}):
            try:
                paths.append(nx.dijkstra_path(G, i, j))
            except:
                continue
    paths = {i:path for i,path in enumerate(paths)}
    cp_paths = paths.copy()
    for k1, path1 in paths.items():
        for k2, path2 in paths.items():
            if path1 == path2:
                continue
            try:
                if set(path1) < set(path2):
                    cp_paths.pop(k1)
                elif set(path2) < set(path1):
                    cp_paths.pop(k2)
            except:
                continue
    return G, cp_paths


def build_sentence(paths):
    for i, path in paths.items():
        yield ''.join([ns[p]['surface'] for p in path])


def SVO(ns, paths, quotes):
    regex = re.compile(r"^.*?<正規化代表表記:(.+?)>.*$")
    regex2 = re.compile(r"/.*")
    S = []
    V = []
    O = []
    for i, path in paths.items():
        con = []
        con_S = []
        for j, p in enumerate(path):
            t = ''.join([re.sub(regex2, "", x) for x in re.sub(regex, r'\1', ns[p]['feature']).split("+")])
            t2 = ns[p]['surface'].replace("。","")
            for k,v in quotes.items():
                if "[[{}]]".format(k) in t2:
                    t = t.replace("[[quote]]", v)
                    t2 = t.replace("[[{}]]".format(k), v) 
            if ('<ハ>' in ns[p]['feature']) or ('<係:ガ格>' in ns[p]['feature']):
                if t not in S:
                    con_S.append(t)
                    S.append(''.join(con_S))
                    con_S = []
            elif j+1 < len(path) and ('<ト>' in ns[p]['feature'] and (('<ハ>' in ns[path[j+1]]['feature']) or ('<係:ガ格>' in ns[path[j+1]]['feature']))):
                if t not in S:
                    con_S.append(t)
                    S.append(''.join(con_S))
                    con_S = []
            elif j+1 < len(path) and ("<係:ノ格>" in ns[p]['feature'] and (('<ハ>' in ns[path[j+1]]['feature']) or ('<係:ガ格>' in ns[path[j+1]]['feature']))):
                continue
            elif j+1 < len(path) and ("<裸名詞>" in ns[p]['feature'] and (('<ハ>' in ns[path[j+1]]['feature']) or ('<係:ガ格>' in ns[path[j+1]]['feature']))):
                con_S.append(t)
            elif j == len(path)-1:
                if t2 not in V:
                    V.append(t2)
            else:
                con.append(t2)
        if con and con not in O:
            O.append(''.join(con))

    return {"S":S, "V":V, "O":O}


if __name__ == "__main__":
    from pprint import PrettyPrinter
    import sys
    pp = PrettyPrinter(depth=8)

    with open("example.txt") as f:
        sents = f.read().split("\n")
    
    knp = KNP()
    cp_sent, quotes = sent_format(sents[int(sys.argv[1])])
    ns, es = extract(cp_sent, knp)
    G, paths = calc_paths(ns, es, None)
    #pp.pprint(list(build_sentence(paths)))
    pp.pprint(SVO(ns, paths, quotes))
    #pp.pprint([v['feature'] for k,v in ns.items()])
    #pp.pprint((ns, quotes, es))

実行

for((i=0;i<13;i++));do python knptree.py $i; done
{'O': ['14日午後、', '2019年度与党税制改正大綱を'], 'S': ['自民公明両党'], 'V': ['決定する']}
{'O': ['「消費税率10%への引き上げを19年10月に確実に実施する」'], 'S': ['大綱'], 'V': ['明記']}
{'O': ['消費税増税に伴う消費下支えのため、', '自動車や住宅関連の減税を'], 'S': [], 'V': ['実施する']}
{'O': ['米軍普天間飛行場の移設のために', '約160ヘクタールを'], 'S': ['政府'], 'V': ['埋め立てようとしている']}
{'O': ['土砂の投入によって失われ、及ぼす', '豊かな失われ、及ぼす', '生態系に及ぼす', '大きな影響を及ぼす'],
 'S': ['こと', '自然環境'],
 'V': ['懸念される']}
{'O': ['14日、',
       '特定危険指定暴力団「工藤会」本部事務所について、検討していることを',
       '差し押さえも含め検討していることを',
       '撤去に向けた対応を検討していることを'],
 'S': ['北九州市'],
 'V': ['明らかにした']}
{'O': ['宇宙開発ベンチャー、', '13日、', 'カリフォルニア州で', '宇宙船「スペースシップ2」有人飛行試験を'],
 'S': ['ヴァージンギャラクティック社'],
 'V': ['行った']}
{'O': ['米国でされる高度80キロに', '宇宙空間とされる高度80キロに'], 'S': [], 'V': ['達した']}
{'O': ['13日の', '午後5時現在、', '前日比30銭円安・ドル高の1ドル=113円52~62銭を'],
 'S': ['円相場'],
 'V': ['つけた']}
{'O': ['会する',
       '一堂に会する',
       '13日、',
       'ネバダ州ラスベガスで終え、目指す',
       '4日間の日程を終え、目指す',
       'プロ野球西武から目指す',
       'ポスティングシステムで目指す',
       'メジャー移籍を目指す'],
 'S': ['球団幹部', 'ウインタミーティング', '所属先'],
 'V': ['決まらなかった']}
{'O': ['銀座の洋食の歴史を物語る', 'ポークカツレツを出したことで', '日本で最初に出したことで'],
 'S': ['『煉瓦亭』'],
 'V': ['知られる']}
{'O': ['フランス東部ストラスブール中心部のクリスマスマーケット近くで起きた銃撃テロで、射殺したと',
       '11日起きた銃撃テロで、射殺したと',
       '逃走していたシェリフ・シェカット容疑者を射殺したと',
       '13日、逃走していたシェリフ・シェカット容疑者を射殺したと',
       '射殺したと',
       'ストラスブールで射殺したと'],
 'S': ['カスタネール相', '警察'],
 'V': ['発表した']}
{'O': ['11、12の両日、'], 'S': ['国連気候変動枠組み条約24回締約国会議', '閣僚演説'], 'V': ['行われた']}

仕組み

最初に、文を整形します。文内の引用句「.+?」をquote{}に置き換えます。(.+?)は削除します。 構文解析して、ツリーを生成し、文のパスを生成します。 パス内で、「○○は」と「○○が」を全部Sにしてしまいます。 次に、パス内の最後のノードをVにします。 それ以外は、Oにします。 最後に、quote{}を復元します。

SVOが自動抽出できると何が楽しいか

SVOの自動抽出により、知識グラフの自動生成が期待できます。SとOはエンティティとなり、Vはリレーションとなります。知識グラフをグラフデータベースなどに詰め込めば、情報検索のクエリの拡張や、ナレッジカードの表示などに役立てられます。

ルールベースの限界

構文解析をPOSタグからルールベースで行うことに限界があるのと同じように、SVOの抽出をKNPの出力を用いてルールベースで行うのにも限界があるような気がします。

述語項構造がわかる格解析の機能を使ったほうが良さそうな気もします。また、KNPにはWikipediaエンティティと対応付ける機能や、固有表現抽出機能もあるため、それらを使ってSVOを抽出したほうがエンティティが明確に定義できそうな気はします。

KNPは沢山の素性が獲得できるため、それらの素性を使って機械学習モデリングしたほうが精度は良いかもしれません。しかしそのためには膨大な訓練データが必要になります。

JUMANについてひとこと

"JUMAN, jumanppは速度が遅い!"とディスりまくったことがありますが、jumanppの良いところの一つは、KNPという高機能なツールと組合せて使える点だと思いました。jumanppとknpを組合せて使ってみると、確かに速度的には遅く感じますが、沢山の素性を使えるというメリットはあると思いました。

参考

[0] http://nlp.ist.i.kyoto-u.ac.jp/?KNP [1] https://github.com/ku-nlp/jumanpp