ナード戦隊データマン

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

ginzaとnetworkxを使って係り受けパスで文をインデクシング

「共通性の高い文を見つける」という曖昧なタスクにおいて、動作とその目的が一致するものを選ぶ方法を試しています。要件として「類似性と共通性は違う」というものがあるため、単語ベクトルによる方法は使っていません。

github.com

概要

ginzaを使って係り受けのタイプを取得すると、ROOTがその文の述語であり、ROOTにかかるobjが主要な目的だとわかります。さらに、e = (obj, ROOT)というエッジに対するパスが存在するようないくつかのタイプ(nmod, amod, compound)の語を取得することにより、より具体的な共通性を獲得することができると仮定します。

モジュール

以下のモジュールは、ginzaによってトーカナイズされたトークンに対し、概要で書いたような語とそのパスを取得するためのものです。

[gzhandle.py]

# coding: utf-8

import spacy
import networkx as nx

NLP = spacy.load("ja_ginza")
NLP.tokenizer.set_enable_ex_sudachi(True)


def extract(text):
    nlp = NLP
    doc = nlp(text)
    root = []
    tmp_objs = []
    out = []
    for sent in doc.sents:
        for token in sent:
            if "ROOT" == token.dep_:
                root += [(token.i, token)]
            elif "obj" == token.dep_:
                tmp_objs += [(token.i, token)]
    root = dict(root)
    for t in tmp_objs:
        tid = t[1].head.i
        if tid in root:
            out += [{"ROOT": root[tid], "obj": t[1]}]
    return out

def build_path(edges_list):
    G = nx.DiGraph()
    edges = []
    identity = {}
    root = None
    obj = None
    for edges_ in edges_list:
        for edge, token in edges_.items():
            if edge[1] is None:
                root = edge[0]
                edge = (edge[0], edge[0])
            elif token.dep_ == "obj":
                obj = edge[0]
            edges += [edge]
            identity[edge[0]] = token

    assert root is not None
    G.add_edges_from(edges)
    for k, v in identity.items():
        if (nx.has_path(G, k, root)) and (nx.has_path(G, k, obj) or k == obj or k == root):
            yield k, v.lemma_, nx.shortest_path(G, k, root)
    
def extract_advance(text):
    nlp = NLP
    doc = nlp(text)
    root = []
    tmp_objs = []
    tmp_nmods = []
    tmp_amods = []
    tmp_comps = []
    out = []
    for sent in doc.sents:
        for token in sent:
            #print(token.i,
            #      token.orth_,
            #      token.lemma_,
            #      token.pos_,
            #      token.dep_,
            #      token.head.i)
            if "ROOT" == token.dep_:
                root += [((token.i, None), token)]
            elif "obj" == token.dep_:
                tmp_objs += [((token.i, token.head.i), token)]
            elif "nmod" == token.dep_:
                tmp_nmods += [((token.i, token.head.i), token)]
            elif "amod" == token.dep_:
                tmp_amods += [((token.i, token.head.i), token)]
            elif "compound" == token.dep_:
                tmp_comps += [((token.i, token.head.i), token)]
                
    root = dict(root)
    nmods = dict(tmp_nmods)
    objs = dict(tmp_objs)
    comps = dict(tmp_comps)
    amods = dict(tmp_amods)
    return build_path([root, objs, nmods, amods, comps])


if __name__ == "__main__":
    import sys
    print(extract(sys.argv[1]))

モジュールをつかってみる

import gzhandle as gzh
print(list(gzh.extract_advance("珈琲屋さんで試供品でもらったすごいハワイのカウアイ・コーヒーというのを飲む")))

[out]

[(18, '飲む', [18]),
 (13, 'コーヒー', [13, 18]),
 (9, 'ハワイ', [9, 13, 18]),
 (8, '凄い', [8, 9, 13, 18]),
 (11, 'カウアイ', [11, 13, 18])]
from tqdm import tqdm
import networkx as nx

tags = [tuple(list(gzh.extract_advance(d))) for d in tqdm(data)]
def build_graph(tag):
    nodes = {x[0]: x[1] for x in tag}
    for nid, token, path in tag:
        yield ':'.join([nodes[i] for i in path][::-1])

print(list(build_graph(tags[0])))

[out]

['飲む', '飲む:コーヒー', '飲む:コーヒー:噂', '飲む:コーヒー:マック']

ここで出力されたパスを用いて文をインデクシングします。ただし、インデクシングのためにElasticsearchを使うとプロトタイプの作成に時間がかかるため、以下のようにシミュレートします。

