ナード戦隊データマン

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

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]
            for lab in labs:
                x2 = entvecs[lab[1]]
                x3 = gramvecs[lab[0]]
                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」という問題に対してチャレンジしたい方はぜひやってみて、その結果を教えてください。

参考

deeppavlov: BERTによる固有表現抽出の多言語モデル

deeppavlov1は、ダイアログシステムやチャットボットのための、ディープラーニングを使ったエンドツーエンドのオープンソースライブラリです。

NERコンポーネント

deeppavlovはチャットボットなどのためのライブラリですが、様々なコンポーネントを公開しており、その一つとして固有表現抽出のモデル2があります。 http://docs.deeppavlov.ai/en/master/components/ner.html#ner-multi-bert

モデルのいくつかはBERTが使われており、特にマルチリンガルのner_ontonotes_bert_multモデルは、polyglot3などとは違って一つのモデルだけであらゆる言語に対応しているようです。

実行方法

公式通りの方法だと、まずvirtualenv内で実行・インストールします。

virtualenv env
source ./env/bin/activate
pip install deeppavlov
python -m deeppavlov install ner_ontonotes_bert

次に、ipythonでもなんでも開いて、nerモデルをダウンロードして使います。

from deeppavlov import configs, build_model

ner_model = build_model(configs.ner.ner_ontonotes_bert_mult, download=True)

ner_modelに対して、単語をスペースで区切った入力を渡せば、NERの出力が得られます。

ner_model(["安倍 晋三 さん は 総理大臣 だから 1億円 ぐらい 持っ て いる"])

[結果]

[[['安倍', '晋三', 'さん', 'は', '総理大臣', 'だから', '1億円', 'ぐらい', '持っ', 'て', 'いる']],
 [['B-PERSON', 'I-PERSON', 'O', 'O', 'O', 'O', 'B-MONEY', 'O', 'O', 'O', 'O']]]

訓練方法

データの準備

以下の形式のデータを用意します。

EU B-ORG
rejects O
the O
call O
of O
Germany B-LOC
to O
boycott O
lamb O
from O
Great B-LOC
Britain I-LOC
. O

China B-LOC
says O
time O
right O
for O
Taiwan B-LOC
talks O
. O

...

各単語一行で、スペースで区切って、NERのタグを付与します。Bは先頭、IはBに続くもの、Oは固有表現以外を意味します。

訓練コード

訓練のために、設定情報とデータへのパスが必要です。

from deeppavlov import configs, train_model
ner_model = train_model(configs.ner.ner_ontonotes_bert_mult)

データパスの中には、train.txtとvalid.txt, test.txtという3つのデータが必要です。データの形式は前述のとおりです。

精度

評価セットはRussian corpus Collection 34と呼ばれるものを使っているようです。その精度は以下です:

TOTAL 79.39
PER 95.74
LOC 82.62
ORG 55.68

参考


  1. https://github.com/deepmipt/DeepPavlov

  2. http://docs.deeppavlov.ai/en/master/components/ner.html

  3. https://polyglot.readthedocs.io/en/latest/NamedEntityRecognition.html

  4. Mozharova V., Loukachevitch N., Two-stage approach in Russian named entity recognition // International FRUCT Conference on Intelligence, Social Media and Web, ISMW FRUCT 2016. Saint-Petersburg; Russian Federation, DOI 10.1109/FRUCT.2016.7584769

corefとopenieで知識グラフを自動構築

知識グラフとは、主語・述語・目的語のトリプルからなるグラフです。主語と目的語はエンティティであり、述語はリレーションとなります。ここでは、CoreNLPのco-reference resolutionとopenieを使って知識グラフの自動構築をします。

事前準備

  1. CoreNLPサーバーを立ち上げる。
  2. サンプル文をexample_sentence.txtという名前で保存する。

サンプル文: https://github.com/sugiyamath/openie_experiment/blob/master/example_sentence.txt

コード

# coding: utf-8
import json

def fix_corefs(corefs):
    out = {}
    for _, coref in corefs.items():
        keys = []
        prop = ""
        for item in coref:
            n = item["sentNum"]-1
            s = item["startIndex"]-1
            e = item["endIndex"]-1
            if item["type"] == "PROPER":
                if item["text"] > prop:
                    prop = item["text"]
                    if prop.endswith("'s"):
                        prop = prop.replace("'s", "").strip()
            
            keys.append((n,s,e))
        for key in keys:
            out[key] = prop
    return out


