もふもふ技術部

IT技術系mofmofメディア

spaCy + GiNZAを使って固有表現抽出とカスタムモデルの学習をしてみる

自然言語処理をするときはよくRasa NLUを使っているのですが、内部的にはspaCyが使われている模様です。どちらもパイプラインでモジュールをつなげていって自然言語処理をシンプルにするフレームワークだと理解しているのですが、spaCy単独で使うとどういう感じなのか把握したかったんで試してみます。

こちらのエントリを参考にspaCyの基本的な動きを確認。

https://qiita.com/moriyamanaoto/items/e98b8a6ff1c8fcf8e293

$ mkdir spacy-ner
$ cd spacy-ner

必要なライブラリをインストール。GiNZAはspaCyフレームワークのっかった形で提供されている日本語の学習済みモデルを含むライブラリです。簡単にいえばspaCyを日本語で動かせるようにするものです。

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

jupyter notebookを起動

$ jupyter notebook

参考にしたエントリのコードを1つずつ試してみます。spaCy + GiNZAを使用したトークナイズ、品詞タグ付け。文章のネタは鬼滅の刃のwikipediaから。

import spacy

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

doc = nlp("大正時代を舞台に、主人公(竈門炭治郎)とその友人(山田太郎)が家族を殺した「鬼」と呼ばれる敵や鬼と化した妹を人間に戻す方法を探すために戦う姿を描く和風剣戟奇譚")

for token in doc:
    print(token.text, token.pos_, token.vector[:2]) 

結果。それっぽく動いている。

大正 PROPN [ 1.2609882 -1.8544328]
時代 NOUN [0.17437701 1.2378558 ]
を ADP [ 0.14823031 -0.36073384]
舞台 NOUN [-1.0718312   0.45039943]
に ADP [-0.3364834 -0.3304015]
、 PUNCT [-0.13570154  0.03189861]
主人公 NOUN [-0.8172074 -0.6832892]
( PUNCT [0. 0.]
竈門 PROPN [0. 0.]
炭 NOUN [-0.04609787 -1.3838435 ]
治郎 PROPN [0.27201614 0.41034862]
) PUNCT [0. 0.]
と ADP [-0.02821635  0.5194656 ]
その DET [-0.2767215   0.02424498]
友人 NOUN [1.3353887  0.12673263]
( PUNCT [0. 0.]
山田 PROPN [-1.2223538  1.7251085]
太郎 PROPN [-1.0317757  -0.38781956]
) PUNCT [0. 0.]
が ADP [-0.4157958   0.29251373]
家族 NOUN [ 1.5659457  -0.66452795]
を ADP [ 0.14823031 -0.36073384]
殺し VERB [2.1111438 0.4228326]
た AUX [ 0.66177905 -0.2524343 ]
「 PUNCT [ 0.01591846 -0.05320641]
鬼 NOUN [-0.62780833 -1.4280137 ]
」 PUNCT [-0.10184797  0.06876247]
と ADP [-0.02821635  0.5194656 ]
呼ば VERB [-1.1725932   0.23408073]
れる AUX [-0.8632934  -0.51987904]
敵 NOUN [-1.6597164 -1.3300856]
や ADP [-0.30917946 -0.268496  ]
鬼 NOUN [-0.62780833 -1.4280137 ]
と ADP [-0.02821635  0.5194656 ]
化し VERB [ 0.37669662 -0.1019146 ]
た AUX [ 0.66177905 -0.2524343 ]
妹 NOUN [-0.00991604 -0.41693878]
を ADP [ 0.14823031 -0.36073384]
人間 NOUN [-0.37564573 -0.43735412]
に ADP [-0.3364834 -0.3304015]
戻す VERB [0.11787337 1.1006371 ]
方法 NOUN [-0.23677486 -0.20755866]
を ADP [ 0.14823031 -0.36073384]
探す VERB [0.9846685 0.5519358]
ため NOUN [0.6051109  0.03168863]
に ADP [-0.3364834 -0.3304015]
戦う VERB [-0.81541866 -0.3226985 ]
姿 NOUN [ 1.0275202  -0.53041035]
を ADP [ 0.14823031 -0.36073384]
描く VERB [ 0.390107  -0.9785259]
和風 NOUN [-1.6279625 -1.2886974]
剣戟 NOUN [-0.36807743 -0.6534568 ]
奇譚 NOUN [-0.13639468  0.12651034]

