Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

spaCyを用いて日本語の固有表現抽出(NER)モデルを学習する

はじめに

最近、固有表現抽出(Named Entity Recognition: NER)の学習をspaCyを用いて行う機会があったため、そのやり方について簡単にまとめたいと思います。

Ref

spacy.io

Version

  • python: 3.11.3
  • spaCy: 3.6.0

使用したNotebook

github.com

全体の流れ

  • 学習データの用意
  • spaCyのconfigファイルの用意
  • 学習
  • 評価
  • 推論

学習データの用意

今回は、ストックマーク株式会社が公開しているWikipediaを用いた日本語の固有表現抽出データセットを利用します。

まずはデータセットを読み込みます。

with open("../ner-wikipedia-dataset/ner.json") as f:
    stockmark_data = json.load(f)

次にデータセットを、train, dev, testに分割します。(StockmarkDataは自分で定義したデータ型です。気になる方はNotebookを見てみてください。)

def random_split_to_train_dev_test(data: StockmarkData) -> tuple[StockmarkData, StockmarkData, StockmarkData]:
    all_len = len(data)

    train_len = int(all_len * 0.6)
    dev_len = train_len + int(all_len * 0.2)

    random.shuffle(data)

    train = data[:train_len]
    dev = data[train_len:dev_len]
    test = data[dev_len:]

    return train, dev, test

train, dev, test = random_split_to_train_dev_test(stockmark_data)

次に、データセットをspaCyの学習データの形式(.spacyファイル)へと変換します。

def make_spacy(data: StockmarkData, name: str) -> None:
    nlp = spacy.blank("ja")
    db = DocBin()
    for training_example in tqdm(data):
        text = training_example['text']
        annotations = training_example['entities']
        doc = nlp(text)
        ents = []
        for annotation in annotations:
            start = annotation["span"][0]
            end = annotation["span"][1]
            label = annotation["type"]

            span = doc.char_span(start, end, label=label)

            if span is None:
                print("Skipping entity")
            else:
                ents.append(span)
        doc.ents = ents
        db.add(doc)
    db.to_disk(f"../data/{name}.spacy")

make_spacy(data=train, name="train")
make_spacy(data=dev, name="dev")
make_spacy(data=test, name="test")

こちらのコードについて、補足説明をします。

doc = nlp(text)
ents = []
for data in annotations:
    start = data["span"][0]
    end = data["span"][1]
    label = data["type"]

    span = doc.char_span(start, end, label=label)

まず、上記コードにてannotationの情報を、spacy.tokens.span.Spanに変換しています。

例えば、

  • start: 0
  • end: 10
  • text: Sansan株式会社は、「出会いからイノベーションを生み出す」をミッションとして掲げています

とした時、span

  • 出力: Sansan株式会社
  • データ型: spacy.tokens.span.Span

となります。

下記はコード例です。

text = "Sansan株式会社は、「出会いからイノベーションを生み出す」をミッションとして掲げています"
start = 0
end = 10
label = "法人名"

nlp = spacy.blank("ja")
doc = nlp(text)
span = doc.char_span(start, end, label=label)

print(span)
print(type(span))

出力は下記となります。

Sansan株式会社
<class 'spacy.tokens.span.Span'>

このspanについて、startとendが正しく与えられていてもNoneになってしまうパターンが存在します。例えば、下記のようなパターンです。

text = "また、草戸稲荷神社前には遊女町を造ったといわれる。"
start = 3
end = 9
label = "施設名"

nlp = spacy.blank("ja")
doc = nlp(text)
span = doc.char_span(start, end, label=label)

print(f"期待出力: {text[start:end]}")
print(f"実際の出力: {span}")
print(f"データ型: {type(span)}")

出力は下記となります。

期待出力: 草戸稲荷神社
実際の出力: None
データ型: <class 'NoneType'>

原因は、トークナイザーで区切られる単位と、spanの単位が一致していないためです。こちらのissueにて記載があります。

github.com

実際に確認してみましょう。今回のモデルで利用しているtokenizerで、また、草戸稲荷神社前には遊女町を造ったといわれる。を分かち書きしてみます。