def fix_openie(sents, crdict):
    out = []
    for i, sent in enumerate(sents):
        sent = sent["openie"]
        for er in sent:
            sbjs = er["subjectSpan"]
            objs = er["objectSpan"]
            sidx = tuple([i] + sbjs)
            oidx = tuple([i] + objs)
            if sidx in crdict:
                er["subject"] = crdict[sidx]
            if oidx in crdict:
                er["object"] = crdict[oidx]
            out.append((er["subject"], er["relation"], er["object"]))
    return out

def tuple2dict(data):
    out = {}
    for d in data:
        if d[0] not in out:
            out[d[0]] = {}
        if d[1] not in out[d[0]]:
            out[d[0]][d[1]] = {}
        if d[2] not in out[d[0]][d[1]]:
            out[d[0]][d[1]][d[2]] = 0
        out[d[0]][d[1]][d[2]] += 1
    return out
                

if __name__ == "__main__":
    from pprint import pprint
    from pycorenlp import StanfordCoreNLP
    import pickle
    
    nlp = StanfordCoreNLP("http://localhost:9000")
    pros = {"annotators": "coref,openie", "outputFormat": "json", "timeout": 500000}
    with open("./example_sentence.txt") as f:
        text = f.read().replace("\n", " ")
    res = nlp.annotate(text, pros)
    with open("data.pkl", "wb") as f:
        pickle.dump(res, f)
    crdict = fix_corefs(res["corefs"])
    out = fix_openie(res["sentences"], crdict)
    out = tuple2dict(out)
    pprint(out)

実行

python openie.py > out.txt

結果

{'Barack Obama': {'is': {'U.S.': 1, 'president': 1}},
 'Canada': {'has': {'Parliament': 1}, 'in': {'capital': 1}},
 'Canada 2020 chairman Tom Pitfield': {'has called': {'generation-defining leader': 1,
                                                      'generation-defining political leader': 1,
                                                      'leader': 1,
                                                      'political leader': 1}},
 'Canada chairman Tom Pitfield': {'has called': {'generation-defining leader': 1,
                                                 'generation-defining political leader': 1,
                                                 'leader': 1,
                                                 'political leader': 1}},
 'Donald Trump': {'is on behalf of': {'President': 1}},
 'Justin Trudeau': {'is': {'Prime Minister': 1}},
 'Mike Pence': {'is': {'U.S.': 1, 'Vice-President': 1}},
 'Obama': {"'s become": {"Obama 's presidency ended": 1,
                         "Obama 's presidency ended in January 2017": 1,
                         'big name': 1,
                         'big name on paid speaking circuit': 1,
                         'big name on speaking circuit': 1,
                         'name': 1,
                         'name on paid speaking circuit': 1,
                         'name on speaking circuit': 1},
           'addressed': {"Canada 's Parliament": 1},
           'also visited': {'the think-tank Canada 2020': 1},
           'also visited Canada in': {'winter': 1, 'winter of 2009': 1},
           'appeared at': {'event': 1,
                           'event in Calgary': 1,
                           'similar event': 1,
                           'similar event in Calgary': 1},
           'appeared in': {'March': 1},
           'big name on': {'paid speaking circuit': 1, 'speaking circuit': 1},
           'give': {'answer questions': 1, 'speech': 1},
           'give answer questions at': {'event': 1,
                                        "event in Canada 's capital": 1},
           'give answer questions later tonight at': {'event': 1,
                                                      "event in Canada 's capital": 1},
           'give answer questions tonight at': {'event': 1,
                                                "event in Canada 's capital": 1},
           'give later tonight': {'answer questions': 1, 'speech': 1},
           'give speech at': {'event': 1, "event in Canada 's capital": 1},
           'give speech later tonight at': {'event': 1,
                                            "event in Canada 's capital": 1},
           'give speech tonight at': {'event': 1,
                                      "event in Canada 's capital": 1},
           'give tonight': {'answer questions': 1, 'speech': 1},
           'has': {'become': 1, 'presidency': 1},
           'is': {'generation-defining': 1,
                  'generation-defining leader': 1,
                  'generation-defining political leader': 1,
                  'leader': 1,
                  'political': 1,
                  'political leader': 1,
                  'set': 1},
           'name on': {'paid speaking circuit': 1, 'speaking circuit': 1},
           'received': {'cheers': 1, 'standing ovation': 1},
           'received cheers in': {'House': 1, 'House of Commons': 1},
           'received standing ovation in': {'House': 1, 'House of Commons': 1},
           'shortly taking': {'office': 1},
           'taking': {'office': 1},
           'visited': {'the think-tank Canada 2020': 1},
           'visited Canada in': {'winter': 1, 'winter of 2009': 1}},
 "Obama 's presidency": {'ended in': {'January 2017': 1}},
 'Ottawa': {'at': {'Tire Centre': 1}},
 'Tom Pitfield': {'is': {'the think-tank Canada 2020': 1}},
 'Trudeau': {'to': {'leadership': 1}},
 'Trump': {'Tweeting': {'One': 1},
           'called': {'dishonest': 1, 'very dishonest': 1},
           'pay': {'solo visit': 1, 'visit': 1},
           'pay visit to': {'Ottawa': 1}},
 'U.S. Vice-President Mike Pence': {'brought': {'greetings': 1,
                                                'warm greetings': 1},
                                    'brought greetings from': {'Trump': 1},
                                    'give': {'momentum': 1},
                                    'give momentum to': {'American trade deal': 1,
                                                         'North American trade deal': 1,
                                                         'new American trade deal': 1,
                                                         'new North American trade deal': 1,
                                                         'new trade deal': 1,
                                                         'trade deal': 1},
                                    'was in': {'On Thursday Ottawa': 1,
                                               'Ottawa': 1,
                                               'town': 1}},
 'event': {'hosted by': {'think-tank Canada 2020': 1},
           'is in': {"Canada 's capital": 1}},
 'office': {'is in': {'2016': 1}},
 'similar event': {'is in': {'Calgary': 1}},
 'today': {'for': {'event': 1}}}