続いて学習済みモデルの固有表現抽出。

doc = nlp("大正時代を舞台に、主人公(竈門炭治郎)とその友人(山田太郎)が家族を殺した「鬼」と呼ばれる敵や鬼と化した妹を人間に戻す方法を探すために戦う姿を描く和風剣戟奇譚")

for ent in doc.ents: 
    print(ent.text, ent.label_) 

結果。鬼滅の刃はわりかし最近のマンガなので、「竈門炭治郎」はコーパスに入っていない模様。一方で適当に文章に追加しておいた「山田太郎」は人名として抽出出来ている。

大正時代 DATE
山田太郎 PERSON

spaCyには可視化するためのメソッドも準備されている。

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

ラベル付けされた箇所がひと目で分かる。

visualization1

続いて、「竈門炭治郎」をルールベースで抽出出来るようにします。学習モデルには追加していないので、単純なキーワードマッチになると思う。

from spacy.pipeline import EntityRuler 

nlp = spacy.load("ja_ginza")
patterns = [{"label": "PERSON", "pattern": "竈門炭治郎"}]

ruler = EntityRuler(nlp)
ruler.add_patterns(patterns)
nlp.add_pipe(ruler) 

doc = nlp("大正時代を舞台に、主人公(竈門炭治郎)とその友人(山田太郎)が家族を殺した「鬼」と呼ばれる敵や鬼と化した妹を人間に戻す方法を探すために戦う姿を描く和風剣戟奇譚")

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

visualization2

単語には分散表現ベクトルが割り当てられている。詳しく調べていないが、パッと見word2vecで得た分散表現っぽい。

print(doc[0])
print(doc[0].vector)
大正
[ 1.2609882  -1.8544328  -0.01765668 -1.7253405  -0.51265866  3.080516
 -0.00494834  1.2680451  -0.53580296 -0.9260193  -0.83004767  0.87986016
  2.5190527   0.48240358  0.6395301  -2.5245152   1.0259303   0.87750596
 -0.4642734   0.4478059   1.5369825   0.37122342  1.2176057  -0.61093205
 -1.8588319  -0.3247063  -1.1391201  -2.5158114   0.48598552  0.17967227
 -0.909635    0.7024898  -0.67727345 -0.03285981  0.46746278 -0.24119906
 -1.2951324   1.9623458  -0.7238021  -0.23148884 -0.08193963 -0.09844626
 -0.32880294 -0.03795228 -0.0930308   3.6376054   1.534259    1.3507557
  0.5824114  -0.36070898  1.8535563  -1.4741706  -2.0518763   0.5220707
 -2.1441307  -2.0625668   2.3251622  -0.0181041  -0.48446006  1.8046355
 -2.5405672   1.6827643   0.8025467  -3.067421   -2.1723306   0.56597745
  0.67912555 -0.2858433  -0.6009591  -1.2793301  -0.2257984  -0.01186415
  0.9099855   1.3142278  -2.0913594  -1.1585426   0.78215873  1.7032512
 -2.092018   -0.40028927  0.07626612  1.8400302  -1.5168506   0.50777805
 -0.59304506  1.4314798  -0.13076243 -2.8678913  -0.6400403  -1.3437397
  0.70188963  0.8539209  -0.4367367   0.10333569  1.6020036   1.0228035
 -0.6453279   1.655896   -1.4225256   3.0131648 ]

自前のデータを学習させて、独自の固有表現抽出モデルを構築する

このエントリを参考にした。コードはそのまま拝借して、学習データだけ日本語に差し替えてます。

https://medium.com/@manivannan_data/how-to-train-ner-with-custom-training-data-using-spacy-188e0e508c6

import spacy
import random


