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

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

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

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

github.com

今回は第 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_particlenil になることもあるので、ここで係り元 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 とかで教えてもらえるとうれしいです!

github.com