説明

co-reference resolutionとは、代名詞が指す名詞を特定するタスクです。もっと正確に言うと、テキスト内で同じエンティティを指し示す表現を抽出するタスクです。

一方、OpenIEとは、主語・述語・目的語というトリプルを抽出するタスクです。

OpenIEで抽出すると、主語や目的語が代名詞の状態で抽出されることがあります。知識グラフを構築する上では、これらの代名詞を名詞に変換したほうが便利なので、corefで変換しています。

corefとopenieは、CoreNLPのパラメータとして指定することが可能です。

考察

リレーションやエンティティを、構築済みの知識グラフに対して参照することによって、既存の知識グラフを拡大する目的でこのツールを使うことも考えられます。その場合、抽出された名詞・述語を、既存のエンティティとリレーションのどれにあたるのかを特定するという別のタスクを解く必要が出てくると思います。

一方、抽出された知識グラフをそのまま使うようなユースケースでは、検索エンジンのクエリの拡張やオートコンプリートに役立てたりできるかもしれません。ただし、Webからクロールした情報から自動的に知識グラフを構築する場合、2つの問題があるような気はします:

  1. Webページからコンテンツのみを自動抽出する。
  2. 知識グラフ全体のデータのサイズ的な問題。

Webページからコンテンツを自動抽出するために、私はdomextractというツールを開発したことがありますが、精度はまだ改善の余地があります。

GitHub - sugiyamath/domextract: DOM based web content extractor for Japanese websites

  1. Webからクロール
  2. コンテンツタイプを判定。
  3. コンテンツタイプが「記事」ならコンテンツ本文を抽出。
  4. 抽出された本文をcorefとopenieを使って抽出。
  5. 抽出されたエンティティとリレーションを知識グラフへ挿入。

というような自動化が行えれば、世界の知識の関係を自動的にグラフ形式で構築することができます。

参考

感情分析: 英語で訓練するだけで数十言語に対応する

LASER1というSentence Encoderを使えば、zero-shotで感情分析の多言語モデルを作成可能だと思ったので、試してみます。

実行フロー

  1. LASERとデータの準備
  2. Sentence Encoderのpythonモジュール(ラッパー)を作成。
  3. 英語で単純なKerasによるDNNモデルを訓練。なお、ツイート文はLASERでエンコードした上でモデルの入力に渡す。
  4. 英語とアラビア語でテスト。

事前準備

