iMind Developers Blog

iMind開発者ブログ

ginza(spacy)で固有表現抽出のtrain

概要

固有表現抽出を行いたかったので、spacyでnerのtrainを行ってみる。

例として既存のginzaのモデルでは「10,000円」はMONEYとして抽出されるけど「\10,000」は認識されない問題を解決するモデルを作成。

バージョン情報

  • ginza==2.2.0
  • Python 3.7.4

参考ページ

ner(Named Entity Recognition)の学習については下記ページを参考にした。

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

ginzaではtrain_ner.pyというスクリプトが用意されている。

https://github.com/megagonlabs/ginza/blob/develop/ginza_util/train_ner.py

既存の動作の確認

10,000円や1万円がちゃんと抽出できることを確認。

import spacy
nlp = spacy.load('ja_ginza') 

doc = nlp('10,000円の売上')
for ent in doc.ents:  
    print(ent.text, ent.start_char, ent.end_char, ent.label_)  
    #=> 10,000円 0 7 MONEY

doc = nlp('1万円の売上')
for ent in doc.ents:  
    print(ent.text, ent.start_char, ent.end_char, ent.label_)  
    #=> 1万円 0 3 MONEY

\10,000が抽出できないことを確認。

doc = nlp(r'''\10,000の売上''')
for ent in doc.ents:  
    print(ent.text, ent.start_char, ent.end_char, ent.label_) 

   #=> 何も抽出されない

学習によって上記を抽出できるようにしたい。

学習データの用意

参考サイトでは今感じでテストデータを用意している。

TRAIN_DATA = [
    ('what is the price of polo?', {'entities': [(21, 25, 'PrdName')]})
]

設定されるentityの21, 25は切り出す単語のstartとendで、下記のようにindex指定して部分文字列を取り出す際に設定する値と同じになる。

'what is the price of polo?'[21:25]
    #=> polo

バックスラッシュがエスケープされないようにしつつ上の例に倣って訓練データを1つ作ってみる。

# 今回の訓練データ
TRAIN_DATA = [
    (r'''今回の売上は\10,000になります。''', {'entities': [(6, 13, 'MONEY')]})
]

# 表示確認
sent = TRAIN_DATA[0][0]
sent[6:13]
    #=> '\\10,000'

訓練の実行

まず既存のja_ginzaのモデルをロード。

import spacy

# 既存のモデルのロード
nlp = spacy.load('ja_ginza') 

訓練実行。

import random
from spacy.util import minibatch, compounding

n_iter = 500

optimizer = nlp.begin_training()
# n_iterで指定した回数だけiteratie
for itn in range(n_iter):
    random.shuffle(TRAIN_DATA) # 1件だから意味ないけど
    # 学習処理
    batches = minibatch(TRAIN_DATA, 
                        size=compounding(4., 32., 1.001))
    for batch in batches:
        texts, annotations = zip(*batch) 
        nlp.update(texts, annotations, sgd=optimizer, drop=0.35)

# 問題の文の固有表現抽出を実行
doc = nlp(r'''\10,000の売上''')
for ent in doc.ents:
    print(ent.text, ent.start_char, ent.end_char, ent.label_)

    #=> \10,000 0 7 MONEY

めでたくMONEYが取れた。

学習結果の保存

nlp.to_diskで保存する。

import os

output_dir = 'new_model'
if not os.path.exists(output_dir):
    os.mkdir(output_dir)
nlp.to_disk(output_dir)

これで下記のようなファイルが展開される。

new_model/
├── meta.json
├── ner
│   ├── cfg
│   ├── model
│   └── moves
├── parser
│   ├── cfg
│   ├── model
│   └── moves
└── vocab
    ├── key2row
    ├── lexemes.bin
    ├── strings.json
    └── vectors

保存したモデルをロードしてみる。spacy.loadで保存したモデルを指定する。

nlp = spacy.load('new_model')

doc = nlp(r'''\10,000の売上''')
for ent in doc.ents:
    print(ent.text, ent.start_char, ent.end_char, ent.label_)

    #=> \10,000 0 7 MONEY

ちゃんと学習済みデータがロードできている。

新規ラベルの追加

上記では既存のMONEYラベルに追加したが、新規にラベルを追加する処理も入れてみる。

(尚、この処理を実行すると既存のモデルで学習された固有表現がうまく抽出されなくなってしまった。学習に使われた元データも必要なのだろうか)

例として山をいくつか登録してMOUNTAINというラベルを振ってみた。15レコードほど。

