Penn Tree Bankで定義されている品詞タグ一覧

はじめに

英文を句構造文法によって自動で構文解析すると,多くのライブラリではPenn Tree Bankの仕様に従って品詞タグが付与されます.

ニューラル言語処理ばかりやっていると意外とこれらの品詞タグが何を表しているかについて馴染みがないので,この機会にまとめてみました.

品詞タグの一覧はこちらで閲覧できます(http://www.ai.mit.edu/courses/6.863/tagdef.html).

より正確なガイドラインはこちらを参照してください(https://www.semanticscholar.org/paper/Part-of-speech-tagging-guidelines-for-the-penn-Santorini/c2fbd6cead3815b8e7038fda6f0f0254a2218ca7).

品詞タグ一覧

1. CC

  • 等位接続詞 (Coordinating conjunction).
    • 例: and, but, or, for, so.
    • 四則計算や分数として用いられる plus, minus, less, times, over もこれに含まれる.

2. CD

  • 基数 (Cardinal number).
    • 数量の大きさを表現する語.
    • 序数 (first, second など) にはCDタグを用いず,形容詞 (JJ) や副詞 (RB) などのタグを割り当てる.

3. DT

  • 限定詞 (Determiner).
    • 例1: 冠詞: a, an, the など
    • 例2: 指示限定詞: this, these など.
    • 例3: 数量詞: every, no, all, some など.
    • 注1: 所有限定詞 (my, your, its, ourなど) には別のタグ (PPS) が割り当てられている.
    • 注2: 疑問限定詞 which (どの,どんな) には別のタグ (WDT) が割り当てられている.

4. EX

  • 存在の There (Existential "there").

5. FW

  • 外国語 (Foreign word).
    • 英語以外の言語に由来すると判断された語に付与される.
    • たとえば医療言語処理では "bronchus" (気管支), in vivo の "vivo" (生体内で) などには FWタグが振られる傾向にある.

6. IN

  • 従属接続詞 (Preposition or subordination conjunction).
    • 例: before, because, if, although, whether.

7. JJ

  • 形容詞 (Adjective).

8. JJR

  • 形容詞の比較級 (Adjective, comparative).

9. JJS

  • 形容詞の最上級 (Adjective, superlative).

10. LS

  • List item marker.
    • 箇条書きに用いられている "1." "a." "i." などに割り振られる.

11. MD

  • 助動詞 (Modal).
    • 例: can, may, must, shall, will, could, might, should, would.

12. NN

  • 可算名詞の単数形もしくは非可算名詞 (Noun, singular or mass).

13. NNS

  • 可算名詞の複数形 (Noun, plural).

14. NP

  • 固有名詞,単数 (Proper noun, singular).

15. NPS

  • 固有名詞,複数 (Proper noun, plural).

16. PDT

  • 前限定辞 (Predeterminer).
    • 学校の英語文法ではあまり聞かない語なので理解しにくい.
    • 冠詞や人称代名詞所有格を前から修飾するような語に割り当てられる.
    • 例: all, both, half, many, quite, rather, such.

17. POS

  • 所有の ‘s (Possessive ending).

18. PP

  • 人称代名詞 (Personal pronoun).
    • 日本の英語教育でいうところの人称代名詞の主格および目的格. さらに所有代名詞,再帰代名詞にもこのタグを割り当てる.
    • 例: I, you, they, myself, themselves, mine, ours.

19. PPS

  • 所有代名詞 (Possessive pronoun).
    • 名称こそ "所有代名詞" だが,実際は人称代名詞の所有格に割り当てられている.
    • 例: my, your, its, his, her, our, their.

20. RB

  • 副詞 (Adverb).
    • enough, indeed, not, n't, neverなどもこれに含まれる.

21. RBR

  • 副詞の比較級 (Adverb, comparative).

22. RBS

  • 副詞の最上級 (Adverb, superlative).

23. RP

  • 不変化詞 (Particle).
    • これも学校の英語文法ではあまり聞かない語なので理解しにくい.
      • 例1: 自動詞としてはたらく動詞句の中で,動詞に後置される語 (call offの"off", rule outの"out", throw upの"up"など).
      • 例2: 助動詞的に使われる動詞句の中で,動詞に後置される語 (used toの"to", had betterの"better", ought toの"to"など).
      • 例3: 限定詞句に登場するもの (many ofの"of"など).
    • https://parentingpatch.com/particle-english-grammar/ を参考にしています.

24. SYM

  • 記号 (Symbol).
    • 主にピリオド, カンマ, コロン, セミコロン以外の記号に割り当てられる.

25. TO

  • "to" に割り当てられる (to).
    • 前置詞であろうとto-不定詞の一部であろうと役割に関係なくこのタグを用いる.

26. UH

  • 間投詞 (Interjection).

27. VB

  • 動詞の原形 (Verb, base form).
    • 命令形の動詞や, 助動詞の後ろもしくはsuggest thatなどの後ろの動詞に割り当てられる.

28. VBD

  • 動詞の過去形 (Verb, past tense).

29. VBG

  • 動名詞または動詞の現在分詞 (Verb, gerun or present participle).

30. VBN

  • 動詞の過去分詞 (Verb, past particle).

31. VBP

  • 動詞の1人称単数現在形または2人称単数現在形 (Verb, non-3rd person singular present).

32. VBZ

  • 動詞の3人称単数現在形 (Verb, 3rd person singular present).

33. WDT

  • 疑問限定詞 (Wh-determiner).
    • "which" に割り当てられる.
    • また,関係代名詞として用いられる "that" にも割り当てられる.

34. WP

  • 疑問代名詞 (Wh-pronoun).
    • "what", "who", "whom" に割り当てられる.

35. WP$

  • 疑問所有代名詞 (Possessive Wh-pronoun).
    • "whose" に割り当てられる.

36. WRB

  • Wh-adverb.
    • wh疑問文やwh疑問詞節などをつくる疑問詞に割り当てられる.
    • 例: when, where, why, how.

SentencePieceでの日本語分かち書きをTransformersのパイプラインに組み込む

背景

PyTorchでHugging Face Transformersを使って自然言語処理を行うとき,文章をモデルに入力するためにはまず単語単位に分かち書き (tokenize) しなければなりません.

この前処理が思ったよりもやっかいなのです.

事前学習済みのモデルをTransformers公式から提供されているものから選んでしまえば,ここはあまり問題になりません.Transformers付属のtokenizerを使って一発で分かち書きできるからです.

実際,東北大からTransformersを通じて日本語BERT事前学習済みモデルが公開されて久しいので,日本語BERTモデルを使うのはだいぶ楽になりました.

huggingface.co

しかし,別の事前学習済みの日本語BERTモデルで,Transformersのプラットフォームに載っていないものはいくつか存在します.

これらのモデルを使う場合,分かち書きの際には Transformers 付属の tokenizer がそのまま使えないため,SentencePiece,MeCab,Juman++などを駆使してパイプラインをその都度書き直さなければなりませんでした.

しかし,Transformers のアップデートが進むにつれて分かち書き処理の整備もかなり進んできた印象があります.そこで,Yohei Kikuta さん提供の日本語BERTモデルの分かち書き (本来はSentencePieceを使う) を Transformers 仕様に書き直せないかどうかを検討してみました.

github.com

動作環境

  • Ubuntu 16.04.7 LTS
  • Python 3.8.10
  • transformers 4.6.1
  • tokenizers 0.10.3
  • sentencepiece 0.1.95

方法

すでにYohei Kikuta さん提供の日本語BERTモデルはダウンロード済みであるものとします.

①まず同梱されている SentencePiece モデルを,sentencepiece ライブラリではなく tokenizers ライブラリで読み込みます:

from tokenizers.implementations import SentencePieceUnigramTokenizer
from tokenizers.processors import BertProcessing
from transformers import PreTrainedTokenizerFast

spm_tokenizer = SentencePieceUnigramTokenizer.from_spm(
    'path/to/model/wiki-ja.model'
)

②読み込んだモデルをさらに transformers ライブラリの PreTrainedTokenizerFast に渡すと,これだけで transformers 仕様の分かち書き器に変換できます.ただ単に分かち書きしたいだけならこれだけで完成です.

transformers_tokenizer = PreTrainedTokenizerFast(
    tokenizer_object=spm_tokenizer,
    unk_token = '<unk>',
    bos_token = '<s>',
    eos_token = '</s>',
    cls_token = '[CLS]',
    sep_token = '[SEP]',
    pad_token = '[PAD]',
    mask_token = '[MASK]',
)

# BertTokenizers などと同じ使い方ができるようになる
print(
    transformers_tokenizer.batch_encode_plus(
        ['吾輩は猫である。', '名前はまだ無い。'], padding=True
    )
)
>>> {'input_ids': [[9, 5361, 31082, 11, 4324, 27, 8], [9, 1515, 6389, 8404, 8, 3, 3]], 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0]]}

