機械学習・自然言語処理の勉強メモ

学んだことのメモやまとめ

Snorkelの生成モデルについて(実装編)

はじめに



前回はラベリング関数の作り方を確認した。
kento1109.hatenablog.com

今回はいよいよ生成モデルに関するチュートリアルを読んでいく。
尚、理論的なことはこっちにまとめた。
Snorkelの生成モデルについて(理論編) - 機械学習・自然言語処理の勉強メモ

前回定義したラベリング関数であるが、個々の関数は弱教師(精度が低く単独で使えない識別器)だった。生成モデルでは、これらを統合して精度の高い識別器の生成を目指す(アンサンブル学習に近い学習手法)。
最も簡単な統合方法は、ラベリング関数の結果で多数決を取ることである。しかし、この場合、個々のラベリング関数の精度を無視することになる。
例えば下記の2つのラベリング関数の精度を比較する。

  1. 「人物名の周りにmarryという単語があれば1、なければ0」を返す。
  2. 「人物名のlast nameが同じであれば1、同じでなければ0」を返す。

真のラベルが「1(関係あり)」と仮定した場合、2.のラベリング関数の方が、「1(関係あり)」を返す可能性(あくまで経験的な予測)が高いと考えられる。そうなると、単なる多数決ではなく、2.の投票をより重視(重みづけ)すべきである。
つまり、この潜在的な精度を考慮して分類するべきであり、このような考えに基づくモデルが生成モデルである。
f:id:kento1109:20180109162304p:plain
[1605.07723] Data Programming: Creating Large Training Sets, Quicklyより引用

真のラベルなしに潜在的な精度を学習する際、各ラベリング関数のoverlapconflictが重要になる。
生成モデルの推定方法に関しては後で確認する。

コードを読んでいく



今回のチュートリアルは以下にある。
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

ラベリング関数の精度を生成モデルにより学習する。
学習後、生成モデルは各候補のノイズ対応トレーニングラベルを生成する。これは、この後の識別モデルで使用される。
f:id:kento1109:20180116143903p:plain
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()

f:id:kento1109:20180116151609p:plain:w250
3)Plotting Marginal Probabilities
生成モデルを訓練データに適用

train_marginals = gen_model.marginals(L_train)

訓練データは0~1の値を取る二項分布となる。一番右の分布のように2つの山が出来ていてそれらが離れているほど理想的(TrueラベルとFalseラベルの分布が良く分かれている)。一番左は多くの候補がTrueラベルの確率が0.5の状態(ほとんどランダムによる識別と変わらない)。これは、LFscoverageが低い場合に頻繁にみられる分布であり、この場合は候補全体を網羅するラベリング関数を定義する必要がある。真ん中の分布は、LFsがFalseラベルの識別は出来ているが、Tureラベルの識別ができていない状態(Tureラベルを返すためのラベリング関数をもっと定義する必要がある。)
f:id:kento1109:20180116152347p:plain:w1000
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の前処理について(実装編)① - 機械学習・自然言語処理の勉強メモ
前処理が完了すると、関係の候補となるペアが出来る。
f:id:kento1109:20180115150612p:plain
※「配偶者」の候補となる人物名のペア

ただし、抽出した候補はエンティティが「人物名」のチャンクを抽出しただけ。なので、
f:id:kento1109:20180115151606p:plain
のように文章中の人物名のペアでも「配偶者」の関係ではない場合が存在する(なので、この時点では配偶者の関係をもつ「候補」と言われている)。
では、どのようにして「配偶者」の関係を持つ候補を見分ければよいか。
例えば以下の文章を見てみる。
f:id:kento1109:20180115151954p:plain
候補は、

Spouse('Rachel Hattingh' 'Graham Marshall')

だが、これは「配偶者」の関係を持つ候補と言えるだろうか。殆どの人は「YES」と答えるだろう。そして、その理由に「her husband」があるからと答える。
では、次の文章はどうだろう。
f:id:kento1109:20180115152802p:plain
候補は、

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

f:id:kento1109:20180115161533p:plain
※ここまでの画像は全て下記より引用
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つの関数を定義する。

  1. 「人物名の周りにmarryという単語があれば1、なければ0」を返す。
  2. 「人物名の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などの周辺に出現する人物名同士は配偶者である可能性が高いことを知識として知っている)が、厳密には、キーワードと関係の条件付き確率に基づき定義することが出来る。
例えば、真の正解ラベルの情報から
P(candidate=0|keyword='boyfriend')=0.9
P(candidate=0|keyword='girlfriend')=0.8
※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)

f:id:kento1109:20180116105718p:plain:w1000
また、候補の真のラベルを持っている場合、ラベリング関数による抽出精度も測定可能。

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)

f:id:kento1109:20180116114843p:plain:w1000
正解ラベル付きデータでAccuracyを確認する。

L_dev.lf_stats(session, labels=L_gold_dev.toarray().ravel())

f:id:kento1109:20180116115110p:plain:w1000
実際の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内で行う場合、今回と同様のやり方でほとんど対応可能だと思う。しかし、固有表現抽出は今回のMESHのような外部リソースを活用する方法に限定される。
別の方法で固有表現抽出を行いたい場合、事前にタグ付けが行い、それをSnorkelの入力とするのが良いと思う。
いずれの場合もparseで「Stanford CoreNLP」や「Spcay」が利用できないので、その部分の実装はどうしても必要になる。

Snorkelの前処理について(実装編)①

はじめに



前回まででSnorkelの概要、生成モデル、識別モデルに関してまとめた。
Snorkelの概要 - 機械学習・自然言語処理の勉強メモ
Snorkelにおける生成モデル - 機械学習・自然言語処理の勉強メモ
Snorkelにおける識別モデル - 機械学習・自然言語処理の勉強メモ

途中、数式が理解できない箇所もあったが、Snorkelの理論的な部分はだいたい分かった。

今回は前処理に関して実際のTutorialを見ながらまとめる。
尚、Tutorialのタスクはこんな感じで文章から人物名の組み合わせを抽出すること。

f:id:kento1109:20180114145046p:plain
snorkel/Snorkel-Workshop-FINAL.pdf at master · HazyResearch/snorkel · GitHubより抜粋

人物名はIREX標準で定義されている固有表現であり、「Stanford CoreNLP」や「Spcay」を用いて容易に抽出できる。ただし、これらのライブラリは日本語はサポートしておらず、それに依存するSnorkelの関数だけでは日本語は対応できない。なので、日本語文を対象とする場合、前処理を独自に行う必要がある。不安だったので、質問したら素早く教えてくれた。
github.com
まとめると、「candidateの抽出後」は、Snorkelを使って英語と同じように処理すればよいとのこと。

用語の整理
  • Entity・・意味のある(興味のある)概念。(固有表現)
  • Relation・・2つ以上のEntityの意味的な関係

この絵が分かりやすい。
f:id:kento1109:20180112124038p:plain
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で再度候補となることはない(この例の場合、「BarackObama」が候補となることは無い)。
また、実際は単語ではなく、単語の開始位置で候補を判定するので、同じ単語が存在しても不都合が生じることは無い。
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の基本操作

spaCyとは



pythonで動かす自然言語処理ライブラリ。
品詞タグ付け、固有表現抽出、構文解析などが出来る。

詳しくはここ。
spacy.io

基本操作

基本的な操作を備忘録として残す。

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の識別モデルについて(理論編)

前回で生成モデルを用いてP(y_i|\lambda)を推定するところをまとめた。
kento1109.hatenablog.com
(論文の解釈に自信が無い部分もあるが・・)

生成モデルにより、データx_iに対応するラベルy_iが生成できた。
f:id:kento1109:20180110145508p:plain
https://hazyresearch.github.io/snorkel/pdfs/snorkel_demo.pdf

教師ありデータを識別モデルを用いてモデリングする。
f:id:kento1109:20180110145722p:plain
Weak Supervisionより引用

