Created
August 21, 2020 12:48
-
-
Save dakeshi19/41be7a0d867ce33c727390b390dfdfef to your computer and use it in GitHub Desktop.
spaCyの練習
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import json | |
import spacy | |
import re | |
import sys | |
import itertools | |
nlp = spacy.load('ja_core_news_md') | |
nlp.remove_pipe('ner') # 固有表現は使わないので除外 | |
数詞 = ('名詞-数詞', '名詞-数詞-') | |
例外名詞 = ('名詞-普通名詞-副詞可能', '名詞-普通名詞-助数詞可能') | |
def compound取得(tokens, i): | |
""" | |
注目しているトークン(Token.iで指定)が複合語(例. 国際展示場)の構成単語の場合は、複合語になるように結合する | |
""" | |
t = tokens[i] | |
cnt = 1 | |
compound = '' | |
while True: | |
# 厳格に処理するには、t.i - cntが0より小さい値になる場合の考慮が必要だが、それを考慮したロジックにせずとも、「係り受け」などの関係からおおよそうまく行く前提で割り切りとする | |
t_ = tokens[t.i - cnt] | |
if t_.head.i == t.i and t_.dep_ == 'compound': | |
compound = t_.text + compound | |
else: | |
if t_.tag_.startswith(数詞) and t_.head.i == (t_.i + 1): | |
return t_.text + compound | |
return compound | |
cnt = cnt + 1 | |
def 名詞系フレーズリスト取得(tokens, idx, UPLIMIT=6): | |
フレーズ成立判定先頭品詞tag = ('名詞-', '形容詞-', '形状詞', '限定詞', '接辞', '接頭辞', '動詞-') | |
フレーズ成立不可品詞tag = ('句読点', '補助記号-') # '設置辞', '接尾辞', '感嘆詞' | |
def tokens2text(tokens): | |
""" | |
得られたフレーズを文字列に変換する。 | |
ただし、抽出意図に沿わないようなフレーズを手直しもしくは、個別に除外することもある。 | |
""" | |
単独対象外名詞 = ('名詞-固有名詞-地名-', '名詞-固有名詞-人名-', '名詞-数詞', '補助記号-', | |
'接尾語-名詞的-', '名詞-普通名詞-副詞可能', '名詞-普通名詞-助数詞可能') | |
助詞 = '助詞-' | |
def 特定品詞始まり等を除外(tokens): | |
""" | |
品詞としてはフレーズの始まりとして期待される動詞であったが、目的語を伴わないと不自然になることが経験的に分かっているものについて、 | |
該当部分を削除する。フレーズとしては短くなるものの体感的に自然はフレーズに近くと想定している。 | |
""" | |
if len(tokens) >= 2: | |
t0, t1 = tokens[0:2] | |
prefix = t0.text + t1.text | |
if (prefix in ['いった', 'ような']) and t0.tag_.startswith(('動詞-', '形状詞-')) and t1.tag_.startswith(('助動詞')): | |
return tokens[2:] | |
if (prefix in ['ことの']) and t0.tag_.startswith(('動詞-')) and t1.tag_.startswith(('助詞')): | |
return tokens[2:] | |
if len(tokens) >= 1: | |
t0 = tokens[0] | |
prefix = t0.text | |
if (prefix in ['よる']) and t0.tag_.startswith(('動詞-一般')): | |
return tokens[1:] | |
if t0.tag_.startswith(助詞): | |
return tokens[1:] | |
return tokens | |
def 住所表記の可能性が高いため除外(tokens): | |
""" | |
住所表記は一般には「固有表現」として重要な場合もあるが、ターゲット文書ではどちらかと言えば本来の特徴のノイズとなる傾向があると考えて一律除外 | |
""" | |
cnt = 0 | |
for t in tokens: | |
if t.tag_.startswith(単独対象外名詞): | |
cnt = cnt + 1 | |
if len(tokens) == cnt: | |
return [] | |
return tokens | |
def 数詞をNに変換他(tokens): | |
""" | |
XX市YY町4丁目→XX市YY町N丁目 | |
三連覇→N連覇 | |
のようにある種の名寄せ | |
""" | |
N_word = 'N' | |
s = '' | |
地名と数詞以外あり = False | |
for t in tokens: | |
if not t.tag_.startswith(単独対象外名詞): | |
地名と数詞以外あり = True | |
if t.tag_.startswith(数詞): | |
s = s + N_word | |
else: | |
s = s + t.text | |
if 地名と数詞以外あり: | |
return s | |
else: | |
return '' | |
def N桁以下のひらがな除外(text, n=2): | |
""" | |
から」「そら」「いと」といったひらがなの短い単語となりフレーズとして期待される役割に満たないようなものは除外。 | |
""" | |
hiragana = re.compile(r'[\u3041-\u309F]{1,' + str(n) + '}') | |
if hiragana.fullmatch(text): | |
return '' | |
return text | |
def myfilter(text): | |
""" | |
気に入らない抽出結果は除外(最後に適用される想定) | |
""" | |
if text.startswith(('関する', '__DUMMYSTARTSWITH2__')): | |
return '' | |
if text.endswith(('__DUMMYENDSWITH1__', '__DUMMYENDSWITH2__')): | |
return '' | |
if text in (('丁目')): | |
return '' | |
return text | |
_tokens = 特定品詞始まり等を除外(tokens) | |
_tokens = 住所表記の可能性が高いため除外(_tokens) | |
text = 数詞をNに変換他(_tokens) | |
text = N桁以下のひらがな除外(text) | |
text = myfilter(text) | |
return text | |
def 前方のワードと結合(tokens, idx, hop): | |
def 品詞XXXを含む(tokens, XXX): | |
for t in reversed(tokens): | |
if t.tag_.startswith(XXX): | |
return t | |
return None | |
def フレーズ成立不可品詞を含むタグ検索(tokens): | |
""" | |
""" | |
return 品詞XXXを含む(tokens, フレーズ成立不可品詞tag) | |
def フレーズ成立かどうか判定(token): | |
""" | |
特定の品詞で始まるかどうかで、フレーズとして抜き出した時に自然なフレーズに見えるかどうか | |
というところを便宜上判定するようにしている。 | |
""" | |
tag = token.tag_ | |
if tag.startswith(例外名詞): | |
return False | |
if '非自立可能' in tag: | |
return False | |
if tag.startswith(フレーズ成立判定先頭品詞tag): | |
return True | |
return False | |
end = idx + 1 | |
start = idx - hop | |
start = start if start >= 0 else 0 | |
# 途中に句点「、」などが含まれる場合は、それ以降をフレーズ登録対象にする | |
# 末尾がendではなくidxなのは、idxのワードを含めないため | |
if x := フレーズ成立不可品詞を含むタグ検索(tokens[start:idx]): | |
start = x.i + 1 | |
newhop = idx - start | |
return 前方のワードと結合(tokens, idx, newhop) | |
# 最初の単語がフレーズの始まりとして自然ならばそれを以降をフレーズ登録する | |
_start = start | |
limit = 0 | |
if フレーズ成立かどうか判定(tokens[start]): | |
while True: | |
s = tokens[_start] | |
h = s.head | |
if h.i == idx: | |
return tokens2text(tokens[start:end]) | |
if s.dep_ == 'ROOT': | |
return None | |
if limit == UPLIMIT: | |
return None | |
limit = limit + 1 | |
_start = h.i | |
return None | |
# 名詞相当の単語を見つけたら、最大でUPLIMIT前方の単語から始まるフレーズのうち、 | |
# 今回の条件において「自然」なフレーズになるようなフレーズの集合を取得する。 | |
tmp = [] | |
for i in range(UPLIMIT): | |
if p := 前方のワードと結合(tokens, idx, i): | |
tmp.append(p) | |
# この集合の中の重複を排除する。また、長い順に並べる。 | |
# 結合ルールおよび除外ルール等にしたがってフレーズ形成したが、結局軸にした名詞のトークンのテキストと同じになってしまった場合も除外する。 | |
# (名詞単体の抽出は別ロジックで専用に抽出しており、ここでは複数語の「フレーズ」を優先したいため) | |
tmp = list(set(tmp) - set([compound取得(tokens, idx) + tokens[idx].text])) | |
tmp = list(reversed(sorted((tmp), key=len))) | |
def adHoc名寄せ(_phrases): # _phrasesは長い順に格納されていることを前提 | |
if (l := len(_phrases)) > 0: | |
max = _phrases[0] | |
min = _phrases[-1] | |
if len(max) <= len(min) + 1: # 1文字差であれば | |
return [max] | |
return _phrases | |
return [] | |
tmp = adHoc名寄せ(tmp) | |
if len(tmp): | |
return tmp | |
return [] | |
def 動詞系形容詞系フレーズ取得(tokens, idx, UPLIMIT=7): | |
def 動詞または形容詞フレーズを成立させる(tokens, i): | |
""" | |
動詞や形容詞の活用部分をふくめつつ「フレーズ」として見た際にキレの良いところまで抜き出す。 | |
必要ならアドホックな補正を行う。 | |
なお、ランキングとして集計するなら、見出し語化して名寄せしやすくする案もあるが、 | |
「XXではない」といった否定の意味のフレーズなどは助動詞を除外したくないため語尾の活用はある程度活かす方針とした。 | |
""" | |
s = '' | |
t = tokens[i] | |
end = t.i + 1 | |
if len(tokens) <= end: | |
return s + t.lemma_ | |
x = tokens[end] | |
if x.dep_ == 'aux': | |
auxcnt = 1 | |
s = s + t.text + tokens[end].text | |
while True: | |
if (len(tokens) - 1) == x.i: | |
return s | |
x = tokens[x.i + 1] | |
if x.dep_ == 'aux': # auxが続く限りつづける | |
auxcnt = auxcnt + 1 | |
s = s + x.text | |
else: # auxが途切れたため打ち止めとする | |
t1 = tokens[i + auxcnt] | |
t2 = tokens[i + auxcnt - 1] | |
# 得られたものが「多いです」のようなフレーズの場合、「多い」に変換 | |
if t2.lemma_ == t2.text and t2.tag_.startswith('形容詞') and t1.text in ['です']: | |
return re.sub('です$', '', s) | |
if t2.lemma_ != t2.text and t2.tag_.startswith('動詞') and t1.text in ['ます']: | |
return re.sub(t2.text + 'ます$', t2.lemma_, s) | |
# その他のですます関連の語尾補正(である調風に変換) | |
s = re.sub('ようです$', 'ようだ', s) | |
s = re.sub('ところです$', 'ところだ', s) | |
s = re.sub('しれません$', 'しれない', s) | |
s = re.sub('かもしれません$', 'かもしれない', s) | |
s = re.sub('そうです$', 'そうだ', s) | |
s = re.sub('みたいです$', 'みたいだ', s) | |
s = re.sub('らしいです$', 'らしい', s) | |
s = re.sub('わけです$', 'わけだ', s) | |
s = re.sub('のです$', 'のだ', s) | |
s = re.sub('とのことです$', 'とのことだ', s) | |
s = re.sub('あります$', 'ある', s) | |
return s | |
else: | |
return s + t.lemma_ | |
def 前方の目的語などを取得(tokens, i): | |
""" | |
tokens[i]は動詞や形容詞であるが、これの前方にある対応する目的語や主語にあたる単語を見つけて、tokens[i]のトークンと結合し、フレーズを形成する。 | |
""" | |
def 依存XXXとなるトークンを探す(tokens, XXX, rvs=True): | |
""" | |
XXXで指定のUniversal Dependencyとなるトークンを含むか。 | |
含む場合は、そのトークンを戻す。 | |
""" | |
_ = reversed(tokens) if rvs else tokens | |
work = [] | |
for t in _: | |
if t.dep_ in XXX: | |
work.append(t) | |
if work: | |
work = sorted(work, key=lambda x: XXX.index(x.dep_)) | |
return work | |
def token2text(tokens): | |
def mychop(tokens): | |
if tokens: | |
t0 = tokens[0] | |
if t0.text == 'ため' and t0.tag_.startswith('名詞'): | |
return tokens[1:] | |
if len(tokens) >= 2: | |
t1 = tokens[1] | |
if t0.text == 'こと' and t0.tag_.startswith('名詞'): | |
if t1.tag_.startswith('助詞'): | |
return tokens[2:] | |
return tokens | |
def myfilter(token): | |
t = token | |
if t.tag_.startswith(('補助記号-', '句読点')): | |
return '' | |
return t.text | |
_ = [myfilter(i) for i in mychop(tokens)] | |
_ = ''.join(_) | |
return _ | |
# tokens[i]が形容詞か動詞かによって、フレーズ形成の起点にする単語のUDの優先度を変えるための設定。 | |
# 形容詞はどちらかと言えば主語やそれに近い位置付けのものの捕獲を優先 | |
if tokens[i].tag_.startswith(('形容詞', '形状詞')): | |
depprior = ['nsubj', 'iobj', 'nmod', 'obl', 'obj'] | |
# 動詞はどちらかと言えば目的語やそれに近い位置付けのものの捕獲を優先 | |
elif tokens[i].tag_.startswith('動詞'): | |
depprior = ['obj', 'nmod', 'nsubj', 'iobj', 'obl'] | |
else: | |
depprior = ['obj', 'nmod', 'nsubj', 'iobj', 'obl'] | |
# Token.dep_から得られる関係を取得して、最終的な宛先が当該動詞/形容詞となるような修飾句でなるべく自然になるような部分を取得する。 | |
end = i + 1 | |
start = i - UPLIMIT | |
start = start if start >= 0 else 0 | |
# 依存種別の優先度順のうち遠い方から取得 | |
xlist = 依存XXXとなるトークンを探す(tokens[start:i], depprior, rvs=True) | |
if xlist: | |
for x in xlist: | |
# ターゲットの動詞・形容詞に「係る」ものでなければ、スキップ。 | |
if x.head.i != i: | |
continue | |
y = 依存XXXとなるトークンを探す( | |
tokens[x.i:i], ['case'], rvs=False) # 助詞-格助詞を今注目しているトークンに近い順かつ依存しているものから取得 | |
if y: | |
_tmpcompound = '' | |
if (_pre := i - 1) >= 0: | |
# ターゲットの動詞・形容詞と直接の複合語になるものが、これらの直前に存在するならそれを戻す。 | |
# 例.「お断りしたい」の「断り」に対して、「お」を取得する。 | |
if tokens[_pre].dep_ == 'compound' and tokens[_pre].head.i == i: | |
_tmpcompound = compound取得( | |
tokens, _pre) + tokens[_pre].text | |
return compound取得(tokens, x.i) + token2text(tokens[x.i:y[0].i+1]) + _tmpcompound | |
else: | |
return compound取得(tokens, x.i) + token2text(tokens[x.i:x.i + 1]) | |
return '' | |
def phrasefilter(text): | |
# 5文字以下で全てひらがなの場合は動詞・形容詞系フレーズにふさわしくないとして除外 | |
hiragana = re.compile(r'[\u3041-\u309F]{1,5}') | |
if hiragana.fullmatch(text): | |
return '' | |
return text | |
return phrasefilter(前方の目的語などを取得(tokens, idx) + 動詞または形容詞フレーズを成立させる(tokens, idx)) | |
def 一括analyze(lots_of_texts): | |
def inputtextの整備(text): | |
if not text: | |
return '' | |
if not (type(text) == str): | |
return '' | |
# 今回イメージしているインプットのドキュメントでは、改行を読点相当の扱いとしているセンテンスが多いため精度を犠牲にしても事前に読点に置き換えて取り扱う。 | |
return text.replace('\n', '。').replace(' ', '、').replace(' ', '、') | |
def 意味のある名詞相当の単語判定(token): | |
tag = token.tag_ | |
if '名詞的' in tag: | |
return True | |
if tag.startswith(数詞): | |
return False | |
if tag.startswith(例外名詞): | |
return False | |
if tag.startswith('名詞-'): | |
return True | |
return False | |
def 意味のある動詞や形容詞相当の単語判定(token): | |
""" | |
動詞、形容詞のうち、「自立可能」なもの(「非自立可能」でないもの) | |
""" | |
tag = token.tag_ | |
if tag.startswith('動詞') and '非自立可能' not in tag: | |
return True | |
if tag.startswith(('形容詞', '形状詞')) and '非自立可能' not in tag: | |
return True | |
return False | |
docs = nlp.pipe([inputtextの整備(txt) for txt in lots_of_texts]) | |
results = [] | |
for d in docs: | |
""" | |
各ドキュメントについての処理 | |
""" | |
# 名詞相当の単語を見つけたら、最大でUPLIMIT前方の単語から始まるフレーズのうち、 | |
# 今回の条件において「自然」なフレーズになるようなフレーズの集合を取得する。 | |
tokens = [t for t in d] | |
noun = [] # 名詞(単語) | |
nphrases = [] # 名詞のフレーズ | |
v_a = [] # 動詞、形容詞 | |
phrases = [] # 動詞・形容詞を軸にしたフレーズ | |
if len(d): | |
for t in d: | |
""" | |
当該ドキュメント内のトークンを前方から走査し、名詞、動詞、形容詞を見つけたら、 | |
そのトークンの前後のトークンを今回想定しているルールで結合する。 | |
""" | |
if 意味のある名詞相当の単語判定(t): | |
_nphrases = 名詞系フレーズリスト取得(tokens, t.i) | |
nphrases.extend(_nphrases) | |
noun.append(compound取得(tokens, t.i) + t.text) | |
if 意味のある動詞や形容詞相当の単語判定(t): | |
v_a.append(t.lemma_) | |
_ = 動詞系形容詞系フレーズ取得(tokens, t.i) | |
if _: | |
if _ not in ['よる', '関する', '基づく', 'つく', 'おく']: | |
if _ != t.lemma_: | |
phrases.append(_) | |
results.append([noun, v_a, nphrases, phrases]) | |
return results | |
analyzer = 一括analyze | |
if __name__ == '__main__': | |
text = """ | |
酒を飲みすぎたため、電車で眠りこけてしまったため、寝過ごしてしまった。終点の駅で目が醒めたのだが、周りに何もなくてとても淋しい気持ちになった。ふと夜空を見上げると、八分咲きの美しい桜の花が慰めてくれるようだった。 | |
本当はもう少しこなれた文章をどこかから引用したかったが、著作権的なところの問題を考えたくなかったのと、ルールのベンチマークに使うには都合の良い文章が見つからなかったため、ひとまず創作した。 | |
冒頭のテキストは決して私の美的感覚を反映したものではないことをお断りしたい。もちろん、本来はもっと文才があるということを言いたいわけではない。また、この文章は「クワイン(Quine)」的なものになっているわけでも、なんらかの縦読みなどの暗号的なものや仕掛けが含まれているものではない。深読みしないでください。 | |
ムーンサルトはお断りしたい。 | |
ムーンサルト斬りしたい。 | |
""" | |
print(一括analyze([text])) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment