ナード戦隊データマン

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

係り受けパスによるインデクシングをパスのタイプで一般化

前回は日英での一般化において、パスに特定の係り受けタイプを持つトークンを残す方法を使いました。今回は、パスのタイプが複数あるという事実を確認し、それを一般化します。

github.com

係り受けのパスのタイプとは

これまで行ってきたパスは、(obj, ROOT)というエッジを含むパスでした。これは、述語と目的語の関係を表すものです。しかし、一般化すれば(X, ROOT)というエッジをもつパスを考えることができます。Xをパスのタイプと定義するとします。

John gives Anderson punch rudely.

という文がある場合、

  • subjパス = give:John
  • dobjパス(今までやってきたもの) = give:punch
  • iobjパス = give:Anderson
  • advmodパス = give:rudely

というようなパスがありえます。このようなパスに対応できるようにします。

モジュール

[gzhandle.py]

# coding: utf-8

import spacy
import networkx as nx

NLP = None
DEFAULT_TYPES="ROOT,obj,dobj,iobj,nsubj,advcl,advmod,amod,nmod,conj,compound,pobj,poss,dative,nummod,xcomp"

def load_global_nlp(lang="ja"):
    global NLP
    if lang == "ja":
        NLP = spacy.load("ja_ginza")
        NLP.tokenizer.set_enable_ex_sudachi(True)
    else:
        NLP = spacy.load("en_core_web_lg")
    return NLP



def _inner_build_path(edges_list):
    edges = []
    identity = {}
    roots = []
    objs = []
    subs = []
    verbs = []
    iobjs = []
    vmods = []
    for edges_ in edges_list:
        for edge, token in edges_.items():
            if edge[1] is None:
                roots.append(edge[0])
                edge = (edge[0], edge[0])
            elif token.dep_ in ["obj", "dobj"]:
                objs.append(edge)
            elif token.dep_ == "nsubj":
                subs.append(edge)
            elif token.dep_ in ["iobj", "dative", "obl"]:
                iobjs.append(edge)
            elif token.dep_ in ["advmod", "nmod", "amod","nummod"]:
                vmods.append(edge)
            edges.append(edge)
            identity[edge[0]] = token
    objs = [o[0] for o in objs if o[1] in roots]
    subs = [o[0] for o in subs if o[1] in roots]
    iobjs = [o[0] for o in iobjs if o[1] in roots]
    vmods = [o[0] for o in vmods if o[1] in roots]
    return identity, edges, roots, objs, subs, iobjs, vmods

def _check_path_type(G, k, v, root, objs, subs, iobjs, vmods):
    tp = None
    if (nx.has_path(G, k, root)):
        if k == root:
            tp = "root"
        elif any([nx.has_path(G, k, obj) or k==obj for obj in objs]):
            tp = "obj"
        elif any([nx.has_path(G, k, sub) or k==sub for sub in subs]):
            tp = "sub"                
        elif any([nx.has_path(G, k, iobj) or k==iobj for iobj in iobjs]):
            tp = "iobj"
        elif any([nx.has_path(G, k, vmod) or k==vmod for vmod in vmods]):
            tp = "vmod"
    return tp

def build_path(edges_list, version="0.0.2"):
    identity, edges, roots, objs, subs, iobjs, vmods = _inner_build_path(edges_list)
    yield identity
    assert roots
    G = nx.DiGraph()
    G.add_edges_from(edges)
    tp = None
    for k, v in identity.items():
        for root in roots:
            tp = _check_path_type(G, k, v, root, objs, subs, iobjs, vmods)
            if version == "0.0.1":
                if tp in ["root", "obj"]:
                    yield k, v.lemma_, nx.shortest_path(G, k, root)
            else:
                if tp is not None:
                    yield k, v.lemma_, nx.shortest_path(G, k, root), tp
    
            
def cut_path(path, identity, ntypes=DEFAULT_TYPES):
    tps = ntypes.strip().split(",")
    out = []
    for nid in path:
        if identity[nid].dep_ in tps:
            out += [nid]
    return tuple(out)


def build_index(tag, allow="sub,obj,vmod,iobj,root"):
    allow_tps = allow.strip().split(",")
    nodes = tag[0]
    tag = tag[1:]
    for p in set((cut_path(path, nodes),tp) for _, _, path, tp in tag):
        if p[1] in allow_tps:
            yield ':'.join([nodes[i].lemma_ for i in p[0]][::-1]),p[1]


def extract_advance(text, version="0.0.1"):
    global NLP
    nlp = NLP
    doc = nlp(text)
    out = []
    is_exist_root = False
    for sent in doc.sents:
        for token in sent:
            if token.dep_ == "ROOT" or (token.dep_ in ["conj", "advcl", "xcomp"] and token.pos_ == "VERB"):
                out += [((token.i, None), token)]
                is_exist_root = True
            else:
                out += [((token.i, token.head.i), token)]
    if is_exist_root:
        out = dict(out)
        return build_path([out], version)
    else:
        return None, None, None

説明

パスのタイプを判定するロジックを追加し、インデクシングするためのフォーマットにパスタイプ情報を追加しただけです。

出力例は以下から見れます:

検索時にはパスタイプに応じて検索することが可能なので、検索方法の幅が広がります。たとえば、「コーヒーを飲む」という行為について「どのように飲んでいるのか」という説明が欲しい場合は、obj::drink:coffeeで絞った上で、vmod::drinkを検索することができます。

vmod::drink:aggressively        I'm drinking my coffee so aggressively behind this shit.
vmod::飲む:がぶがぶ     今日一日でお茶とコーヒーがぶがぶ飲んでたら久しぶりにめまいが酷い\(^o^)/

考察

今回重要だと思うのはアルゴリズムそのものよりもむしろ、「ROOTにつながるパスを分類できる」という考え方だと思います。

インデクシングの際に、単一のエッジではなく、ROOTに向かうすべての「パス」を使うことができれば、検索時の利便性は向上しそうに思えます。同時に、それらのパスの係り受けタイプを利用してパスの種類が分類されれば、パスタイプ指定による検索ができるようになり、より柔軟に検索できると考えられます。

用いたとして方法は、ROOTにつながるノードとその係り受けタイプによってパスの種類を分類しています。

ところで、「一般化」と書きましたが、実際には対応が難しいケースもあります。

例えば、複文や接続詞、ネストを伴う接続詞などが含まれる場合、ROOTと同等の機能を持つ動詞が複数、文の中に存在するため、それらのパスを分割する必要があります。

また、熟語や助動詞を含む文は構造的に複雑になりえるため、"looking forward to", "will be able to see"のような結合された単位をインデクシングすることを考慮すると、複数言語における一般化が困難になりえます。

さらに、パスの種類はこの4種類とは限らず、一般的には「ROOTと直接つながるエッジをもつ係り受けタイプ」をすべて考慮できるため、それらのパスを別々に扱うのか、いくつかのパスタイプをまとめて一つの抽象化されたパスタイプとするのかを考える必要があります。

参考

  1. GiNZA+Elasticsearchで係り受け検索の第一歩 - Taste of Tech Topics

※ 実際にはこのリンクを参考にする前に手法を実装しましたが、「似たようなことをやっている人はいないか」と調べたら出てきた上に、我々よりも前に類似のこと(主述パスのみ)を試していたようなので、参考としています。