Re:ゼロから始めるML生活

どちらかといえばエミリア派です

LightGBMで含意関係認識をしてみる

f:id:nogawanogawa:20200814230237p:plain

この前はBERTを使って含意関係認識をやってみました。

前回は何も考えずにとにかくBERTに突っ込んで、とりあえずやってみたって感じでした。 今回は、もう少し泥臭い方法で含意関係認識をやってみたいと思います。

今回参考にさせていただいたのはこちら。

watlab-blog.com

こちらの記事を参考にやっていきたいと思います。

LIghtGBM

実は前にも使ったことあるんですが、今回はLightGBMを使ってみたいと思います。

github.com

ここ数年、主にKaggleを始めとするデータサイエンスコンペでよく目にするライブラリです。 私もあまり詳しくないのですが、他の勾配ブースティング系のライブラリに比べて高速に学習ができるのが特徴のようです。

今回はこちらを使用して含意関係認識をやってみたいと思います。

いろいろ準備

データセット

今回も含意関係認識なので、京都大学の黒橋・褚・村脇研究室で公開しているTextual Entailment評価データを使用します。

nlp.ist.i.kyoto-u.ac.jp

このデータセットなかったら、ここ最近の記事全部書けてなかったです。本当にありがとうございます。

特徴量

NLP系の特徴量については、あまり詳しくなかったので、今回はこちらの文献を参考に特徴量を作っていきたいと思います。

https://www.unisys.co.jp/tec_info/ik15po000006di7t-att/12702.pdf

こちらの文献では特徴量を、

  • 数量/時間表現一致
  • 固有表現一致
  • 内容語一致率
  • 内容語の先頭文字の一致率
  • word2vecコサイン距離
  • 排他語の有無
  • 内容語不一致率

としています。

今回はこちらを参考にしつつ、簡易的な特徴量を作成してそれをLightGBMへ突っ込んでみたいと思います。

やってみる

特徴量エンジニアリング

なにはともあれ、特徴量を作っていきたいと思います。

数量/時間表現一致

この辺りは、GiNZAを使えば簡単に取得できます。

抽出用のクラスはこんな感じになりました。

import pandas as pd
import ginza
import spacy
from spacy import displacy


class FeatureNumber:
    def __init__(self):
        super().__init__()
        self.nlp = spacy.load('ja_ginza')

    def get_quantity(self, text: str):
        """数量の抽出"""
        doc = self.nlp(text)

        result = []
        word = ""

        for sent in doc.sents:
            for token in sent:
                if token._.ne.endswith(("QUANTITY", "TIME")):

                    # 節にまとめる
                    if token._.ne.startswith("B_") and word != "":  # 新規の数詞のとき
                        result.append(word)
                        word = ""

                    word = word + token.orth_

        if word != "":
            result.append(word)

        return result


    def get(self, primary_text: str, secondary_text: str) -> float:
        """数値の矛盾が存在するか検知する"""

        t1 = self.get_quantity(primary_text)
        t2 = self.get_quantity(secondary_text)

        f1 = 0.9

        for word in t2:
            if word not in t1:
                f1 = 0.1

        return f1

固有表現一致

ちょっと参考元の記事とはずれますが、GiNZAで固有表現としてとれたものすべて抽出してみたいと思います。

import pandas as pd
import ginza
import spacy
from spacy import displacy

class FeatureNamedEntity:
    def __init__(self):
        super().__init__()
        self.nlp = spacy.load('ja_ginza')

    def get_named_entity(self, text: str):
        """固有表現の抽出"""
        doc = self.nlp(text)

        result = []
        word = ""

        for sent in doc.sents:
            for token in sent:
                if token._.ne != "": # 何らかの固有表現だったら

                    # 節にまとめる
                    if token._.ne.startswith("B_") and word != "":  # 新規の数詞のとき
                        result.append(word)
                        word = ""

                    word = word + token.orth_

        if word != "":
            result.append(word)

        return result


    def get(self, primary_text: str, secondary_text: str) -> float:
        """同じ固有表現が存在するか判定する"""

        t1 = self.get_named_entity(primary_text)
        t2 = self.get_named_entity(secondary_text)

        f2 = 0.9

        for word in t2:
            if word not in t1:
                f2 = 0.1

        return f2

内容語一致率

こちらは、2つの文の間で共通の単語を見つけます。 参考元の文献では、wikipediaを元に関連語を推測してそこまで判定しているようですが、今回は簡易的に完全一致の単語にしぼりたいと思います。

import pandas as pd
import ginza
import spacy
from spacy import displacy

class FeatureIntersectionWords:
    def __init__(self):
        super().__init__()
        self.hinsi = ["NOUN", "VERB", "ADJ", "ADV"]
        self.nlp = spacy.load('ja_ginza')


    def get_words(self, text: str):
        """内容語の抽出"""
        doc = self.nlp(text)

        result = []

        for sent in doc.sents:
            for token in sent:
                if token._.ne == "" and token.pos_ in self.hinsi:
                    result.append(token.lemma_)

        return result


    def get(self, primary_text: str, secondary_text: str) -> float:
        """内容語の一致率を取得する"""

        t1 = self.get_words(primary_text)
        t2 = self.get_words(secondary_text)

        count = 0

        for word in t2:
            if word not in t1:
                count += 1

        if len(t2) != 0:
            f3 = count / len(t2)
        else :
            f3 = 0

        return f3

内容語の先頭文字の一致率

内容語の先頭文字の一致率も特徴量とします。

import pandas as pd
import ginza
import spacy
from spacy import displacy


class FeatureInitialCharactor:
    def __init__(self):
        super().__init__()
        self.hinsi = ["NOUN", "VERB", "ADJ", "ADV"]
        self.nlp = spacy.load('ja_ginza')

    def get_words(self, text: str):
        """内容語の先頭文字の抽出"""
        doc = self.nlp(text)

        result = []

        for sent in doc.sents:
            for token in sent:
                if token._.ne == "" and token.pos_ in self.hinsi:
                    result.append(token.orth_[0])

        return result


    def get(self, primary_text: str, secondary_text: str) -> float:
        """内容語の先頭文字の一致率を取得する"""

        t1 = self.get_words(primary_text)
        t2 = self.get_words(secondary_text)

        count = 0

        for word in t2:
            if word not in t1:
                count += 1
        if len(t2) != 0:
            f4 = count / len(t2)
        else:
            f4 = 0

        return f4

word2vecコサイン距離

こちらはGiNZAに付属している学習済みのword2vecのベクトルを使用して、単語の数だけ平均します。

import pandas as pd
import ginza
import spacy
from spacy import displacy
import math


class FeatureW2V:
    def __init__(self):
        super().__init__()
        self.hinsi = ["NOUN", "VERB", "ADJ", "ADV"]
        self.nlp = spacy.load('ja_ginza')

    def get_words(self, text: str):
        """内容語の抽出"""
        doc = self.nlp(text)

        result = []

        for sent in doc.sents:
            for token in sent:
                if token._.ne == "" and token.pos_ in self.hinsi:
                    result.append(token.lemma_)

        return result


    def get(self, primary_text: str, secondary_text: str) -> float:
        """W2Vのコサイン距離の最大値の平均を取得する"""

        t1 = self.get_words(primary_text)
        t2 = self.get_words(secondary_text)

        l_similarity = []

        for w2 in t2:
            w_t2= self.nlp(w2)
            similarity = 0

            for w1 in t1:
                w_t1= self.nlp(w1)
                if similarity < w_t1.similarity(w_t2):
                    similarity = w_t1.similarity(w_t2)
                
            l_similarity.append(similarity)

        if len(l_similarity) != 0:
            f5 = sum(l_similarity) / len(l_similarity)
        else:
            f5 = 1

        return f5

排他語の有無

元の文献では排他語を日本語 WordNetを使って判定しています。 ちょっとめんどくさかったので、こちらは省略します。

内容語不一致率

内容語の一致率とは反対に、不一致率についても計算します。

import pandas as pd
import ginza
import spacy
from spacy import displacy

class FeatureNonIntersectionWords:
    def __init__(self):
        super().__init__()
        self.hinsi = ["NOUN", "VERB", "ADJ", "ADV"]
        self.nlp = spacy.load('ja_ginza')


    def get_words(self, text: str):
        """内容語の抽出"""
        doc = self.nlp(text)

        result = []

        for sent in doc.sents:
            for token in sent:
                if token._.ne == "" and token.pos_ in self.hinsi:
                    result.append(token.lemma_)

        return result


    def get(self, primary_text: str, secondary_text: str) -> float:
        """内容語の不一致率を取得する"""

        t1 = self.get_words(primary_text)
        t2 = self.get_words(secondary_text)

        count = 0

        for word in t2:
            if word not in t1:
                count += 1

        if len(t2) != 0:
            f7 = len(t2) - count / len(t2)
        else :
            f7 = 1

        return f7

LightGBMへ突っ込む

最後に作った特徴量をLightGBMへ突っ込んでいきます。

import lightgbm as lgb
import pandas as pd

class LightGBMClassifer:
    def __init__(self):
        # LightGBMのハイパーパラメータを設定
        self.lgb = lgb.LGBMClassifier()    

    def train(self, X_train, y_train, X_valid, y_valid):
        train_data = lgb.Dataset(X_train, label=y_train)
        validation_data = lgb.Dataset(X_valid, label=y_valid, reference=train_data)

        params = {
          'metric': 'gbdt',      # GBDTを指定
          'objective': 'binary',        # 多クラス分類を指定
          'metric': "binary_logloss",   # 多クラス分類の損失(誤差)
          'learning_rate': 0.05,        # 学習率
          'num_leaves': 21,             # ノードの数
          'min_data_in_leaf': 3,        # 決定木ノードの最小データ数
          'early_stopping_rounds':100,  # 
          'num_iteration': 1000}        # 予測器(決定木)の数:イタレーション

        self.model = lgb.train(params,
               train_set=train_data,
               valid_sets=validation_data,
               num_boost_round=10000,
               early_stopping_rounds=100,
               verbose_eval=50)
        
        return self.model

結果

結果としては精度はこんなもんになりました。

              precision    recall  f1-score   support

           0     0.3529    0.1348    0.1951        89
           1     0.6402    0.8616    0.7346       159

    accuracy                         0.6008       248
   macro avg     0.4966    0.4982    0.4649       248
weighted avg     0.5371    0.6008    0.5410       248

まあ、こんなもんなんでしょう。 いかに前回のBERTの精度が良かったかがわかる結果になりました。

使ったコード

今回使った残骸はこちら。

github.com

今回は諸事情によりMetaflowを使ってコードを書いていますので、その点はご注意ください。

参考にさせていただいた記事

この記事を書くにあたって、下記の記事を参考にさせていただきました。

watlab-blog.com

blog.amedama.jp

感想

前回は、過去にこのタスクでSOTAをこともあるBERTをいきなり使ったので、出てきた結果がどれだけすごいのかわかりませんでしたが、今回やってみて改めてただBERTに突っ込むだけでかなり良いスコアが出ることがわかりました。

実際には、BERTを始めとするNN系のアルゴリズムとLightGBMなどの木系のアルゴリズムはアンサンブルするとさらに効果が上がることも期待されるので、組み合わせて使うのがこの先やることになるのかなと思います。