2012年7月26日木曜日

Rubyでフック処理

ここ2日ほどフック処理をするためのモジュールを作ろうとして四苦八苦していた。あちこち検索していろんなソースを参考にしつつ、とりあえずこんな感じになった。

module Hooker
  def self.included(klass)
    klass.extend(ClassMethods)
    klass.__send__ :include, InstanceMethods
  end
  module ClassMethods
    def define_hook(name, &hook)
      define_method(name) do |*args, &block|
        singleton_class =(class << self; self; end)
        singleton_class.module_eval{define_method(:__instance_exec, &hook)}
        orig = proc{|*args, &block| super(*args, &block)}
        begin
          return send(:__instance_exec, orig, *args, &block)
        ensure
          singleton_class.module_eval{remove_method(:__instance_exec)} rescue nil
        end
      end
    end
  end
  module InstanceMethods
  end
end

どういうときに使うかというと、例えばArrayクラスを継承したMyArrayクラスを定義して、MyArray#mapの戻り値をArrayではなくMyArrayで受け取りたい、というような場合に使う。普通にクラスを継承させただけでは、MyArray#mapの戻り値はArrayオブジェクトになる。

class MyArray < Array
end

ary = MyArray.new([1,2,3])
new_ary = ary.map{|n| n + 1}
puts new_ary.class #=> Array

Hookerモジュールを使うと、次のように#mapの戻り値をMyArrayオブジェクトで返すMyArrayクラスを定義することができる。

class MyArray < Array
  include Hooker
  define_hook(:map){|orig, &block| self.class.new(orig.call(&block))}
end

ary = MyArray.new([1,2,3])
new_ary = ary.map{|n| n + 1}
puts new_ary.class #=> MyArray

おおまかな使い方としては、define_hookメソッドでフックしたいメソッドをオーバーライドする。define_hookの引数はフックしたいメソッドの名前で、ブロックはフック処理である。ブロックの第一引数origはsuperをProc化したものである(定義ブロック内ではsuperが使えないのでこのような方法を取っている)。ブロックの第二引数以降では*argsや&blockなど、メソッドに渡したい引数を与えることができる。引数を加工した上で元メソッドに引き渡したい時は、argsやblockを加工した上でorig.call(*args, &block)などとする。メソッドの戻り値を加工した上で取得したい時は、orig.callの戻り値を加工する。上記では、mapの戻り値を加工してArrayからMyArrayに書き換えている。

別の使い方としては、eachメソッドをフックして#eachのブロックの引数を加工することもできる。

class MyArray < Array
  include Hooker
  define_hook(:each){|orig, &block| orig.call{|x| block.call(self.class.new(x))}}
end

ary = Bar.new([[1,2,3], [4,5,6], [7,8,9]])
ary.each{|x| puts "#{x}:#{x.class}"}

#=>
[1, 2, 3]:MyArray
[4, 5, 6]:MyArray
[7, 8, 9]:MyArray

手こずったポイントの一つは、define_hookに渡す定義ブロックが、クラスのコンテキストで生成されてしまうということ。このため、定義ブロック内でselfを使うとインスタンス(=ary)ではなくクラス(=MyArray)を参照してしまうという問題が生じた。そこでinstance_execを使って定義ブロック(=&hook)を強制的にインスタンスのコンテキストで実行することを考えたが、instance_execは&hookに引数を引き渡すことはできるものの、ブロックの引渡し方がよく分からない。このため、eachなどブロックを取るメソッドをフックしたいときに、このブロックをどうやって&hookに引き渡せばいいかで悩むことになった。最終的に、instance_execのソースを探してきて、似たようなコードを自前で書くことによって問題を解決した。あとはsuperが使えるようになれば、もっと直感的な使い勝手になると思うのだが・・・。

2012年7月23日月曜日

RubyのDelegatorを使ってみる話

例えば、Arrayクラスにhelloというメソッドを追加したい場合、次のようなやり方ができる。

class Array
  def hello
    puts "Hello world !!"
  end
end

しかし、もともとあるArrayクラスに手を加えたくない場合もある。いくつかやり方があるが、Arrayクラスを継承した別のクラスを定義するという方法がある。

class HelloArray < Array
  def hello
    puts "Hello world !!"
  end
end

しかし、HelloArrayクラスのオブジェクトにmapなどのメソッドを使うと、Arrayクラスに戻ってしまう。

obj1 = HelloArray.new([1, 2, 3])
obj2 = obj1.map{|n| n + 1}
obj2.class # => Array

そこで、obj1に何か仕事をさせて、戻り値がArrayであれば自動的にHelloArrayにするという処理を実現したいとする。これはdelegateという標準ライブラリを使って次のように実現することができる。

require 'delegate'
class HelloArray < SimpleDelegator
  def method_missing(name, *args, &block)
    obj = super(name, *args, &block)
    obj = HelloArray.new(obj) if obj.class == Array
    return obj
  end
end

obj1 = HelloArray.new([1, 2, 3])
obj2 = obj1.map{|n| n + 1}
obj2.class # => HelloArray

SimpleDelegatorはmethod_missingを利用してオブジェクトへの委譲を行う。そこで、SimpleDelegatorを継承したHelloArrayクラスを定義して、method_missingを書き換えることによって「ほとんど全てのメソッドを対象としたフック処理」を行うことができる。これにより、メソッドの戻り値がArrayかどうかを監視して、ArrayであればHelloArrayにするという処理を実現することができる。

これで、obj1によって生成されるArrayオブジェクトは全て自動的にHelloArrayオブジェクトになるようになったはずである。ところが、次のような処理はうまく行かない。

obj1 = HelloArray.new([[1,2,3],[4,5,6],[7,8,9]])
obj1.each{|item| item.hello}

itemには[1,2,3]や[4,5,6]や[7,8,9]が順番に代入されるが、これらはobj1のメソッドの結果として生成されるわけではないので、HelloArrayの監視対象ではない。そこでHelloArrayのeachメソッドを上書きして、これらの要素も監視するようにしよう。