git clone https://github.com/facebookresearch/LASER
cd LASER
mkdir laser
ln -s ${HOME}/LASER/source ${HOME}/LASER/laser/source
ln -s ${HOME}/LASER/tasks ${HOME}/LASER/laser/tasks
export LASER="${HOME}/LASER/laser"
pip install transliterate jieba
./install_models.sh
./install_external_tools.sh
cd laser
mkdir s140

ついで、以下の2つのデータセットをダウンロードします。

コード

モジュール (embed_hander.py)

import os
import sys
from random import choices
from string import ascii_uppercase, digits
from subprocess import check_output
from tempfile import NamedTemporaryFile, TemporaryDirectory
 
import numpy as np
 
assert os.environ.get('LASER'), 'Please set the enviornment variable LASER'
LASER = os.environ['LASER']
 
sys.path.append(LASER + '/source/lib')
sys.path.append(LASER + '/source')
 
from embed import SentenceEncoder, Token, BPEfastApply, EncodeFile
 
 
def prepare_model():
    max_tokens = 12000
    max_sentences = None
 
    encoder = SentenceEncoder(
        os.path.join(LASER, "models/bilstm.93langs.2018-12-26.pt"),
        max_sentences=max_sentences,
        max_tokens=max_tokens,
        sort_kind='mergesort',
        cpu=False)
    return encoder
 
 
def encode_them(encoder, ifname, ofname, lang):
    bpe_codes = os.path.join(LASER, "models/93langs.fcodes")
    buffer_size = 10000
    buffer_size = max(buffer_size, 1)
    with TemporaryDirectory() as tmpdir:
        if lang != '--':
            tok_fname = os.path.join(tmpdir, 'tok')
            Token(ifname,
                  tok_fname,
                  lang=lang,
                  romanize=True if lang == 'el' else False,
                  lower_case=True,
                  gzip=False,
                  verbose=True,
                  over_write=False)
            ifname = tok_fname
 
        if bpe_codes:
            bpe_fname = os.path.join(tmpdir, 'bpe')
            BPEfastApply(ifname,
                         bpe_fname,
                         bpe_codes,
                         verbose=True,
                         over_write=False)
            ifname = bpe_fname
 
        EncodeFile(encoder,
                   ifname,
                   ofname,
                   verbose=True,
                   over_write=False,
                   buffer_size=buffer_size)
 
 
