GiNZAのja-ginza-electraモデルでELECTRAベースの単語ベクトルを使用できるようにする

はじめに

R&Dチーム所属の伊藤です。相も変わらず自然言語処理と格闘する毎日を送っています。

今回は個人的にとても楽しみにしていたGiNZA v5の新モデルであるja-ginza-electraを使って、前後の文脈を加味した単語ベクトルを求められるようにするまでの手順をまとめました。

GiNZA v5について

GiNZAspaCyをベースにしたPythonの日本語向け自然言語処理ライブラリです。 形態素解析をはじめとして、固有表現抽出や品詞タグ付け、構文解析などを行うことが可能です。

このGiNZAですが、2021年8月26日に最新バージョンであるv5が公開されました。 GiNZA v5での最も大きな変更点はspaCy v3以降で導入されたTransformersとの連携機能の採用です。 Transformersはディープラーニングを用いた事前学習モデルを提供するライブラリであり、BERTXLNetGPT-2等の有名なモデルを簡単に扱うことができます。

GiNZAはv4までja-ginzaというCNNベースのモデルのみを提供してきましたが、v5よりja-ginza-electraというTransformersを使用した解析モデルもリリースされました。 このモデルはELECTRAと呼ばれるBERTの亜種モデルを用いて学習されたもので、従来のja-ginzaモデルと比べて実行速度が落ちるものの、高い解析精度を実現しています。

両モデルを扱うにあたって、基本的なインターフェイス にほぼ変わりはありません。 そのため、単純に精度重視のモデルと速度重視のモデルを使い分けられるようになったという印象です。

セットアップ

GiNZAはPython3.6以上(と対応するpip)での実行を推奨しています。 GiNZAのインストールはpipを用いてワンライナーで行えます。下記コマンドによりspaCyやTransformersを含め、全てのライブラリがインストールされます。

pip install -U ginza ja-ginza ja-ginza-electra

なお、ja-ginzaとja-ginza-electraについては、使用したいモデルのみを指定するだけでも大丈夫です。

モデルのロード + 文の解析方法

下記のコードは、GiNZAモデルをロードして「今日はいい天気です。」という文をトークナイズ(文を単語毎に分割)する例になります。

import spacy

nlp = spacy.load('ja_ginza_electra')  # ja_ginza_electraモデルのロード
# nlp = spacy.load('ja_ginza')  ja-ginzaモデルを使用する場合

doc = nlp("株式会社オプティムは2000年に創業されました。")
print([token for token in doc])  # docはトークンを要素に持つイテレータになる
# => [株式会社, オプティム, は, 2000, 年, に, 創業, さ, れ, まし, た, 。]

この例で得られたdocですが、トークナイズの結果だけではなく固有表現抽出や品詞タグ付け、構文解析等の全ての結果が含まれています。 また、解析のインターフェイス はja_ginza_electraモデルとja_ginzaモデルでほぼ同じように使用できるため、spacy.load()の引数を変更するだけで簡単にモデルを切り替えることが可能です。

行いたいこと

GiNZAのja-ginzaモデルでは、文章を解析することで得られるdocと、その最小の分割単位であるtoken、そしてspan(tokenの集まり)に対してベクトルが提供されます。

import spacy

nlp = spacy.load('ja_ginza')  # ja-ginzaモデルのロード

doc = nlp('今日はいい天気です')  # 文の解析

print(doc.text)  # doc内のテキストの表示
# => 今日はいい天気です
print(doc.has_vector)  # docにベクトルが定義されているか (bool)
# => True
print(doc.vector)  # docに対するベクトル
# => [ 0.09768764 -0.10438251  0.12079241 -0.07675077 -0.11325064  0.14373262
# =>  ...   0.1770648  -0.22321963 -0.0214926  -0.07159497  0.0351887 ]

for token in doc:  #  docを構成する単語を順番にイテレート
    print(token.text, token.has_vector, sum(token.vector))  # sum(token.vector)はベクトルの要素の合計
# => 今日 True 4.391175274271518
# => は True -2.170091641775798
# => いい True 6.659762156414217
# => 天気 True -2.1506830899816123
# => です True -0.5523735368042253

print(doc[0].similarity(doc[3]))  # 単語同士の類似度も計算できる (「今日」と「天気」を比較)
# => 0.51619387    # 類似度はコサイン類似度

ここで扱えるベクトルのうち、いわゆる単語ベクトルというのはtoken毎に定義されているベクトルのことです、span.vectordoc.vectorで求められるベクトルは、それらを構成しているtokenのベクトルを平均したものとなっています。

一方で、ja-ginza-electraではモデルにはベクトルを求めるためのtoken.vectordoc.vectorといったattributeが使えません。 そのため、今回はこれと同じインターフェイスで単語ベクトルを求められるようにja-ginza-electraモデルに機能を追加することを目的とします。