class HelloArray
  def each
    super do |item|
      item = HelloArray.new(item) if item.class == Array
      yield item
    end
  end
end

これで、obj1.each{|item| ... }のitemも、自動的にArrayからHelloArrayに変換されるようになる。当然、eachメソッドを参照するEnumerable系のメソッド(mapとか)にも自動的に適用される・・・と思いきや、実はうまく行かない。

obj1.map{|item| item.hello} # => Error!

HelloArrayクラスにはmapは定義されていないので、method_missingを経由してArrayに委譲されることになる。ところが、Array#mapはあくまでArray#eachを参照するのであって、HelloArray#eachを再定義しても無視されるのではないかと思われる。ならばどうするのかというと、HelloArrayクラスにEnumerableをインクルードしてやる。

class HelloArray
  include Enumerable
end

これで、HelloArray#mapその他が定義され、これらはHelloArray#eachを参照するので、mapも期待通り動作するようになる。が、今度はEnumerableがインクルードされてHelloArray#mapが定義されたことにより、method_missing経由でArray#mapが呼び出されなくなるので、method_missingで行なっていたmapの結果をHelloArrayに変換する処理が行われなくなってしまう。これを解決するには、デリゲーターを二重構造にするという方法がある。

class HelloArray < SimpleDelegator
  def hello
    puts "Hello World !!"
  end
  def method_missing(name, *args, &block)
    obj = super(name, *args, &block)
    obj = HelloArray.new(obj) if obj.class == Array
    return obj
  end
end

class HelloArrayInner < SimpleDelegator
  include Enumerable
  def each
    super do |item|
      item = HelloArray.new(item) if item.class == Array
      yield item
    end
  end
end

obj1 = HelloArray.new([])
obj2 = HelloArrayInner.new(obj1)

これで、obj2#mapのブロック引数はHelloArrayになるし、戻り値もHelloArrayになる。最後に、obj1、obj2と二段階に分けてデリゲーターを生成するのは手間なので、一回ですむようにHelloArray#initializeを修正することにしよう。

class HelloArray
  def initialize(obj)
    super
    self.__setobj__ HelloArrayInner.new(obj)
  end
end

Delegator#__setobj__は委譲先オブジェクトを変更するメソッドである。ここではobjからHelloArrayInnner.new(obj)へと委譲先を差し替えることで、HelloArrayとobjの間にHelloArrayInnerをはさみこんでいる。これにより、次のような書き方で二重構造のデリゲーターを生成することができるようになる。

obj = HelloArray.new([])

2012年7月13日金曜日

正規表現とチョムスキー階層に関する簡単なまとめ

テキスト検索などに利用される正規表現は、チョムスキーのタイプ-3文法(正規文法)に由来する。チョムスキーは句構造文法をタイプ-0からタイプ-3までの4つの階層に分類した。

タイプ-0-制限なし
タイプ-1文脈依存文法αAβ→αγβ
タイプ-2文脈自由文法A→γ
タイプ-3正規文法A→aおよびA→aBまたはA→Ba

おおまかに言って、タイプ-1は左辺が複数ノードの場合もある句構造文法、タイプ-2は左辺が単一ノードの句構造文法、タイプ-3は二股枝分かれの句構造文法に相当する。このうち、正規表現はタイプ-3の正規文法に由来する。

(2012.8.5追記: タイプ-3は単なる二股枝分かれではなく、どちらか一方の枝が終端記号であるような二股枝分かれ)

実際に、簡単な正規表現を例にとって、それが正規文法でどのように表現されるか考えてみることにしよう。/ABC/という正規表現は、次のような句構造文法に対応する。

S1→A+S2
S2→B+S3
S3→C