この分かち書き器は,SentencePiece特有の以下の機能をすでに備えています:

transformers_tokenizer.decode([9, 5361, 31082, 11, 4324, 27, 8])
>>> '吾輩は猫である。'

③②の状態で,すでにLSTMやCNNに入力するには十分な機能が備わっています. しかし,BERTに使うとなるともう一息,'[CLS]', '[SEP]' などの特殊トークンを自動的に付加して欲しくなります.
そこで,先ほどの spm_tokenizer にBERT入力形式を返すような機能を追加しましょう:

spm_tokenizer.post_processor = BertProcessing(
    cls=("[CLS]", spm_tokenizer.token_to_id('[CLS]')),
    sep=("[SEP]", spm_tokenizer.token_to_id('[SEP]'))
)

④③をふたたび PreTrainedTokenizerFast に渡すと,今度は完全にBERT仕様の分かち書き器が完成します:

bert_tokenizer = PreTrainedTokenizerFast(
    tokenizer_object = spm_tokenizer,
    unk_token = '<unk>',
    bos_token = '<s>',
    eos_token = '</s>',
    cls_token = '[CLS]',
    sep_token = '[SEP]',
    pad_token = '[PAD]',
    mask_token = '[MASK]',
)

print(
    bert_tokenizer.batch_encode_plus(
        ['吾輩は猫である。', '名前はまだ無い。'], padding=True
    )
)
>>> {'input_ids': [[4, 9, 5361, 31082, 11, 4324, 27, 8, 5], [4, 9, 1515, 6389, 8404, 8, 5, 3, 3]], 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 0]]}