2値分類の識別モデルなので、損失関数はロジスティック損失を用いる。
ロジスティック損失は、次式で定義される。
L(f(w,x),y)={\rm E}_{x,y}[ \log(1+\exp (-f(w,x)y)]
x,yを与えて、f(w,x)y>0で正しく識別している。y=+1の時、f(w,x)の値が大きいほど、識別境界から離れて余裕をもって識別できる。(その分、\exp (-f(w,x)y)は小さくなるので、損失値も小さい。)パラメータwは、最尤法により求める。
\begin{eqnarray}\tilde {w}=\arg\min_w\sum_{i=1}^n (\log(1+\exp (-f(w,x_i)y_i))\end{eqnarray}
ただし、今回の場合、y_iは観測できないので、生成モデルにより求めたP(y=1|\lambda(x))を利用する。
よってロジスティック損失は、
\begin{eqnarray}\tilde {w}=\arg\min_w\sum_{i=1}^n (\log(1+\exp (-f(w,x_i)y_i|\lambda(x_i)))\end{eqnarray}
となる。
確率的勾配法などで最適な\tilde {w}を求める。

また、論文の後半では、ラベリング関数同士の依存関係を考慮したモデルなどが紹介されているが、まとめられる程理解できていない・・
もう少し、勉強して理解できたら補足していきたい。

Snorkelの生成モデルについて(理論編)

Snorkelとは



一言で言うと、ラベル付き訓練データを簡単に作成できるツール。
詳しくは前回の記事にまとめた。
kento1109.hatenablog.com
尚、今回の内容は以下の論文のまとめみたいなもの。
[1605.07723] Data Programming: Creating Large Training Sets, Quickly

生成モデルとは



観測データを生成する確率分布を想定し、観測データからその確率分布を推定する方法。
(それぞれのクラスにおけるデータの分布を学習するモデル。)
教師なし学習における生成モデルによるアプローチでは、ラベル無しデータをラベリングに関する不完全データとして扱うことで分類器の学習に用いる。
同時確率モデルp(x,y:\Theta)において、訓練データを用いてパラメータ\Theta=\{\theta_k\}_{k=1}^Kを学習する。
xのクラスラベル yは、ベイズ則によりクラス事後確率 P(k|x:\Theta)\propto P(x,k:\theta_k)を求め、その確率を最大化する kを選択することで推定される。
識別モデルと生成モデル - 機械学習・自然言語処理の勉強メモ
にもまとめた。

Data Programming



Data Programmingとは、概念みたいなものであるが、訓練データをラベリング関数を書くことで簡単に生成する手法みたいなもの、と書かれている。
Data Programmingでは、

  1. 生成モデルを用いてラベリング関数の出力をモデリング
  2. 識別モデルを用いてデータをラベリング

の2つのプロセスに分けられる。

今回は1.の方を見ていく。
ラベリング関数の出力結果\lambda_iは、潜在変数yから生成されたと仮定する。
(生成モデリングによるアプローチ)
f:id:kento1109:20180109162304p:plain
※論文より引用
各関数は以下のようなもの

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

\lambda \in [-1,-,1]なので、結果は下記のような行列となる。

\lambda_1 \lambda_2 \lambda_3
x_1 1 0 1
x_2 1 1 1
x_3 -1 1 0
\vdots \vdots \vdots \vdots
x_i 1 1 -1

ここで、\lambda_iに関する確率変数

  • \beta_i:観測データの網羅率(非ゼロの割合)
  • \alpha_i:真のラベルyと「一致」する確率

を導入する。
※関係抽出タスクの場合、関数は、「1(関係あり)、-1(関係なし)、0(不明)」を返す。

例えば、\lambda_i(x)の潜在変数がy=1であった場合を考えると、
\lambda_i(x)=1の時、ラベリング関数は真のラベルyと一致している。(これは\alphaで定義される。)ただし、ラベリング関数がその観測データをカバーしていない(0を返す)可能性もある。なので、カバー率を掛けて\beta\alphaが「真のラベルとラベリング関数の出力が一致する確率」となる。反対に、\beta(1-\alpha)とすると、「真のラベルとラベリング関数の出力が一致しない確率」となる。最後に、(1-\beta)が「ラベリング関数の非カバー率」となる。
まとめると、
  P(\lambda_i(x)|y) = \begin{cases}
    \beta_i\alpha_i & (\lambda_i(x)=y) \\
\beta_i(1-\alpha_i) & (\lambda_i(x)=-y) \\
    (1-\beta_i)& (\lambda_i(x)=0)
  \end{cases}
と書ける。
\beta_i\alpha_i+\beta_i(1-\alpha_i)+(1-\beta_i)=1

\betaであるが、これは観測データから計算可能。一方、真のラベルyは不明なので\alphaは推定する必要がある。(論文では\alpha,\betaをパラメータとしているが、なぜ\betaも推定する必要があるかはよく分からなかった・・)
生成モデルは下記の式で考えられる。
\sum_{i=0}^nP(y_i)P(x|y_i)
今回は2クラスで考えるので、
P(y)P(x|y)=P(y=0)P(x|y=0)+P(y=1)P(x|y=1)
事前確率P(y)であるが、今回は推定できないので、0.5と置いておく。P(x|y)は一般的には正規分布などを仮定してxが生成される確率を計算することが多いが、ここでは\alpha,\betaを使って求める。
\begin{eqnarray}P(\Lambda|y)=\prod _{ i=1 }^{m }{  P(\lambda_i(x)|y)}\end{eqnarray}とすると、分布
\begin{eqnarray}p(\Lambda|y)=\prod _{ i=1 }^{m }{ ( \beta_i\alpha_i\rm 1_{\{\Lambda_i=y\}}+\beta_i(1-\alpha_i)\rm 1_{\{\Lambda_i=-y\}}+(1-\beta_i)\rm 1_{\{\Lambda_i=\rm 0\}})}\end{eqnarray}
事前確率は0.5なので、同時確率の分布は、
\begin{eqnarray}p(\Lambda,y)=\frac{1}{2}\prod _{ i=1 }^{m }{ ( \beta_i\alpha_i\rm 1_{\{\Lambda_i=y\}}+\beta_i(1-\alpha_i)\rm 1_{\{\Lambda_i=-y\}}+(1-\beta_i)\rm 1_{\{\Lambda_i=\rm 0\}})}\end{eqnarray}
となる。
任意の訓練データS\in Xを用意して、
\begin{eqnarray}L=\sum_{x\in S}\log\left(\sum_{y'\in \{-1,1\}}p(\lambda(x),y')  \right) \end{eqnarray}
として、最尤法により\alphaを求める。
論文では、確率的勾配法により\alphaを推定している。(調整過程は省略)
生成モデルのアプローチでは、

\lambda_1 \lambda_2 \lambda_3 \lambda_4 \lambda_5
x_1 1 0 1 1 1
x_2 1 1 1 0 0
x_3 -1 -1 0 -1 0

とデータがあった場合、
x_1,x_2y=+1の分布から、x_3は、y=-1の分布から生成されたとするのが尤もらしいと考える。