言語処理100本ノック2015 をRubyでやる【第5章 前半】
昨日 4/18 に第 10 章の問題 99 までなんとか終えることができました。ブログ更新のほうサボってたので、これから解説をがんばります。
コードは GitHub に上げています。この記事では省略した長い出力も output/ ディレクトリに置いてます。
今回は第 5 章「係り受け解析」です。長いので前半と後半に分けます。
夏目漱石の小説『吾輩は猫である』の文章(neko.txt)をCaboChaを使って係り受け解析し,その結果をneko.txt.cabochaというファイルに保存せよ.このファイルを用いて,以下の問に対応するプログラムを実装せよ.
40. 係り受け解析結果の読み込み(形態素)
形態素を表すクラスMorphを実装せよ.このクラスは表層形(surface),基本形(base),品詞(pos),品詞細分類1(pos1)をメンバ変数に持つこととする.さらに,CaboChaの解析結果(neko.txt.cabocha)を読み込み,各文をMorphオブジェクトのリストとして表現し,3文目の形態素列を表示せよ.
解答
係り受け解析の結果を用いるので、-I0 -O4
オプションが必要です。cabocha -f1 -I0 -O4 neko.txt
で出力された nako.txt.cabocha はこんな感じです。ここでは、EOS がきたら文章が終わるとみなします。
... * 0 2D 0/0 -0.764522 記号,空白,*,*,*,*, , , * 1 2D 0/1 -0.764522 吾輩 名詞,代名詞,一般,*,*,*,吾輩,ワガハイ,ワガハイ は 助詞,係助詞,*,*,*,*,は,ハ,ワ * 2 -1D 0/2 0.000000 猫 名詞,一般,*,*,*,*,猫,ネコ,ネコ で 助動詞,*,*,*,特殊・ダ,連用形,だ,デ,デ ある 助動詞,*,*,*,五段・ラ行アル,基本形,ある,アル,アル 。 記号,句点,*,*,*,*,。,。,。 EOS * 0 2D 0/1 -1.911675 名前 名詞,一般,*,*,*,*,名前,ナマエ,ナマエ は 助詞,係助詞,*,*,*,*,は,ハ,ワ * 1 2D 0/0 -1.911675 まだ 副詞,助詞類接続,*,*,*,*,まだ,マダ,マダ * 2 -1D 0/0 0.000000 無い 形容詞,自立,*,*,形容詞・アウオ段,基本形,無い,ナイ,ナイ 。 記号,句点,*,*,*,*,。,。,。 EOS EOS ...
* 1 2D 0/1 -0.764522
といった行の意味は以下のとおりです。
項目 | 項目名 | 備考 |
---|---|---|
* |
文節の開始位置を示す | |
1 |
文節番号 | 0始まり |
2D |
係り先文節番号 | 係り先が同定されなければ -1 |
0/1 |
主辞/機能語フィールド | 主辞が 0 番目の形態素(吾輩 )、機能語が 1 番目の形態素(は )今後廃止される可能性あり |
-0.764522 |
係り関係のスコア | 係りやすさの度合い 信じていい値かどうかは微妙 |
# util.rb require 'active_record' class Morph attr_accessor :surface, :base, :pos, :pos1 def initialize(surface, base, pos, pos1) @surface = surface @base = base @pos = pos @pos1 = pos1 end end class Chunk attr_accessor :morphs, :dst, :srcs def initialize(morphs: [], dst: -1, srcs: []) @morphs = morphs @dst = dst @srcs = srcs end # どこかの文節に係っているか def depends? self.dst != -1 end end # 係り受け解析結果 def dependencies(filename: 'neko.txt.cabocha') sentence_list = [] File.open(filename) do |lines| sentence = [] chunk = Chunk.new lines.each do |line| # 文節のはじめ line.scan(/\*\s\d+\s(?<dst>-?\d+)D/) do |(dst)| sentence << chunk if chunk.morphs.present? chunk = Chunk.new(dst: dst.to_i) end # 形態素 line.scan(/(?<surface>.+?)\t(?<pos>.+?),(?<pos1>.+?)(?:,.+?){4},(?<base>.+?)(?:,|$)/) do |surface, pos, pos1, base| chunk.morphs << Morph.new(surface, base, pos, pos1) end # 文のおわり if line == "EOS\n" && chunk.morphs.present? sentence << chunk sentence.each.with_index do |chunk, i| sentence[chunk.dst].srcs << i if chunk.depends? end sentence_list << sentence sentence = [] chunk = Chunk.new end end end sentence_list end
以降の問題で使うので util.rb にまとめて書いておきます。問題 40 は 41 と合わせて解いたので、各文は Morph オブジェクトのリストでなく Chunk オブジェクトのリストになっています(どうしても Morph オブジェクトのリストにしたければ Chunk オブジェクトを開いて @morphs
を取り出せば良い)。
入力ファイルを 1 行ずつ読んで、形態素行の場合は正規表現 /(?<surface>.+?)\t(?<pos>.+?),(?<pos1>.+?)(?:,.+?){4},(?<base>.+?)(?:,|$)/
で surface
pos
pos1
base
を取得します。わかりやすさのために正規表現内で名前をつけていますが、ブロック内の変数としてこれらを拾う |surface, pos, po1, base|
と対応しているわけではなく、ここは |hoge, fuga, piyo, bar|
とかでも正規表現内の ()
の順番で拾って変数に代入してくれます。Morph オブジェクトの外からクラス変数を Read / Write したいので attr_accessor
を使っています。
dependencies
は sentence(文) > chunk(文節) > morph(形態素)
という階層でオブジェクトが収められた配列です。以下の出力例は、sentence
は文節を 3 つ持ち、1 つ目の文節は形態素を 2 つ持つことを示します。
sentence = dependencies[2] sentence # => [#<Chunk:0x00007fd76c9f1d48 @dst=2, @morphs= [#<Morph:0x00007fd76c9f1ac8 @base="名前", @pos="名詞", @pos1="一般", @surface="名前">, #<Morph:0x00007fd76c9f18e8 @base="は", @pos="助詞", @pos1="係助詞", @surface="は">], @srcs=[]>, #<Chunk:0x00007fd76c9f1780 @dst=2, @morphs=[#<Morph:0x00007fd76c9f1500 @base="まだ", @pos="副詞", @pos1="助詞類接続", @surface="まだ">], @srcs=[]>, #<Chunk:0x00007fd76c9f13c0 @dst=-1, @morphs= [#<Morph:0x00007fd76c9f1168 @base="無い", @pos="形容詞", @pos1="自立", @surface="無い">, #<Morph:0x00007fd76c9f0fb0 @base="。", @pos="記号", @pos1="句点", @surface="。">], @srcs=[0, 1]>]
上で説明したような処理を施した dependencies
から 3 文目を取り出して、全ての Chunk が持つ Morph を表示します。
# 40.rb require './util' # 3文目 : "名前はまだ無い。" sentence = dependencies[2] sentence.each do |chunk| chunk.morphs.each do |morph| puts "surface: #{morph.surface}, base: #{morph.base}, pos: #{morph.pos}, pos1: #{morph.pos1}" end end
出力
surface: 名前, base: 名前, pos: 名詞, pos1: 一般 surface: は, base: は, pos: 助詞, pos1: 係助詞 surface: まだ, base: まだ, pos: 副詞, pos1: 助詞類接続 surface: 無い, base: 無い, pos: 形容詞, pos1: 自立 surface: 。, base: 。, pos: 記号, pos1: 句点
41. 係り受け解析結果の読み込み(文節・係り受け)
40に加えて,文節を表すクラスChunkを実装せよ.このクラスは形態素(Morphオブジェクト)のリスト(morphs),係り先文節インデックス番号(dst),係り元文節インデックス番号のリスト(srcs)をメンバ変数に持つこととする.さらに,入力テキストのCaboChaの解析結果を読み込み,1文をChunkオブジェクトのリストとして表現し,8文目の文節の文字列と係り先を表示せよ.第5章の残りの問題では,ここで作ったプログラムを活用せよ.
解答
# util.rb ... class Chunk ... # surfaceをまとめて文字列で返す def surfaces(symbol: false) surfaces = symbol ? self.morphs : self.morphs.reject { |morph| morph.pos == '記号' } surfaces.map { |morph| morph.surface }.join end end ... def dependencies(filename: 'neko.txt.cabocha') ... # 文のおわり if line == "EOS\n" && chunk.morphs.present? sentence << chunk sentence.each.with_index do |chunk, i| sentence[chunk.dst].srcs << i if chunk.depends? end sentence_list << sentence sentence = [] chunk = Chunk.new end ... end
以降の問題で記号を省いて surface
を出力することがあるので、記号のあるなしをオプションで指定できるようにしました。問題 41 では記号を含んだまま出力します。
dependencies の中で sentence を開いてひとつずつ chunk を見ていきます。chunk がどこかに係っていれば、係り先の chunk の src に番号(sentence のうち何番目の形態素であるか)をメモします。
# 41.rb require './util' # 8文目 : "この書生というのは時々我々を捕えて煮て食うという話である。" sentence = dependencies[7] sentence.each do |chunk| puts "morphs: #{chunk.surfaces(symbol: true)}, dst: #{chunk.dst}, srcs: #{chunk.srcs.to_s}" end
出力
morphs: この, dst: 1, srcs: [] morphs: 書生というのは, dst: 7, srcs: [0] morphs: 時々, dst: 4, srcs: [] morphs: 我々を, dst: 4, srcs: [] morphs: 捕えて, dst: 5, srcs: [2, 3] morphs: 煮て, dst: 6, srcs: [4] morphs: 食うという, dst: 7, srcs: [5] morphs: 話である, dst: -1, srcs: [1, 6]
42. 係り元と係り先の文節の表示
係り元の文節と係り先の文節のテキストをタブ区切り形式ですべて抽出せよ.ただし,句読点などの記号は出力しないようにせよ.
解答
require './util' dependencies.each do |sentence| sentence.each do |chunk| if chunk.depends? && chunk.surfaces.present? && sentence[chunk.dst].surfaces.present? puts [chunk.surfaces, sentence[chunk.dst].surfaces].join("\t") end end end
条件は
- 係り先が存在する
- 係り元 chunk が記号のみではない
- 係り先 chunk も記号のみではない
です。記号は出力しないとのことなので、前問で定義した chunk.surfaces
を使って記号を省きます。
出力(一部省略)
吾輩は 猫である 名前は 無い まだ 無い どこで 生れたか 生れたか つかぬ とんと つかぬ 見当が つかぬ 何でも 薄暗い 薄暗い 所で じめじめした 所で ...
43. 名詞を含む文節が動詞を含む文節に係るものを抽出
名詞を含む文節が,動詞を含む文節に係るとき,これらをタブ区切り形式で抽出せよ.ただし,句読点などの記号は出力しないようにせよ.
解答
# util.rb class Chunk ... # 特定の品詞を含むか def has_pos?(pos) self.morphs.map(&:pos).include?(pos) end ... end
# 43.rb require './util' dependencies.each do |sentence| sentence.each do |chunk| if chunk.depends? && chunk.surfaces.present? && sentence[chunk.dst].surfaces.present? \ && chunk.has_pos?("名詞") && sentence[chunk.dst].has_pos?("動詞") puts [chunk.surfaces, sentence[chunk.dst].surfaces].join("\t") end end end
前問の条件に加えて、
- 係り元 chunk に名詞を含む
- 係り先 chunk に動詞を含む
場合を考慮します。
出力(一部省略)
どこで 生れたか 見当が つかぬ 所で 泣いて ニャーニャー 泣いて いた事だけは 記憶している 吾輩は 見た ここで 始めて ものを 見た あとで 聞くと 我々を 捕えて ...
44. 係り受け木の可視化
与えられた文の係り受け木を有向グラフとして可視化せよ.可視化には,係り受け木をDOT言語に変換し,Graphvizを用いるとよい.また,Pythonから有向グラフを直接的に可視化するには,pydotを使うとよい.
解答
require 'gviz' require './util' # 4文目 : "どこで生れたかとんと見当がつかぬ" sentence = dependencies[3] Graph do sentence.each do |chunk| if chunk.depends? route chunk.surfaces.to_id => sentence[chunk.dst].surfaces.to_id node chunk.surfaces.to_id, label: chunk.surfaces node sentence[chunk.dst].surfaces.to_id, label: sentence[chunk.dst].surfaces end end save("44", :png) end
Python なら pydot がおすすめされていますが、今回はRuby ということで、DOT言語を吐いてグラフを書ける Gviz を使いました。
route 係り元 => 係り先
というように全てのパターンを書き出していきます。使い方を見ると 単語.to_sym
した形で(route :hoge => :fuga
みたいに)書かれていたのですが、日本語の Symbol では動いてくれなかったので、オブジェクト id を指定することにしました。ただ、そのままだとオブジェクト id が表示されるので label
として表示したい日本語の単語を教えてやります。
対象となる文章は特に指定されていないので、適当に 4 文目を使ってみました。あんまり長い文章だとごちゃごちゃして見にくかったです。
出力
構文木の可視化が面白かったので、けいおんの「ごはんはおかず」の歌詞でやってみました。楽しいね。
なるほど pic.twitter.com/UwF9GZKPYO
— やましー (@yamasy1549) 2017年10月18日
もっと良い書き方あるよ〜などあれば issue とかで教えてもらえるとうれしいです!