ようじょのおえかきちょう

ふぇぇ お医者さんにペン持ったらダメっていわれた〜〜

言語処理100本ノック2015 をRubyでやる【第5章 前半】

昨日 4/18 に第 10 章の問題 99 までなんとか終えることができました。ブログ更新のほうサボってたので、これから解説をがんばります。

コードは GitHub に上げています。この記事では省略した長い出力も output/ ディレクトリに置いてます。

github.com

今回は第 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 を使っています。

dependenciessentence(文) > 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 を使いました。

github.com

route 係り元 => 係り先 というように全てのパターンを書き出していきます。使い方を見ると 単語.to_sym した形で(route :hoge => :fuga みたいに)書かれていたのですが、日本語の Symbol では動いてくれなかったので、オブジェクト id を指定することにしました。ただ、そのままだとオブジェクト id が表示されるので label として表示したい日本語の単語を教えてやります。

対象となる文章は特に指定されていないので、適当に 4 文目を使ってみました。あんまり長い文章だとごちゃごちゃして見にくかったです。

出力

f:id:yamasy1549:20180419221240p:plain


構文木の可視化が面白かったので、けいおんの「ごはんはおかず」の歌詞でやってみました。楽しいね。

もっと良い書き方あるよ〜などあれば issue とかで教えてもらえるとうれしいです!

github.com