ナード戦隊データマン

データサイエンスを用いて悪と戦うぞ

E2Eの多言語エンティティリンキング

エンティティリンキングとは、テキスト内のメンションを、知識ベース内の適切なエンティティに対応させるタスクです。

エンティティリンキングの概要

エンティティリンキングでは、以下の2つのタスクを考慮します。

  1. 検出するメンションの決定。
  2. 検出されたメンションを正しいエンティティに結びつける。

検出するメンションは、文章内で重要なものである必要があります。また、検出されたエンティティは曖昧性解消を正しく行う必要があります。

例えば、

Obama was a president of America.

という文があるなら、

Obama: Barack_Obama America: United_States

という具合に対応させることができます。通常、AmericaというメンションからUnited_Statesという候補エンティティを取得するために、メンションとエンティティのペアを何らかのコーパスから取得します。Wikipediaを使う場合はWikificationと呼ばれ、候補はアンカーの文字列(メンション)と、アンカーが指し示すWikipediaページ名(エンティティ)のペアを生起頻度と共に抽出します。

特に、あるメンションから取得される各々の候補エンティティの生起確率を「リンク確率」と呼びます。

E2E化するには

E2EのEL1では、ゴリゴリと特徴量設計するのではなく、文と候補エンティティをそのまま入力します。候補エンティティは「エンティティベクトル」と呼ばれるベクトルとして入力されますが、Ousiaのwikipedia2vec2を使うことができます。

文の入力は、Embeddingを通してBiLSTMなどへ渡すことができます。この点は、E2Eのテキスト分類タスクと同じように考えることができます。

また、メンション文字列が候補として適しているかを判別するために、メンション文字列も文の入力と同じように入力できるのがよいかもしれません。というのも、{mention: "country", entity: "Russia"}のようなペアが出てきてしまった場合、countryは重要なメンションではありませんが、Russiaは文にとって重要なエンティティかもしれず、その場合はRussiaが抽出対象になってしまう可能性があるからです。

言語化するには

言語化については、私の個人的なアイデアを試します。

  1. 文とメンションの入力はLASER3を使う。
  2. エンティティベクトルの入力は英語を使うが、MediaWikiのlanglinks4テーブルによって、他の言語から英語へエンティティを変換する。

コード

本当はコードの全貌を書きたいのですが、コードの量が多いので、訓練部分のコードを中心に載せます。

1. データジェネレータ

import simplejson as json
import numpy as np
import pickle
from tqdm import tqdm

def generate2(indices, sentvecs, labels, entvecs, gramvecs):
    while True:
        for index in indices:
            X1 = []
            X2 = []
            X3 = []
            X4 = []
            y = []
            labs = labels[index]
            x1 = sentvecs[index] # 文のLASERベクトル
            for lab in labs:
                x2 = entvecs[lab[1]] # 候補エンティティのエンティティベクトル
                x3 = gramvecs[lab[0]] # メンションのLASERベクトル
                x4 = [float(lab[2])] # リンク確率
                X1.append(x1)
                X2.append(x2)
                X3.append(x3)
                X4.append(x4)
                y.append(lab[3])
            if y:
                yield [np.array(X1),
                       np.array(X2),
                       np.array(X3),
                       np.array(X4)], np.array(y)


def load_data2(svecfile="./training_data/sents_encoded.npy",
               labfile="./training_data/labels2.json",
               evecfile="./training_data/ent_encoded.pkl",
               g2vecfile="./training_data/gram_encoded.pkl"):
    sentvecs = np.load(svecfile)
    with open(labfile) as f:
        labels = json.load(f)
    with open(evecfile, "rb") as f:
        entvecs = pickle.load(f)
    with open(g2vecfile, "rb") as f:
        gramvecs = pickle.load(f)
    return sentvecs, labels, entvecs, gramvecs


def gen_indices(num_sents):
    all_inds = list(range(num_sents))
    train_inds = all_inds[:12938]
    val_inds = all_inds[12938:13938]
    test_inds = all_inds[13938:17163]
    return train_inds, val_inds, test_inds

2. 訓練

from data_generator import load_data2, generate2, gen_indices
from keras.layers import Input, Dense, Dropout, concatenate
from keras.models import Model
from keras.optimizers import Adam
from keras.callbacks import ModelCheckpoint


def build_model(sentdim=1024, entdim=100, gramdim=1024):
    in1 = Input(shape=(1024, ))
    in2 = Input(shape=(100, ))
    in3 = Input(shape=(1024, ))
    in4 = Input(shape=(1, ))
    x1 = Dense(1024, activation="relu")(in1)
    x2 = Dense(1024, activation="relu")(in2)
    x3 = Dense(1024, activation="relu")(in3)
    x4 = Dense(1, activation="relu")(in4)
    x = concatenate([x1, x2, x3])
    x = Dense(1024, activation="relu")(x)
    x = Dropout(0.5)(x)
    x = concatenate([x, x4])
    out = Dense(1, activation="sigmoid")(x)
    model = Model([in1, in2, in3, in4], out)
    model.compile(optimizer="nadam",
                  loss="binary_crossentropy",
                  metrics=["acc"])
    return model