最後に

SentencePiece を用いた分かち書きは transformers 仕様に変換できることが分かりました.
Mecab,Juman++,sudachi などの他の分かち書き器であっても基本的に同様の変換はできそうです.機会があれば試してみます.

環境変数を間違えてPoetryがうまく動かなくなった話

はじめに

PoetryはPythonの強力なパッケージ依存関係管理ツールです.

ところで,PoetryはPythonの外部パッケージを扱うツールであって,Python本体そのものを扱うツールではないため,当然ながらPythonの本体そのものが複数存在する場合に,そのうちのどれを使用するかを明示的にコントロールすることはできません.

このため,Pythonのパスの通し方がまずいとPoetryが予想外の挙動をしてしまうことがあります.
私はこれが原因ですっかり泥沼にハマってしまったため,備忘録として残しておきます.

動作環境

~ $ cat /etc/redhat-release
CentOS Linux release 7.7.1908 (Core)
~ $ echo $SHELL
/bin/zsh

何が起こったか

普段私はもともと研究用サーバーの共用部分 (/usr/local/bin) に入っている Python 3.6.4 を使っています.

このため,python hogehoge というコマンドでPythonを実行する際には当然これが使用されます:

~ $ which python
/usr/local/bin
~ $ python --version
Python 3.6.4

これは /usr/local/bin にパスが通っているからこそ実現できることです:

~ $ echo $PATH
/home/myname/.linuxbrew/bin:/usr/local/bin:...
                            ^^^^^^^^^^^^^^

それでは,myprojectというプロジェクト用ディレクトリを作り,プロジェクトを初期化しましょう:

~ $ mkdir myproject && cd $_
~/myproject $ poetry init

This command will guide you through creating your pyproject.toml config.

Package name [myproject]:  
Version [0.1.0]:  
Description []:  
Author [username <user@example.com>, n to skip]:  
License []:  
Compatible Python versions [^3.6]:  

Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Generated file

[tool.poetry]
name = "myproject"
version = "0.1.0"
description = ""
authors = ["username <user@example.com>"]

[tool.poetry.dependencies]
python = "^3.6"

[tool.poetry.dev-dependencies]

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"


Do you confirm generation? (yes/no) [yes] yes