TRAIN_DATA = [
    (r'''今回の売上は\10,000になります。''', {'entities': [(6, 13, 'MONEY')]}),
    (r'''今月は神威岳に登ってみた。''', {'entities': [(3, 6, 'MOUNTAIN')]}),
    (r'''ふと美唄山の眺めを思い出した。''', {'entities': [(2, 5, 'MOUNTAIN')]}),
    (r'''北海道へ行ったら富良野西岳に立ち寄る予定だ。''', {'entities': [(8, 13, 'MOUNTAIN')]}),
    (r'''布部岳(標高1348m)付近の気温。''', {'entities': [(0, 3, 'MOUNTAIN')]}),
    (r'''夕張山地の中天狗は登ったことがない。''', {'entities': [(5, 8, 'MOUNTAIN')]}),
    (r'''崕山は「きりぎしやま」と読む。''', {'entities': [(0, 2, 'MOUNTAIN')]}),
    (r'''国道からすぐのところに松籟山はある。''', {'entities': [(11, 14, 'MOUNTAIN')]}),
    (r'''御茶々岳の山頂まで残り800メートル。''', {'entities': [(0, 4, 'MOUNTAIN')]}),
    (r'''登山道が今日から解禁なので槙柏山に向かう。''', {'entities': [(13, 16, 'MOUNTAIN')]}),
    (r'''ツアーで芦別岳に登った。''', {'entities': [(4, 7, 'MOUNTAIN')]}),
    (r'''花見がてらに幾春別岳に行ってきた。''', {'entities': [(6, 10, 'MOUNTAIN')]}),
    (r'''トレイルランニングしてくる、鉢盛山で。''', {'entities': [(14, 17, 'MOUNTAIN')]}),
    (r'''天気予報によると吉凶岳は雨。''', {'entities': [(8, 11, 'MOUNTAIN')]}),
    (r'''望遠鏡から滝ノ沢岳が見える。''', {'entities': [(5, 9, 'MOUNTAIN')]}),
    (r'''富良野芦別道立自然公園の一部に夕張岳はある。''', {'entities': [(15, 18, 'MOUNTAIN')]}),
]

ラベルの追加。

ner = nlp.get_pipe('ner')
ner.add_label('MOUNTAIN')

ner.labels
    #=> ('DATE',
    #=>  'LOC',
    #=>  'MONEY',
    #=>  'MOUNTAIN',
    #=>  'ORG',
    #=>  'PERCENT',
    #=>  'PERSON',
    #=>  'PRODUCT',
    #=>  'TIME')

nerのラベルにMOUNTAINが追加された。

あとは先ほどと同じ学習処理を回す。n_iterの回数は適当。

from tqdm import tqdm
import random
from spacy.util import minibatch, compounding

n_iter = 100

optimizer = nlp.begin_training()
# n_iterで指定した回数だけiteratie
for itn in tqdm(range(n_iter)):
    random.shuffle(TRAIN_DATA) # 1件だから意味ないけど
    # 学習処理
    batches = minibatch(TRAIN_DATA, 
                        size=compounding(4., 32., 1.001))
    for batch in batches:
        texts, annotations = zip(*batch) 
        nlp.update(texts, annotations, sgd=optimizer, drop=0.35)

doc = nlp(r'''神威岳に登ったことがある''') 
for ent in doc.ents: 
    print(ent.text, ent.start_char, ent.end_char, ent.label_) 

    #=> 神威岳 0 3 MOUNTAIN

学習結果を試してみる。学習対象に入っていない下記の7つの山からいくつ抽出できるか。

mountains = ['七面山', '成田山', '大門山', '笈ヶ岳', '平家岳', '養老山']
for mountain in mountains:
    doc = nlp('%sに登った' % mountain)
    for ent in doc.ents:
        print(ent.text, ent.start_char, ent.end_char, ent.label_)

        #=> 成田山 0 3 MOUNTAIN
        #=> 大門山 0 3 MOUNTAIN
        #=> 笈ヶ 0 2 MOUNTAIN
        #=> 平家岳 0 3 MOUNTAIN
        #=> 養老山 0 3 MOUNTAIN

5/7個取れた。笈ヶ岳の取れ方がおかしいけど。

冒頭に書いた通り、学習後はその他のラベルが機能しなくなっている。

doc = nlp(r'''オバマ前大統領''')  
for ent in doc.ents:  
    print(ent.text, ent.start_char, ent.end_char, ent.label_)  

    #=>オバマ 0 3 MOUNTAIN

コーパスが手元にばうびで既存のモデルに例文を流して訓練データを生成し、そこにオリジナルのデータを追記する方法を検討。

改定履歴

Author: Masato Watanabe, Date: 2019-10-14, 記事投稿