def run():
    pdata = load_data2()
    tinds, vinds, _ = gen_indices(len(pdata[1]))
    callbacks = [
        ModelCheckpoint("./model2_best.h5",
                        monitor="val_loss",
                        save_best_only=True,
                        mode=min)
    ]
    model = build_model()
    model.fit_generator(generate2(tinds, *pdata),
                        steps_per_epoch=300,
                        epochs=15,
                        callbacks=callbacks,
                        validation_data=generate2(vinds, *pdata),
                        validation_steps=1000)


if __name__ == "__main__":
    run()

3. 候補生成モジュール

from marisa_trie import BytesTrie
import json


def load(datafile="../data/mention_stat.marisa"):
    trie = BytesTrie()
    trie.load(datafile)
    return trie


def generate(mention, trie, k=30):
    try:
        tmp_json = trie[mention]
        tmp_json = tmp_json[0].decode("utf-8")
        out = json.loads(tmp_json)
        out = sorted(out.items(), key=lambda x: x[1], reverse=True)[:k]
        return out
    except KeyError:
        return []


def generate_with_linkprob(mention, trie, k=30):
    try:
        tmp_json = trie[mention]
        tmp_json = tmp_json[0].decode("utf-8")
        out = json.loads(tmp_json)
        out = sorted(out.items(), key=lambda x: x[1], reverse=True)[:k]
        total = 0
        for x in out:
            total += x[1]
        out2 = []
        for i, x in enumerate(out):
            out2.append((x[0], float(x[1]) / float(total)))
        return out2
    except KeyError:
        return []


if __name__ == "__main__":
    import sys
    mention = sys.argv[1]
    trie = load()
    #print(generate(mention, trie))
    print(generate_with_linkprob(mention, trie))

4. Entity Vectorエンコードモジュール

from gensim.models import KeyedVectors
import candidate_generator as cg


def load(datafile="../entity_vector/enwiki_20180420_100d.bin"):
    kv = KeyedVectors.load(datafile, mmap="r")
    return kv


def encode(candidates, kv):
    out = []
    for candidate in candidates:
        try:
            out.append({
                "candname": candidate,
                "entvec": kv["ENTITY/" + candidate]
            })
        except KeyError:
            continue
    return out


def encode_with_linkprob(candidates, kv):
    out = []
    for candidate, prob in candidates:
        try:
            out.append({
                "candname": candidate,
                "entvec": kv["ENTITY/" + candidate],
                "linkprob": prob
            })
        except KeyError:
            continue
    return out


if __name__ == "__main__":
    import sys
    mention = sys.argv[1]
    trie = cg.load()
    kv = load()
    candidates = [k for k, _ in cg.generate(mention, trie)]
    print(encode(candidates, kv))

※ mention_stat.marisaは、以下によって生成しています。 https://github.com/sugiyamath/e2e_EL_multilingual_experiments/tree/master/data_processor/build_candidates

※ mention_stat.marisaの部分は、言語ごとに生成する必要があります。英語のメンションから他の言語のメンションに変換できれば理想的ですが、直接の方法はないと思います。

5. 予測モジュール

from keras.models import load_model
import numpy as np
import sys
import nltk
from nltk.corpus import stopwords
import os

sys.path.append("./modules")
import candidate_generator as cg
import entvec_encoder as eenc
import laserencoder as lenc

os.environ["CUDA_VISIBLE_DEVICES"] = "-1"

stps = stopwords.words("english")


def ngram(words, n):
    return list(zip(*(words[i:] for i in range(n))))


def feature(words, trie, kv, enc):
    x1 = enc.encode(' '.join(words))[0]
    X_list = []
    cand_list = []
    gram_list = []
    mentions = {}
    for i in range(1, 6):
        for gram in ngram(words, i):
            mention = ' '.join(list(gram))
            candidates = cg.generate_with_linkprob(mention, trie)
            encoded = eenc.encode_with_linkprob(candidates, kv)
            tmp_X2 = []
            tmp_X1 = []
            tmp_X4 = []
            tmp_cand = []
            tmp_gram = []
            for target in encoded:
                tmp_cand.append(target["candname"])
                tmp_X2.append(target["entvec"])
                tmp_X1.append(x1)
                tmp_X4.append([target["linkprob"]])
                tmp_gram.append(mention)
                mentions[mention] = True
            if tmp_X1:
                X_list.append([
                    np.array(tmp_X1),
                    np.array(tmp_X2), None,
                    np.array(tmp_X4)
                ])
                cand_list.append(tmp_cand)
                gram_list.append(tmp_gram)
    mentions = [x.replace("\n", " ") for x, _ in mentions.items()]
    mvecs = enc.encode('\n'.join(mentions))
    mvecs = dict(zip(mentions, mvecs))
    for i, grams in enumerate(gram_list):
        tmp_X3 = []
        for mention in grams:
            tmp_X3.append(mvecs[mention.replace("\n", " ")])
        X_list[i][2] = np.array(tmp_X3)
    return X_list, cand_list, gram_list


