spaCyを使ってルールベースの記述をシンプルに！

この記事は[自然言語処理アドベントカレンダー 2019](https://qiita.com/advent-calendar/2019/nlp)の12日目です。

昨今自然言語処理界隈ではBERTを始めとする深層学習ベースの手法が注目されています。
一方それらのモデルは計算リソースや推論速度の観点で制約が大きく、プロダクション運用の際は留意すべき事項を多く持ちます。
（[googleが検索にBERTを導入](https://wired.jp/2019/11/14/google-search-advancing-grade-reading/)というニュースを見た時はとても驚きました）

そこで本記事では自然言語処理タスクのシンプルかつ運用しやすい実装方法を考えていきます。
実装にはpythonと以降説明するspaCyとGiNZAの2つのライブラリを使います。

環境: 
ubuntu18.04
python 3.6.8
ライブラリインストールはpipから行います

``` 
pip install spacy
pip install "https://github.com/megagonlabs/ginza/releases/download/latest/ginza-latest.tar.gz"
```

# 今回行うタスク

実務で需要が多いと思われる以下の2タスクを取り上げます。
1. 固有表現抽出
2. フレーズ抽出

##固有表現抽出とは
固有表現抽出(NER)を[Wikipedia](https://ja.wikipedia.org/wiki/%E5%9B%BA%E6%9C%89%E8%A1%A8%E7%8F%BE%E6%8A%BD%E5%87%BA)から引用すると

>
固有表現抽出（こゆうひょうげんちゅうしゅつ、英: named entity recognition、named entity identification、named entity chunking、named entity extraction）とは、計算機を用いた自然言語処理技術の一つであり、情報抽出の一分野である。文中から固有表現 (Named Entity) を抽出し、それを固有名詞（人名、組織名、地名など）や日付、時間表現、数量、金額、パーセンテージなどのあらかじめ定義された固有表現分類へと分類する。

と説明されています。

文から情報を取り出す際に、あらかじめ検索対象の単語や文が分かる場合はそれを元に検索すればよいが、対象が多岐に渡る場合（人物名や組織名など）それらの名称は事前に全て把握せずともまとめて「人物が含まれる」「企業が含まれる」といった固有表現を指定して出力させたいニーズもあると思います。

固有表現抽出は自然言語処理において主要なタスクの一つであり、文脈を考慮して文中の各単語がどの固有表現を持つかをを予測します。機械学習でこれを解く際は学習データとして文を単語に分割し、それぞれの単語に固有表現のラベルを割りあてたものを学習していきます。

これを解くモデルには様々な手法があり、最先端は深層学習を用いた実装になっています。
今回は手元に十分なラベル付き学習データが無く、かつ計算環境が限られていてケースを想定し直接ルールベースで対応したいと思います。

### フレーズ抽出
大量の文書から特定の情報が含まれる文を抽出したい場合、通常は正規表現を使って行ったり、事前に転置インデックスが構築されたデータベースから行うことが多いと思います。
単語ベースの検索であればそちらは高速で有効ですが、単語情報と品詞情報をかけ合わせた検索や前述の固有表現を織り交ぜた検索をする場合は正規表現のパターンやデータベースの構造を複雑にする必要があります。

ここでもできるだけ簡単な記述で記述で対応したいと思います。



# spaCyについて

直接書くコードの量を最小にしたいため、[spaCy](https://spacy.io/)というpython/Cpyhonベースの自然言語処理ライブラリを使って実装します。
spaCyはpython/Cpythonで実装されたオープンソースの自然言語処理ライブラリであり、研究向けではなく商用利用を念頭に設計がされています。本記事ではspaCyについて最小限の説明にとどめますが、詳細な解説として[こちらの記事](https://www.ogis-ri.co.jp/otc/hiroba/technical/similar-document-search/part4.html)を強くおすすめします。
本記事ではspaCyにフォーカスしていますが、spaCyで日本語を扱う際に[GiNZA](https://megagonlabs.github.io/ginza/)の役割はとても重要になりますので、[GiNZAの解説資料](https://www.slideshare.net/MegagonLabs/nlp2019-ginza-139011245)もご覧いただくとより理解が深まります。

###spaCyの基本操作
日本語の文に対して前処理を行う際、以下のステップがが含まれることは多い思います。

1. 文に対してMeCabなど形態素解析器を通じて単語に分割
    - 必要に応じて品詞情報や固有表現を抽出
    - 必要に応じて単語のかかり受け情報を抽出
2. 単語を埋め込みベクトルに変換

これらの処理は通常個別に記述する必要があり、また形態素辞書や学習済みの埋め込みベクトルの管理は煩雑になることが多いのではないでしょうか。

spaCyではこれら処理をパイプラインとして記述する場合が多く、上記の処理は予めパイプラインに組み込まれているため、いきなりメインの処理から書き始めることが可能です。

以降[ヒントン先生](https://ja.wikipedia.org/wiki/%E3%82%B8%E3%82%A7%E3%83%95%E3%83%AA%E3%83%BC%E3%83%BB%E3%83%92%E3%83%B3%E3%83%88%E3%83%B3)のWikipediaの説明文をサンプルに各種処理を見ていきます。

```py3

import spacy

# 日本語自然言語処理のパイプラインを構築されたGiNZAをspaCyから読み込む
nlp = spacy.load('ja_ginza')  

doc = nlp("ジェフリー・ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。\
ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働いている")
for token in doc:
    print(token.text, token.pos_, token.vector[:2]) # 表示簡潔化のため単語ベクトルの最初の2次元のみ出力
```
spacy.loadを通じて事前に定義されたパイプラインと学習済みモデルを読み込み、そこに文を入力することで内部で単語分割と品詞情報を付与したDocオブジェクトが生成されます。各単語はTokenオブジェクトになっており、品詞情報や埋め込みベクトルを呼び出すことが出来ます。
パイプラインでは様々なコンポーネントを追加でき、回帰や分類といった予測も簡単に出来ます。

上のコードは文を単語に分割し、品詞タグと埋め込みベクトルを出力した結果が以下です。

```
ジェフリー PROPN [0.66484624 1.3811903 ]
・ PUNCT [-0.3316787  1.6892753]
ヒントン PROPN [0. 0.]
は ADP [-0.05453783  0.24504063]
、 PUNCT [-0.13570154  0.03189861]
イギリス PROPN [1.5407685  0.04288484]
生まれ NOUN [-0.00636089 -1.0759026 ]
の ADP [-0.15094382 -0.08154755]
コンピュータ NOUN [-0.3816494 -2.0670304]
科学 NOUN [-1.0397909 -1.9793453]
および CCONJ [-1.4511517  -0.09837845]
認知 NOUN [-1.3548261 -0.8274419]
心理学 NOUN [-1.1813248  -0.37017956]
の ADP [-0.15094382 -0.08154755]
研究者 NOUN [-1.6646457  -0.17380947]
。 PUNCT [-0.13984448 -0.6916542 ]
ニューラル ADJ [-0.71282196 -0.08247709]
ネットワーク NOUN [-0.5794245 -0.9146547]
の ADP [-0.15094382 -0.08154755]
研究 NOUN [-1.776817    0.00459613]
で ADP [-0.11067865 -0.5482236 ]
有名 ADJ [ 0.03135953 -1.3499146 ]
。 PUNCT [-0.13984448 -0.6916542 ]
現在 NOUN [-0.89682883 -0.43857205]
は ADP [-0.05453783  0.24504063]
、 PUNCT [-0.13570154  0.03189861]
トロント大学 PROPN [0. 0.]
と ADP [-0.02821635  0.5194656 ]
Google PROPN [0. 0.]
で ADP [-0.11067865 -0.5482236 ]
働い VERB [ 1.851757   -0.10906074]
て SCONJ [ 0.0499614  -0.00680515]
いる AUX [-0.8694664  -0.26786625]
```

他にもかかり受けなど情報を取り出すことが出来ます。

## 固有表現抽出をカスタマイズする
さて、いよいよ固有表現抽出に取り組みます。
おさらいすると固有表現抽出は文中の単語がそれぞれどんな固有表現を持つかを予測することです。先ほどの文に対して固有表現と取り出してみます。

```py3

doc = nlp("ジェフリー・ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。\
ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働いている")

for ent in doc.ents:  # 固有表現を抜き出すentsを指定
    print(ent.text, ent.label_) 
```

出力結果

```
ジェフリー・ヒントン PERSON
イギリス LOC
```

ちなみにspaCyでは出力の可視化機能が豊富にあります。Jupyter環境で可視化するには以下のspacy.displacyを使います。

```py3
from spacy import displacy
displacy.render(doc, style="ent")
```

出力結果
![Screenshot from 2019-12-08 02-26-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65821/d745574c-84c4-156d-f7c6-43e12c0112c9.png)


デフォルトの学習済みモデルでは「ジェフリー・ヒントン」対して人物を示すPERSONのタグ、「イギリス」に対して場所を示すLOCのタグを認識することに成功していますが、できれば「トロント大学」と「Google」に組織を示すORGとして認識させたいです。

これを実現するにはモデルを学習し直すことがベストですが、今回は手元に学習データを用意できない場合を想定しているためルールベースで直接記述します。

対象の語とそのラベル（固有表現）を辞書形式で記述します。

```py3
import spacy
from spacy.pipeline import EntityRuler  # 固有表現を記述できるEntityRulerを使います

nlp = spacy.load("ja_ginza")
patterns = [{"label": "ORG", "pattern": "Google"},
            {"label": "ORG", "pattern": "トロント大学"}]  # Googleとトロント大学に<ORG>を割り当てるルールを辞書として記述

ruler = EntityRuler(nlp)  # 固有表現のルールを処理するコンポーネントを生成
ruler.add_patterns(patterns)  # コンポーネントにルールを組み込む
nlp.add_pipe(ruler)  # パイプラインにコンポーネントを追加

doc = nlp("ジェフリー・ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。\
ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働いている")

displacy.render(doc, style="ent")
```

出力
![Screenshot from 2019-12-08 16-34-50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65821/cdaa45ae-94ac-283e-14a2-1bd85b59c9a6.png)


Googleとトロント大学がORGとして認識されました。
なお、パターンが多岐にわたる場合はそれらをJSONファイルに記述して、spaCyでそれを読み込むことも可能です。

ここまではspaCyを使わなくとも正規表現のマッチングで実現できますが、spaCy上で行うメリットとしてパイプラインを複雑化すること無く、シンプルな記述で実現できることに価値があると考えています。

### フレーズマッチング

先ほどの固有表現抽出では特定の語を固有表現として登録することで認識できる方法を見ました。一方、大量の文書から特定の情報が含まれる文を取り出す場合、網羅すべき語を事前に多く列挙することが難しい場合があります。

ここで人物とその人物に関する情報を収集するケースを考えます。仮説として**人物名 + 位置情報 + 組織情報**が含まれる文に情報価値が高いと考え、まずはそれを取り出すことから着手するとします。正規表現のみで行う場合は人物名や組織名、場所の名称を正規表現パターンに記述する必要がありますので、対象が多く記述と把握が難しいです。

そこで前述の固有表現の情報を使ってルールを書きます。

```py3

import spacy
from spacy.matcher import Matcher #マッチングを行うMatcherを使います。

nlp = spacy.load('ja_ginza')
matcher = Matcher(nlp.vocab)  # matcherオブジェクトに語彙を渡す

# 固有表現がPERSON + (任意の文字列) + LOC + (任意の文字列) +「働」が含まれる語のルールを記述
pattern = [{"ENT_TYPE": "PERSON"}, {"OP": "*"}, {"ENT_TYPE": "LOC"}, {"OP": "*"}, {"TEXT": {"IN": ["働く", "働い"]}}]
# ルールは複数記述出来、それぞれのルールに名称をつけられます（ここではperson_infoとします）
# matcherには中間処理をカスタマイズするコールバック関数を渡せますが、ここでは使わないのでNoneとします
matcher.add("person_info", None, pattern)  # ルールをmatcherに登録

doc = nlp("ジェフリー・ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。\
ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働いている。")
matches = matcher(doc) # docの解析結果をmatcherオブジェクトに渡してマッチングを行う

for match_id, start, end in matches:  # マッチング結果はリストとして渡される
    string_id = nlp.vocab.strings[match_id]  # マッチしたルールの名前を取り出す
    span = doc[start:end]  # マッチした文字列
    print(string_id, span.text)
```

出力
>
person_info ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働い
>
person_info ・ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働い
>
person_info ジェフリー・ヒントンは、イギリス生まれのコンピュータ科学および認知心理学の研究者。ニューラルネットワークの研究で有名。現在は、トロント大学とGoogleで働い

定義したperson_infoのルールでそれぞれの文が表示されています。
(固有表現抽出のステップで「ジェフリー・ヒントン」がファーストネームとラストネームの両方で人物の可能性が発生したため、分割方法に応じて3種の出力がされています。この場合は最も長い句を使って良いと思います。)

このように個別の人物名や組織を記述すること無く少量でシンプルなコードで実現できました。
もちろんspaCyを使わなくとも同様な処理は可能ですが、その場合はそれぞれの機能を実装したり保守のコストは少し高くなるかもしれません。

※ここまでの内容ではspaCyが正規表現より優れるような印象を与えたかもしれませんが、実行速度を求められる場合は正規表現のほうが圧倒的に優れることをご留意ください。

#終わりに
以上spaCyに関する簡単な紹介とそれを使った処理の例を書かせていただきました。spaCyは多機能であり、上に紹介した内容はあくまで一部の機能のみです。一度ドキュメントを見ていただくと機能の豊富さに驚くかもしれません。自然言語処理に関連する機能はほとんど揃っていると思います。
この記事を機にspaCyユーザーが一人でも増えればこの上ない喜びです。

最後となりますが私が勤めている[MNTSQ](https://www.mntsq.co.jp/)では自然言語処理エンジニアを募集しています。リーガルテックと自然言語処理に関心がある方はぜひ[私](https://twitter.com/vimmode)までご連絡ください！