Snorkelの生成モデルについて(実装編)
はじめに
前回はラベリング関数の作り方を確認した。
kento1109.hatenablog.com
今回はいよいよ生成モデルに関するチュートリアルを読んでいく。
尚、理論的なことはこっちにまとめた。
Snorkelの生成モデルについて(理論編) - 機械学習・自然言語処理の勉強メモ
前回定義したラベリング関数であるが、個々の関数は弱教師(精度が低く単独で使えない識別器)だった。生成モデルでは、これらを統合して精度の高い識別器の生成を目指す(アンサンブル学習に近い学習手法)。
最も簡単な統合方法は、ラベリング関数の結果で多数決を取ることである。しかし、この場合、個々のラベリング関数の精度を無視することになる。
例えば下記の2つのラベリング関数の精度を比較する。
- 「人物名の周りにmarryという単語があれば1、なければ0」を返す。
- 「人物名のlast nameが同じであれば1、同じでなければ0」を返す。
真のラベルが「1(関係あり)」と仮定した場合、2.のラベリング関数の方が、「1(関係あり)」を返す可能性(あくまで経験的な予測)が高いと考えられる。そうなると、単なる多数決ではなく、2.の投票をより重視(重みづけ)すべきである。
つまり、この潜在的な精度を考慮して分類するべきであり、このような考えに基づくモデルが生成モデルである。
[1605.07723] Data Programming: Creating Large Training Sets, Quicklyより引用
真のラベルなしに潜在的な精度を学習する際、各ラベリング関数のoverlapとconflictが重要になる。
生成モデルの推定方法に関しては後で確認する。
コードを読んでいく
今回のチュートリアルは以下にある。
github.com
前回のラベル行列をロード
from snorkel.annotations import LabelAnnotator labeler = LabelAnnotator(lfs=LFs) L_train = labeler.load_matrix(session, split=0) L_dev = labeler.load_matrix(session, split=1) pos/neg 196:2603 7.0%/93.0% precision 35.75 recall 32.65 f1 34.13
Generative Model
ラベリング関数の精度を生成モデルにより学習する。
学習後、生成モデルは各候補のノイズ対応トレーニングラベルを生成する。これは、この後の識別モデルで使用される。
https://hazyresearch.github.io/snorkel/pdfs/snorkel_demo.pdfより引用
1)Training the Model
グリッドサーチにより最適なパラメータを求める。
自分のライブラリにはListParameter, RangeParameterクラスが存在しないので、インポートエラーが発生する・・とりあえず、グリッドサーチは省略して生成モデルを訓練する。
from snorkel.learning import GenerativeModel gen_model = GenerativeModel() gen_model.train(L_train, epochs=100, decay=0.95, step_size=0.1 / L_train.shape[0], reg_param=1e-6)
2)Model Accuracies
精度の確認
gen_model.learned_lf_stats()
3)Plotting Marginal Probabilities
生成モデルを訓練データに適用
train_marginals = gen_model.marginals(L_train)
訓練データは0~1の値を取る二項分布となる。一番右の分布のように2つの山が出来ていてそれらが離れているほど理想的(TrueラベルとFalseラベルの分布が良く分かれている)。一番左は多くの候補がTrueラベルの確率が0.5の状態(ほとんどランダムによる識別と変わらない)。これは、LFsのcoverageが低い場合に頻繁にみられる分布であり、この場合は候補全体を網羅するラベリング関数を定義する必要がある。真ん中の分布は、LFsがFalseラベルの識別は出来ているが、Tureラベルの識別ができていない状態(Tureラベルを返すためのラベリング関数をもっと定義する必要がある。)
4)Generative Model Metrics
真のラベルを使って生成モデルを評価する。
dev_marginals = gen_model.marginals(L_dev) _, _, _, _ = gen_model.error_analysis(session, L_dev, L_gold_dev) ======================================== Scores (Un-adjusted) ======================================== Pos. class accuracy: 0.327 Neg. class accuracy: 0.955 Precision 0.352 Recall 0.327 F1 0.339 ---------------------------------------- TP: 64 | FP: 118 | TN: 2485 | FN: 132 ========================================
Structure Learning
ラベリング関数の相関性を考慮した学習。
これまではラベリング関数は独立を仮定していたが、実際はユーザーが自由に定義できるため似たようなラベリング関数が作られることもある。例えば、A, B, C, Dの4つラベリング関数のうち、C, Dの結果はBに依存していたとする。その場合、A, B, C, Dのラベリング関数の結果を同じように扱うのは危険である。
Snorkelでは、ラベリング関数間の依存性をグラフで表現し、マルコフ確率場のアルゴリズムにより最適化する。
ラベリング関数間の依存性を考慮した場合は下記となる。
from snorkel.learning.structure import DependencySelector ds = DependencySelector() deps = ds.select(L_train, threshold=0.1) from snorkel.learning import GenerativeModel gen_model = GenerativeModel() gen_model.train(L_train, deps=deps)
マルコフ確率場のアルゴリズムは別の機会に勉強したい。
Snorkelのラベリング関数について(実装編)
はじめに
前回は前処理(候補の抽出)の流れを確認した。
Snorkelの前処理について(実装編)① - 機械学習・自然言語処理の勉強メモ
前処理が完了すると、関係の候補となるペアが出来る。
※「配偶者」の候補となる人物名のペア
ただし、抽出した候補はエンティティが「人物名」のチャンクを抽出しただけ。なので、
のように文章中の人物名のペアでも「配偶者」の関係ではない場合が存在する(なので、この時点では配偶者の関係をもつ「候補」と言われている)。
では、どのようにして「配偶者」の関係を持つ候補を見分ければよいか。
例えば以下の文章を見てみる。
候補は、
Spouse('Rachel Hattingh' 'Graham Marshall')
だが、これは「配偶者」の関係を持つ候補と言えるだろうか。殆どの人は「YES」と答えるだろう。そして、その理由に「her husband」があるからと答える。
では、次の文章はどうだろう。
候補は、
Spouse('Barack Obama' 'Michelle Obama')
である。
人物名を知らない人でも多くは、「last nameが同じだから配偶者と言える」と答えるだろう。(もちろん、他人同士でlast nameが同じ可能性もあるが、配偶者と考える方が妥当だろう)
おそらく人手で配偶者の関係をアノテーションする場合でも、似たような判断基準を用いるはずだ。このように候補が関係であるかどうかはいくつかのルールに基づいて判断される。Snorkelでは、このルールをラベリング関数として人手で定義し、その結果に基づいて判断を行う。
例えば、候補同士のlast nameが同じかどうかを判断する関数はSnorkelでは以下のように定義する。
def LF_same_last_name(c): ''' Label as positive if both ''' p1_last_name = last_name(c.person1.get_span()) p2_last_name = last_name(c.person2.get_span()) if p1_last_name and p2_last_name and p1_last_name == p2_last_name: if c.person1.get_span() != c.person2.get_span(): return 1 return 0
※書き方は後で確認する。
これは人手で決めた「ルールベース」に基づいて作成するラベリング関数であるが、以下のように外部リソースを利用しても良い。(Distant Supervision)
def known_spouse(x): pair = (x.person1_id, x.person2_id) return 1 if pair in KB else 0
※ここまでの画像は全て下記より引用
snorkel/Snorkel-Workshop-FINAL.pdf at master · HazyResearch/snorkel · GitHub
戻り値について
まず、ラベリング関数の戻り値であるが、これは「1, 0, -1」のいずれかの値である必要がある。
戻り値の基準としては、
- 1・・定義した規則に該当する場合、関係ありと判断する
- -1・・定義した規則に該当する場合、関係なしと判断する
- 0・・根拠がないので、判断を控える
通常、ラベリング関数は「1, 0」もしくは「-1, 0」の戻り値を組み合わせる。上記のLF_same_last_name関数の場合、「人物名のlast_nameが同じなら1、異なる場合は0」を返す。(人物名のlast_nameが異なるからといって、関係ないとは言えないので-1としない。)
評価指標について
定義したラベリング関数はどのように評価すればよいか。例えば以下の2つの関数を定義する。
- 「人物名の周りにmarryという単語があれば1、なければ0」を返す。
- 「人物名のlast nameが同じであれば1、同じでなければ0」を返す。
1.の関数の条件に該当する候補はたくさん見つかるかもしれないが、この条件だけで配偶者かどうかは判断できないので精度が低い。
2.の関数の条件に該当する候補は少ないかもしれないが、この条件に合致する候補は配偶者である可能性が高いので精度は高い。
精度と網羅している割合の両方を評価するため、下記の評価指標を用いる。
- Accuracy:そのラベリング関数が関係あり(なし)を正しく判断した候補の割合
- Coverage:ラベリング関数によってラベリング(1, -1)された候補の割合
上記の場合、1.の方がCoverageは高い。しかし、2.の方がAccuracyは高い。
※正確なAccuracyを求めるためには真のラベルが必要
理想的なラベリング関数は、Accuracy・Coverageがともに高いことであるが、これらはトレード・オフの関係にあることが多い。
また、他のラベリング関数の値も使用した以下の指標も評価に用いられる。
- Overlap:ラベリング(1, -1)&他の関数もラベリング(1, -1)の割合
- Conflict:ラベリング(1, -1)&他の関数もラベリング(1, -1)&それらのラベルが異なる割合
下記3つの指標は常に、Coverage ≧ Overlap ≧ Conflictとなる。
コードを読んでいく
今回のチュートリアルは以下にある。
github.com
Pattern Matching Labeling Functions
キーワードや正規表現を使って関数を作る。
「あるキーワードが出現すると、その候補は関係あり(なし)の可能性が高い」という前提に基づきラベリング関数を定義する。キーワードの選定などは、経験や知識に基づく場合もある(我々はhusband, wifeなどの周辺に出現する人物名同士は配偶者である可能性が高いことを知識として知っている)が、厳密には、キーワードと関係の条件付き確率に基づき定義することが出来る。
例えば、真の正解ラベルの情報から
※candidate=0は関係なし
という条件付き確率が得られる場合、この情報に基づき「周辺にboyfriendやgirlfriendが出現する人物名同士は配偶者でない」と判断する下記のラベリング関数を定義することが出来る。
friend= {'boyfriend', 'girlfriend'} def LF_friend_terms_between(c): return -1 if len(friend.intersection(get_between_tokens(c))) > 0 else 0
Labeling Function Factories
Labeling Function Factoriesを使って、より簡単にラベリング関数を定義する。
1)Term Matching Factory
「人物名の間にhusband, wifeなどが出現する候補は配偶者であるとする」という判定をしたい場合、MatchTermsメソッドを使用してラベリング関数を下記のように記述できる。
marriage = {'husband', 'wife'} # we'll initialize our LFG and test its coverage on training candidates LF_marriage = MatchTerms(name='marriage', terms=marriage, label=1, search='between').lf()
定義した後、この関数のCoverageが下記の方法で確認できる。
labeled = coverage(session, LF_marriage, split=0) # Coverage: 7.66% (1702/22218)
尚、SentenceNgramViewerで実際の候補を確認できる。
SentenceNgramViewer(labeled, session, n_per_page=1)
また、候補の真のラベルを持っている場合、ラベリング関数による抽出精度も測定可能。
tp, fp, tn, fn = error_analysis(session, LF_marriage, split=1, gold=L_gold_dev) # now let's view what this LF labeled SentenceNgramViewer(fp, session, n_per_page=1) ======================================== LF Score ======================================== Pos. class accuracy: 1.0 Neg. class accuracy: 0.0 Precision 0.356 Recall 1.0 F1 0.525 ---------------------------------------- TP: 63 | FP: 114 | TN: 0 | FN: 0 ========================================
定義したラベリング関数では、精度が35.6%といまいちであることが分かる。
もう一つMatchTermsでのラベリング関数の作成例を確認する。
other_relationship = {'boyfriend', 'girlfriend'} LF_other_relationship = MatchTerms(name='other_relationship', terms=other_relationship, label=-1, search='left', window=1).lf()
2)Regular Expression Factory
キーワードでなく正規表現を使いたい場合、MatchRegexメソッドを使用する。
例えば、単語「ex-husband, ex-wifeは関係なし」と定義したい場合、下記のように記述する。
exes_rgxs = {' ex[- ](husband|wife)'} LF_exes = MatchRegex(name='exes', rgxs=exes_rgxs, label=-1, search='between').lf()
Labeling Function Factoriesで使用するオプションについて:
terms: 関数で使用する文字・正規表現
label: 判別結果(1 or -1)
search: 検索箇所('left'|'right'|'between'|'sentence')
window: 検索箇所を('left'|'right')の場合の範囲指定
Distant Supervision Labeling Functions
DBpediaなどの外部リソースを使用してラベリング関数を定義する。
例えば、DBpediaには以下のような配偶者データが構造化データとして登録されている。
from lib.dbpedia import known_spouses list(known_spouses)[0:5] [('Eleanor Powell', 'Glenn Ford'), ('Andronikos Doukas', 'Maria of Bulgaria'), ('Marjorie Rambeau', 'Willard Mack'), ('Margo St. James', 'Paul Avery'), ('Joan of England', 'William II the Good')]
候補がDBpediaのknown_spousesに登録されている場合、関係ありとするラベリング関数は以下のように定義できる。
LF_distant_supervision = DistantSupervision("dbpedia", kb=known_spouses).lf() labeled = coverage(session, LF_distant_supervision, split=1)
Custom Labeling Functions
その他、自作関数を用いて任意のラベリング関数を定義できる。例えば、人物名の出現箇所に距離がある場合、それらは関係がないと判定する関数は下記のように記述できる。
def LF_too_far_apart(c): """Person mentions occur at a distance > 50 words""" return -1 if len(list(get_between_tokens(c))) > 50 else 0
Composing Labeling Functions
ラベリング関数を組み合わせて新しいラベリング関数を定義することもできる。
def LF_marriage_and_too_far_apart(c): return 1 if LF_too_far_apart(c) != -1 and LF_marriage(c) == 1 else 0 LF_marriage_and_not_same_person = lambda c: LF_too_far_apart(c) != -1 and LF_marriage(c) score(session, LF_marriage_and_too_far_apart, split=1, gold=L_gold_dev)
Applying Labeling Functions
作成したラベリング関数をリストにまとめて、それら全てを候補に適用する。
LFs = [ LF_marriage, LF_other_relationship, LF_exes, LF_distant_supervision ]
Generating the Label Matrix
ラベル行列を生成する。
from snorkel.annotations import LabelAnnotator labeler = LabelAnnotator(lfs=LFs) np.random.seed(1701) L_train = labeler.apply(split=0, lfs=LFs, parallelism=1) print L_train.shape >> (22218, 4) L_dev = labeler.apply_existing(split=1, lfs=LFs, parallelism=1) print L_dev.shape >> (2799, 4)
ラベル行列を確認する。
L_train.lf_stats(session)
正解ラベル付きデータでAccuracyを確認する。
L_dev.lf_stats(session, labels=L_gold_dev.toarray().ravel())
実際のAccuracyやCoverageを確認して、ラベリング関数を追加したりして調整する。
Snorkelの前処理について(実装編)②
はじめに
前回はcandidateとして文章から人物名の組み合わせを抽出するチュートリアルを見た。
kento1109.hatenablog.com
前回の固有表現抽出は対象が人物であったので、「Stanford CoreNLP」や「Spcay」を用いて容易に抽出できた。
今回はIREX標準の固有表現でないEntityを対象とする。
今回のタスクは「Chemical-Disease Relations」(薬品と病名の関係抽出)。
目標は以下の文章から
Warfarin-induced artery calcification is accelerated by growth and vitamin D.
薬品と病名の組み合わせ「("warfarin", "artery calcification")」を抽出すること。
今回は薬品名や病名といった固有表現をどのように抽出するのかを理解する。
コードを読んでいく
今回のチュートリアルは以下にある。
github.com
DocPreprocessor
今回はXMLファイルを読み込む。
XMLファイルの以下のような定義
<document> <id>227508</id> <passage> <infon key="type">title</infon> <offset>0</offset> <text>Naloxone reverses the antihypertensive effect of clonidine.</text> <annotation id='0'> <infon key="type">Chemical</infon> <infon key="MESH">D009270</infon> <location offset='0' length='8' /> <text>Naloxone</text> </annotation> <annotation id='1'> <infon key="type">Chemical</infon> <infon key="MESH">D003000</infon> <location offset='49' length='9' /> <text>clonidine</text> </annotation> </passage>
CorpusParser
パース処理に関するクラス。
前回は以下のように定義していた。(引数にSpacy()を指定していた。)
from snorkel.parser.spacy_parser import Spacy from snorkel.parser import CorpusParser corpus_parser = CorpusParser(parser=Spacy()) corpus_parser.apply(doc_preprocessor, count=n_docs)
今回はSpacyのNERは使えないので、以下のように定義している。
from snorkel.parser import CorpusParser from utils import TaggerOneTagger tagger_one = TaggerOneTagger()
TaggerOneTagger
これが今回のパースの鍵を握るクラスなのだろう。
説明を読んでみると、PubMedのNERで用いられる固有表現抽出器のようだ。
class CDRTagger(object): def __init__(self, fname='data/unary_tags.pkl.bz2'): with bz2.BZ2File(fname, 'rb') as f: self.tag_dict = load(f) class TaggerOneTagger(CDRTagger): def __init__(self, fname_tags='data/taggerone_unary_tags_cdr.pkl.bz2', fname_mesh='data/chem_dis_mesh_dicts.pkl.bz2'): with bz2.BZ2File(fname_tags, 'rb') as f: self.tag_dict = load(f) with bz2.BZ2File(fname_mesh, 'rb') as f: self.chem_mesh_dict, self.dis_mesh_dict = load(f)
下記のファイルが辞書の役割を果たす。
- taggerone_unary_tags_cdr.pkl.bz2
- chem_dis_mesh_dicts.pkl.bz2
内容を少し見てみる。
taggerone_unary_tags_cdr.pkl.bz2
import bz2 from six.moves.cPickle import load with bz2.BZ2File('data/taggerone_unary_tags_cdr.pkl.bz2', 'rb') as f: tag_dict = load(f) for i, (key, value) in enumerate(tag_dict.iteritems()): print "PMID:", key for j, val in enumerate(value): print val if j > 3: break if i > 3: break PMID: 11672959 ('Disease|D008206', 117, 132) ('Disease|D009102', 668, 687) ('Disease|D056486', 137, 146) ('Disease|D001172', 227, 247) ('Disease|D056486', 519, 541) PMID: 9636837 ('Chemical|D002945', 1097, 1101) ('Chemical|D016190', 472, 477) ('Disease|D020258', 191, 201) ('Chemical|D016190', 0, 11) ('Chemical|D010984', 1210, 1218) PMID: 1445986 ('Chemical|D015313', 273, 282) ('Chemical|D015313', 284, 293) ('Disease|D000743', 50, 66) ('Disease|D000743', 25, 41) ('Chemical|D002511', 186, 200) PMID: 19274460 ('Disease|D006402|D005767', 1036, 1081) ('Chemical|D003907', 294, 307) ('Chemical|C400082', 84, 94) ('Disease|D009101', 424, 426) ('Disease|D009101', 1281, 1283) PMID: 35781 ('Chemical|D009638', 770, 772) ('Disease|D002375', 605, 614) ('Chemical|C009695', 727, 741) ('Disease|D002375', 244, 254) ('Chemical|D009278', 137, 148)
chem_dis_mesh_dicts.pkl.bz2
薬品名と病名のMESHID対応辞書
with bz2.BZ2File('data/chem_dis_mesh_dicts.pkl.bz2', 'rb') as f: chem_mesh_dict, dis_mesh_dict = load(f) for i, (key, value) in enumerate(chem_mesh_dict.iteritems()): print "MESHID:", key print "value:", value if i > 3: break MESHID: 'catechols' value: 'D002396' MESHID: 'transferrin-binding protein complex, bacterial' value: 'D033901' MESHID: 'glp 1 receptor' value: 'D000067757' MESHID: 'carbamoyl-phosphate synthase (glutamine)' value: 'D002223' MESHID: 'c fes proto oncogene proteins' value: 'D051578' for i, (key, value) in enumerate(dis_mesh_dict.iteritems()): print "MESHID:", key print "value:", value if i > 3: break MESHID: 'leukemia, plasmacytic' value: 'D007952' MESHID: 'fusion of kidney' value: 'D000069337' MESHID: 'epilepsies, anterior fronto-polar' value: 'D017034' MESHID: 'cholera infantum' value: 'D005767' MESHID: 'hunermann-conradi syndrome' value: 'D002806'
これらの関係を確認する。
例えば、
PMID: 1445986
('Chemical|D015313', 273, 282)
('Chemical|D015313', 284, 293)
('Disease|D000743', 50, 66)
('Disease|D000743', 25, 41)
('Chemical|D002511', 186, 200)
の病名と薬品名を調べる。
例えば、D015313の薬品名は、
chem_mesh_dict_inv = {v:k for k, v in chem_mesh_dict.items()} chem_mesh_dict_inv.get('D015313') >> 'ym09330'
であり、D000743の病名は、
dis_mesh_dict_inv = {v:k for k, v in dis_mesh_dict.items()} dis_mesh_dict_inv.get('D000743') >> 'anemia, microangiopathic'
だということが分かる。
※PMID: 1445986の論文名は「Cefotetan-induced immune hemolytic anemia.」
脱線したが、このような辞書をTaggerOneTaggerのインスタンスメソッドで定義する。
次にtagメソッドについて見ていく。
*呼び出し側
corpus_parser = CorpusParser(fn=tagger_one.tag) corpus_parser.apply(list(doc_preprocessor))
※1行目ではfnの引数として定義しただけで実行はしていない
class CDRTagger(object): def tag(self, parts): pubmed_id, _, _, sent_start, sent_end = parts['stable_id'].split(':') sent_start, sent_end = int(sent_start), int(sent_end) tags = self.tag_dict.get(pubmed_id, {}) for tag in tags: if not (sent_start <= tag[1] <= sent_end): continue offsets = [offset + sent_start for offset in parts['char_offsets']] toks = offsets_to_token(tag[1], tag[2], offsets, parts['lemmas']) for tok in toks: ts = tag[0].split('|') parts['entity_types'][tok] = ts[0] parts['entity_cids'][tok] = ts[1] return parts class TaggerOneTagger(CDRTagger): def tag(self, parts): parts = super(TaggerOneTagger, self).tag(parts) for i, word in enumerate(parts['words']): tag = parts['entity_types'][i] if len(word) > 4 and tag is None: wl = word.lower() if wl in self.dis_mesh_dict: parts['entity_types'][i] = 'Disease' parts['entity_cids'][i] = self.dis_mesh_dict[wl] elif wl in self.chem_mesh_dict: parts['entity_types'][i] = 'Chemical' parts['entity_cids'][i] = self.chem_mesh_dict[wl] return parts
2行目のapply関数でパース処理を実行する。
今回はparser引数を指定していないので、self.parserにはStanfordCoreNLPServerが格納される。
(ここで、parser引数を指定すると対応するクラスでパース処理が行われる。)
class CorpusParser(UDFRunner): def __init__(self, parser=None, fn=None): self.parser = parser or StanfordCoreNLPServer() super(CorpusParser, self).__init__(CorpusParserUDF, parser=self.parser, fn=fn) class CorpusParserUDF(UDF): def __init__(self, parser, fn, **kwargs): super(CorpusParserUDF, self).__init__(**kwargs) self.parser = parser self.req_handler = parser.connect() self.fn = fn def apply(self, x, **kwargs): """Given a Document object and its raw text, parse into Sentences""" doc, text = x for parts in self.req_handler.parse(doc, text): parts = self.fn(parts) if self.fn is not None else parts yield Sentence(**parts)
self.req_handler.parseの実体は、StanfordCoreNLPServer.parse
class StanfordCoreNLPServer(Parser): def parse(self, document, text, conn): ''' Parse CoreNLP JSON results. Requires an external connection/request object to remain threadsafe :param document: :param text: :param conn: server connection :return: ''' if len(text.strip()) == 0: sys.stderr.write("Warning, empty document {0} passed to CoreNLP".format(document.name if document else "?")) return # handle encoding (force to unicode) if isinstance(text, str): text = text.encode('utf-8', 'error') # POST request to CoreNLP Server try: content = conn.post(self.endpoint, text) content = content.decode(self.encoding) except socket.error as e: sys.stderr.write("Socket error") raise ValueError("Socket Error") # check for parsing error messages StanfordCoreNLPServer.validate_response(content) try: blocks = json.loads(content, strict=False)['sentences'] except: warnings.warn("CoreNLP skipped a malformed document.", RuntimeWarning) position = 0 for block in blocks: parts = defaultdict(list) dep_order, dep_par, dep_lab = [], [], [] for tok, deps in zip(block['tokens'], block[StanfordCoreNLPServer.BLOCK_DEFS[self.version]]): # Convert PennTreeBank symbols back into characters for words/lemmas parts['words'].append(StanfordCoreNLPServer.PTB.get(tok['word'], tok['word'])) parts['lemmas'].append(StanfordCoreNLPServer.PTB.get(tok['lemma'], tok['lemma'])) parts['pos_tags'].append(tok['pos']) parts['ner_tags'].append(tok['ner']) parts['char_offsets'].append(tok['characterOffsetBegin']) dep_par.append(deps['governor']) dep_lab.append(deps['dep']) dep_order.append(deps['dependent']) # certain configuration options remove 'before'/'after' fields in output JSON (TODO: WHY?) # In order to create the 'text' field with correct character offsets we use # 'characterOffsetEnd' and 'characterOffsetBegin' to build our string from token input text = "" for t in block['tokens']: # shift to start of local sentence offset i = t['characterOffsetBegin'] - block['tokens'][0]['characterOffsetBegin'] # add whitespace based on offsets of originalText text += (' ' * (i - len(text))) + t['originalText'] if len(text) != i else t['originalText'] parts['text'] = text # make char_offsets relative to start of sentence abs_sent_offset = parts['char_offsets'][0] parts['char_offsets'] = [p - abs_sent_offset for p in parts['char_offsets']] parts['abs_char_offsets'] = [p for p in parts['char_offsets']] parts['dep_parents'] = sort_X_on_Y(dep_par, dep_order) parts['dep_labels'] = sort_X_on_Y(dep_lab, dep_order) parts['position'] = position # Add full dependency tree parse to document meta if 'parse' in block and document: tree = ' '.join(block['parse'].split()) if 'tree' not in document.meta: document.meta['tree'] = {} document.meta['tree'][position] = tree # Link the sentence to its parent document object parts['document'] = document if document else None # Add null entity array (matching null for CoreNLP) parts['entity_cids'] = ['O' for _ in parts['words']] parts['entity_types'] = ['O' for _ in parts['words']] # Assign the stable id as document's stable id plus absolute character offset abs_sent_offset_end = abs_sent_offset + parts['char_offsets'][-1] + len(parts['words'][-1]) if document: parts['stable_id'] = construct_stable_id(document, 'sentence', abs_sent_offset, abs_sent_offset_end) position += 1 yield parts
その後、sentence毎にtagger_one.tag(parts)を呼び出す。ここで、parts['words']がインスタンスメソッドで定義したPubMed辞書に存在するか確認する。
存在した場合、
- parts['entity_types'][i] = 'Disease|Chemical'
- parts['entity_cids'][i] = self.dis_mesh_dict[wl]| self.chem_mesh_dict[wl]
を格納する。
utils.py
def offsets_to_token(left, right, offset_array, lemmas, punc=set(punctuation)): token_start, token_end = None, None for i, c in enumerate(offset_array): if left >= c: token_start = i if c > right and token_end is None: token_end = i break token_end = len(offset_array) - 1 if token_end is None else token_end token_end = token_end - 1 if lemmas[token_end - 1] in punc else token_end return range(token_start, token_end) class CDRTagger(object): def __init__(self, fname='data/unary_tags.pkl.bz2'): with bz2.BZ2File(fname, 'rb') as f: self.tag_dict = load(f) def tag(self, parts): pubmed_id, _, _, sent_start, sent_end = parts['stable_id'].split(':') sent_start, sent_end = int(sent_start), int(sent_end) tags = self.tag_dict.get(pubmed_id, {}) for tag in tags: if not (sent_start <= tag[1] <= sent_end): continue offsets = [offset + sent_start for offset in parts['char_offsets']] toks = offsets_to_token(tag[1], tag[2], offsets, parts['lemmas']) for tok in toks: ts = tag[0].split('|') parts['entity_types'][tok] = ts[0] parts['entity_cids'][tok] = ts[1] return parts class TaggerOneTagger(CDRTagger): def __init__(self, fname_tags='data/taggerone_unary_tags_cdr.pkl.bz2', fname_mesh='data/chem_dis_mesh_dicts.pkl.bz2'): with bz2.BZ2File(fname_tags, 'rb') as f: self.tag_dict = load(f) with bz2.BZ2File(fname_mesh, 'rb') as f: self.chem_mesh_dict, self.dis_mesh_dict = load(f) def tag(self, parts): parts = super(TaggerOneTagger, self).tag(parts) for i, word in enumerate(parts['words']): tag = parts['entity_types'][i] if len(word) > 4 and tag is None: wl = word.lower() if wl in self.dis_mesh_dict: parts['entity_types'][i] = 'Disease' parts['entity_cids'][i] = self.dis_mesh_dict[wl] elif wl in self.chem_mesh_dict: parts['entity_types'][i] = 'Chemical' parts['entity_cids'][i] = self.chem_mesh_dict[wl] return parts
PretaggedCandidateExtractor
Candidateの抽出までは前回とほとんど同じだが、抽出部の処理は少し異なる。
前回の抽出はこう書いた。(NER='PERSON'を抽出する専用のクラスを使用した。)
from snorkel.candidates import Ngrams, CandidateExtractor from snorkel.matchers import PersonMatcher ngrams = Ngrams(n_max=7) person_matcher = PersonMatcher(longest_match_only=True) cand_extractor = CandidateExtractor(Spouse, [ngrams, ngrams], [person_matcher, person_matcher], symmetric_relations=False)
今回はこのままは使えないので下記のように書く。
from snorkel.candidates import PretaggedCandidateExtractor candidate_extractor = PretaggedCandidateExtractor(ChemicalDisease, ['Chemical', 'Disease'])
名前の通りPretaggedCandidateExtractorクラスが事前に定義したタグ付き単語を抽出してくれる。
抽出部はとても長いので要点のみまとめる。
- 指定したエンティティの辞書を用意
# Do a first pass to collect all mentions by entity type / cid entity_idxs = dict((et, defaultdict(list)) for et in set(self.entity_types)) L = len(context.words) >> entity_idxs = {'chemical':{[]}, 'disease':{[]}}
- 単語のentity_typesが空かどうかチェック
- 空でない場合、entity_typesがentity_idxsに含まれるかチェック
- 含まれる場合、entity_idxsに追加
if context.entity_types[i] is not None: ets = context.entity_types[i].split(self.entity_sep) cids = context.entity_cids[i].split(self.entity_sep) for et, cid in zip(ets, cids): if et in entity_idxs: entity_idxs[et][cid].append(i) >> entity_idxs = {'chemical':{D002396:[catechols]}, 'disease':{D000069337:[fusion, of, kidney]}}
これで指定した固有表現の候補を抽出することが出来る。
Snorkelの前処理について(実装編)①
はじめに
前回まででSnorkelの概要、生成モデル、識別モデルに関してまとめた。
Snorkelの概要 - 機械学習・自然言語処理の勉強メモ
Snorkelにおける生成モデル - 機械学習・自然言語処理の勉強メモ
Snorkelにおける識別モデル - 機械学習・自然言語処理の勉強メモ
途中、数式が理解できない箇所もあったが、Snorkelの理論的な部分はだいたい分かった。
今回は前処理に関して実際のTutorialを見ながらまとめる。
尚、Tutorialのタスクはこんな感じで文章から人物名の組み合わせを抽出すること。
snorkel/Snorkel-Workshop-FINAL.pdf at master · HazyResearch/snorkel · GitHubより抜粋
人物名はIREX標準で定義されている固有表現であり、「Stanford CoreNLP」や「Spcay」を用いて容易に抽出できる。ただし、これらのライブラリは日本語はサポートしておらず、それに依存するSnorkelの関数だけでは日本語は対応できない。なので、日本語文を対象とする場合、前処理を独自に行う必要がある。不安だったので、質問したら素早く教えてくれた。
github.com
まとめると、「candidateの抽出後」は、Snorkelを使って英語と同じように処理すればよいとのこと。
用語の整理
- Entity・・意味のある(興味のある)概念。(固有表現)
- Relation・・2つ以上のEntityの意味的な関係
この絵が分かりやすい。
snorkel/Snorkel-Workshop-FINAL.pdf at master · HazyResearch/snorkel · GitHubより抜粋
candidateとは、sentence中の目的のrelation(ここではSpouse)の候補となるEntity
なので、興味のあるのEntityのペアがcandidateとして抽出できればOK。
コードを読んでいく
今回の前処理に関するTutorialはここにある。
github.com
文書に関して
対象とする「articles.tsv」はこんな形式
id | sentence |
9b28e780-... | NEW YORK -- Theatergoers who check out "Beautiful" on tour won't get to see ... |
corpus_parser
from snorkel.parser.spacy_parser import Spacy from snorkel.parser import CorpusParser corpus_parser = CorpusParser(parser=Spacy()) corpus_parser.apply(doc_preprocessor, count=n_docs, parallelism=1)
corpus_parser.py
class CorpusParser(UDFRunner): def __init__(self, parser=None, fn=None): self.parser = parser or StanfordCoreNLPServer() super(CorpusParser, self).__init__(CorpusParserUDF, parser=self.parser, fn=fn) class CorpusParserUDF(UDF): def __init__(self, parser, fn, **kwargs): super(CorpusParserUDF, self).__init__(**kwargs) self.parser = parser self.req_handler = parser.connect() self.fn = fn def apply(self, x, **kwargs): """Given a Document object and its raw text, parse into Sentences""" doc, text = x for parts in self.req_handler.parse(doc, text): parts = self.fn(parts) if self.fn is not None else parts yield Sentence(**parts)
parserは、デフォルトで「Stanford CoreNLP」だが、Tutorialでは、「Spcay」を使用している。
applyメソッドでパース処理を実行する。
このapplyメソッドにたどり着くまでが少しややこしい。まず、CorpusParserの親クラスUDFRunnerのapplyメソッドを実行する。
内部でapply_stを経由してCorpusParserUDF.applyを呼び出す。
class UDFRunner(object): """Class to run UDFs in parallel using simple queue-based multiprocessing setup""" def __init__(self, udf_class, **udf_init_kwargs): self.udf_class = udf_class self.udf_init_kwargs = udf_init_kwargs self.udfs = [] if hasattr(self.udf_class, 'reduce'): self.reducer = self.udf_class(**self.udf_init_kwargs) else: self.reducer = None def apply(self, xs, clear=True, parallelism=None, progress_bar=True, count=None, **kwargs): """ Apply the given UDF to the set of objects xs, either single or multi-threaded, and optionally calling clear() first. """ # Clear everything downstream of this UDF if requested if clear: print("Clearing existing...") SnorkelSession = new_sessionmaker() session = SnorkelSession() self.clear(session, **kwargs) session.commit() session.close() # Execute the UDF print("Running UDF...") if parallelism is None or parallelism < 2: self.apply_st(xs, progress_bar, clear=clear, count=count, **kwargs) else: self.apply_mt(xs, parallelism, clear=clear, **kwargs) def apply_st(self, xs, progress_bar, count, **kwargs): """Run the UDF single-threaded, optionally with progress bar""" udf = self.udf_class(**self.udf_init_kwargs) # Set up ProgressBar if possible pb = None if progress_bar and hasattr(xs, '__len__') or count is not None: n = count if count is not None else len(xs) pb = ProgressBar(n) # Run single-thread for i, x in enumerate(xs): if pb: pb.bar(i) # Apply UDF and add results to the session for y in udf.apply(x, **kwargs): # Uf UDF has a reduce step, this will take care of the insert; else add to session if hasattr(self.udf_class, 'reduce'): udf.reduce(y, **kwargs) else: udf.session.add(y) # Commit session and close progress bar if applicable udf.session.commit() if pb: pb.close()
後、apply_st関数でxs
をイテレートしている。xs
はdoc_preprocessorインスタンスであるので、この時点でdoc_preprocessorインスタンスの__iter__()メソッドが呼ばれることが分かる。
class DocPreprocessor(object): """ Processes a file or directory of files into a set of Document objects. :param encoding: file encoding to use, default='utf-8' :param path: filesystem path to file or directory to parse :param max_docs: the maximum number of Documents to produce, default=float('inf') """ def __init__(self, path, encoding="utf-8", max_docs=float('inf')): self.path = path self.encoding = encoding self.max_docs = max_docs def generate(self): """ Parses a file or directory of files into a set of Document objects. """ doc_count = 0 for fp in self._get_files(self.path): file_name = os.path.basename(fp) if self._can_read(file_name): for doc, text in self.parse_file(fp, file_name): yield doc, text doc_count += 1 if doc_count >= self.max_docs: return def __iter__(self): return self.generate() class TSVDocPreprocessor(DocPreprocessor): """Simple parsing of TSV file with one (doc_name <tab> doc_text) per line""" def parse_file(self, fp, file_name): with codecs.open(fp, encoding=self.encoding) as tsv: for line in tsv: (doc_name, doc_text) = line.split('\t') stable_id = self.get_stable_id(doc_name) doc = Document( name=doc_name, stable_id=stable_id, meta={'file_name': file_name} ) yield doc, doc_text
__iter__→generate()→parse_fileメソッドが呼ばれ、パース処理が実行される。xs
には、doc
, text
が格納されており、それらはタブ毎にCorpusParserUDF.applyに渡される。
corpus_parser.py
class CorpusParserUDF(UDF): def __init__(self, parser, fn, **kwargs): super(CorpusParserUDF, self).__init__(**kwargs) self.parser = parser self.req_handler = parser.connect() self.fn = fn def apply(self, x, **kwargs): """Given a Document object and its raw text, parse into Sentences""" doc, text = x for parts in self.req_handler.parse(doc, text): parts = self.fn(parts) if self.fn is not None else parts yield Sentence(**parts)
self.req_handler.parse(doc, text)の実体は、Spacy.parse関数。この関数がSpacyによるパース処理を行う。
class Spacy(Parser): def parse(self, document, text): ''' Transform spaCy output to match CoreNLP's default format :param document: :param text: :return: ''' text = self.to_unicode(text) doc = self.model.tokenizer(text) for proc in self.pipeline: proc(doc) assert doc.is_parsed position = 0 for sent in doc.sents: parts = defaultdict(list) text = sent.text for i,token in enumerate(sent): parts['words'].append(str(token)) parts['lemmas'].append(token.lemma_) parts['pos_tags'].append(token.tag_) parts['ner_tags'].append(token.ent_type_ if token.ent_type_ else 'O') parts['char_offsets'].append(token.idx) parts['abs_char_offsets'].append(token.idx) head_idx = 0 if token.head is token else token.head.i - sent[0].i + 1 parts['dep_parents'].append(head_idx) parts['dep_labels'].append(token.dep_) # Add null entity array (matching null for CoreNLP) parts['entity_cids'] = ['O' for _ in parts['words']] parts['entity_types'] = ['O' for _ in parts['words']] # make char_offsets relative to start of sentence parts['char_offsets'] = [ p - parts['char_offsets'][0] for p in parts['char_offsets'] ] parts['position'] = position # Link the sentence to its parent document object parts['document'] = document parts['text'] = text # Add null entity array (matching null for CoreNLP) parts['entity_cids'] = ['O' for _ in parts['words']] parts['entity_types'] = ['O' for _ in parts['words']] # Assign the stable id as document's stable id plus absolute # character offset abs_sent_offset = parts['abs_char_offsets'][0] abs_sent_offset_end = abs_sent_offset + parts['char_offsets'][-1] + len(parts['words'][-1]) if document: parts['stable_id'] = construct_stable_id(document, 'sentence', abs_sent_offset, abs_sent_offset_end) position += 1 yield parts
パースした内容は、Document・Sentenceモデルで確認できる。(この辺の操作はSQLAlchemyで行う。)
例えば、Sentenceモデルのテーブル構造はこんな感じ。
列名 | 型 |
id | Integer |
words | String |
char_offsets | Integer |
abs_char_offsets | Integer |
lemmas | String |
pos_tags | String |
ner_tags | String |
dep_parents | Integer |
dep_labels | String |
entity_cids | String |
entity_types | String |
*ここで定義されている。
snorkel/context.py at master · HazyResearch/snorkel · GitHub
以下は先頭の文章をからいくつかの列の値抽出した結果。
sent1 = session.query(Sentence).first() print sent1.words ['NEW', 'YORK', '--', 'Theatergoers', 'who', 'check', 'out', '"', 'Beautiful', '"', 'on', 'tour', 'wo', "n't", 'get', 'to', 'see', 'Tony', 'winner', 'Jessie', 'Mueller', 'but', 'they', 'may', 'get', 'the', 'next', 'best', 'thing', '--', 'someone', 'with', 'her', 'DNA', '.', ' '] print sent1.char_offsets [0, 4, 9, 12, 25, 29, 35, 39, 40, 49, 51, 54, 59, 61, 65, 69, 72, 76, 81, 88, 95, 103, 107, 112, 116, 120, 124, 129, 134, 140, 143, 151, 156, 160, 163, 165] print sent1.pos_tags [u'JJ', u'NN', u':', u'NNS', u'WP', u'VBP', u'RP', u'``', u'JJ', u"''", u'IN', u'NN', u'MD', u'RB', u'VB', u'TO', u'VB', u'NNP', u'NN', u'NNP', u'NNP', u'CC', u'PRP', u'MD', u'VB', u'DT', u'JJ', u'JJS', u'NN', u':', u'NN', u'IN', u'PRP$', u'NNP', u'.', u'SP'] print sent1.ner_tags ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', u'PERSON', 'O', u'PERSON', u'PERSON', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', u'ORG', 'O', 'O']
一連のパース処理は、英文だからできる。(Spcayが対応している言語ならできる。)
残念ながら日本語は対応していない。(janome/spaCyで一部パースは可能)
ここらへんをどう処理するかを考える必要がある。
candidate_subclass
タスクのcandidateを定義する。
以下では、person1・person2のエンティティを関係Spouse(配偶者)として定義している。
from snorkel.models import candidate_subclass Spouse = candidate_subclass('Spouse', ['person1', 'person2'])
こうしたら固有表現抽出でも使えるらしい。
from snorkel.models import candidate_subclass Person = candidate_subclass('Person', ['person1'])
Training data for training a NER model · Issue #599 · HazyResearch/snorkel · GitHub
後で、詳しく見てみたい。
CandidateExtractor
Candidateの抽出。ここが前処理において一番大事と思われる。
ここでSpcayによりパースしたNERタグが使われる。
from snorkel.candidates import Ngrams, CandidateExtractor from snorkel.matchers import PersonMatcher ngrams = Ngrams(n_max=7) person_matcher = PersonMatcher(longest_match_only=True) cand_extractor = CandidateExtractor(Spouse, [ngrams, ngrams], [person_matcher, person_matcher], symmetric_relations=False)
Ngramsのインスタンスメソッドを確認する。
class Ngrams(CandidateSpace): """ Defines the space of candidates as all n-grams (n <= n_max) in a Sentence _x_, indexing by **character offset**. """ def __init__(self, n_max=5, split_tokens=('-', '/')): CandidateSpace.__init__(self) self.n_max = n_max self.split_rgx = r'('+r'|'.join(split_tokens)+r')' if split_tokens and len(split_tokens) > 0 else None
ngramsの属性を確認する。
print ngrams.__dict__ {'n_max': 7, 'split_rgx': '(-|/)'}
次にPersonMatcherについて
person_matcher = PersonMatcher(longest_match_only=True)
PersonMatcherのインスタンスメソッドを確認する。
class PersonMatcher(RegexMatchEach): """ Matches Spans that are the names of people, as identified by CoreNLP. A convenience class for setting up a RegexMatchEach to match spans for which each token was tagged as a person. """ def __init__(self, *children, **kwargs): kwargs['attrib'] = 'ner_tags' kwargs['rgx'] = 'PERSON' super(PersonMatcher, self).__init__(*children, **kwargs)
person_matcherの属性を確認する。
print person_matcher.__dict__ {'ignore_case': True, 'longest_match_only': True, 'sep': ' ', 'rgx': 'PERSON$', 'r': <_sre.SRE_Pattern object at 0x7f4ff55e6ea0>, 'attrib': 'ner_tags', 'children': (), 'opts': {'rgx': 'PERSON', 'attrib': 'ner_tags', 'longest_match_only': True}}
person_matcherはNERタグが「PERSON」の単語をターゲットとするための定義を行う。
snorkelでは、PERSON, LOCATION, ORGANIZATION などの標準的なNERタグをcandidateのターゲットとするためのクラスを用意してくれている。
※独自のタグをターゲットとする場合、これらのクラスに倣って専用のクラスを作ればよい。
最後にCandidateExtractorの引数に定義したものをセットする。
※symmetric_relationsは、集合「A, B」を候補「A, B」・「B, A」として扱うか。一つの候補「A, B」としてのみ扱うか。
そして、applyメソッドでcandidateの抽出処理を行う。
for i, sents in enumerate([train_sents, dev_sents, test_sents]): cand_extractor.apply(sents, split=i, parallelism=1) print("Number of candidates:", session.query(Spouse).filter(Spouse.split == i).count())
applyメソッドで重要と思われる箇所を見ていく。
大事なところは「candidate_spacesインスタンスのapplyメソッドの戻り値をmatchersインスタンスのapplyメソッドの引数」としていること。
def apply(self, context, clear, split, **kwargs): # Generate TemporaryContexts that are children of the context using the candidate_space and filtered # by the Matcher for i in range(self.arity): self.child_context_sets[i].clear() for tc in self.matchers[i].apply(self.candidate_spaces[i].apply(context)): tc.load_id_or_insert(self.session) self.child_context_sets[i].add(tc)
Ngramsクラスのapplyメソッドを確認する。
class Ngrams(CandidateSpace): def apply(self, context): # These are the character offset--**relative to the sentence start**--for each _token_ offsets = context.char_offsets # Loop over all n-grams in **reverse** order (to facilitate longest-match semantics) L = len(offsets) seen = set() for l in range(1, self.n_max+1)[::-1]: for i in range(L-l+1): w = context.words[i+l-1] start = offsets[i] end = offsets[i+l-1] + len(w) - 1 ts = TemporarySpan(char_start=start, char_end=end, sentence=context) if ts not in seen: seen.add(ts) yield ts
ts
の内容を下記sentenceを例に数件確認する。
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
words | NEW | YORK | -- | Theatergoers | who | check | out | " | Beautiful | " | on | tour | wo | n't | get | to | see |
offset | 0 | 4 | 9 | 12 | 25 | 29 | 35 | 39 | 40 | 49 | 51 | 54 | 59 | 61 | 65 | 69 | 72 |
外側のループ1回目:
内側のループ1回目:
まず、L=16、外側のループでl=7(self.n_max=7)、内側のループでi=0がセットされる。
各変数は、
w = "out" start = 0 end = 37 # 35 + 3 - 1 ts = TemporarySpan(char_start=0, char_end=37, sentence=context) # ts.char_start=0 # ts.char_end=37 # ts.sentence=context
内側のループ2回目:
i=1に更新
各変数は、
w = "" start = 4 end = 38 # 39 + 0 - 1 ts = TemporarySpan(char_start=4, char_end=38, sentence=context) # ts.char_start=4 # ts.char_end=38 # ts.sentence=context
となる。
外側のループ1回目では、7-gramsの集合を作る。
外側のループ2回目:
内側のループ1回目:
l=6、i=0がセットされる。
各変数は、
w = "check" start = 0 end = 33 # 29 + 5 - 1 ts = TemporarySpan(char_start=0, char_end=33, sentence=context) # ts.char_start=0 # ts.char_end=33 # ts.sentence=context
となる。
外側のループ1回目では、6-gramsの集合を作る。
これを最終的にunigramの集合まで作成する。
実際はts
を生成するごとに、それをmatchersインスタンスのapplyメソッドに渡す。
PersonMatcherクラスのapplyメソッドを確認する。
※正確には、継承元Matcherから引き継いだもの
n-gramsのcandidateを一つずつチェックする。
def apply(self, candidates): """ Apply the Matcher to a **generator** of candidates Optionally only takes the longest match (NOTE: assumes this is the *first* match) """ seen_spans = set() for c in candidates: if self.f(c) and (not self.longest_match_only or not any([self._is_subspan(c, s) for s in seen_spans])): if self.longest_match_only: seen_spans.add(self._get_span(c)) yield c
class RegexMatchEach(RegexMatch): """Matches regex pattern on **each token**""" def _f(self, c): tokens = c.get_attrib_tokens(self.attrib) return True if tokens and all([self.r.match(t) is not None for t in tokens]) else False
具体的には、_f
関数でn-grams全てがマッチングするか(今回の場合、tokenのner_tags属性が全てPERSONか)判定。
例えば、下記の文章で考える。
Barack Obama and first lady Michelle Obama ...
最大3gramsで考えた場合、
Barack Obama and |
Obama and first |
and first lady |
first lady Michelle |
lady Michelle Obama |
Barack Obama and |
Obama and first |
and first lady |
first lady Michelle |
lady Michelle Obama |
Barack Obama |
Obama and |
and first |
first lady |
lady Michelle |
Michelle Obama |
Barack |
Obama |
and |
first |
lady |
Michelle |
Obama |
Obama and |
and first |
first lady |
lady Michelle |
Michelle Obama |
Barack |
Obama |
and |
first |
lady |
Michelle |
Obama |
が候補になる。
n-gramが大きい方から候補を探すので、bi-gramの「Barack Obama」が候補となる。
また、既に候補が存在する場合、そのspanはseen_spans
に格納済みなので、より小さいn-gramで再度候補となることはない(この例の場合、「BarackやObama」が候補となることは無い)。
また、実際は単語ではなく、単語の開始位置で候補を判定するので、同じ単語が存在しても不都合が生じることは無い。
DB登録まで追えてはいないが、候補単位で登録を行うことで、同じ候補での重複(候補がBarack Obama同士になる)を無くしているのだと考えられる。
最後に抽出後のCandidateを確認する。
cands = session.query(Candidate).filter(Candidate.split == 0).all() print cands[0] # Spouse(Span("Sarko", sentence=44253, chars=[55,59], words=[10,10]), Span("Carla Bruni", sentence=44253, chars=[79,89], words=[15,16])) print cands[0].person1 # Span("Sarko", sentence=44253, chars=[55,59], words=[10,10]) print cands[0].person2 # Span("Carla Bruni", sentence=44253, chars=[79,89], words=[15,16]) # the raw word tokens for the person1 Span print cands[0].person1.get_attrib_tokens("words") # ['Sarko'] print cands[0].person1.get_attrib_tokens("pos_tags") # [u'NNP'] print cands[0].person1.get_attrib_tokens("ner_tags") # [u'PERSON']
これで、前処理が完了した。
次は生成モデルの構築について見ていきたい。
spaCyの基本操作
基本操作
基本的な操作を備忘録として残す。
import spacy nlp = spacy.load('en') doc = nlp(u'Jeffrey Navin saw the girl with the telescope. She looked very strong.')
Spacyの単語は文字列ではなく品詞情報などを含む特殊なオブジェクト
doc[0] >> Jeffrey type(doc[0]) >> spacy.tokens.token.Token
sentenceに分ける。
sentences = list(doc.sents) >> [Jeffrey Navin saw the girl with the telescope., She looked very strong.] len(sentences ) >> 2
品詞情報の抽出
(doc[0].pos_, doc[0].pos) >> (u'PROPN', 94)
名詞のチャンクの抽出
list(doc.noun_chunks)[0].text >> u'Jeffrey Navin'
原形・品詞タグの取得
for sent in doc.sents: for token in sent: print str(token),token.lemma_, token.tag_
Jeffrey jeffrey NNP
Navin navin NNP
saw see VBD
the the DT
girl girl NN
with with IN
the the DT
telescope telescope NN
. . .
She -PRON- PRP
looked look VBD
very very RB
strong strong JJ
. . .
固有表現の抽出
for sent in doc.sents: for token in sent: print str(token), token.ent_type_ if token.ent_type_ else 'O'
Jeffrey PERSON
Navin PERSON
saw O
the O
girl O
with O
the O
telescope O
. O
She O
looked O
very O
strong O
. O
もう少し詳細な固有表現抽出
doc = nlp(u'San Francisco considers banning sidewalk delivery robots') ents = [(ent.text, ent.start_char, ent.end_char, ent.label_) for ent in doc.ents] >> [(u'San Francisco', 0, 13, u'GPE')] doc = nlp(u'Tom goes to New York') ents = [(ent.text, ent.start_char, ent.end_char, ent.label_) for ent in doc.ents] >> [(u'Tom', 0, 3, u'PERSON'), (u'New York', 12, 20, u'GPE')]
基本操作はこんな感じ。
Snorkelの識別モデルについて(理論編)
前回で生成モデルを用いてを推定するところをまとめた。
kento1109.hatenablog.com
(論文の解釈に自信が無い部分もあるが・・)
生成モデルにより、データに対応するラベルが生成できた。
https://hazyresearch.github.io/snorkel/pdfs/snorkel_demo.pdf
教師ありデータを識別モデルを用いてモデリングする。
Weak Supervisionより引用
2値分類の識別モデルなので、損失関数はロジスティック損失を用いる。
ロジスティック損失は、次式で定義される。
を与えて、で正しく識別している。の時、の値が大きいほど、識別境界から離れて余裕をもって識別できる。(その分、は小さくなるので、損失値も小さい。)パラメータは、最尤法により求める。
ただし、今回の場合、は観測できないので、生成モデルにより求めたを利用する。
よってロジスティック損失は、
となる。
確率的勾配法などで最適なを求める。
また、論文の後半では、ラベリング関数同士の依存関係を考慮したモデルなどが紹介されているが、まとめられる程理解できていない・・
もう少し、勉強して理解できたら補足していきたい。
Snorkelの生成モデルについて(理論編)
Snorkelとは
一言で言うと、ラベル付き訓練データを簡単に作成できるツール。
詳しくは前回の記事にまとめた。
kento1109.hatenablog.com
尚、今回の内容は以下の論文のまとめみたいなもの。
[1605.07723] Data Programming: Creating Large Training Sets, Quickly
生成モデルとは
観測データを生成する確率分布を想定し、観測データからその確率分布を推定する方法。
(それぞれのクラスにおけるデータの分布を学習するモデル。)
教師なし学習における生成モデルによるアプローチでは、ラベル無しデータをラベリングに関する不完全データとして扱うことで分類器の学習に用いる。
同時確率モデルにおいて、訓練データを用いてパラメータを学習する。
のクラスラベル は、ベイズ則によりクラス事後確率 を求め、その確率を最大化する を選択することで推定される。
識別モデルと生成モデル - 機械学習・自然言語処理の勉強メモ
にもまとめた。
Data Programming
Data Programmingとは、概念みたいなものであるが、訓練データをラベリング関数を書くことで簡単に生成する手法みたいなもの、と書かれている。
Data Programmingでは、
- 生成モデルを用いてラベリング関数の出力をモデリング
- 識別モデルを用いてデータをラベリング
の2つのプロセスに分けられる。
今回は1.の方を見ていく。
ラベリング関数の出力結果は、潜在変数から生成されたと仮定する。
(生成モデリングによるアプローチ)
※論文より引用
各関数は以下のようなもの
def lambda_1(x): return 1 if (x.gene,x.pheno) in KNOWN_RELATIONS_1 else 0 def lambda_2(x): return -1 if re.match(r’.*not cause.*’ , x.text_between) else 0 def lambda_3(x): return 1 if re.match(r’.*associated.*’ ,x. text_between) and (x.gene , x.pheno) in KNOWN_RELATIONS_2 else 0
なので、結果は下記のような行列となる。
1 | 0 | 1 | |
1 | 1 | 1 | |
-1 | 1 | 0 | |
1 | 1 | -1 |
ここで、に関する確率変数
- :観測データの網羅率(非ゼロの割合)
- :真のラベルと「一致」する確率
を導入する。
※関係抽出タスクの場合、関数は、「1(関係あり)、-1(関係なし)、0(不明)」を返す。
例えば、の潜在変数がであった場合を考えると、
の時、ラベリング関数は真のラベルと一致している。(これはで定義される。)ただし、ラベリング関数がその観測データをカバーしていない(0を返す)可能性もある。なので、カバー率を掛けてが「真のラベルとラベリング関数の出力が一致する確率」となる。反対に、とすると、「真のラベルとラベリング関数の出力が一致しない確率」となる。最後に、が「ラベリング関数の非カバー率」となる。
まとめると、
と書ける。
※
であるが、これは観測データから計算可能。一方、真のラベルは不明なのでは推定する必要がある。(論文ではをパラメータとしているが、なぜも推定する必要があるかはよく分からなかった・・)
生成モデルは下記の式で考えられる。
今回は2クラスで考えるので、
事前確率であるが、今回は推定できないので、0.5と置いておく。は一般的には正規分布などを仮定してが生成される確率を計算することが多いが、ここではを使って求める。
とすると、分布
事前確率は0.5なので、同時確率の分布は、
となる。
任意の訓練データを用意して、
として、最尤法によりを求める。
論文では、確率的勾配法によりを推定している。(調整過程は省略)
生成モデルのアプローチでは、
1 | 0 | 1 | 1 | 1 | |
1 | 1 | 1 | 0 | 0 | |
-1 | -1 | 0 | -1 | 0 |
とデータがあった場合、
はの分布から、は、の分布から生成されたとするのが尤もらしいと考える。