TRAIN_DATA = [
              ('時は大正。主人公・竈門炭治郎は亡き父親の跡を継ぎ、炭焼きをして家族の暮らしを支えていた。', {'entities': [(9, 14, 'CHARACTER')]}), 
              ('禰󠄀豆子に襲われかけた炭治郎を救ったのは冨岡義勇と名乗る剣士だった。', {'entities': [(20, 24, 'CHARACTER')]}), 
              ('鬼化して辛うじて生き残った禰󠄀豆子を人間に戻すため、冨岡義勇の紹介で鱗滝の元を訪れる', {'entities': [(26, 30, 'CHARACTER')]}), 
              ('修得者は竈門炭治郎・冨岡義勇・鱗滝左近次。基本型は10つ。日輪刀の色は青色', {'entities': [(4, 9, 'CHARACTER'), (10, 14, 'CHARACTER')]})
]


def train_spacy(data,iterations):
    TRAIN_DATA = data
#     nlp = spacy.blank('en')  # create blank Language class
    nlp = spacy.blank('ja')  # create blank Language class
    # create the built-in pipeline components and add them to the pipeline
    # nlp.create_pipe works for built-ins that are registered with spaCy
    if 'ner' not in nlp.pipe_names:
        ner = nlp.create_pipe('ner')
        nlp.add_pipe(ner, last=True)
       

    # add labels
    for _, annotations in TRAIN_DATA:
         for ent in annotations.get('entities'):
            ner.add_label(ent[2])

    # get names of other pipes to disable them during training
    other_pipes = [pipe for pipe in nlp.pipe_names if pipe != 'ner']
    with nlp.disable_pipes(*other_pipes):  # only train NER
        optimizer = nlp.begin_training()
        for itn in range(iterations):
            print("Statring iteration " + str(itn))
            random.shuffle(TRAIN_DATA)
            losses = {}
            for text, annotations in TRAIN_DATA:
                nlp.update(
                    [text],  # batch of texts
                    [annotations],  # batch of annotations
                    drop=0.2,  # dropout - make it harder to memorise data
                    sgd=optimizer,  # callable to update weights
                    losses=losses)
            print(losses)
    return nlp


prdnlp = train_spacy(TRAIN_DATA, 20)

# Save our trained Model
modelfile = input("Enter your Model Name: ")
prdnlp.to_disk(modelfile)

#Test your text
test_text = input("Enter your testing text: ")
doc = prdnlp(test_text)
for ent in doc.ents:
    print(ent.text, ent.start_char, ent.end_char, ent.label_)

4件のテキストしかないが、まあlossが収束している様子がうかがえる。

「正確な関係性は吾峠や集英社側から明かされていないが、福岡県太宰府市の竈門神社が、主人公の竈門炭治郎の姓と同じ」を入力すると、正しく「竈門炭治郎」がCHARACTERとして抽出される。

Statring iteration 0
{'ner': 82.17208164930344}
Statring iteration 1
{'ner': 41.146095491945744}
Statring iteration 2
{'ner': 12.008268219657566}
Statring iteration 3
{'ner': 9.588352275736817}
Statring iteration 4
{'ner': 7.213292599983049}
Statring iteration 5
{'ner': 18.256361221541738}
Statring iteration 6
{'ner': 11.388036334006756}
Statring iteration 7
{'ner': 41.27039462463952}
Statring iteration 8
{'ner': 8.117803982249697}
Statring iteration 9
{'ner': 3.42672460889456}
Statring iteration 10
{'ner': 1.9930606423816382}
Statring iteration 11
{'ner': 1.3308087871567078}
Statring iteration 12
{'ner': 7.690899508374623}
Statring iteration 13
{'ner': 14.756750680556053}
Statring iteration 14
{'ner': 1.3003842644948473}
Statring iteration 15
{'ner': 1.078062185809716}
Statring iteration 16
{'ner': 0.023442613068813335}
Statring iteration 17
{'ner': 0.0007957125476218103}
Statring iteration 18
{'ner': 0.02575388511765824}
Statring iteration 19
{'ner': 0.04215925145704117}
Enter your Model Name: hogemdl
Enter your testing text: 正確な関係性は吾峠や集英社側から明かされていないが、福岡県太宰府市の竈門神社が、主人公の竈門炭治郎の姓と同じ
竈門炭治郎 44 49 CHARACTER