Contextualな単語ベクトル

GiNZA v4やv5のja-ginzaモデルでは、単語ベクトルとしてchiVeを使用しています。 chiVeは静的な単語ベクトルのデータセットであり、ある単語における300次元からなるベクトルを提供します。GiNZAはその単語ベクトルのうち、頻出の35,000語の単語ベクトルのみを使用するようになっています。 ちなみに、chiVeは最大で3,197,456語に対応したベクトルを提供していますので、それを考えるとGiNZAモデルで扱える単語ベクトルは正直なところオマケ程度の存在です。

前述のchiVeや、有名なWord2Vecで得られる単語ベクトルはstatic(静的)な単語ベクトルと呼ばれることがあります。これは、ある単語に対して定義されるベクトルは常に変わらないということです。 しかしながら、単語の中には同音異義語が存在します。例えば、「きしゃのきしゃはきしゃできしゃ(貴社の記者は汽車で帰社)」という文において、一つ目の「きしゃ=貴社」と二つ目の「きしゃ=記者」は当然ながら別のものですが、staticな単語ベクトルではこの二つを同じ単語ベクトルで表すことになってしまいます。まあ日本語だと漢字を使えばおおよそ解決します。

ここで登場するのがContextual(文脈的)な単語ベクトルです。Contextualな単語ベクトルはBERTELMoのようなDeep Neural Networkベースのモデルによって得られるベクトル表現であり、文脈に応じた意味を表すベクトルを構築することができます。

GiNZA v5で追加されたja-ginza-electraモデルはELECTRAというBERTと同じアーキテクチャのモデルを採用しているため、その出力をとることでContextualな単語ベクトルを使えるようになることが期待できます。

ELECTRAモデルの出力と単語ベクトルの計算

ELECTERAも含め、Deepな自然言語処理モデルでは、その出力の最終層が単語ベクトルとして用いられることが多いです。spaCyのドキュメントによると、doc._.trf_dataにそのデータがTransformerDataとして格納されているようです。

import spacy
nlp = spacy.load('ja_ginza_electra')  # ja-ginza-electraモデルのロード
doc = nlp('今日はいい天気です')  # 文の解析

print(doc._.trf_data)
# => TransformerData(wordpieces=..,    # 入力文のtokenize結果
# =>    tensors=...,    # ELECTRAの出力データ
# =>    align=...)     # ELECTRAのtokenize結果とspaCyのtokenの対応情報

例えば、ELECTRAの出力を得るには次のようにします。

# docは'今日はいい天気です'という文の解析結果
tensor = doc._._trf_data.tensors[0]    # なぜか要素1のリストに格納されている

ここからある単語に対応する出力を取り出すには、doc.trf_data.alignを使用します。

import itertools
import numpy as np

# docは'今日はいい天気です'という文の解析結果
idx = 0
print(doc[idx])
# => 今日

# '今日'に対応する出力のインデックス
token_idxes = list(itertools.chain.from_iterable(doc._.trf_data.align[idx].data))
print(token_idxes)
# => [1]

tensors = doc._.trf_data.tensors[0]    # 出力を取得
# token_idxesのインデックスはflattenなベクトルに対応しているのでreshapeする
tensors = np.reshape(tensors, (tensors.shape[0] * tensors.shape[1], -1))
print(tensors[token_idxes])
# => [[-5.74883148e-02 -2.75077462e-01  4.10549641e-01  1.40265480e-01
# =>    5.92217445e-02 ... -3.91274303e-01]]
print(tensors[token_idxes].shape)
# => (1, 768)

上記の例では、「今日」という単語に対応する768次元の単語ベクトルが得られました。 ただ、ELECTRAのtokenizerによってサブワードに分割されている単語や、入力が長すぎてオーバーラップが適用されている単語だと、tensors[token_idxes]で得られるベクトルが複数になる場合があります。 その場合のベクトルの扱いとして、今回は単純にそれらのベクトルの平均を単語ベクトルとすることにします。

word_vector = np.mean(tensors[token_idxes], axis=1)

また、入力単語によってはトークナイズ結果が未知語([UNK])になる場合も存在します。その場合はtoken_idxesが空配列になるため、全ての要素が0の単語ベクトルを生成して渡す必要があります。

spaCyのUser hooksの追加

先ほど得られた単語ベクトルをtoken.vectorのようにして使えるようにするために、spaCyのUser hooksを追加します。詳しい解説についてはこのリンクをお読みください。User hooksで追加できる機能はいくつかありますが、今回は以下の3つを実装しました。

  • has_vector : ベクトルがあるかどうかのbool値
  • vector : ベクトルを表す配列
  • similarity : ベクトル同士の類似度を計算する

またspaCyではGPUを使用した推論も可能ですが、その場合はELECTRAの推論結果がCyPyの型として返ってくることを考慮する必要があります。