def compute_emb(encoder, text, lang):
    dim = 1024
    input_name = None
    output_name = ''.join(choices(ascii_uppercase + digits, k=16))
    with NamedTemporaryFile(mode="w+t", delete=False) as input_file:
        input_file.write(text)
        input_name = input_file.name
        print(input_name)
    encode_them(encoder, input_name, output_name, lang)
    X = np.fromfile(output_name, dtype=np.float32, count=-1)
    X.resize(X.shape[0] // dim, dim)
    os.remove(input_name)
    os.remove(output_name)
    return X

英語で訓練

import pandas as pd
from embed_handler import prepare_model, compute_emb
from keras.layers import Dense, Dropout
from keras.models import Sequential
 
 
def preprocessing(sents, encoder):
    return compute_emb(
        encoder, '\n'.join([sent.replace("\n", " ") for sent in sents]),
        "en")
 
 
def build_model():
    model = Sequential([
        Dense(1024, input_shape=(1024, )),
        Dense(1024, activation="relu", kernel_initializer="he_normal"),
        Dropout(0.5),
        Dense(1, activation="sigmoid", kernel_initializer="normal")
    ])
    model.compile(loss="binary_crossentropy",
                  optimizer="adam",
                  metrics=["acc"])
    return model
 
 
if __name__ == "__main__":
    model = build_model()
    df = pd.read_csv("./data/english/training.1600000.processed.noemoticon.csv",
                     header=None,
                     encoding="latin")
    y = df[0] > 0
    sents = df[5]
    encoder = prepare_model()
    X = preprocessing(sents, encoder)
    model.fit(X, y, epochs=2)
    model.save("model.h5")

テスト

英語のテスト

import pandas as pd
from embed_handler import prepare_model, compute_emb
from keras.models import load_model
from sklearn.metrics import classification_report
 
 
def preprocessing(sents, encoder):
    return compute_emb(encoder,
                       '\n'.join([sent.replace("\n", " ") for sent in sents]),
                       "en")
 
 
if __name__ == "__main__":
    df = pd.read_csv("./data/english/testdata.manual.2009.06.14.csv",
                     header=None,
                     encoding="latin")
    df = df[df[0] != 2]
    y = df[0] > 0
    sents = df[5]
    encoder = prepare_model()
    X = preprocessing(sents, encoder)
    model = load_model("./model.h5")
    y_pred = model.predict_classes(X)
    print(classification_report(y, y_pred))

アラビア語のテスト

from embed_handler import prepare_model, compute_emb
from keras.models import load_model
import os
from tqdm import tqdm
 
 
def load_data(path="./data/arabic/"):
    y_test = []
    X_test = []
    for x in tqdm(os.listdir(path)):
        target = os.path.join(path, x)
        if os.path.isfile(target):
            with open(target, encoding="utf-8") as f:
                if "negative" in str(target):
                    label = False
                elif "positive" in str(target):
                    label = True
                try:
                    sent = f.read().replace("\n", "")
                    X_test.append(sent)
                    y_test.append(label)
                except Exception:
                    continue
    return X_test, y_test
 
 
if __name__ == "__main__":
    from sklearn.metrics import classification_report
    model = load_model("./model.h5")
    encoder = prepare_model()
    sents, labels = load_data()
    y_test = labels
    X_test = compute_emb(encoder, '\n'.join(sents), "ar")
    y_pred = model.predict_classes(X_test)
    print(classification_report(y_test, y_pred))

テスト結果

[english]
              precision    recall  f1-score   support

       False       0.85      0.81      0.83       177
        True       0.82      0.86      0.84       182

   micro avg       0.83      0.83      0.83       359
   macro avg       0.83      0.83      0.83       359
weighted avg       0.83      0.83      0.83       359

[arabic]
              precision    recall  f1-score   support

       False       0.72      0.77      0.75       991
        True       0.76      0.70      0.73      1000

   micro avg       0.74      0.74      0.74      1991
   macro avg       0.74      0.74      0.74      1991
weighted avg       0.74      0.74      0.74      1991

TSAのための自動ラベリング

TSAのデータセットにはSentiHoodなどがありますが、データセットのサイズはそれほど大きくありません。今回は、Unsupervised Aspect Term Extraction with B-LSTM & CRF using Automatically Labelled Datasets1 という論文にかかれている自動ラベリング手法を実装します。

コード

モジュール

from nltk.corpus import stopwords
 
_stps = {word: True for word in stopwords.words("english")}
 
def load_lexicon(posfile="../data/lexicon/opinion-lexicon/positive-words.txt",
                 negfile="../data/lexicon/opinion-lexicon/negative-words.txt",
                 pos_label="pos",
                 neg_label="neg"):
    out = {}
    for infile, label in zip([posfile, negfile], [pos_label, neg_label]):
        with open(infile, encoding="latin") as f:
            for line in f:
                if line.startswith(";"):
                    continue
                line = line.strip()
                if line:
                    out[line] = label
    return out
 
 
def tokens2dict(tokens):
    out = {}
    for token in tokens:
        out[token["index"]] = token["word"]
    return out
 
 
def _dep_ex(dep):
    return dep["dep"], dep["governor"], dep["dependent"]
 
 
def _get_sentiment(target,
                   id2word,
                   lexicon,
                   negs,
                   pos_label="pos",
                   neg_label="neg"):
    sentiment = lexicon[id2word[target]]
    if target in negs:
        if sentiment == pos_label:
            return neg_label
        elif sentiment == neg_label:
            return pos_label
    else:
        return sentiment
 
 
def _create_neg_dict(deps):
    negs = {}
    for dep in deps:
        p, g, d = _dep_ex(dep)
        if p == "neg":
            negs[g] = True
    return negs
 
 
def _search_stepone(negs,
                    deps,
                    id2word,
                    lexicon,
                    pos_label="pos",
                    neg_label="neg"):
    out = {}
    stack = []
    for dep in deps:
        p, g, d = _dep_ex(dep)
        if p == "dobj" and id2word[g] in lexicon:
            out[d] = lexicon[id2word[g]]
            stack.append(d)
        if p == "nsubj":
            for dep2 in deps:
                p2, g2, d2 = _dep_ex(dep2)
                if g == g2 and p2 == "cop" and id2word[g2] in lexicon:
                    out[d] = _get_sentiment(g2, id2word, lexicon, negs,
                                            pos_label, neg_label)
                    stack.append(d)
                if g == g2 and p2 in ["advmod", "xcomp"
                                      ] and id2word[d2] in lexicon:
                    out[d] = _get_sentiment(d2, id2word, lexicon, negs,
                                            pos_label, neg_label)
                    stack.append(d)
        if p in ["pojb", "dobj"]:
            for dep2 in deps:
                p2, g2, d2 = _dep_ex(dep2)
                if d == g2 and p2 == "amod" and id2word[d2] in lexicon:
                    out[d] = _get_sentiment(d2, id2word, lexicon, negs,
                                            pos_label, neg_label)
                    stack.append(d)
    return out, stack
 
 
def _search_steptwo(out, stack, deps):
    for i in range(2):
        for dep in deps:
            p, g, d = _dep_ex(dep)
            if p in ["conj"] and d in stack:
                out[g] = out[d]
                stack.append(g)
            if p == "compound" and d in stack:
                out[g] = out[d]
                stack.append(g)
            if p in ["conj"] and g in stack:
                out[d] = out[g]
                stack.append(d)
            if p == "compound" and g in stack:
                out[d] = out[g]
                stack.append(d)
 
    return out, stack
 
 
def rules(deps, id2word, lexicon, pos_label="pos", neg_label="neg"):
    out = {}
    stack = []
    negs = _create_neg_dict(deps)
    out, stack = _search_stepone(negs, deps, id2word, lexicon, pos_label,
                                 neg_label)
    out, stack = _search_steptwo(out, stack, deps)
    return out
 
 
def iob_format_tokens(id2word, labels, stps=_stps):
    out = []
    tokens = sorted(id2word.items(), key=lambda x: int(x[0]))
    prev = None
    for k, token in tokens:
        k = int(k)
        word = id2word[k]
        if word.lower() in stps:
            label = "o"
            prev = None
        elif k in labels:
            if prev == labels[k]:
                label = "i-" + labels[k]
            else:
                label = "b-" + labels[k]
            prev = labels[k]
        else:
            label = "o"
            prev = None
        out.append((word, label))
    return out
 
 
if __name__ == "__main__":
    from pycorenlp import StanfordCoreNLP
    nlp = StanfordCoreNLP("http://localhost:9000")
 
    lexicon = load_lexicon()
 
    ys = [["screen"], ["speakers"], ["touchpad"], ["price"],
          ["Screen", "speakers"], ["wifi", "card"], ["Alice"],
          ["Sheldon", "Cooper", "friends"]]
    sents = [
        "I like the screen", "The internal speakers are amazing",
        "The touchpad works perfectly", "This laptop has great price",
        "Screen and speakers are awful", "The wifi card is not good",
        "Alice is very beautiful",
        "Sheldon Cooper and his friends are very good"
    ]
 
    for sent, y in zip(sents, ys):
        out = nlp.annotate(sent,
                           properties={
                               'annotators': 'ssplit,depparse',
                               'outputFormat': 'json'
                           })
 
        id2word = tokens2dict(out["sentences"][0]["tokens"])
        assert {
            id2word[k]
            for k, v in rules(out["sentences"][0]["basicDependencies"],
                              id2word, lexicon).items()
        } == set(y)

sentiment140に対する例

import sys
import pandas as pd
from tqdm import tqdm
import re
from pycorenlp import StanfordCoreNLP
 
sys.path.append("../module/")
 
from tsa_annotate import load_lexicon, tokens2dict, rules, iob_format_tokens
 
regex1 = re.compile(r"[@#]\S+")
regex2 = re.compile(r"http\S+")
nlp = StanfordCoreNLP("http://localhost:9000")
 
 
def annotate(tweets, outfile="out.txt"):
    lexicon = load_lexicon()
    for tweet in tqdm(tweets):
        tweet = re.sub(regex1, "", tweet)
        tweet = re.sub(regex2, "", tweet)
 
        out = nlp.annotate(tweet,
                           properties={
                               'annotators': 'ssplit,depparse',
                               'outputFormat': 'json'
                           })
        sents = out["sentences"]
        for sent in sents:
            id2word = tokens2dict(sent["tokens"])
            labels = rules(sent["basicDependencies"], id2word, lexicon)
            result = iob_format_tokens(id2word, labels)
            with open(outfile, "a") as f:
                f.write('\n'.join(['\t'.join(list(r)) for r in result]))
                f.write("\n\n")
 
 
if __name__ == "__main__":
    datafile = "../data/sentiment140/training.1600000.processed.noemoticon.csv"
    df = pd.read_csv(datafile, encoding="latin", header=None)
    annotate(df[5])

アノテーションされた文の一つ

I       o
'm      o
guessing        o
Rickys  o
not     o
a       o
fan!Can o
'       o
t       o
read    o
his     o
blog    o
,       o
only    o
got     o
limited o
internet        b-neg
access  i-neg
from    o
my      o
phone   o

説明

corenlp serverを立ち上げ、ssplitとdepparseを使います。depparseによって係り受け構造を取得します。さらに、sentiment lexicon2を使います。係り受け構造とlexiconを利用し、ルールベースでアノテーションします。詳細はコードか論文を読んでください。

系列ラベリングのより良いモデル: 単語 + 文字

系列ラベリングの基本的なモデルは、単語ベースの埋め込みを利用します。これに加えて文字ベースの埋め込みを追加する方法があります。

目的

TSA (targeted sentiment analysis) のための、より良いモデルを作成する。

コード

データの読み込み

from gensim.models.fasttext import FastText
from gensim.test.utils import datapath
import numpy as np
from tqdm import tqdm

kv = FastText.load_fasttext_format("./cc.en.300.bin")
n_words = 300000

def create_embedding_weights(kv, nb_words=300000, emb_dim=300):
    embedding_matrix = np.zeros((nb_words, emb_dim))
    embedding_dict = {}
    embedding_dict["<PAD>"] = 0
    embedding_dict["<UNK>"] = 1
    embedding_matrix[1].fill(1.0)
    for i, (word, _) in tqdm(enumerate(kv.wv.vocab.items())):
        if i >= nb_words-2:
            break
        embedding_dict[word] = i+2
        embedding_matrix[i+2] = kv.wv[word]
    return embedding_dict, embedding_matrix

def load_data(datafile="../data/conll/all.conll.train"):
    labels = {"o":0,"b-neutral":1,"i-neutral":2,"b-positive":3,"i-positive":4,"b-negative":5,"i-negative":6}
    rows_word = []
    row_word = []
    rows_tag = []
    row_tag = []
    with open(datafile) as f:
        for line in f:
            line = line.strip()
            if line:
                tmp = line.split()
                if len(tmp) != 2:
                    continue
                word, tag = line.split()
                if tag not in labels:
                    continue                
                row_word.append(word)
                row_tag.append(tag)
            else:
                assert len(row_word) == len(row_tag)
                rows_word.append(row_word)
                rows_tag.append(row_tag)
                row_word = []
                row_tag = []
    if row_word and row_tag:
        assert len(row_word) == len(row_tag)
        rows_word.append(row_word)
        rows_tag.append(row_tag)
        
    assert len(rows_word) == len(rows_tag)
    return rows_word, rows_tag, labels

embedding_dict, embedding_matrix = create_embedding_weights(kv)

rows_word, rows_tag, labels = load_data()
rows_word_dev, rows_tag_dev, _ = load_data("../data/conll/all.conll.dev")
rows_word_test, rows_tag_test, _ = load_data("../data/conll/all.conll.test")

前処理

import nltk
from keras.preprocessing.sequence import pad_sequences
from keras.utils import to_categorical

char_dict = {c: i for i, c in enumerate(["<PAD>", "<UNK>"] + list("abcdefghijklmnopqrstuvwxyz"))}
n_char = len(char_dict.items())

def char_tokenize(text, char_dict, sequence=False):
    out = []
    if sequence:
        words = text
    else:
        words = nltk.word_tokenize(text)
    for word in words:
        tmp = []
        for c in list(word):
            if c in char_dict:
                tmp.append(char_dict[c])
            else:
                tmp.append(1)
        out.append(tmp)
    return out


def tokenize(text, embedding_dict, sequence=False):
    out = []
    if sequence:
        words = text
    else:
        words = nltk.word_tokenize(text)
        
    for word in words:
        word = word.lower()
        try:
            out.append(embedding_dict[word])
        except KeyError:
            out.append(embedding_dict["<UNK>"])
    return np.array(out, dtype=np.int32)

def my_pad_sequences(sequences):
    max_sent_len = 0
    max_word_len = 0
    for sent in sequences:
        max_sent_len = max(len(sent), max_sent_len)
        for word in sent:
            max_word_len = max(len(word), max_word_len)
    x = np.zeros((len(sequences), max_sent_len, max_word_len)).astype('int32')
    for i, sent in enumerate(sequences):
        for j, word in enumerate(sent):
            x[i, j, :len(word)] = word
    return x

def preprocess(rows_word, rows_tag, labels, embedding_dict, char_dict):
    X1 = [tokenize(words, embedding_dict, sequence=True) for words in rows_word]
    X1 = pad_sequences(X1, padding="post")
    X2 = [char_tokenize(words, char_dict, sequence=True) for words in rows_word]
    X2 = my_pad_sequences(X2)
    y = [[labels[tag] for tag in tags] for tags in rows_tag]
    y = pad_sequences(y, padding="post")
    y = np.array([to_categorical(i, num_classes=len(list(labels.items()))) for i in y])
    return X1, X2, y

X1_train, X2_train, y_train = preprocess(rows_word, rows_tag, labels, embedding_dict, char_dict)
X1_dev, X2_dev, y_dev = preprocess(rows_word_dev, rows_tag_dev, labels, embedding_dict, char_dict)
X1_test, X2_test, y_test = preprocess(rows_word_test, rows_tag_test, labels, embedding_dict, char_dict)

モデリング

from keras.models import Model, Input
from keras.layers import LSTM, Embedding, Dense, TimeDistributed, Dropout, Bidirectional, Concatenate
from keras_contrib.layers import CRF

def build_model(n_words, n_char, dim1=300, dim2=25):
    input_words = Input(batch_shape=(None, None), dtype='int32')
    input_chars = Input(batch_shape=(None, None, None), dtype='int32')
    wemb = Embedding(n_words, dim1, mask_zero=True)(input_words)
    cemb = Embedding(n_char, dim2, mask_zero=True)(input_chars)
    cemb = TimeDistributed(Bidirectional(LSTM(dim2)))(cemb)
    wemb = Concatenate()([wemb, cemb])
    out = Dropout(0.5)(wemb)
    out = Bidirectional(LSTM(100, return_sequences=True))(out)
    out = Dense(100, activation='tanh')(out)
    crf = CRF(7)
    out = crf(out)
    model = Model([input_words, input_chars], out)
    model.compile(optimizer="rmsprop", loss=crf.loss_function, metrics=[crf.accuracy])
    return model

model = build_model(n_words, n_char)
history = model.fit([X1_train, X2_train], y_train, batch_size=32, epochs=2, validation_data=([X1_dev, X2_dev], y_dev), verbose=1)

テスト

from seqeval.metrics import classification_report

y_pred = model.predict([X1_test, X2_test], verbose=1)
idx2tag = {i: w for w, i in labels.items()}

def pred2label(pred, idx2tag):
    out = []
    for pred_i in pred:
        out_i = []
        for p in pred_i:
            p_i = np.argmax(p)
            out_i.append(idx2tag[p_i])
        out.append(out_i)
    return out
    
pred_labels = pred2label(y_pred, idx2tag)
test_labels = pred2label(y_test, idx2tag)
print(classification_report(test_labels, pred_labels))

結果

           precision    recall  f1-score   support

        o       0.69      0.58      0.63      2151
  neutral       0.52      0.57      0.54       581
 positive       0.59      0.16      0.26       294
 negative       0.56      0.09      0.16       295

micro avg       0.65      0.50      0.56      3321
macro avg       0.64      0.50      0.54      3321

考察

このモデルは、固有表現を抽出することはできていますが、文脈に応じて感情極性を与えることはあまりできていません。しかし、precisionは高いので、検索したものの中で正解であった確率は高いようです。一方で、recallは低いので、正解したものの中で検索したものである確率は低いと言えます。

要するに、検出する能力は低いけど、検出したものが正解する可能性は高いということです。

データの不均衡の問題もありそうですが、もっと良いモデルがあると思います。ここで用いたモデルは、あくまでも固有表現抽出で使われるようなモデルなので、TSAへの応用力については確かなことは言えません。

最近は、Memory Network Attention という手法を使うのが流行っているらしいので、そちらも着手したいです。

参考