正規表現でよく使われる繰り返し記号/*/、/+/、/?/などは次のような句構造文法で表現することができる。

/A*B/
S1→A+S1
S1→B

/A+B/
S1→A+S2
S2→A+S2
S2→B

/A?B/
S1→A+S2
S1→B
S2→B

実際には、最近の処理系で用いられる正規表現は、正規文法が本来表現できる範囲を超える文字列を表現できるとされる。が、大雑把な仕組みとしてはこんな感じである。

2012年3月7日水曜日

gem作成に関するメモ

はじめてgemパッケージを作るにあたってやったことのメモ。

1. GitHubにアカウントを作る。
さらにログイン後に見ることができるチュートリアル(http://help.github.com/mac-set-up-git/)して、自分のPCにgitをインストールし、sshとかの設定をする。

2. 以下のサイトを読んでjewelerのセットアップやプロジェクトの作成をする。
http://technicalpickles.com/posts/craft-the-perfect-gem-with-jeweler/

jewelerのインストール。

gem sources -a http://gems.github.com
sudo gem install technicalpickles-jeweler

以下の設定は1.で既に終わっているはず。

$ git config --global user.email johndoe@example.com
$ git config --global user.name 'John Doe'
$ git config --global github.user johndoe
$ git config --global github.token 55555555555555

その後の新規プロジェクトの作成のところは別のサイトも参照しつつ3.で。

3. 以下のサイトを読んで新規プロジェクトの作成。
http://jp.rubyist.net/magazine/?0037-CreateRailsPlugin

ここではプロジェクト名をrenkonとし、ローカルの置き場は~/project/renkonとしておく。projectフォルダを作ってそこに移動し、以下のようにする。

$ jeweler --create-repo renkon

--create-repoは何回やってもタイムアウトして失敗するけど、ときどき成功するのでよく分からんまま先に進む。--rspecはrspec 1.3ではなく2.0がインストールされているとrakeのときにいろいろエラーが出るようなのではしょる。

公開準備。まずバージョンファイルを作る。

$ rake version:write

なんか警告が出るので、無視しても問題ないと思うが、Rakefileの以下の部分を書き換えておいた方がいいかも。

#require 'rake/rdoctask'
require 'rdoc/task'

バージョンを上げる。

$ rake version:bump:minor

ビルドする。上記サイトの指示通り、先にRakefileのsummaryとdescriptionのところを書き換えておく。

$ rake build

公開する。

$ git add .
$ git commit -m "create new library."
$ git push origin master
$ rake release

4. プロジェクトの更新

lib/renkon.rb
lib/renkon/subfile1.rb

みたいな感じでプロジェクトを作って更新していくものと思われる。

2012年2月8日水曜日

論文を書くときのフォントについて心得ておくべき3つのこと

Wordとかで論文を書くときのフォントについて心得ておくべきこと。

1. デフォルトに従え

長年コンピューターを使っていて得た教訓の一つは、特に必要のない限り、デフォルトから外れたことはしない方がいいということである。例えば、ソフトウェアをインストールするとき、わざわざインストール先フォルダをD:¥Appsとかに変えない方がいい。必要があればそうしても構わないが、そういうことをして後で面倒なことになったことは一度や二度ではない。フォントについても同じで、WindowsのWordで原稿を書け、と言われたら、たとえヒラギノの熱烈なファンであっても、素直にMS明朝を使っておくのが無難である。そうしておくことで、起こり得るかなりの種類のトラブル(例えば、全てMS明朝に直して再提出することを後から要求されたりとか)を事前に回避することができる。文書のところどころに標準的でないフォントを混ぜ込むようなことも、努々してはならない。どうしても特殊なフォントを使用しなければならないような場合には、Wordのファイルと一緒にフォントを埋め込んだpdfファイルも併せて提出しておいた方がいいだろう。印刷から出版まで全て自分の好きにできるような場合であれば、ご自由にどうぞ。

2. 等幅フォントを使え(日本語の場合)

原則として、Wordで原稿を書くときには、空白文字を使って位置調整することは邪道であり、タブとインデントを使用すべきである。にもかかわらず、等幅フォントを使っておくことで、レイアウト上の些細な問題を回避することができる場面が少なからず存在する。等幅フォントとは全ての文字の横幅が同じであるようなフォントのことである。これに対して、文字によって横幅が異なるフォントをプロポーショナルフォントと言う。

Windowsであれば、MSゴシックやMS明朝は等幅フォントであり、MS P ゴシックやMS P 明朝はプロポーショナルフォントである。ただしMS P ゴシックやMS P 明朝も漢字は全て横幅が同じであり、平仮名、カタカナ、英字など漢字以外の文字で横幅が変動する。ちなみにMacで使われるヒラギノやAdobeのソフトウェアについてくる小塚の場合は、漢字、平仮名、カタカナなどは等幅であるが、英字はプロポーショナルのようである。

欧文については、等幅フォントにこだわる必要はない。というより、欧文フォントは普通はプロポーショナルフォントである。後述するように、欧文フォントについてはTimesを使用することを推奨する。欧文の等幅フォントとしては、CourierやCourier Newが有名であり、プログラムのコードを記述する際などによく使われる。

3. Timesを使え(欧文の場合)

最初に、デフォルトに従えと言ったが、唯一デフォルトに逆らうべきであると思うのは欧文フォントの選択である。Wordのデフォルトの欧文フォントはCenturyだが、新規文書を開いたら最初にTimes New Romanに変更すべきである。Centuryがよくないと思う理由は2つある。第1に、Timesは斜体にしたとき、ちゃんと筆記体風のイタリック体になるが、Centuryは単純に斜めに傾けただけのオブリーク体になるだけで、ださい。第2に、Centuryはギリシャ文字の書体がださい。日本語環境のWordでは、ギリシャ文字はデフォルトでは日本語フォント(MS明朝とか)になってしまうが、手動で欧文フォントに変更することも可能であり、数式の中などで使う場合には当然、欧文フォントにすべきである。明らかに、CenturyよりTimesの方が、我々が(数式中の変数などとして)普段見慣れたギリシャ文字のイメージに近いと思うだろう。

ちなみに私はTeXを使うときにはTimesではなくPalatinoをよく使うが、これは日本語フォント(ヒラギノとか)との相性がTimesよりいいような気がするからである。欧文だけで文書を作るときにはPalatinoよりTimesの方が見やすいような気がする。

2012年1月25日水曜日

現代日本語書き言葉均衡コーパス

BCCWJのDVDが届いたので簡単にレビューしてみる。

DVDについて

BCCWJのDVDは2枚構成である。次のようなフォルダ構成になっている。
  • Disk 1
    • DOC
      • マニュアルや書誌情報など。
    • CORE
      • いわゆるコアデータ。M-XML、LUW、SUWの3つのサブフォルダを含む。後述する同名のフォルダの内容の一部を抽出したもの。
    • C-XML
      • 文書構造タグ付XML文書。FIXED(固定長)とVARIABLE(可変長)の2つのサブフォルダを含む。
    • M-XML
      • 形態論情報付XML文書。
  • Disk 2
    • LUW
      • 表形式の形態論データ。長単位。
    • SUW
      • 表形式の形態論データ。短単位。
ざっと見たところ、CSJの場合とは違って、構文情報を付与したコーパスは含まれていないようである。検索用のツールも特に付属していない。C-XML/FIXED、C-XML/VARIABLE、M-XML、LUW、SUWの各フォルダは、次のようなサブフォルダを含む(FIXEDフォルダはLB、OW、PB、PM、PNの5つのみ。他は全部)。
サブコーパスフォルダ名
出版サブコーパスPB(書籍)、PM(雑誌)、PN(新聞)
図書館サブコーパスLB(書籍)
特定目的サブコーパスOB(ベストセラー)、OW(白書)、OP(広報紙)、OL(法律)、OM(国会会議録)、 OT(教科書)、OV(韻文)、OC(Yahoo!知恵袋)、OY(Yahoo!ブログ)

データファイルはいずれも上記のサブフォルダ単位ぐらいでzip圧縮されているため、利用する前に展開する必要がある。

C-XML

文書構造タグ付XMLが格納されている。FIXED(固定長)は、句読点など記号類を含めず1000字を1サンプルとしている。VARIABLE(可変長)は、長さを固定せず節や章などの文章のまとまりを1サンプルとしている(ただし1万字を上限とする)。

[OW1X_00000.xml (固定長)]
<?xml version="1.0" encoding="UTF-8"?>
<sample sampleID="OW1X_00000" version="1.0" type="fixedLength">
<article articleID="OW1X_00000_F001" isWholeArticle="false">
<paragraph>
<sentence>これは円高で輸入価格が低下したため,輸入数量は増え<sampling type="start" />ても海外への輸入代金の支払いが減少したことを示す。</sentence>
<sentence>こうした動きが,最近の企業収益の改善に寄与している。</sentence>
<br type="automatic_original" />
</paragraph>

[OW1X_00000.xml (可変長)]
<?xml version="1.0" encoding="UTF-8"?>
<sample sampleID="OW1X_00000" version="1.0" type="variableLength">
<article articleID="OW1X_00000_V001" isWholeArticle="false">
<titleBlock>
<title>
<sentence type="quasi">第2節 内外均衡の背景</sentence>
<br type="automatic_original" />
</title>
</titleBlock>
<paragraph>
<sentence> 53年度中にみられた内外均衡回復に向けての動きは,それぞれがバラバラに生じてきたわけではない。</sentence>

M-XML

形態論情報付XMLが格納されている。

[OW1X_00000.xml]
<?xml version="1.0" encoding="UTF-8"?>
<mergedSample sampleID="OW1X_00000" type="BCCWJ-MorphXML" version="1.0">
<article articleID="OW1X_00000_V001" isWholeArticle="false">
<titleBlock>
<title>
<sentence type="quasi"><LUW B="S" SL="v" l_lemma="第二節" l_lForm="ダイニセツ" l_wType="漢" l_pos="名詞-数詞" l_formBase="ダイニセツ"><SUW orderID="10" lemmaID="22937" lemma="第" lForm="ダイ" wType="漢" pos="接頭辞" formBase="ダイ" pron="ダイ" start="10" end="20">第</SUW><SUW orderID="20" lemmaID="28181" lemma="二" lForm="ニ" wType="漢" pos="名詞-数詞" formBase="ニ" pron="ニ" start="20" end="30">2</SUW><SUW orderID="30" lemmaID="20244" lemma="節" lForm="セツ" wType="漢" pos="名詞-普通名詞-助数詞可能" formBase="セツ" usage="助数詞" pron="セツ" start="30" end="40">節</SUW></LUW>

LUW

長単位形態論データ。タブ区切りテキストである。C-XMLやM-XMLがOW1X_00000.xml, ...など多くのxmlファイルに分割されているのに対して、このデータはOW.txtのような比較的大きなテキストとしてまとめられているようである。フィールドについては同梱のPDF版マニュアルで説明されているのでここでは割愛するが、概ね10刻みの数字は文書中における位置情報(10で割ると開始位置や終了位置になる)、0か1はフラグ(固定長か可変長かなど)のようである。

[OW.txt]
OW OW1X_00000 10 40 B 1 0 1 第二節 ダイニセツ 漢 名詞-数詞   ダイニセツ 第2節 第2節 第2節 ダイニセツ 10 10 20 B
OW OW1X_00000 40 50 B 0 0 1    記号 空白           20 40 50 I
OW OW1X_00000 50 90  1 0 1 内外均衡 ナイガイキンコウ 漢 名詞-普通名詞-一般   ナイガイキンコウ 内外均衡 内外均衡 内外均衡 ナイガイキンコー 30 50 70 I

SUW

短単位形態論データ。タブ区切りテキストである。フィールドの構成は、LUWとはかなり違うようである。

[OW.txt]
OW OW1X_00000 10 20 10 10 20 0 1 B 6304883175203328 22937 第 ダイ  漢 接頭辞   ダイ  第 第 第 ダイ
OW OW1X_00000 20 30 20 20 30 0 1 I 7746342986326528 28181 二 ニ  漢 名詞-数詞   ニ  2 2 2 ニ
OW OW1X_00000 30 40 30 30 40 0 1 I 5564636971803136 20244 節 セツ  漢 名詞-普通名詞-助数詞可能   セツ 助数詞 節 節 節 セツ

感想

待ちに待った画期的資料であることは言うまでもない。コンテンツは基本的にデータだけなので、使いこなすにはそれなりの技術が要求される。とりあえずzipファイルを全部展開してハードディスクにコピーしてくれるインストーラーとか、ひまわりや茶器にインポートするためのツールぐらい付いててもよかったのではなかろうか。あるいはDiskもう一枚増やしてsqliteファイル付けといてくれるとか。もっとも、そういうツール必要な人は中納言使えということか。
それと個人的に残念なのは構文解析済みデータが入っていなかったことである。みんな形態素解析済みデータあれば十分で、構文解析済みデータなんて使う人あんまりいないから、そういうことしたい人は勝手にやれという方針なのだろうか。これだけのデータ、自分で構文解析かけるのは手間だし、あれば使いたいけどなぁ。

2012年1月12日木曜日

Rubyでコーパス言語学(5)

ここまでのまとめ

第2回から第4回までで示したコードを以下にまとめた。単一のXMLファイルに対する処理はXMLというクラスに、複数のXMLファイルに対する処理はXMLFilesというクラスにまとめてある。次回以降、このファイルをxmlc.rbという名前で保存しておき、requireして使うことにする。

# -*- coding: utf-8 -*-
# xmlc.rb
require 'nokogiri'

class XML
  attr_accessor :xml
  def initialize(path)
    @xml = open(path){|f| Nokogiri::XML(f){|config| config.noblanks}}
  end
  def nodes(xpath = '//*')
    nodes = Hash.new(0)
    @xml.xpath('//*').each{|node| nodes[node.node_name] += 1}
    return nodes
  end
  def attributes(xpath = '//*')
    attributes = Hash.new(0)
    @xml.xpath(xpath).each do |node|
      node.attributes.keys.each do |attr|
        attributes["#{node.node_name}/@#{attr}"] += 1
      end
    end
    return attributes
  end
  def attr_values(xpath)
    values = Hash.new(0)
    @xml.xpath(xpath).each{|attr| values[attr.value] += 1}
    return values
  end
  def frequency(xpath, *attrs)
    attrs = [:text, :read, :base, :pos, :ctype, :cform] if attrs == []
    text = attrs.delete(:text)
    words = Hash.new(0)
    @xml.xpath(xpath).each do |word|
      items = []
      items << word.text if text
      items += attrs.map{|attr| word.attr(attr)}
      words[items] += 1
    end
    return words
  end
  def kwic(xpath)
    lines = []
    words = @xml.xpath(xpath)
    words.each do |tok|
      sen = tok.at_xpath('ancestor::sentence')
      senid = sen.attr('S-ID')
      tokid = tok.attr('id')
      prev = sen.xpath('.//tok[@id < %i]' % tokid)
      foll = sen.xpath('.//tok[@id > %i]' % tokid)
      lines << [senid, tokid, prev.text, tok.text, foll.text]
    end
    return lines
  end
  def regexp(re)
    list = []
    @xml.xpath('//sentence').each do |sen|
      pos1 = []; pos2 = [0]
      sen.text.scan(re){|s| pos1 << [$~.begin(0), $~.end(0)]}
      next if pos1 == []
      words = sen.xpath('.//tok')
      words.each{|tok| pos2 << pos2[-1] += tok.text.size}
      pos1.each do |b1, e1|
        b2 = pos2.find_index{|n| n > b1}
        e2 = pos2.find_index{|n| n >= e1}
        list << words[b2..e2]
      end
    end
    return list
  end
  def regexp_inspect(re)
    data = Hash.new(0)
    self.regexp(re).each do |words|
      key = words.map{|word|
        "#{word.text}(#{word.attr('pos').split('-').last})"
      }.join(' ')
      data[key] += 1
    end
    return data
  end
  def method_missing(sym, *args, &block)
    @xml.send sym, *args, &block
  end
end

class XMLFiles
  include Enumerable
  def initialize(path)
    @path = path
    @files = Dir.glob(path)
  end
  def each
    @files.each do |file|
      doc = open(file){|f| Nokogiri::XML(f){|config| config.noblanks}}
      yield doc
    end
  end
  def xpath(xpath)
    enum = Enumerator.new do |y|
      self.each do |doc|
        doc.xpath(xpath).each{|node| y << node}
      end
    end
    return enum
  end
end

上記のライブラリは以下のようにして使う。

# -*- coding: utf-8 -*-
require './xmlc.rb'
doc = XML.new('(中略)/950101.xml')

ここで変数docの中身はNokogiri::XML::Documentではなく新たに定義したXMLというクラスのオブジェクトであることに注意。Nokogiri::XML::Documentオブジェクトに直接アクセスしたい場合はdoc.xmlでアクセスすることができる。XMLクラスの役割は、第2回と第3回で示したような各種のメソッドを実装することである。XMLクラスには次のようなメソッドが実装してある。

  • nodes
  • attributes
  • attr_values
  • frequency
  • kwic
  • regexp
  • regexp_inspect

nodes、attributes、attr_valuesの各メソッドは、XML文書中のノード名、属性名、属性の値の一覧と頻度(出現数)を取得するためのメソッドである。戻り値はハッシュであるので、ppを使って表示すると見やすい。なおnodesとattributesは引数を省略すると全てのノードや属性をリストアップするが、引数としてxpathを指定することで特定の種類のノードやその属性のみリストアップすることもできる。nodesとattributesの引数となるxpathはノード(".../tok"など)、attr_valueの引数となるxpathは属性(".../@pos"など)でなければならない。

require 'pp'
pp doc.nodes
pp doc.attributes
pp doc.attr_values('//tok/@pos')

[結果]
{"document"=>1, "sentence"=>1134, "chunk"=>10268, "tok"=>26739}

{"sentence/@S-ID"=>1134,
 "sentence/@KNP"=>1134,
 (中略)
 "tok/@cform"=>26739,
 "sentence/@MEMO"=>14}

{"名詞-人名"=>623,
 "名詞-普通名詞"=>4756,
 (中略)
 "感動詞"=>11,
 "未定義語-その他"=>2}

frequencyメソッドは特定の語の頻度を数えるためのメソッドである。第1引数には検索する語をxpathで指定する。活用語の活用形のリストを取得するためにも使える。

pp doc.frequency('//tok[@pos="判定詞"]')

[結果]
{["な", "な", "だ", "判定詞", "判定詞", "ダ列基本連体形"]=>7,
 ["で", "で", "だ", "判定詞", "判定詞", "ダ列タ系連用テ形"]=>53,
 (中略)
 ["であろう", "であろう", "だ", "判定詞", "判定詞", "デアル列基本推量形"]=>1,
 ["だろう", "だろう", "だ", "判定詞", "判定詞", "ダ列基本推量形"]=>1}

frequencyメソッドは、第2引数以降で出力する属性の種類を指定することができる。省略した場合のデフォルトは[:text, :read, :base, :pos, :ctype, :cform]である(:textはtext()関数として解釈される)。第2引数以降を指定した場合は、指定した属性に基づいて集計が行われる。

pp doc.frequency('//tok[@pos="判定詞"]', :pos)

[結果]
{["判定詞"]=>212}

kwicメソッドはkwicもどきの結果を二次元配列で返す。

pp doc.kwic('//tok[@pos="判定詞"]')

[結果]
[["950101008-006",
  "11",
  "ようやく経済も明るさを取り戻しつつある微妙な段階",
  "な",
  "ので、今は解散の時期ではないと考えている。"],
 ["950101008-006",
  "19",
  "ようやく経済も明るさを取り戻しつつある微妙な段階なので、今は解散の時期",
  "で",
  "はないと考えている。"],
(以下略)

regexpメソッドとregexp_inspectメソッドは正規表現検索してマッチした部分のノードを返す。regexpメソッドの戻り値はNokogiri::XML::NodeSetのリストである。regexp_inspectは見やすいように文字列化して集計したものを返す。

pp doc.regexp_inspect(/とは/)

[結果]
{"こと(形式名詞) は(副助詞)"=>12,
 "えと(普通名詞) は(副助詞)"=>1,
 "と(格助詞) は(副助詞)"=>10,
 "もともと(副詞) は(副助詞)"=>1,
 "あと(副詞) は(副助詞)"=>2}

その他、XMLクラスにはmethod_missingメソッドが仕込んであり、未定義のメソッドは@xmlに丸投げする。例えば、doc.xpath(...)は実際にはdoc.xml.xpath(...)として実行される。もう一つのXMLFilesクラスは第4回で示したのと同じものなのでここでは説明しない。XMLFilesクラスは今のところeachとxpathしかメソッドを定義していないが、今後拡張していくことにする。

2012年1月10日火曜日

Rubyでコーパス言語学(4)

複数のファイルをまとめて処理

複数のXMLファイルをまとめて処理したい場合は、次のようにする。

files = Dir.glob('C:¥KyotoCorpus4.0¥xml¥syn¥*.xml')
files.each do |file|
  STDERR.puts file
  doc = open(file){|f| Nokogiri::XML(f){|config| config.noblanks}}
  (ここに処理を記述)
end

Dir.globはワイルドカードを含むファイル名を展開してファイル名のリスト(Array)で返す。STDERR.putsは標準エラー出力(普通はコマンドプロンプト)に文字列を出力する命令で、現在処理中のファイルの名前を画面に表示する。複数のファイルをまとめて処理する場合、処理が完了するまでに時間がかかって、ちゃんと動いているのか不安になることがあるので動作確認のために入れてある。上記のコードを関数として定義しておいて次のように使うこともできる。

def xml_enum(path)
  files = Dir.glob(path)
  files.each do |file|
    STDERR.puts file
    doc = open(file){|f| Nokogiri::XML(f){|config| config.noblanks}}
    yield doc
  end
end

path = 'C:¥KyotoCorpus4.0¥xml¥syn¥*.xml'
xml_enum(path) do |doc|
  (ここに処理を記述)
end

さらに応用してクラスとして定義する場合。

class XMLFiles
  include enumerable
  def initialize(path)
    @path = path
    @files = Dir.glob(path)
  end
  def each
    @files.each do |file|
      doc = open(file){|f| Nokogiri::XML(f){|config| config.noblanks}}
      yield doc
    end
  end
end

path = 'C:¥KyotoCorpus4.0¥xml¥syn¥*.xml'
corpora = XMLFiles.new(path)
corpora.each do |doc|
  (ここに処理を記述)
end

このクラスはeachメソッドが定義されており、enumerableモジュールをインクルードしてあるが、to_aとかすると全てのXMLファイルを一度にメモリに読み込むことになるのでエラーに注意。mapとかで必要な情報だけを拾い集める分には問題ないと思われる。さらにファイルが複数あることを意識せずにxpath検索するメソッドを追加してみる。

class XMLFiles
  def xpath(xpath)
    self.each do |doc|
      doc.xpath(xpath).each{|node| yield node}
    end
  end
end

corpora.xpath(xpath) do |node|
  (ここに処理を記述)
end

Nokogiri::XML::Documentなどに対するxpathメソッドがNodeSetを返すのに対して、上記のxpathメソッドはeachのように動作する。複数のXML文書から集めたノードを集めて、それらのリストを返すようなメソッドとして定義してしまうと、メモリエラーになる可能性があるのでこのような方法を取っている。ノードセットに似たオブジェクトを返すようにしたいのであれば、次のようにEnumeratorを使う方法が考えられる。

class XMLFiles
  def xpath(xpath)
    enum = Enumerator.new do |y|
      self.each do |doc|
        doc.xpath(xpath).each{|node| y << node}
      end
    end
    return enum
  end
end

corpora.xpath(xpath).each do |node|
  (ここに処理を記述)
end

この方法であれば、each以外のmapやcountなどのメソッドも利用することが可能になる。to_aするとメモリエラーになる可能性があるのは同じなので注意すること。

Rubyでコーパス言語学(3)

ノードや属性の一覧を取得する

XMLファイルに含まれるノードや属性の一覧を取得するには次のようにする。

data = Hash.new{|h, k| h[k] = Hash.new(0)}
doc.xpath('//*').each do |node|
  node.attributes.keys.each do |attr|
    data[node.node_name][attr] += 1
  end
end
data.each do |k1, v1|
  puts k1
  v1.each do |k2, v2|
    puts "  #{k2}: #{v2}"
  end
end

このコードでは、ノードや属性のリストを保存するためにハッシュを使用している。RubyのHashは、Hash.newの後に{|h,k| h[k] = ...}や()をつけることによって初期値を...に指定することができる。xpathの*はワイルドカードで、全てのノードを意味する。attributesはノードの属性をハッシュで返す。keysはvaluesと対をなすHashのメソッドで、キー名のリスト(Array)を返す。node_nameはノード名を返す。950101.xmlに対してこのプログラムを実行した結果は次の通りである。

sentence
  S-ID: 1134
  KNP: 1134
  MOD: 1109
  MEMO: 14
chunk
  id: 10268
  link: 10268
  rel: 10268
tok
  id: 26739
  read: 26739
  base: 26739
  pos: 26739
  ctype: 26739
  cform: 26739

属性の値の一覧を取得する

ある属性の値の一覧を取得するには、次のようにする。例えば、pos属性の場合。

puts doc.xpath('//@pos').map{|attr| attr.value}.uniq.sort

950101.xmlに対する結果は以下の通りである。

判定詞
副詞
助動詞
助詞-副助詞
助詞-接続助詞
助詞-格助詞
助詞-終助詞
(以下略)

基本形から活用形のリストを取得する

例えば、判定詞の「だ」の活用形リストを取得する場合は次のようにする。

data = Hash.new(0)
words = doc.xpath('//tok[@base="だ" and @pos="判定詞"]')
words.each do |word|
  items = [word.text, word.attr('ctype'), word.attr('cform')]
  data[items] += 1
end
puts data.map{|rec| rec.join("¥t")}.sort

結果は以下の通り。

じゃ 判定詞 ダ列タ系連用ジャ形 1
だった 判定詞 ダ列タ形 37
だろう 判定詞 ダ列基本推量形 1
で 判定詞 ダ列タ系連用テ形 53
であった 判定詞 デアル列タ形 3
であって 判定詞 デアル列タ系連用テ形 1
であり 判定詞 デアル列基本連用形 10
である 判定詞 デアル列基本形 14
であろう 判定詞 デアル列基本推量形 1
でした 判定詞 デス列タ形 4
でしょう 判定詞 デス列基本推量形 2
です 判定詞 デス列基本形 15
な 判定詞 ダ列基本連体形 7
の 判定詞 ダ列特殊連体形 10

無理やり正規表現検索

検索したい表現の語の切れ目とか分からないときに、とりあえず正規表現で検索してマッチする語の組み合わせをリストアップする方法。ここでは「とは」の例を示す。もっとうまいやり方があるかも。

re = /とは/
list = []
doc.xpath('//sentence').each do |sen|
  pos1 = []
  sen.text.scan(re){|s| pos1 << [$~.begin(0), $~.end(0)]}
  next if pos1 == []
  tokens = sen.xpath('.//tok')
  pos2 = [0]
  tokens.each{|tok| pos2 << pos2[-1] += tok.text.size}
  items = pos1.map{|b1,e1|
    b2 = pos2.find_index{|n| n > b1}
    e2 = pos2.find_index{|n| n >= e1}
    tokens[b2..e2]
  }
  list += items
end
data = Hash.new(0)
list.each do |items|
  key = items.map{|item| "#{item.text}(#{item.attr('pos').split('-').last})"}
  data[key] += 1
end
puts data.map{|rec| rec.join("¥t")}

pos1とpos2はpart_of_speechとは全く関係なく、正規表現とマッチした文字列の位置(position)を記録しておくための変数。xpathで全てのsentence要素のリストを取得し、それの文のtextに順番に正規表現検索をかけていって、マッチしたらその部分のノードのリストを取得する。正規表現のマッチはscanメソッドで行い、マッチング結果を格納する$~という特別な変数を使ってマッチした部分の開始位置と終了位置を取得する。次にxpathでそのsentenceの中のtok要素を全て取得し、それらを順番に数えていって正規表現とマッチした部分と一文字でも重複するノードがあれば拾って行く。結果は以下の通り。

あと(副詞) は(副助詞) 2
えと(普通名詞) は(副助詞) 1
こと(形式名詞) は(副助詞) 12
と(格助詞) は(副助詞) 10
もともと(副詞) は(副助詞) 1

Rubyでコーパス言語学(2)

XMLコーパスを読み込む

前回のエントリで作成したXMLコーパスに対して、いろいろな操作を試みる。まずはnokogiriを用いてXMLコーパスをロードする。

# -*- coding: utf-8 -*-
require 'nokogiri'
f = open('c:¥KyotoCorpus4.0¥xml¥syn¥950101.xml')
doc = Nokogiri::XML(f){|config| config.noblanks}
f.close

XMLファイルのロードはNokogiri::XML(f)で行う。このとき、後ろにブロックをつけて読み込み時の設定を行うことができる。noblanksは空白だけのテキスト要素を無視せよというもので、これがないとXMLファイル中のインデントとかがいちいちテキスト要素として読み込まれるのでうざい。ちなみに最後の三行は次のように書くこともできる。

file = 'c:¥KyotoCorpus4.0¥xml¥syn¥950101.xml'
doc = open(file){|f| Nokogiri::XML(f){|config| config.noblanks}}

文、文節、語の数を数える

今回使用するXMLコーパスの基本的な構造は次の通りである。

<document>文書全体
<sentence>
<chunk>文節
<tok>

特定の種類のノードのリスト(ノードセット)を取得するには、xpathメソッドを使う。xpathはxmlにおける正規表現みたいなもので、詳しくはぐぐれ。

sentences = doc.xpath('//sentence')
chunks = doc.xpath('//chunk')
tokens = doc.xpath('//tok')

ノードセットの数を取得するにはsizeメソッドを使う。

puts <<"END"
sentences: #{sentences.size}
chunks: #{chunks.size}
tokens: #{tokens.size}
END

形態素情報で検索する

特定の語のノードセットを取得したい場合はtok要素の属性を利用する。例えば判定詞の「で」だけを検索したい場合は次のようにする。

de = doc.xpath('//tok[text()="で" and @pos="判定詞"]')

xpath中のtext()はテキスト要素を取得する関数で、@posのように@で始まる名前は属性を表す。この例では、<tok pos="判定詞">で</tok>のようなパターンのtok要素のセットを取得する。


手抜きKWIC

文ID、語ID、前文脈、検索語、後文脈をタブ文字で区切ったKWICもどきを出力する。

# -*- coding: utf-8 -*-
require 'nokogiri'
file = '(中略)950101.xml'
xpath = '//tok[@pos="判定詞"]'

doc = open(file){|f| Nokogiri::XML(f){|config| config.noblanks}}
tokens = doc.xpath(xpath)
tokens.each do |tok|
  sen = tok.at_xpath('ancestor::sentence')
  senid = sen.attr('S-ID')
  tokid = tok.attr('id')
  prev = sen.xpath('.//tok[@id < %i]' % tokid)
  foll = sen.xpath('.//tok[@id > %i]' % tokid)
  puts [senid, tokid, prev.text, tok.text, foll.text].join("¥t")
end

at_xpathはノードセットではなく最初にマッチしたノードだけを返すメソッド。最初から該当する要素が一つしか無いと予想される場合によく使う。xpath中のancestorは軸と呼ばれるもので、現在のノードの祖先(親ノードや祖父ノードなど)を検索せよという指示を与える。attr('S-ID')はS-IDという名前の属性の値を返せというメソッドで、戻り値は多分文字列(String)。ちなみに類似するattributeというメソッドはNokogiri::XML::Attrオブジェクトだったかを返したはず。textはテキスト要素を文字列として返すメソッドで、ノードにもノードセットにも使える。ノードセットの場合は、各ノードのtextを連結した文字列を返す(スペースなどは挟まない)。

このプログラムは、kwic.rbとか適当な名前で保存して次のように使う。

ruby kwic.rb > result.txt

出力結果のresult.txtは、Excelとかで開くと閲覧しやすい。コマンドラインでXMLファイルや検索文字列(xpath)を指定できるようにするとか改造はお好みで。

Rubyでコーパス言語学(1)

京都大学テキストコーパスをXML形式に変換する

京都大学テキストコーパスは毎日新聞95年度版のデータに構文情報や形態素情報をタグ付けしたものであり、おおよそKNPの出力形式になっている。このコーパスをRubyなどのスクリプトで処理しやすいようにXML形式に変換する。XML形式は、CaboChaのXML形式に似せたものを用いる。

[kc2xml.rb]
# -*- coding: utf-8 -*-

# kc2xml.rb ver 0.20 [2011.8.26]

require 'nokogiri'

module KC2XML

def KC2XML.parse(file)
  xml = Nokogiri::XML::Document.new
  xml.encoding = 'UTF-8'
  xml << doc = xml.create_element('document')
  sen = chu = tag = tok = tokid = nil
  lines = open(file, 'r:eucjp-ms:utf-8'){|f| f.readlines}
  lines.each do |line|
    items = line.split(' ')
    case items[0]
    when '#'
      attr = Hash.new
      items.each do |item|
        k, v = item.split(':')
        attr[k] = v
      end
      sen = xml.create_element('sentence', attr)
      doc << sen
      tokid = 0
    when '*'
      attr = Hash.new
      attr['id'] = items[1]
      attr['link'] = items[2][0 .. -2]
      attr['rel'] = items[2][-1]
      chu = xml.create_element('chunk', attr)
      sen << chu
    when '+'
      attr = Hash.new
      attr['id'] = items[1]
      attr['link'] = items[2][0 .. -2]
      attr['rel'] = items[2][-1]
      tag = xml.create_element('tag', attr)
      tag.add_child items[3] if items[3]
      chu << tag
    when 'EOS'; # nothing to do
    else
      attr = Hash.new
      attr['id'] = tokid
      attr['read'] = items[1]
      attr['base'] = items[2]
      attr['pos'] = items[3 .. 4].delete_if{|s| s == '*'}.join('-')
      attr['ctype'] = items[5]
      attr['cform'] = items[6]
      text = items[0]
      tok = xml.create_element('tok', text, attr)
      (tag or chu) << tok
      tokid += 1
    end
  end
  return doc.to_xml
end

def KC2XML.auto_conv(dir = '.', code = 'eucjp-ms')
  Dir.mkdir('./xml') unless FileTest.directory?('./xml')
  Dir.mkdir("./xml/syn") unless FileTest.directory?("./xml/syn")
  Dir.mkdir("./xml/rel") unless FileTest.directory?("./xml/rel")
  for subdir in ['syn', 'rel']
    files = Dir.glob(File.join(dir, "dat/#{subdir}/*.KNP"))
    files.each do |file|
      STDERR.print file
      des = "./xml/#{subdir}/#{File.basename(file, '.KNP')}.xml"
      STDERR.puts ' -> ' + des
      open(des, 'w', :encoding => code){|f| f.puts parse(file)}
    end
  end
end

end

KC2XML.auto_conv *ARGV[0, 2] if $0 == __FILE__


# 修正履歴
# 2011.08.26 ver 0.20
#  - nokogiriを使って全面的に書き直し
#  - sentenceタグの属性は特に調整していない(今後はnokogiri使うつもりなので)
#  - 全角ダッシュの問題はeucjp-ms使ったら解決した。
# 2010.08.30 ver 0.10
#  - Ruby1.9で動くように全面的に書き直し
# 2008.06.02 ver 0.03
#  - MSXMLでparse Errorしないように調整
#    (sentenceタグの属性をS-ID、KNP、MOD、MEMOに限定)
#  - posが「判定詞-*」とかなってたのを「判定詞」に修正
#  - kconvをiconvに差し替え(全角ダッシュがうまく変換されてなかったので)
# 2008.02.18 ver 0.02 tokの属性がずれていたのを修正
# 2008.02.13 ver 0.01

この程度の変換処理であればnokogiriとか使わない方が多分速いのだが、コード量を少なくするためにnokogiriを使用している。このスクリプトは以下のように使用する。

ruby kc2xml.rb c:¥KyotoCorpus4.0 utf-8

第一引数は京大コーパスが置いてあるフォルダ(C:¥KyotoCorpus4.0など)、第二引数は出力するXMLファイルの文字コードである。kc2xml.rbをあらかじめKyotoCorpus4.0フォルダに置いておけば、第一引数は省略できる(デフォルトはカレントフォルダ)。第二引数を省略した場合は、eucjp-msで出力する。なお、出力するXMLファイルはカレントフォルダ以下にxmlという名前のフォルダを作成し、その中に作成される。