言語処理100本ノック2015 をRubyでやる【第5章 後半】
コードは GitHub に上げています。この記事では省略した長い出力も output/ ディレクトリに置いてます。
今回は第 5 章「係り受け解析」です。長いので前半と後半に分けます。
夏目漱石の小説『吾輩は猫である』の文章(neko.txt)をCaboChaを使って係り受け解析し,その結果をneko.txt.cabochaというファイルに保存せよ.このファイルを用いて,以下の問に対応するプログラムを実装せよ.
45. 動詞の格パターンの抽出
今回用いている文章をコーパスと見なし,日本語の述語が取りうる格を調査したい. 動詞を述語,動詞に係っている文節の助詞を格と考え,述語と格をタブ区切り形式で出力せよ. ただし,出力は以下の仕様を満たすようにせよ.
- 動詞を含む文節において,最左の動詞の基本形を述語とする
- 述語に係る助詞を格とする
- 述語に係る助詞(文節)が複数あるときは,すべての助詞をスペース区切りで辞書順に並べる
「吾輩はここで始めて人間というものを見た」という例文(neko.txt.cabochaの8文目)を考える. この文は「始める」と「見る」の2つの動詞を含み,「始める」に係る文節は「ここで」,「見る」に係る文節は「吾輩は」と「ものを」と解析された場合は,次のような出力になるはずである.
始める で
見る は を
このプログラムの出力をファイルに保存し,以下の事項をUNIXコマンドを用いて確認せよ.
解答
# util.rb class Chunk ... # 最左にある特定の品詞 def first_morph(pos) self.morphs.find { |morph| morph.pos == pos } end ...
該当 chunk に登場する最左(一番最初)の特定の品詞を取り出します。
require './util' dependencies.each do |sentence| sentence.each do |chunk| verb = chunk.first_morph("動詞") if verb predicate = verb.base particles = chunk.srcs.map { |src| sentence[src].first_morph("助詞")&.base }.compact puts [predicate, particles.sort.join(' ')].join("\t") if particles.present? end end end
動詞を述語(predicate)として、それに係っている形態素(chunk の srcs から見ていけばわかる)の中から最左の助詞であるものを格(particles)として取り出します。最左の助詞が無い(nil
)場合もあるので「ぼっち演算子」&.
を使っています。nil&.base
のせいで map は nil
を含んだ配列を返すので、compact して nil
を取り除きます。
出力(一部省略)
生れる で つく か が 泣く で する だけ て 始める で 見る は を 聞く で 捕える を 煮る て 食う て ...
解答
頻出する述語と格パターンの組み合わせを見るには、回数付きで uniq して sort すれば良さそうです。
sort 45.txt | uniq -c | sort -r
「する」「見る」「与える」が左側に来る時の右側の助詞(格)を、上と同じように表示します。
sed -nE "s/^する (.*)/\1/p" 45.txt | sort | uniq -c | sort -r sed -nE "s/^見る (.*)/\1/p" 45.txt | sort | uniq -c | sort -r sed -nE "s/^与える (.*)/\1/p" 45.txt | sort | uniq -c | sort -r
出力(一部省略)
# 頻出する述語と格パターンの組み合わせ 584 云う と 443 する を 256 思う と 207 なる に 199 ある が 188 する に 179 見る て 134 する と 117 する が 110 する に を ...
# 「する」が左側に来る時の右側の助詞(格) 443 を 188 に 134 と 117 が 110 に を 102 て を 88 て 61 は 60 が を 51 で を ...
46. 動詞の格フレーム情報の抽出
45のプログラムを改変し,述語と格パターンに続けて項(述語に係っている文節そのもの)をタブ区切り形式で出力せよ.45の仕様に加えて,以下の仕様を満たすようにせよ.
- 項は述語に係っている文節の単語列とする(末尾の助詞を取り除く必要はない)
- 述語に係る文節が複数あるときは,助詞と同一の基準・順序でスペース区切りで並べる
「吾輩はここで始めて人間というものを見た」という例文(neko.txt.cabochaの8文目)を考える. この文は「始める」と「見る」の2つの動詞を含み,「始める」に係る文節は「ここで」,「見る」に係る文節は「吾輩は」と「ものを」と解析された場合は,次のような出力になるはずである.
始める で ここで
見る は を 吾輩は ものを
解答
require './util' dependencies.each do |sentence| sentence.each do |chunk| verb = chunk.first_morph("動詞") if verb predicate = verb.base particles = chunk.srcs.map { |src| sentence[src].first_morph("助詞")&.base }.compact chunks = chunk.srcs.map { |src| sentence[src].surfaces if sentence[src].first_morph("助詞") }.compact particles, chunks = particles.zip(chunks).sort do |a, b| # 同じ格なら文節でもソート (a.first <=> b.first).nonzero? || (a.last <=> b.last) end.transpose puts [predicate, particles.join(' '), chunks.join(' ')].join("\t") if particles.present? end end end
前問のプログラムに chunks を考慮した処理を追加します。述語に係っている chunk に助詞が含まれる(格パターンが存在する)なら、その chunk 全ての単語列(ここでは surfaces)を書き出します。
ソート部分は他の人と出力を揃えるためにやりました。比較対象の変数たちは次のように [格, 単語]
の形になっています。
a #=> ["か", "生れたか"] b #=> ["が", "見当が"] a.first <=> b.first #=> -1
(a.first <=> b.first).nonzero? || (a.last <=> b.last)
より 1 つ目の条件式で true が返るので、格が違えば格でソートされます。もし格が同じだった場合はこれが false になるので 2 つ目の条件式が評価され、単語のほうでソートされます。zip してるので最後に行と列を入れ替えるため transpose しておしまいです。
出力(一部省略)
生れる で どこで つく か が 生れたか 見当が 泣く で 所で する だけ て いた事だけは 泣いて 始める で ここで 見る は を 吾輩は ものを 聞く で あとで 捕える を 我々を 煮る て 捕えて 食う て 煮て ...
47. 機能動詞構文のマイニング
動詞のヲ格にサ変接続名詞が入っている場合のみに着目したい.46のプログラムを以下の仕様を満たすように改変せよ.
- 「サ変接続名詞+を(助詞)」で構成される文節が動詞に係る場合のみを対象とする
- 述語は「サ変接続名詞+を+動詞の基本形」とし,文節中に複数の動詞があるときは,最左の動詞を用いる
- 述語に係る助詞(文節)が複数あるときは,すべての助詞をスペース区切りで辞書順に並べる
- 述語に係る文節が複数ある場合は,すべての項をスペース区切りで並べる(助詞の並び順と揃えよ)
例えば「別段くるにも及ばんさと、主人は手紙に返事をする。」という文から,以下の出力が得られるはずである.
返事をする と に は 及ばんさと 手紙に 主人は
このプログラムの出力をファイルに保存し,以下の事項をUNIXコマンドを用いて確認せよ.
解答
# util.rb class Chunk ... # サ変接続名詞 + を(助詞) def sahen_wo self.morphs.each.with_index do |morph, i| next_morph = self.morphs[i+1] if next_morph.present? && next_morph.pos == '助詞' && next_morph.surface == 'を' \ && morph.pos == '名詞' && morph.pos1 == 'サ変接続' return morph.surface + 'を' else return '' end end end def has_sahen_wo? self.sahen_wo.present? end
サ変接続名詞 + を
の組み合わせを見つけます。sahen_wo
を持つ chunk を見たい時があるので、has_sahen_wo?
も作りました(でも結局 has_sahen_wo?
してからもう一回 sahen_wo
してるので 2 回同じ処理してて良くなさそう)。
# 47.rb require './util' dependencies.each do |sentence| sentence.each do |chunk| next unless chunk.has_sahen_wo? && chunk.depends? dst = sentence[chunk.dst] verb = dst.first_morph("動詞") next unless verb # 述語(〜を〜) predicate = chunk.sahen_wo + verb.base src_pairs = [] dst.srcs.each do |src| # 自分自身に係るものであれば除く next if sentence[src] == chunk src_chunk = sentence[src] src_particle = src_chunk.first_morph("助詞")&.surface src_pairs << [src_chunk.surfaces, src_particle] if src_chunk && src_particle end surfaces, particles = src_pairs.sort_by{ |_, particle| particle }.transpose puts [predicate, particles.join(' '), surfaces.join(' ')].join("\t") if particles.present? end end
["彼が", "昼寝を", "する", "ときは", "必ず", "その", "背中に", "乗る"]
という sentence(chunk の配列)を例に説明します。
ソースコード | 説明 |
---|---|
chunk.has_sahen_wo? && chunk.depends? |
該当する chunk として ["昼寝", "を"] を Morph に持つ chunk が引っかかる |
dst = sentence[chunk.dst] |
該当 chunk の係り先 chunk は する |
verb = dst.first_morph("動詞") |
係り先 chunk の最左の動詞は する |
predicate = chunk.sahen_wo + verb.base |
述語 昼寝をする |
src_chunk = sentence[src] |
係り元 chunk として ["彼", "が"] を Morph に持つ chunk が引っかかる |
src_particle = src_chunk.first_morph("助詞")&.surface |
係り元 chunk に含まれる最左の助詞 "が" |
src_chunk && src_particle |
src_particle が nil になることもあるので、ここで係り元 chunk と助詞の対応を確認する |
あとは対応の保証された src_pairs
を助詞でソートして出力します。
出力(一部省略)
決心をする と こうと 返報をする んで 偸んで 昼寝をする が 彼が 迫害を加える て 追い廻して 投書をする て へ やって ほととぎすへ 話をする に 時に 昼寝をする て 出て 欠伸をする から て て なったから して 押し出して 報道をする に 耳に 御馳走を食う と 見ると ...
解答
述語(サ変接続名詞 + を + 動詞)は 1 列目にあるので cut -f 1
で切り出します。
cut -f 1 ../../output/47.txt | uniq -c | sort -r
述語と助詞は 1, 2 列目です。
cut -f 1,2 ../../output/47.txt | uniq -c | sort -r
出力(一部省略)
# 頻出する述語 3 返事をする 3 相談をする 2 結婚を申し込む 2 宙返りをする 2 返事をする 2 返事をする 2 返事をする 2 質問をする 2 講釈をする 2 行水を使う ...
# 頻出する述語と助詞 2 安心を得る が 1 ストライキをしでかす から て 1 御馳走を食わせる から に に 1 ストライキを起す が て に 1 相談を持ちかける に は 1 いたずらを始める て と 1 降参を申し込む かい て ながら は 1 御馳走をあるく って て 1 仕事を片付ける から へ 1 北面を取り囲む て として に は ...
48. 名詞から根へのパスの抽出
文中のすべての名詞を含む文節に対し,その文節から構文木の根に至るパスを抽出せよ. ただし,構文木上のパスは以下の仕様を満たすものとする.
- 各文節は(表層形の)形態素列で表現する
- パスの開始文節から終了文節に至るまで,各文節の表現を"->"で連結する
「吾輩はここで始めて人間というものを見た」という文(neko.txt.cabochaの8文目)から,次のような出力が得られるはずである.
吾輩は -> 見た
ここで -> 始めて -> 人間という -> ものを -> 見た
人間という -> ものを -> 見た
ものを -> 見た
解答
# util.rb class Chunk ... def path_to_root(sentence) self.depends? ? [self] + sentence[self.dst].path_to_root(sentence) : [self] end end
# 48.rb require './util' dependencies.each do |sentence| sentence.each do |chunk| if chunk.depends? && chunk.has_pos?("名詞") path = chunk.path_to_root(sentence).map(&:surfaces) puts path.join(" -> ") unless path[-1].blank? end end end
path の最後が ?
など記号のときにどう扱っていいか分からなかったので、飛ばすことにしました(path[-1]
が空文字になるときは出力しない)。
出力(一部省略)
吾輩は -> 猫である 名前は -> 無い どこで -> 生れたか -> つかぬ 見当が -> つかぬ 何でも -> 薄暗い -> 所で -> 泣いて -> 記憶している 所で -> 泣いて -> 記憶している ニャーニャー -> 泣いて -> 記憶している いた事だけは -> 記憶している 吾輩は -> 見た ここで -> 始めて -> 人間という -> ものを -> 見た ...
49. 名詞間の係り受けパスの抽出
文中のすべての名詞句のペアを結ぶ最短係り受けパスを抽出せよ.ただし,名詞句ペアの文節番号がiとj(i<j)のとき,係り受けパスは以下の仕様を満たすものとする.
- 問題48と同様に,パスは開始文節から終了文節に至るまでの各文節の表現(表層形の形態素列)を"->"で連結して表現する
- 文節iとjに含まれる名詞句はそれぞれ,XとYに置換する
また,係り受けパスの形状は,以下の2通りが考えられる.
- 文節iから構文木の根に至る経路上に文節jが存在する場合: 文節iから文節jのパスを表示
- 上記以外で,文節iと文節jから構文木の根に至る経路上で共通の文節kで交わる場合: 文節iから文節kに至る直前のパスと文節jから文節kに至る直前までのパス,文節kの内容を"|"で連結して表示
例えば,「吾輩はここで始めて人間というものを見た。」という文(neko.txt.cabochaの8文目)から,次のような出力が得られるはずである.
Xは | Yで -> 始めて -> 人間という -> ものを | 見た
Xは | Yという -> ものを | 見た
Xは | Yを | 見た
Xで -> 始めて -> Y
Xで -> 始めて -> 人間という -> Y
Xという -> Y
解答
require './util' dependencies.each do |sentence| noun_pairs = sentence.select { |chunk| chunk.has_pos?("名詞") }.combination(2) noun_pairs.each do |i, j| noun_i = i.first_morph("名詞") noun_j = j.first_morph("名詞") surface_i = noun_i.surface surface_j = noun_j.surface noun_i.surface = "X" noun_j.surface = "Y" path_i = i.path_to_root(sentence) path_j = j.path_to_root(sentence) if path_i.include?(j) # 文節iから構文木の根に至る経路上に文節jが存在する場合 puts path_i[0...path_i.index(j)].map(&:surfaces).push("Y").join(" -> ") else # 文節iと文節jから構文木の根に至る経路上で共通の文節kで交わる場合 k = (path_i & path_j).first output_paths = [ path_i[0...path_i.index(k)].map(&:surfaces).join(" -> "), path_j[0...path_j.index(k)].map(&:surfaces).join(" -> "), k.surfaces ] puts output_paths.join(" | ") end noun_i.surface = surface_i noun_j.surface = surface_j end end
combination(2)
で名詞を含む chunk(名詞句)のペアを作り、それぞれの名詞を一旦 X
Y
に置き換えます(後で戻すので別の変数に隠しておきます)。
経路上に文節 j が存在する場合は j までのパスを表示しますが、素直に path_i[0..path_i.index(j)].map(&:surfaces)
と書くと Xで -> 始めて -> Yという
みたいに、出力例と一致しないので、Y
を含む分節は Y
だけを出力してやる必要がありました。「文節 j」だったら Yという
まで出力してやってもいいと思うのですが、日本語の説明と出力例が違ってややこしいですね……。ということで、j の 1 つ手前まで + Y
、すなわち path_i[0...path_i.index(j)].map(&:surfaces).push("Y")
で期待した出力になります。
経路上で共通の文節 k で交わる場合はそのまま実装すればよいです。path_i と path_j は順番に並んでいるので、共通集合(集合に順番の概念はありませんが、ここでは配列になって返ってくるので順番が保たれます)の最初の要素 (path_i & path_j).first
が k です。あとは path_i と path_j についてそれぞれ k の手前まで出力、続いて k を出力すれば良いです。
出力(一部省略)
Xは -> Yである Xで -> 生れたか | Yが | つかぬ Xでも -> 薄暗い -> Yで Xでも -> 薄暗い -> 所で | Y | 泣いて Xでも -> 薄暗い -> 所で -> 泣いて | Yだけは | 記憶している Xでも -> 薄暗い -> 所で -> 泣いて -> Yしている Xで | Y | 泣いて Xで -> 泣いて | Yだけは | 記憶している Xで -> 泣いて -> Yしている X -> 泣いて | Yだけは | 記憶している ...
もっと良い書き方あるよ〜などあれば issue とかで教えてもらえるとうれしいです!