assert len(tags) == len(data)
outdata = []
for tag, line in zip(tags, data):
    for label in build_graph(tag):
        outdata.append((label, line))
outdata = sorted(outdata, key=lambda x: x[0])

with open("example_out2.txt", "w") as f:
    prevtag = outdata[9]
    for label, line in outdata[9:]:
        if prevtag != label:
            f.write("\n")
        f.write("{}\t{}\n".format(label, line))
        prevtag = label

[example_out2.txt]

作る   クモにコーヒーをのませたらでたらめな角度の巣を作る>コーヒーの成分カフェインがクモの中枢神経を麻痺させ、人間で言う酔っ払った状態になるため、でたらめな巣をつくる
作る  クモにコーヒーをのませたらでたらめな角度の巣を作る
作る  蜘蛛にコーヒーを飲ませるとデタラメな巣を作る
作る  通常、エスプレッソはお湯で淹れるけど徹夜で眠い時にはコーヒーをお湯代わりにしてエスプレッソを作る
作る  今日自宅で初めて本格的なエスプレッソを作りました
作る  ついにエスプレッソを作りました
作る  今朝は夕張メロンのロールケーキを作りました
作る  今朝は意外と好評だった安納芋のロールケーキを作りました
作る  コーヒー豆が生産できたら次はコーヒーマシンでお客さんに出すコーヒーを作ろう
作る  明日はエスプレッソ用豆を大量に買って水出しコーヒーを作ろう

作る:エスプレッソ   通常、エスプレッソはお湯で淹れるけど徹夜で眠い時にはコーヒーをお湯代わりにしてエスプレッソを作る
作る:エスプレッソ   今日自宅で初めて本格的なエスプレッソを作りました
作る:エスプレッソ   ついにエスプレッソを作りました

作る:エスプレッソ:本格的 今日自宅で初めて本格的なエスプレッソを作りました

作る:ケーキ    今朝は夕張メロンのロールケーキを作りました
作る:ケーキ    今朝は意外と好評だった安納芋のロールケーキを作りました

作る:ケーキ:メロン  今朝は夕張メロンのロールケーキを作りました

作る:ケーキ:メロン:夕張   今朝は夕張メロンのロールケーキを作りました

作る:ケーキ:ロール  今朝は夕張メロンのロールケーキを作りました
作る:ケーキ:ロール  今朝は意外と好評だった安納芋のロールケーキを作りました

作る:ケーキ:薯    今朝は意外と好評だった安納芋のロールケーキを作りました

作る:ケーキ:薯:好評 今朝は意外と好評だった安納芋のロールケーキを作りました

作る:ケーキ:薯:好評:意外  今朝は意外と好評だった安納芋のロールケーキを作りました

作る:ケーキ:薯:安納 今朝は意外と好評だった安納芋のロールケーキを作りました

作る:コーヒー コーヒー豆が生産できたら次はコーヒーマシンでお客さんに出すコーヒーを作ろう
作る:コーヒー 明日はエスプレッソ用豆を大量に買って水出しコーヒーを作ろう

作る:コーヒー:水出し   明日はエスプレッソ用豆を大量に買って水出しコーヒーを作ろう
...と続く。

出力の例は以下にアップロードしてあります。

https://github.com/sugiyamath/text_commonality_experiments/blob/master/experiments/example_out2.txt

考察

「共通性」とは何なのかを探索するために、このようなインデクシングのアルゴリズムを考えています。

前回の探索では「文の抽象度の想定の違いにより、共通性の認識が変わる」と仮定しましたが、今回はその仮定を「パス」によって表現しています。パスが深ければ「具体的な言及」をしていると考えられます。

Sudachiの正規化機能により、トークンを一般的な形式に置き換えることができます。これにより、インデクシングのために使うパス内の語の種類を削減することができます。

今後、この出力に対してさらに要件が追加される場合は、以下の2通りの意思決定を仮定できます:

  • 現在のいくつかの仮定を容認しながら、新しい仮定を追加する。
  • 現在のいくつかの仮定を棄却して、新しい仮定を再度考え直す。

特に仮定を容認する場合、現在の手法に対して新しい手法を適用するような方法を模索することになりそうです。これは検索アルゴリズムにおけるLTRに似ています。LTRでは、通常のインデクシングに基づく検索システムに対して、top-kを機械学習によってrerankするものです。

参考

  1. GitHub - megagonlabs/ginza: A Japanese NLP Library using spaCy as framework based on Universal Dependencies
  2. NetworkX — NetworkX

※ ビジネス固有の問題や用語以外の、一般性の高いアルゴリズムや用語の公開が許可されています。