次に仮想環境を作成し,作成した仮想環境に入ります:

~/myproject $ poetry shell
Creating virtualenv myproject in /home/username/myproject/.venv
Spawning shell within /home/username/myproject/.venv
(.venv) ~/myproject . /home/username/myproject/.venv/bin/activate

この状態でPythonインタラクティブセッションを立ち上げてみましょう:

Python 3.9.5 (default, May 13 2021, 20:57:30) 
[GCC 5.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 

急に Python 3.9.5 になった!?!?

なぜこうなったのか

実は自分の $HOME ディレクトリ以下にいつインストールしたのかも覚えていないようなPythonがもう1つあり,そちらが代わりに呼ばれてしまっていたのです.

つまり以下の2つのPythonが共存していたことになります.

  • ①もともと共用サーバーに入っていたPython 3.6.4
    • 場所: /usr/local/bin
  • ②いつの間にかHomebrewで自分の $HOME 以下にインストールしていたPython 3.9.4
    • 場所: ~/.linuxbrew/bin

ここでもう一度環境変数 $PATH をみてみましょう.

~ $ echo $PATH
/home/myname/.linuxbrew/bin:/usr/local/bin:...
                            ^^^^^^^^^^^^^^

~/.linuxbrew/bin と ①/usr/local/bin の両方にパスが通っていますが,先に書かれているのは②のほうです.

どうやらPoetryでPython仮想環境を作成する場合,Pythonの本体は パスが最初に通る場所に格納されているものが選ばれる ようであり,このため②にインストールされている Python 3.9.5 が使用されることになったようです.

したがって,仮想環境内でも①のPythonを使いたければ,$PATH の順序を入れ替えて

export PATH=/usr/local/bin:$PATH

とでもすべきということになります.

では,なぜ which python では①のパスが返されたのでしょうか?
答えはシンボリックリンクの有無の違いにありました.

①からは pythonシンボリックリンクが張られている一方で...

~ $ ls -l /usr/local/bin | grep python
lrwxrwxrwx. 1 root root ... python -> /usr/local/python/bin/python3

②からは pythonシンボリックリンクが張られていません.一方 python3, python3.9 などのシンボリックリンクは張られています.

~ $ ls -l .linuxbrew/bin | grep python
lrwxrwxrwx. 1 myname myname ... python3 -> ../Cellar/python@3.9/3.9.5/bin/python3
lrwxrwxrwx. 1 myname myname ... python3-config -> ../Cellar/python@3.9/3.9.5/bin/python3-config
lrwxrwxrwx. 1 myname myname ... python3.9 -> ../Cellar/python@3.9/3.9.5/bin/python3.9
lrwxrwxrwx. 1 myname myname ... python3.9-config -> ../Cellar/python@3.9/3.9.5/bin/python3.9-config

おそらく仮想環境構築時にはPython本体は python コマンドだけでなく(?),python3コマンドなどでも探索されるのだと思われます.

そのような探索の仕方をすれば,当然Python本体がはじめに見つかる場所は②です.

しかし,python コマンドのパスだけは①に通っています.そして,普段使っているコマンドも python であったために,python3 のパスが②に通っていることに気付けなかったというわけです.

シェル変数と環境変数の違いを理解する

はじめに

自前で開発環境を整えようとしているうちに泥沼にはまってしまいました.

直そうとしているうちにLinuxについてまだ理解していない点が沢山あることに思い至ったので,こちら↓の本を参考にシェル変数と環境変数の違いについて整理しました.

www.amazon.co.jp

おそらく基本事項だと思うのですが,体系的に調べたことがないと意外と知らないんですよね.

本題

シェル変数と環境変数の違い

  • シェルで用いる変数は「シェル変数である」「環境変数である」のどちらかの状態をとる.
  • 同一の変数がシェル変数でありしかも環境変数でもあるということはない.
  • シェル変数は現在開かれているシェルでのみ有効.
    • 現在のシェル上で新しく別のシェルを開いた場合,新しいシェルにシェル変数は引き継がれない.
  • 環境変数は現在のシェル上で新しく開いた別のシェルにも引き継がれる.
    • 単に新しくシェルを開く際に現在のシェルからコピーされて引き継がれるというだけ.
    • グローバル変数のような機能は持たない
    • たとえば新しいシェル上で環境変数に変更を加えても,もとのシェルには反映されない.
    • もちろん現在のシェルと無関係のシェルや他のユーザーに影響したりすることもない.
  • export 変数名 で既存のシェル変数を環境変数に変更できる.
  • export -n 変数名 で既存の環境変数をシェル変数に変更できる(bashの場合.zshでは -n オプションは無効?)

具体例

$ HOGE=42         # シェル①でシェル変数を設定
$ echo $HOGE
42
$ bash            # 別のシェル②を起動させると...
$ echo $HOGE      # シェル②にシェル変数は引き継がれない

$ exit            # シェル①に戻る
$ export HOGE     # HOGEを環境変数に変える
$ bash            # 別のシェル③を起動させると...
$ echo $HOGE      # 環境変数は引き継がれる
42
$ HOGE=84         # 環境変数を書き換えてから
$ exit            # もとのシェル①に戻っても
$ echo $HOGE      # 環境変数の変更は反映されない
42
$ HOGE=12345      # 環境変数を書き換える
$ bash            # もう一度別のシェル④をさらに起動させると...
$ echo $HOGE      # 環境変数の変更は引き継がれている
12345
$ export -n HOGE  # HOGEを環境変数からシェル変数に戻してから
$ bash            # さらに別のシェル⑤を起動させると…
$ echo $HOGE      # HOGEが引き継がれなくなっている
$ exit                    # シェル④に戻ると
$ printenv | grep HOGE    # たしかにHOGEは環境変数でなくなっている

$ exit                    # もとのシェル①に戻る
$ printenv | grep HOGE    # シェル①ではHOGEは環境変数のまま
HOGE=12345

Pymetamapで英語電子カルテからUMLS conceptを抽出する

はじめに

NIHが提供しているMetamapというツールを使うと英語テキストから病名,薬剤名,解剖学的部位などを抽出することができます.

metamap.nlm.nih.gov

Metamapは本来Javaで書かれており,Pythonでデータ分析をする際に扱いづらかったのですが,
世の中には親切な方がいるもので,PymetamapというPythonラッパーが作られていました.
そこで今回はPymetamapを使って英語電子カルテから病名抽出を行いたいと思います.

動作環境

準備

Metamapはすでにインストールされているものとします.詳しくはこちらを参照してください.

radiology-nlp.hatenablog.com

PymetamapはこちらのGitHubリポジトリをCloneして入手します.

github.com

cd ~
git clone git@github.com:AnthonyMRios/pymetamap.git

注: pip install pymetamap でインストールしないでくださいPyPIで提供されているものはバージョンが古く,Metamapの一部のオプションに対応していません.
それでも普通に使うぶんには問題ないのですが,高度な使い方をしようとするとエラーが生じる場合があります.

本題

まずはMetamapを動かすための仮想サーバーを起動しておきます.

cd /dir/to/metamap
./bin/skrmedpostctl start
>>> Starting skrmedpostctl:
>>> started.

次に対象とする英語電子カルテを用意し,文単位に区切っておきます.

import nltk
import re
import pymetamap
from typing import Dict, Tuple, List
from pymetamap import MetaMap

text = 'This is a 62 year-old woman with a history of with significant past medical history of diabtes mellitus type 2, hypertension, hyperlipidemia, CAD s/p CABG who comes with three weeks of shortness of breath and dyspnea on exertion.'
tokenizer = nltk.tokenize.punkt.PunktSentenceTokenizer()
sentences = tokenizer.sentences_from_text(text)

Metamapのインスタンスを読み込みます.

# XX にはMetamapのバージョン番号を入れる.2020年版であれば path = '(中略)/bin/metamap20'
path = '/path/to/metamap/bin/metamapXX'
mm = MetaMap.get_instance(path)

すると簡単にUMLS conceptが抽出できます.

concepts, error = mm.extract_concepts(sentences, range(len(sentences)))

print(concepts)
>>> [ConceptMMI(index='0', mm='MMI', score='7.02', preferred_name='Medical History', cui='C0262926', semtypes='[fndg]', trigger='["History"-tx-1-"history"-noun-0,"History of MEDICAL"-tx-1-"medical history of"-noun-0]', location='TX', pos_info='36/7;69/18', tree_codes=''),
 ConceptMMI(index='0', mm='MMI', score='5.18', preferred_name='Dyspnea on exertion', cui='C0231807', semtypes='[sosy]', trigger='["Dyspnoea on exertion"-tx-1-"dyspnea on exertion"-noun-0]', location='TX', pos_info='210/19', tree_codes=''),
 ConceptMMI(index='0', mm='MMI', score='5.18', preferred_name='Hyperlipidemia', cui='C0020473', semtypes='[dsyn]', trigger='["Hyperlipidaemia, NOS"-tx-1-"hyperlipidemia"-noun-0]', location='TX', pos_info='127/14', tree_codes=''),
 ConceptMMI(index='0', mm='MMI', score='5.18', preferred_name='Hyperlipidemia, CTCAE', cui='C4555212', semtypes='[fndg]', trigger='["Hyperlipidemia"-tx-1-"hyperlipidemia"-noun-0]', location='TX', pos_info='127/14', tree_codes=''),
...

この出力結果からでは,テキスト中のどの語がUMLS conceptとして認識されたのか直接はわかりません. そこでもう少し分かりやすく見ていきましょう.

def convert_concepts_to_superficial_concept_pair(
    concepts: List[pymetamap.Concept.ConceptMMI],
    sentences: List[str]
) -> List[Dict]:
    entities = []
    for concept in concepts:
        sentence_id = int(concept.index)
        # テキスト中の位置が文字数ベースで示されている (1-indexedであることに注意)
        raw_pos_info: List[Tuple[str, str]] = re.findall(r'(\d+)/(\d+)', concept.pos_info)
        positions = [{'start' : int(pos_info[0]) - 1, 'end' : int(pos_info[0]) + int(pos_info[1]) - 1} for pos_info in raw_pos_info]
        superficials = [sentences[sentence_id][pos['start']:pos['end']] for pos in positions]
        # それぞれの UMLS concept が参照している表出形を "superficials" キーに入れる
        entities.append({'superficials':superficials, 'concept':concept})
    return entities

entities = convert_concepts_to_superficial_concept_pair(
    concepts, sentences
)

それぞれの UMLS concept が文中のどの単語に該当しているかを辞書のリストとして格納しました.

print(entities[0])
>>> {'superficials': ['history', 'medical history of'],
 'concept': ConceptMMI(index='0', mm='MMI', score='7.02', preferred_name='Medical History', cui='C0262926', semtypes='[fndg]', trigger='["History"-tx-1-"history"-noun-0,"History of MEDICAL"-tx-1-"medical history of"-noun-0]', location='TX', pos_info='36/7;69/18', tree_codes='')}

ちょっと情報量が多いので,表出形,semantic type,標準形,CUI (Concept Unique Identifier)だけを抜き出して表示してみましょう.
それぞれの UMLS concept は pymetamap.Concept.ConceptMMI というオブジェクトとして与えられるため,各属性にも簡単にアクセスすることができます.

for entity in entities:
    superficials = entity['superficials']
    concept = entity['concept']
    print(f'{superficials}\t{concept.semtypes}\t{concept.preferred_name}\t{concept.cui}')

>>> ['history', 'medical history of']    [fndg]  Medical History C0262926
['dyspnea on exertion']    [sosy]  Dyspnea on exertion C0231807
['hyperlipidemia'] [dsyn]  Hyperlipidemia  C0020473
['hyperlipidemia'] [fndg]  Hyperlipidemia, CTCAE   C4555212
['hypertension']   [fndg]  Hypertension, CTCAE C1963138
['hypertension']   [dsyn]  Hypertensive disease    C0020538
['hyperlipidemia'] [fndg]  Serum lipids high (finding) C0428465
['CABG']   [topp]  Coronary Artery Bypass Surgery  C0010055
['of shortness', 'breath']    [sosy]  Dyspnea C0013404
...

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)

続・Dockerことはじめ: コンテナにホスト側のディレクトリをマウントする

0. はじめに

前回の記事はこちら

  • 前回はTensorFlow公式のDocker Imageを入手してコンテナを起動してみました.
  • 今回は研究プロジェクトを実際にDocker コンテナを使って進めていく準備をしたいと思います.

動作環境

1. ホスト側のディレクトリをコンテナにマウントする

1-0. 何もマウントしないとどうなるか

自分の研究プロジェクトを共用GPUサーバーの /home/me/myproject 内に置いているとします.

me@server myproject$ pwd
/home/me/myproject
me@server myproject$ ls
README.md data docs models myenv notebooks papers src

ここで, TensorFlow公式のDocker Imageからtoy_containerという名前のコンテナを作って起動してみます.

me@server myproject$ sudo docker run -it --name toy_contrainer \
> tensorflow/tensorflow:latest-gpu-jupyter \
> bash
________                               _______________                
___  __/__________________________________  ____/__  /________      __
__  /  _  _ \_  __ \_  ___/  __ \_  ___/_  /_   __  /_  __ \_ | /| / /
_  /   /  __/  / / /(__  )/ /_/ /  /   _  __/   _  / / /_/ /_ |/ |/ / 
/_/    \___//_/ /_//____/ \____//_/    /_/      /_/  \____/____/|__/


WARNING: You are running this container as root, which can cause new files in
mounted volumes to be created as the root user on your host machine.

To avoid this, run the container by specifying your user's userid:

$ docker run -u $(id -u):$(id -g) args...

コンテナ内部を少し探検してみます.

root@0123456789ab:/tf# pwd   # 起動直後の作業ディレクトリはtfというディレクトリ
/tf
root@0123456789ab:/tf# cd ~
root@0123456789ab:~# pwd
/root
root@0123456789ab:~# ls      # /rootの中身には何も入っていない
root@0123456789ab:~# cd /
root@0123456789ab:/# ls      # ディレクトリツリーのトップをみてみる
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tf  tmp  usr  var

私の研究プロジェクトが見当たりませんね.
どうやらこのままではコンテナのファイルシステムはホスト側のファイルシステムとは隔離されているため, ホスト側に置いてあるファイルやスクリプトをコンテナ内で利用できないようです.

一旦このコンテナからは抜けて, コンテナも削除しておきます.

root@0123456789ab:/# exit
exit
me@server myproject$ sudo docker rm toy_container
toy_container

1-1. docker run -v でマウントする

docker run -v ホスト側のディレクトリ:マウント先のディレクトリ を使うと, ホスト側のディレクトリをマウントしたコンテナを作成し, 起動できます.

# ホスト側の /home/me 以下をコンテナ内の /home/me にマウントする
# コンテナ内にもともと /home/me ディレクトリは存在しないが, 新しく作成される
me@server myproject$ sudo docker run -it --name toy_contrainer \
> -v /home/me:/home/me \
> tensorflow/tensorflow:latest-gpu-jupyter \
> bash
________                               _______________                
___  __/__________________________________  ____/__  /________      __
__  /  _  _ \_  __ \_  ___/  __ \_  ___/_  /_   __  /_  __ \_ | /| / /
_  /   /  __/  / / /(__  )/ /_/ /  /   _  __/   _  / / /_/ /_ |/ |/ / 
/_/    \___//_/ /_//____/ \____//_/    /_/      /_/  \____/____/|__/


WARNING: You are running this container as root, which can cause new files in
mounted volumes to be created as the root user on your host machine.

To avoid this, run the container by specifying your user's userid:

$ docker run -u $(id -u):$(id -g) args...
root@123456789abc:/tf# cd /home/me/myproject
root@123456789abc:/myproject# ls
# たしかにホスト側のmyprojectの中身がコンテナの/home/me/myproject以下に置かれている
README.md data docs models myenv notebooks papers src

これでホスト側のファイルをコンテナ内でも利用する準備ができそうです!

1-2. コンテナ内でホスト側のファイルを読み書き

コンテナ内でi_am_in_the_container.txtという空のテキストファイルを作成してみます.

root@123456789abc:/myproject# touch i_am_in_the_container.txt
root@123456789abc:/myproject# ls
README.md data docs i_am_in_the_container.txt models myenv notebooks papers src

この状態でコンテナを抜けてホストに戻ると, i_am_in_the_container.txtが残っています.
たしかにホスト側のディレクトリをコンテナ内から操作できていたことがわかります.

root@123456789abc:/myproject# exit
exit
me@server myproject$ ls
README.md data docs i_am_in_the_container.txt models myenv notebooks papers src

docker inspect で現在マウントされているディレクトリを確認できます.

me@server myproject$ sudo docker inspect toy_container
... ()
        "Mounts": [
            {
                "Type": "bind",
                "Source": "/home/me",
                "Destination": "/home/me",
                "Mode": "",
                "RW": true,
                "Propagation": "rprivate"
            }
        ],
... ()

コンテナは一旦削除しておきます.

me@server myproject$ sudo docker rm toy_container
toy_container

1-3. 注意点

基本的に, ディレクトリのマウントはコンテナの作成と同時に行わなければならないようです.
一旦作成されたコンテナに対して後からディレクトリを新規にマウントすることはできないと考えた方が良さそうです.

2. root以外のユーザーとして使う

2-0. rootでコンテナを作成するとどうなるか

これでホスト側のファイルをコンテナ内から読み書きできるようになりました.
そういえば, さっきからコンテナ起動時に

WARNING: You are running this container as root, which can cause new files in
mounted volumes to be created as the root user on your host machine.

というメッセージが出ているのは何なんでしょうね.
ファイルの権限を確かめてみましょう.

me@server myproject$ ls -al
... (中略) ...
-rw-r--r--.  1 root     root        0  41 12:34 i_am_using_docker.txt

どうやら, 先ほどのi_am_using_docker.txtはrootとして作成したことになっており, 一般ユーザーに編集権限がありません.
すると, Dockerコンテナ内で作成されたファイルはコンテナ外からは読み取り専用でしか開けなくなってしまいます.
これでは困りますね.

2-1. docker run -uでユーザーを指定する

docker run -u ユーザー名:ユーザーグループ によってコンテナを作成&起動すると, コンテナ内でもrootではなく指定したユーザーとして振舞うことができます.

なお, コンテナ内にはユーザー情報は格納されていないので, ホスト側の /etc/group/etc/passwd をマウントすることも忘れないようにしましょう.
(/etc/group/etc/passwd のマウントを忘れると, コンテナは未知のユーザー名を指定されたと思ってエラーを吐きます)

# -v foo:bar:ro のようにマウントしたディレクトリはコンテナ内では読み取り専用になる
me@server myproject$ sudo docker run -it --name tf_contrainer \
> -v /home/me:/home/me \
> -v /etc/group:/etc/group:ro \
> -v /etc/passwd:/etc/passwd:ro \
> -u $(id -u $USER):$(id -g $USER) \
> tensorflow/tensorflow:latest-gpu-jupyter \
> bash

すると, 今までとは違い, ホスト側と同じユーザーでコンテナを作成&起動することができています.

________                               _______________                
___  __/__________________________________  ____/__  /________      __
__  /  _  _ \_  __ \_  ___/  __ \_  ___/_  /_   __  /_  __ \_ | /| / /
_  /   /  __/  / / /(__  )/ /_/ /  /   _  __/   _  / / /_/ /_ |/ |/ / 
/_/    \___//_/ /_//____/ \____//_/    /_/      /_/  \____/____/|__/


You are running this container as user with ID 1234 and group 1234,
which should map to the ID and group for your user on the Docker host. Great!

2-2. 一般ユーザーとしてホスト側のファイルを読み書き

この状態で, コンテナ内でi_am_in_the_container_as_nonroot.txtという空のテキストファイルを作成してみます.

tf-docker /tf > cd /home/me/myproject
tf-docker /myproject > touch i_am_in_the_container_as_non_root.txt
tf-docker /myproject > ls
README.md  i_am_in_the_container.txt              myenv
data       i_am_in_the_container_as_non_root.txt  notebooks
docs       models                                 src

コンテナを抜けると, i_am_in_the_container_as_nonroot.txt は読み取り専用になってはいますが, オーナーは自分自身なので実質的にホスト側でも自由に編集可能です.

tf-docker /myproject > exit
exit
me@server myproject$ ls -al
... (中略) ...
-rw-r--r--.  1 root     root        0  41 12:34 i_am_using_docker.txt
-rw-r--r--.  1 me       me          0  41 12:45 i_am_in_the_container_as_non_root.txt

3. おわりに

Dockerコンテナからホスト側のファイルを読み書きできるようになりました.
これでDocker hubに上がっているDocker imageを自分の研究用プロジェクトに利用することができそうです.

参考にした記事:
https://qiita.com/yohm/items/047b2e68d008ebb0f001
https://qiita.com/Yarimizu14/items/52f4859027165a805630