はじめに
言語処理100本ノックの続き、今回は第5章をやっていきます。
過去分はこちら↓
第1章 00~09 - 第2章 10~19 - 第3章20~29 - 第4章30~39
夏目漱石の小説『吾輩は猫である』の文章(neko.txt)をCaboChaを使って係り受け解析し,その結果をneko.txt.cabochaというファイルに保存せよ.このファイルを用いて,以下の問に対応するプログラムを実装せよ.
第4章の形態素解析につづいて、この章では構文解析結果を活用していきます。
前の章にも書きましたが、最初に mecab, mecab-ipadic, crf++, cabocha をインストールしておきましょう。
Macの人はこの辺をご参照あれ。
まずは前座
夏目漱石の小説『吾輩は猫である』の文章(neko.txt)をCaboChaを使って係り受け解析し,その結果をneko.txt.cabochaというファイルに保存せよ.
まずはこちらをやっておきましょう。
cabochaのデフォルト出力は簡易的なツリー表示での出力なので、以下のような見た目になります。
$ echo 昔々、あるところにおじいさんとおばあさんがありました。 | cabocha
昔々、---------D
ある-D |
ところに-----D
おじいさんと-D |
おばあさんが-D
ありました。
EOS
人間の眼には分かりやすいですが、確かにこれを機械的に処理するのはきつい。
なので、-f1
というオプションで機械的に処理しやすいフォーマットにしてあげます。
$ echo 昔々、あるところにおじいさんとおばあさんがありました。 | cabocha -f1
* 0 5D 0/0 -2.593951
昔 名詞,副詞可能,*,*,*,*,昔,ムカシ,ムカシ
々 記号,一般,*,*,*,*,々,々,々
、 記号,読点,*,*,*,*,、,、,、
* 1 2D 0/0 2.294325
ある 連体詞,*,*,*,*,*,ある,アル,アル
* 2 5D 0/1 -2.593951
ところ 名詞,非自立,副詞可能,*,*,*,ところ,トコロ,トコロ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
* 3 4D 0/1 0.797370
おじいさん 名詞,一般,*,*,*,*,おじいさん,オジイサン,オジーサン
と 助詞,並立助詞,*,*,*,*,と,ト,ト
* 4 5D 0/1 -2.593951
おばあさん 名詞,一般,*,*,*,*,おばあさん,オバアサン,オバーサン
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
* 5 -1D 0/2 0.000000
あり 動詞,自立,*,*,五段・ラ行,連用形,ある,アリ,アリ
まし 助動詞,*,*,*,特殊・マス,連用形,ます,マシ,マシ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
。 記号,句点,*,*,*,*,。,。,。
EOS
たしかにこっちのほうが機械的に処理しやすいですねー。
ということで、
$ cat neko.txt | cabocha -f1 > neko.txt.cabocha
40. 係り受け解析結果の読み込み(形態素)
形態素を表すクラスMorphを実装せよ.このクラスは表層形(surface),基本形(base),品詞(pos),品詞細分類1(pos1)をメンバ変数に持つこととする.さらに,CaboChaの解析結果(neko.txt.cabocha)を読み込み,各文をMorphオブジェクトのリストとして表現し,3文目の形態素列を表示せよ.
ということですね。
とりあえず、この次の問題でもう少しきれいになっていくはずなので、多少雑な実装にしておきます。
import re
class Morph(object):
def __init__(self, line):
"""
mecab / cabocha の解析結果の形態素1行を渡すと解析してMorphクラスのインスタンスを返します
>>> m = Morph('あり\\t動詞,自立,*,*,五段・ラ行,連用形,ある,アリ,アリ')
>>> m.surface
'あり'
>>> m.base
'ある'
>>> m.pos
'動詞'
>>> m.pos1
'自立'
"""
self.surface, rest = line.split('\t')
attr = rest.split(',')
self.base = attr[6]
self.pos = attr[0]
self.pos1 = attr[1]
def __str__(self):
return 'surface: "{}"/base: "{}"/pos: {}/pos1: {}'.format(
self.surface, self.base, self.pos, self.pos1
)
def read_cabocha_file(filename):
sentence = []
with open(filename, mode='rt', encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
if line == 'EOS':
yield sentence
sentence = []
continue
elif re.match(r'^\*[^\t]+$', line):
# '*' が形態素になってる可能性もあるので、
# タブを含まない行の行頭が '*' な場合だけ読み飛ばす。
continue
sentence.append(Morph(line))
sentences = list(read_cabocha_file('neko.txt.cabocha'))
if __name__ == '__main__':
for m in sentences[2]:
print(m)
import doctest
doctest.testmod()
結果はこちら。
surface: " "/base: " "/pos: 記号/pos1: 空白
surface: "吾輩"/base: "吾輩"/pos: 名詞/pos1: 代名詞
surface: "は"/base: "は"/pos: 助詞/pos1: 係助詞
surface: "猫"/base: "猫"/pos: 名詞/pos1: 一般
surface: "で"/base: "だ"/pos: 助動詞/pos1: *
surface: "ある"/base: "ある"/pos: 助動詞/pos1: *
surface: "。"/base: "。"/pos: 記号/pos1: 句点
3文目(最初の2文は「一」と空文なので)がしっかり表示されましたね。
41. 係り受け解析結果の読み込み(文節・係り受け)
40に加えて,文節を表すクラスChunkを実装せよ.このクラスは形態素(Morphオブジェクト)のリスト(morphs),係り先文節インデックス番号(dst),係り元文節インデックス番号のリスト(srcs)をメンバ変数に持つこととする.
さらに,入力テキストのCaboChaの解析結果を読み込み,1文をChunkオブジェクトのリストとして表現し,8文目の文節の文字列と係り先を表示せよ.
第5章の残りの問題では,ここで作ったプログラムを活用せよ.
というわけで、一個クラスを追加します。
(dst, srcは実際はインデックスで指し示してるわけですが)
これを実装するとこんな感じになりました。
import re
class Morph(object):
def __init__(self, line):
"""
mecab / cabocha の解析結果の形態素1行を渡すと解析してMorphクラスのインスタンスを返します
>>> m = Morph('あり\\t動詞,自立,*,*,五段・ラ行,連用形,ある,アリ,アリ')
>>> m.surface
'あり'
>>> m.base
'ある'
>>> m.pos
'動詞'
>>> m.pos1
'自立'
"""
self.surface, rest = line.split('\t')
attr = rest.split(',')
self.base = attr[6]
self.pos = attr[0]
self.pos1 = attr[1]
def __str__(self):
return 'surface: "{}"/base: "{}"/pos: {}/pos1: {}'.format(
self.surface, self.base, self.pos, self.pos1
)
class Chunk(object):
def __init__(self, line):
"""
cabocha の解析結果の文節の最初の行(*始まりの行)を渡して、Chunkオブジェクトを作ります。
>>> Chunk('* 0 5D 0/0 -2.593951').dst
5
>>> Chunk('* 5 -1D 0/2 0.000000').dst
-1
"""
self.morphs = []
self.srcs = [] # この時点では設定できない
self.dst = int(line.split(' ')[2].rstrip('D'))
def read_cabocha_file(filename):
sentence = []
chunk = None
with open(filename, mode='rt', encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
if line == 'EOS':
# ここでsentenceのsrcを設定していきます。
for index, chunk in enumerate(sentence):
# 係り先が -1 の時は係り先が無いので飛ばす
if chunk.dst != -1:
sentence[chunk.dst].srcs.append(index)
yield sentence
sentence = []
elif re.match(r'^\*[^\t]+$', line):
# '*' が形態素になってる可能性もあるので、
# タブを含まない行の行頭が '*' な場合だけ読み飛ばす。
chunk = Chunk(line)
sentence.append(chunk)
else:
chunk.morphs.append(Morph(line))
if __name__ == '__main__':
sentences = list(read_cabocha_file('neko.txt.cabocha'))
for i, chunk in enumerate(sentences[7]):
print('* ', i)
print('srcs: {}, dst: {}'.format(chunk.srcs, chunk.dst))
print('morphs: {}'.format(''.join([str(m) for m in chunk.morphs])))
import doctest
doctest.testmod()
これを実行すると、こんな出力が得られます。
* 0
srcs: [], dst: 5
morphs: surface: "吾輩"/base: "吾輩"/pos: 名詞/pos1: 代名詞, surface: "は"/base: "は"/pos: 助詞/pos1: 係助詞
* 1
srcs: [], dst: 2
morphs: surface: "ここ"/base: "ここ"/pos: 名詞/pos1: 代名詞, surface: "で"/base: "で"/pos: 助詞/pos1: 格助詞
* 2
srcs: [1], dst: 3
morphs: surface: "始め"/base: "始める"/pos: 動詞/pos1: 自立, surface: "て"/base: "て"/pos: 助詞/pos1: 接続助詞
* 3
srcs: [2], dst: 4
morphs: surface: "人間"/base: "人間"/pos: 名詞/pos1: 一般, surface: "という"/base: "という"/pos: 助詞/pos1: 格助詞
* 4
srcs: [3], dst: 5
morphs: surface: "もの"/base: "もの"/pos: 名詞/pos1: 非自立, surface: "を"/base: "を"/pos: 助詞/pos1: 格助詞
* 5
srcs: [0, 4], dst: -1
morphs: surface: "見"/base: "見る"/pos: 動詞/pos1: 自立, surface: "た"/base: "た"/pos: 助動詞/pos1: *, surface: "。"/base: "。"/pos: 記号/pos1: 句点
「吾輩は - 見た」「人間という - ものを - 見た」という係り受けが正しく取れていますね。
一方で、「ここで - 始めて」「始めて - 人間という」という係り受けが取れていますが、これらは本当は「ここで - 見た」「始めて - 見た」という係り受けになる方が正しそうです。
まあ、このへんは係り受け解析器(に与えているモデル)の精度次第ですね。
42. 係り元と係り先の文節の表示
係り元の文節と係り先の文節のテキストをタブ区切り形式ですべて抽出せよ.ただし,句読点などの記号は出力しないようにせよ.
前の節の最後では出力を眼で確認して係り受け関係を観察しましたが、ここではそれを機械的に出力します。
「句読点などの記号」ですが、ここでは「pos=記号」のものを出力しないことにしましょう。
まず、先程の41の問題で作ったChunk
クラスに、__str__
メソッドを加えて、記号以外の形態素の表層形を連結してくれるようにします。
class Chunk(object):
def __init__(self, line):
"""
cabocha の解析結果の文節の最初の行(*始まりの行)を渡して、Chunkオブジェクトを作ります。
>>> Chunk('* 0 5D 0/0 -2.593951').dst
5
>>> Chunk('* 5 -1D 0/2 0.000000').dst
-1
"""
self.morphs = []
self.srcs = [] # この時点では設定できない
self.dst = int(line.split(' ')[2].rstrip('D'))
def __str__(self):
"""
品詞が「記号」以外の表層形を連結して返す。
"""
return ''.join([m.surface for m in self.morphs if m.pos != '記号'])
その上で、このファイルをインポートして残りの部分を作ります。
from nlp41 import *
sentences = list(read_cabocha_file('neko.txt.cabocha'))
for sentence in sentences:
for chunk in sentence:
if chunk.dst != -1:
print('{}\t{}'.format(str(chunk), str(sentence[chunk.dst])))
出力がこちら。
猫である
吾輩は 猫である
名前は 無い
まだ 無い
どこで 生れたか
生れたか つかぬ
とんと つかぬ
見当が つかぬ
何でも 薄暗い
薄暗い 所で
じめじめした 所で
所で 泣いて
...(略)
最初の行は間違いではなくて、冒頭の字下げの全角空白一文字が「猫である」の文節に係るような解析結果になっているようです。
* 0 2D 0/0 -0.764522
記号,空白,*,*,*,*, , ,
* 1 2D 0/1 -0.764522
吾輩 名詞,代名詞,一般,*,*,*,吾輩,ワガハイ,ワガハイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
* 2 -1D 0/2 0.000000
猫 名詞,一般,*,*,*,*,猫,ネコ,ネコ
で 助動詞,*,*,*,特殊・ダ,連用形,だ,デ,デ
ある 助動詞,*,*,*,五段・ラ行アル,基本形,ある,アル,アル
。 記号,句点,*,*,*,*,。,。,。
EOS
43. 名詞を含む文節が動詞を含む文節に係るものを抽出
名詞を含む文節が,動詞を含む文節に係るとき,これらをタブ区切り形式で抽出せよ.ただし,句読点などの記号は出力しないようにせよ.
さあ、面白くなってまいりました。
from nlp41 import *
sentences = list(read_cabocha_file('neko.txt.cabocha'))
for sentence in sentences:
for chunk in sentence:
if chunk.dst == -1:
continue
if any([m.pos == '名詞' for m in chunk.morphs]) and \
any([m.pos == '動詞' for m in sentence[chunk.dst].morphs]):
print('{}\t{}'.format(str(chunk), str(sentence[chunk.dst])))
結果がこちら。
どこで 生れたか
見当が つかぬ
所で 泣いて
ニャーニャー 泣いて
いた事だけは 記憶している
吾輩は 見た
ここで 始めて
ものを 見た
あとで 聞くと
我々を 捕えて
掌に 載せられて
スーと 持ち上げられた
ipadicだと「ニャーニャー」とか「スー」のようなオノマトペも名詞扱いなんですね。
44. 係り受け木の可視化
与えられた文の係り受け木を有向グラフとして可視化せよ.
可視化には,係り受け木をDOT言語に変換し,Graphvizを用いるとよい.
また,Pythonから有向グラフを直接的に可視化するには,pydotを使うとよい.
Graphvizの入れ方はググるといいと思うよ! 多分、Mac なら brew install graphviz
、Linux系なら yum install graphviz
。Windowsは知らん。
あと、Python からgraphgiz, pydot を使うのに、pip install graphviz pydot
して上げる必要があるっぽいです。少なくとも入れたら動いた。
それから、画像を扱うので、pillow
というライブラリも入れておいたほうがいいです。とっても便利。
さあ、以上のツールを組み合わせてやってみましょう。
from pydot import Dot, Node, Edge
from PIL import Image
from nlp41 import *
def create_graph(sentence: [Chunk]) -> Dot:
graph = Dot(graph_type='graph') # 有向グラフ
nodes = []
# まずノードを作っておく
for i, chunk in enumerate(sentence):
node = Node(f'"{i}"', label=str(chunk))
nodes.append(node)
graph.add_node(node)
# 次にエッジを登録
for i, chunk in enumerate(sentence):
if chunk.dst == -1:
continue
node_src = nodes[i]
node_dst = nodes[chunk.dst]
edge = Edge(node_src, node_dst)
graph.add_edge(edge)
return graph
sentences = list(read_cabocha_file('neko.txt.cabocha'))
sentence = sentences[5]
# こっちの文を採用するとすごく長いよ
# sentence = max(sentences, key=lambda s: len(s))
graph = create_graph(sentence)
# グラフを画像に書き出して表示
graph.write_png('nlp44.png')
Image.open('nlp44.png').show()
create_graph
メソッドに [Chunk]
を渡すと、ノード・エッジを作って係り受け木を作ってくれます。
Node(f'"{I}"', label=str(chunk))
とノード名はその文節のインデックス番号を振ることにしました。
graph.write_png(ファイル名)
でPNGファイルに書き出して、PILのImage
クラスでお手軽表示をしています。
で、できた画像がこちら。
正しい係り受けになっていますね。
ちなみに、途中でコメントアウトしてある「吾輩は猫である」で一番文節数が多い(と解析された)文をグラフにすると・・・
長いよ!!!!
45. 動詞の格パターンの抽出
今回用いている文章をコーパスと見なし,日本語の述語が取りうる格を調査したい.
動詞を述語,動詞に係っている文節の助詞を格と考え,述語と格をタブ区切り形式で出力せよ.
ただし,出力は以下の仕様を満たすようにせよ.
- 動詞を含む文節において,最左の動詞の基本形を述語とする
- 述語に係る助詞を格とする
- 述語に係る助詞(文節)が複数あるときは,すべての助詞をスペース区切りで辞書順に並べる
「吾輩はここで始めて人間というものを見た」という例文(neko.txt.cabochaの8文目)を考える.
この文は「始める」と「見る」の2つの動詞を含み,「始める」に係る文節は「ここで」,「見る」に係る文節は「吾輩は」と「ものを」と解析された場合は,次のような出力になるはずである.始める で
見る は をこのプログラムの出力をファイルに保存し,以下の事項をUNIXコマンドを用いて確認せよ.
- コーパス中で頻出する述語と格パターンの組み合わせ
- 「する」「見る」「与える」という動詞の格パターン(コーパス中で出現頻度の高い順に並べよ)
急に問題文が長くなりましたね。まず、問題文をちゃんと解釈していきましょう。
「格」というのは述語に係る名詞句が述語に対してどのような役割を担うかを示す形態的な特徴、と言えばいいと思います。
わかりにくいと思った人は「てにをは」だと雑に理解してくれればいいです。あるいはwikipedia を御覧ください。
で、日本語の動詞(に限らず述語)は、語彙ごとに特徴的な格のとり方というのが存在します。
- 〜が〜を食べる
- 〜が〜に行く
- 〜に〜をもらう
- 〜に〜を塗る
- 〜は〜が長い1
などなど。
こういう「述語がとりがちな格のパターン」を集計しましょうというのがこのお題です。
で、問題文では触れられていないのですが、日本語の名詞句では複数の助詞が含まれることがあります。「〜〜をも」とか「〜〜には」とか「〜〜と〜〜とへ」とか。
ここでは、複数の助詞が一つの名詞句に含まれる場合は、全部連結して一つの「格」と扱うことにします。
まずは Python で集計用データを作りましょう。
- 2019.7.29修正: 名詞句じゃない文節の助詞まで列挙していたので修正しました。
from nlp41 import *
sentences = list(read_cabocha_file('neko.txt.cabocha'))
for sentence in sentences:
for chunk in sentence:
# pos=動詞の形態素があれば最初に出現したそれを、なければNoneを返す
verb = next((m for m in chunk.morphs if m.pos == '動詞'), None)
if verb is None:
continue
# 動詞がある文節を発見!
verb_base = verb.base # 動詞の基本形
# 係り先の文節が助詞1つ以上の連続で終わっていて、かつその直前が名刺の場合、
# その文節を名詞句とみなします。
# 助詞の連続を列挙
cases = []
for child_chunk_id in chunk.srcs:
child_chunk = sentence[child_chunk_id]
# この文節の後ろの形態素から順番に助詞の間だけ見て行きます
particles = []
for morph in reversed(child_chunk.morphs):
if morph.pos == '助詞':
# 助詞であれば一旦記録しておきます
particles.append(morph.surface)
elif morph.pos == '名詞' and len(particles) > 0:
# 名詞が出てきて、かつそのあとに助詞が一つ以上あった場合
cases.append(''.join(reversed(particles)))
break
else:
# 以上のパターンに合わなかった場合はこの文節は名詞句ではない
break
# 助詞で終わる名詞句がかかっていれば表示
if len(cases) > 0:
print(verb_base + ' ' + ' '.join(cases))
この出力結果を nlp45.txt
にリダイレクトして保存しました。
中身の冒頭を覗いてみると・・・
生れる で
つく が
泣く で
する だけは
始める で
見る は を
聞く で
捕える を
載せる に
持ち上げる と
ある が
落ちつく で
見る を
...(略)
問題文にもあった、「始める で」「見る は を」が取れていますね。
じゃあ、頻出格パターンをまずは見てみましょう。
第1章の問題19 を思い出すと、こんな感じで簡単に集計できますね。
$ sort < nlp45.txt | uniq -c | sort -k 1 -r
694 する を
279 ある が
278 なる に
243 云う と
192 する に
117 見る を
114 する は
90 する が
83 出来る が
78 する は を
77 云う を
74 する を に
66 行く へ
66 する が を
61 もつ を
60 する に を
60 する と
60 ある は
59 食う を
59 ある の
58 云う は
58 ある も
57 する に
...(略)
それから、「する」「見る」「与える」の格パターンも見ていきます。
$ cat nlp45.txt | egrep ^する | sort| uniq -c | sort -k 1 -r
694 する を
249 する に
180 する が
135 する は
78 する は を
74 する を に
66 する が を
60 する に を
60 する と
59 する も
48 する は に
47 する の
45 する で
39 する で を
38 する から
23 する が に
...(略)
これはわりとフーンって感じですね。
多分「する」が含まれる文節はサ変名詞に接続する例が多いと思われるので、実際は多様な述語の格パターンが入り混じっているものだと思われます。
$ cat nlp45.txt | egrep ^見る | sort| uniq -c | sort -k 1 -r
130 見る を
28 見る は
21 見る から
13 見る に
13 見る が
11 見る と
9 見る は を
8 見る も
7 見る が を
6 見る を に
5 見る に を
$ cat nlp45.txt | egrep ^与える | sort| uniq -c | sort -k 1 -r
4 与える を
4 与える に を
2 与える は を
2 与える は に を
1 与える は に を に
1 与える には を
1 与える には に対してのみは も
1 与える に けを
1 与える として をか
1 与える だけに を
「与える」はそもそも例が少なかったです。
ただ、「〜に〜を与える」という文型(そう、「格パターン」って言いかえると「文型」です)はたしかに見えています。
はい、というわけで第5章はちょっとヘビーなので、一旦この辺で切ります。
次は第5章の続きをやっていきましょう。
それではまた。
-
「は」が格だとさらっと言ってしまってますが、本当に格なのかというと、mecabでのパース結果でも「係助詞」であって「格助詞」ではないです。が、それを言い始めると長いのでやめとく。 ↩