User hooksの実装は以下になります。

import itertools
import sys

import numpy as np
import spacy
from spacy.language import Language
from spacy.tokens import Doc

try:
    import cupy    # spaCyでGPUが使用可能な場合はcuPyをインポートする必要がある
except ImportError:
    pass

class TrfVectors:
    def __init__(self, name):
        self.name = name

    def __call__(self, doc):
        if not Doc.has_extension('doc_vector'):    # doc._.doc_vectorというattributeを追加
            Doc.set_extension('doc_vector', default=None)
        doc._.doc_vector = self._trf_vectors_getter(doc)    # docを得る際に単語ベクトルを計算する

        # token.vector, span.vector, doc.vectorの定義
        doc.user_token_hooks["vector"] = self.vector
        doc.user_span_hooks["vector"] = self.vector
        doc.user_hooks["vector"] = self.vector

        # token.has_vector, span.has_vector, doc.has_vectorの定義
        doc.user_token_hooks["has_vector"] = self.has_vector
        doc.user_span_hooks["has_vector"] = self.has_vector
        doc.user_hooks["has_vector"] = self.has_vector

        # token.similarity, span.similarity, doc.similarityの定義
        doc.user_token_hooks["similarity"] = self.similarity
        doc.user_span_hooks["similarity"] = self.similarity
        doc.user_hooks["similarity"] = self.similarity

        return doc

    def _trf_vectors_getter(self, doc):
        vectors = []
        tensors = doc._.trf_data.tensors[0]
        if 'cupy' in sys.modules:    # GPUを使用しているならtensorはCuPy配列になるためNumPy配列に変換する必要あり
            tensors = cupy.asnumpy(tensors)
        tensors = np.reshape(tensors, (tensors.shape[0] * tensors.shape[1], -1))
        for idx, token in enumerate(doc):
            token_idxes = list(itertools.chain.from_iterable(token.doc._.trf_data.align[idx].data))
            if token_idxes:
                vectors.append(np.mean(tensors[token_idxes], axis=0))
            else:
                vectors.append(np.zeros(768))    # 入力トークンが未知語だった場合はゼロベクトルとする
        return np.array(vectors)

    def vector(self, item):
        if isinstance(item, spacy.tokens.Token):
            idx = item.i
            return item.doc._.doc_vector[idx]
        else:
            return np.mean([token.vector for token in item], axis=0)

    def has_vector(self, item):
        return True

    def similarity(self, obj1, obj2):
        v1 = obj1.vector
        v2 = obj2.vector
        try:
            return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))    # コサイン類似度
        except ZeroDivisionError:
            return 0.0


@Language.factory('trf_vector', assigns=["doc._.doc_vector"])
def create_trf_vector_hook(nlp, name):
    return TrfVectors(nlp)

docspanに対するベクトルは、ja-ginzaモデルと同じようにtokenベクトルの平均で求めています。また、類似度の計算もja-ginzaと同様にコサイン類似度を使用しています。

ここで定義したUser hookを次のように追加することで、ja-ginza-electraモデルでベクトルが使えるようになります。

# GPUを使用する場合は下記をコメントアウト
# set_gpu_allocator("pytorch")
# gpu_id = 0
# require_gpu(gpu_id)

nlp = spacy.load('ja_ginza_electra')
nlp.add_pipe('trf_vector')    # hookの追加

doc = nlp('きしゃのきしゃはきしゃできしゃ')    # 貴社の記者は汽車で帰社
print([token for token in doc])    # 単語分割の結果
# => [きしゃ, の, きしゃ, は, きしゃ, で, きしゃ]
print(doc.has_vector) 
# => True
print(doc.vector.shape)    # 文に対応するベクトル
# => (768, )
print(doc[0].vector.shape)    # 1番目の'きしゃ'に対応するベクトル
# => (768, )
print(doc[0].similarity(doc[2])    # '貴社'と'記者'を比較
# => 0.87774956
print(doc[0].similarity(doc[4])    # '貴社'と'汽車'を比較
# => 0.8501035
print(doc[0].similarity(doc[6])    # '貴社'と'帰社'を比較
# => 0.73156434
print(doc.[0].similarity(doc[0])   # 同じ単語同士を比較
# => 1.0000001    # 同じ単語では類似度は1になる(計算誤差でわずかに1より大きい)

同じ「きしゃ」という単語において、文脈に応じて別のベクトルが与えられていることが類似度の計算から確認できるため、Contextualな単語ベクトルが得られていると考えられます。

おわりに

GiNZA v5のja-ginza-electraモデルにおいて、Contextualな単語ベクトルを計算するためのUser hookを実装しました。 ただ、ここで得られた単語ベクトルが有用であるかどうかについてはまだ不明なため、それについても検証していきたいと思います。

オプティムでは自然言語処理に興味のあるエンジニアを募集しています。