def predict(model, X_list, cand_list, gram_list):
    out = []
    for X, cands, grams in zip(X_list, cand_list, gram_list):
        y_preds = [x[0] for x in model.predict(X)]
        if np.max(y_preds) < 0.2:
            continue
        index = np.argmax(y_preds)
        out.append((cands[index], grams[index]))
    return out


if __name__ == "__main__":
    trie = cg.load("../data/mention_stat.marisa")
    kv = eenc.load("../entity_vector/enwiki_20180420_100d.bin")
    enc = lenc.Encoder()
    model = load_model("../models/model2_best.h5")

    sent = None
    while sent != "-1":
        sent = input("sent>")
        words = nltk.word_tokenize(sent)
        X_list, cand_list, gram_list = feature(words, trie, kv, enc)
        result = predict(model, X_list, cand_list, gram_list)
        print(result)

※ laserencoder5を使っています。

6. テスト

import os
import sys
import json
from tqdm import tqdm
from keras.models import load_model
from sklearn.metrics import classification_report

os.environ["CUDA_VISIBLE_DEVICES"] = "-1"

sys.path.append("../modules")

import candidate_generator as cg
import entvec_encoder as eenc
import laserencoder as lenc
import predictor2 as pr


def run():
    out = []
    with open("../data.json") as f:
        data = json.load(f)[946:1163]
        sents = []
        for doc in data:
            sents += doc
    model = load_model("../models/model2_best.h5")
    trie = cg.load("../data/mention_stat.marisa")
    kv = eenc.load("../entity_vector/enwiki_20180420_100d.bin")
    enc = lenc.Encoder()

    for d in tqdm(sents):
        X_list, cand_list, gram_list = pr.feature(d["sentence"], trie, kv, enc)
        result = pr.predict(model, X_list, cand_list, gram_list)
        out.append({"true": d["entities"], "pred": result})

    with open("result2.json", "w") as f:
        json.dump(out, f, indent=4)

        
if __name__ == "__main__":
    run()
import json
from tqdm import tqdm

if __name__ == "__main__":
    with open("./result2.json") as f:
        data = json.load(f)
    recalls = []
    precisions = []

    for sent in tqdm(data):
        true_in_pred = 0
        pred_in_true = 0
        if len(sent["pred"]) == 0 or len(sent["true"]) == 0:
            continue
        sent["pred"] = [x[0] for x in sent["pred"]]
        for e in sent["true"]:
            if e in sent["pred"]:
                true_in_pred += 1
        precisions.append(float(true_in_pred)/float(len(sent["pred"])))
        for e in sent["pred"]:
            if e in sent["true"]:
                pred_in_true += 1
        recalls.append(float(pred_in_true)/float(len(sent["true"])))
    recall = sum(recalls)/len(recalls)
    precision = sum(precisions)/len(precisions)
    f1 = (2 * precision * recall) / (precision + recall)
           
    print("recall:", recall)
    print("precision:", precision)
    print("f1:", f1)

結果

recall: 0.5300793668205437
precision: 0.5110972619972625
f1: 0.5204152792611918

説明

これらのコードは、以下の2つを実行しています:

  1. 抽出対象メンションを選ぶ。
  2. 抽出対象のメンションの候補エンティティから最も適したエンティティを選ぶ。

精度はあまり高くはありませんが、E2Eの多言語ELという新しい試みとして行えたという点ではまあ良いのではないかと。通常、ELのマルチリンガルモデルと言うと、一つのモデルが多言語に対応しているという意味よりは、むしろ「そのアルゴリズムは多言語対応できる」ということを意味しているようです。実際、deeptype6はそのようなものだと思います。

一方、今回試した方法は、「一つのモデルが多言語対応できる」ようなものです。精度は高くありませんが、もしこのようなモデルが作れるのであれば非常に便利であることは間違いありません。

もし、「E2Eで一つのモデルで多言語に対応するEL」という問題に対してチャレンジしたい方はぜひやってみて、その結果を教えてください。

追記

精度の評価方法は雑です。より正確に評価したい場合はこのコードを参考にしないでください。

参考