nlp = spacy.blank("ja")
doc = nlp("また、草戸稲荷神社前には遊女町を造ったといわれる。")
print([token for token in doc])

出力は下記となります。

[また, 、, 草戸, 稲荷, 神社前, に, は, 遊女, 町, を, 造っ, た, と, いわ, れる, 。]

理想としては、草戸稲荷神社が1tokenとして区切られて欲しいですが、草戸, 稲荷, 神社前と区切られてしまいました。 このような場合、spanNoneを返します。

良い対処法が思いつかないため、もったいないですが、今回はNoneを返すspanを無視して進めることにします。下記コードにて、spanNoneの場合にはSkipping entityの出力のみを行い、spanの追加は行わないコードにしています。

    if span is None:
        print("Skipping entity")
    else:
        ents.append(span)

全固有表現数が13185なのに対し、Nonespanは58だったため、学習データが大幅に減ってしまうこともありません。

spaCyのconfigファイルの用意

spaCyの学習方法として推奨されているのは、configファイルに全ての設定を記載し、spacy trainコマンドにて学習をすることです。 そのため、Training Pipelines & Models上にて、configファイルを取得する機能が提供されています。

まず、基本的な設定を行ったconfigファイルを、spaCyのサイトから取得します。

configファイルの取得の画面

その後、下記のコマンドにて、defaultのsettingを自動補完することができます。

!python -m spacy init fill-config ../config/base_config.cfg ../config/config.cfg

どのような補完がされたか気になったためdiffを見てみると、先頭部分は下記のようになっていました。

spacy init fill-configの前後におけるdiff

学習

下記コマンドにて学習を行います。--outputには、学習したモデルを保存するパスを、--paths.train, --paths.devには、作成した.spacyファイルを指定します。

!python -m spacy train ../config/config.cfg --output ./ --paths.train ../data/train.spacy --paths.dev ../data/dev.spacy

学習を開始すると、下記のようなメトリクスが出力されます。

============================= Training pipeline =============================
ℹ Pipeline: ['tok2vec', 'ner']
ℹ Initial learn rate: 0.001
E    #       LOSS TOK2VEC  LOSS NER  ENTS_F  ENTS_P  ENTS_R  SCORE 
---  ------  ------------  --------  ------  ------  ------  ------
  0       0          0.00     51.59    0.00    0.00    0.00    0.00
  0     200        241.81   3057.86    3.64    6.87    2.48    0.04
  0     400       1078.83   4190.00   26.86   30.05   24.28    0.27
  0     600       1349.77   4286.86   39.50   40.39   38.65    0.40
  0     800       1011.61   4476.62   41.54   45.30   38.35    0.42
  1    1000        777.36   4225.80   48.91   50.56   47.35    0.49
  1    1200        495.57   4679.23   51.48   53.36   49.72    0.51
  2    1400        673.15   5070.43   51.82   51.44   52.20    0.52
  3    1600        709.94   5342.73   60.23   58.76   61.76    0.60
  4    1800       1003.89   5872.42   65.90   67.48   64.39    0.66
  5    2000       1082.21   5468.31   64.72   64.94   64.50    0.65
  7    2200       1442.92   5723.62   66.89   69.06   64.84    0.67
  8    2400       1394.84   5288.18   67.29   70.11   64.69    0.67
 10    2600       1279.43   4287.84   67.86   70.37   65.52    0.68
 12    2800       1198.31   3641.89   68.14   68.67   67.62    0.68
 14    3000       1144.16   2891.50   67.43   68.76   66.15    0.67
 16    3200       1105.80   2452.79   69.87   71.60   68.22    0.70
 17    3400       1026.97   2153.10   68.54   72.55   64.95    0.69
 19    3600        865.50   1604.89   69.96   71.33   68.63    0.70
 21    3800       1082.88   1617.34   69.45   71.02   67.95    0.69
 23    4000        874.30   1329.86   69.70   71.66   67.84    0.70
 25    4200       1089.45   1332.97   69.24   72.10   66.60    0.69
 27    4400        736.01    963.30   70.68   73.19   68.33    0.71
 28    4600        894.48   1005.92   70.06   72.69   67.62    0.70
 30    4800        670.78    769.95   69.16   69.89   68.44    0.69
 32    5000        678.29    727.29   70.85   70.79   70.92    0.71
 34    5200        659.66    590.80   70.98   72.40   69.61    0.71
 36    5400        708.78    641.77   69.84   71.88   67.92    0.70
 37    5600        823.40    590.72   70.17   72.12   68.33    0.70
 39    5800        659.13    492.57   70.63   70.60   70.66    0.71
 41    6000        755.01    528.94   69.45   70.57   68.37    0.69
 43    6200       1380.70    704.92   70.39   74.31   66.87    0.70
 45    6400        864.96    453.74   69.56   70.83   68.33    0.70
 47    6600        908.95    383.52   69.67   69.22   70.13    0.70
 48    6800        947.74    391.76   69.36   71.87   67.02    0.69
