spaCyで文字単位のNERアノテーションを単語単位に変換する

はじめに

固有表現抽出 (Named Entity Recognition (NER)) は,英語データに対して行う場合,基本的に単語単位の系列ラベリングタスクとなります.

このため,データセットもあらかじめ単語単位でラベル付けされていると便利です.

しかし,世の中には残念ながら単語単位でラベル付けされていない場合も沢山あります.

たとえば brat でアノテーションされたデータセットでは,各ラベルの位置は文書頭から「何単語目か」ではなく「何文字目」で表されています(!)

そこで,spaCyを用いて文字単位のNERデータセットを単語単位に素早く変換してみました.

動作環境

  • python v3.6.4
  • beautifulsoup4 v4.9.3
  • spacy v2.1.9
  • pandas v1.1.5

対象データ

ここでは i2b2 2012 shared task を例にとります.

https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3756273/

医療言語処理データセットの1つで,XML形式で提供されています.

このsubtaskの1つとして,入院中に起こった出来事 (<EVENT>タグで記述) とその時間的表現 (<TIMEX3> タグで記述) をそれぞれ抽出する固有表現抽出タスクがあります.

つまり...

<?xml version="1.0" encoding="utf-8"?>
<ClinicalNarrativeTemporalAnnotation>
<TEXT>
Admission Date :
2012-04-27
Discharge Date :
2012-04-30
...
</TEXT>
<TAGS>
<EVENT end="10" id="E0" modality="FACTUAL" polarity="POS" start="1" text="Admission" type="OCCURRENCE"/>
<EVENT end="1011" id="E9" modality="FACTUAL" polarity="POS" start="1003" text="admitted" type="OCCURRENCE"/>
...
<TIMEX3 end="1360" id="T0" mod="APPROX" start="1351" text="two weeks" type="DATE" val="2012-05-14"/>
<TIMEX3 end="1454" id="T3" mod="NA" start="1440" text="q.24h. x3 days" type="FREQUENCY" val="R3P24H"/>
...
</TAGS>
</ClinicalNarrativeTemporalAnnotation>

この <TEXT> ... </TEXT> から <EVENT>, <TIMEX3> タグで示された固有表現を抽出して来いというタスクですが,
各固有表現の位置は単語数ではなく文字数でしか示されていません.
このため,このままではデータセットとしては非常に扱いづらいものになってしまいます.

方法

①データセットからラベル付けされていない英語の生テキストを抜き出します.

text = """
Admission Date :
2012-04-27
Discharge Date :
2012-04-30
Service :
...
"""

②そしてNERラベルの情報を,(開始位置(文字数ベース), 終了位置(文字数ベース), タグ名) のタプルのリストとして用意します.
ここは素早い方法はあまりないので,bs4を駆使して地道に処理します.

event_annotations = [
    (1, 10, 'EVENT'), (29, 38, 'EVENT'), (161, 170, 'EVENT'), ...
]

③あとは以下のようにすると単語単位のBILUOラベルが得られます.
自力で「何文字目から何文字目は何単語目か」を頑張って数え上げる必要がありません.

from typing import List
from spacy.lang.en import English
from spacy.gold import biluo_tags_from_offsets
# 注: spaCy v3.0以上の場合は from spacy.training import offsets_to_biluo_tags

nlp = English()
doc = nlp.tokenizer(text)
biluo_labels: List[str] = biluo_tags_from_offsets(doc, event_annotations)
print(list(doc))
>>> [\n, Admission, Date, :, \n, 2012, -, 04, -, 27, \n, Discharge, Date, :, \n, 2012, -, 04, -, 30, \n, Service, :, \n, Neurosurgery, \n, HISTORY, OF, PRESENT, ILLNESS, :, \n, Patient, is, a, 79, -, year, -, old, female, with, a, history, of, cataracts, ,, glaucoma, ,, and, diabetes, ,, who, fell, and, tripped, over, a, wheelchair, of, a, friend, with, no, loss, of, ...
print(biluo_labels)
>>> ['O', 'U-EVENT', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'U-EVENT', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'U-EVENT', 'O', 'U-EVENT', 'O', 'O', 'U-EVENT', 'O', 'O', 'B-EVENT', 'I-EVENT', 'L-EVENT', ...

④ここまで来れば簡単に自前のNERデータセットを好きな形式で作ることができます.
たとえばTSVとして作るなら以下のとおりです.

import pandas as pd
df = pd.DataFrame({'tokens':list(doc), 'labels':biluo_labels})
df.to_csv('/path/to/tsv', sep='\t', index=False, header=False)