✔ Saved pipeline to output directory
model-last

メトリクスの説明はspaCyのUnderstanding the training output and score typesにあります。

FはF-Score, PはPrecision, RはRecallです。

また、TOK2VECはtokenからvectorを抽出するcomponentを意味します。

評価

下記コマンドにて評価を行います。--outputにて、評価を行ったファイルを出力するパスを指定し、--displacy-pathにて、test.spacyに対しタグ付けしたファイルを出力するパスを指定します。

!python -m spacy benchmark accuracy model-best ../data/test.spacy --output ../evaluate_result/test_metrics.json --displacy-path ../evaluate_result

評価が終わると、下記のようなログが表示されます。

================================== Results ==================================

TOK     100.00
NER P   70.85 
NER R   68.16 
NER F   69.48 
SPEED   1604  


=============================== NER (per type) ===============================

              P       R       F
人名        74.68   78.90   76.73
法人名       71.00   66.60   68.73
イベント名     77.99   62.31   69.27
地名        70.90   85.49   77.51
施設名       59.05   53.68   56.24
製品名       56.70   51.84   54.16
政治的組織名    80.33   73.28   76.65
その他の組織名   69.09   41.53   51.88

この結果は、../evaluate_result/test_metrics.jsonにも出力されます。

次に、../evaluate_result/entities.htmlを確認します。

entities.html

本ファイルは、test.spacyにタグ付けしたhtmlファイルです。どのようにタグ付けされたかが一目で分かりますね。

推論

学習したモデルを呼び出して推論を行いたい場合は、下記のようにします。出力されたモデルはmodel-lastmodel-bestがありますが、今回はmodel-bestを利用します。

model = spacy.load("model-best")

text = """
働き方を変えるDXサービスを提供するSansan株式会社は、契約DXサービス「Contract One」がサービス価値向上を目的に、マイクロソフト社が提供するAzure OpenAI Serviceを活用した「Contract One AI」を搭載したことを発表します。
今回は第一弾として文章内検索機能を追加します。契約書の内容について、定型質問から選択または質問内容を直接問いかけると、「Contract One AI」が適切な情報を抽出し質問に回答します。本機能の追加によって、法務担当者に限らず誰もが早く、簡単に契約情報を把握することが可能となります。「Contract One AI」は順次アップデートしていく予定です。
"""

colors = {"法人名": "#F67DE3", "製品名": "#7DF6D9"}
options = {"colors": colors} 

doc = model(text)

spacy.displacy.render(doc, style="ent", options=options, jupyter=True)

textはこちらから引用しました。

結果は下記のようになりました。このテキストについてはうまく抽出できていることが分かります。

NERによる推論

終わりに

spaCyを用いると、データの形式を合わせるだけで、NERの学習・評価・推論までが簡単なコード・コマンドで実行出来て非常に便利です。また、configファイルに設定を全て残しておけるため、実験の再現性も担保できそうです。

疑問点やおかしいところがあったらコメントやTwitterで教えてください。

Twitterアカウントはこちら twitter.com

© Sansan, Inc.