kwic (keyword in context) 出力を得る

さて、とりあえず時間がないので、以前ブログに書いたスクリプトだけをコピーしてきた。時間があるときに、何をしているのかを書き加えていくつもり。あと、これを書いた頃よりは少しは知識がついたので、その際に手を加えるかも。

Mac の場合、これをテキストエディタにコピー・ペーストして拡張子 .rb をつけて保存して。それと同じディレクトリにフォルダを作り、その中にテキストファイルを入れる。例えば、デスクトップに kwic.rb という名前で保存したとすると、同じくデスクトップに corpus という名前のフォルダを作ってテキストファイルを入れるとする。ターミナル.app でデスクトップに移動して、ruby kwic.rb と入力して実行すると、フォルダの場所を聞かれるので、corpus と入力してリターンキーを押す。次に、検索したい語を聞かれるので、入力してリターンを押すと、検索結果が表示されるはず。

全体のスクリプトは最後に書いておくとして、それで何をしているのかを順に説明してみる。

まずは、お決まりで、Ruby のパスを指定する。

#! /usr/bin/ruby

次に、バージョン 1.8 では、文字コードの指定を $KCODE でする。ここでは、UTF-8 にしておく。これは、コーパスファイルの文字コードにあわせて指定してください。

$KCODE = "UTF-8"

バージョン 1.9 以降では、$KCODE が使えないので次のように表記してください。

# -*- coding: utf-8 -*-

そして、ファイル・ディレクトリでも触れたように、フォルダ(ディレクトリ)を指定した場合にその中にある全てのファイル(サブディレクトリを含む)を読み込むために、Find というモジュールを読み込んでおく。

require "find"

そしたら、適当な文字列で、ファイル・フォルダのパスの入力を促して、gets でそれを取得する。ちなみに、ここでのスクリプトで扱う変数名は、RubyCocoa を使っている関係上、その影響を受けている。Ruby っぽくしたい人は、それなりに変更してください。

print "\nWhere is the folder/file?\n"

fileDirectory = gets.strip.gsub(/\\/,"")

ここでは、fileDirectory という String オブジェクトに、gets で入力された文字列(パス)を読み込んで、前後の空白を削除している。最後の gsub でバックスラッシュを消しているけど、これは Mac のターミナルで実行したばあいに、ドラッグアンドドロップでファイルもしくはフォルダを指定すると、空白の前にバックスラッシュが入るので、その対策。

次に、inWord に検索語を読み込む。

print "\nSearch word?\n"

inWord = gets.strip

スクリプトを直に実行させる場合は、これらを直接スクリプトに書き込んでもいい。

fileDirectory = "corpus"

inWord = "test"

さて、ここからが、kwic の処理なんだけど、まず下準備。kwic では、文脈を表示させる必要があるので、kSpan という変数を作ってみる。それと、並べ替えもしたいので、その指定もしておく。ここでは、a、b、c の順で3語指定して並べ替えられるようにする。このスクリプトでは、キーワードの位置が 0、キーワードの左から 1、2、3 でキーワードの右側は 4、5、6 の順で入力。これも決めうちでなくて、入力を促してもいい。

kSpan = 60

a = 4;b = 5;c = 6

さらに下準備。このスクリプトでは、正規表現でマッチした物を処理していくんだけど、そこでの情報を溜め込んでおく入れ物を作る。eachInstance はキーワードとそれに付随する情報を溜め込んでいく配列、eachFilen はキーワードが見つかったファイル名を保持するハッシュ。これはとりあえず動けばいい、という感じで付けてあるので、もっといい方法があるかも。fileN は処理したファイル数。この最後の2つは、全体のファイルの中でどれだけのファイルにキーワードが現れたかを処理するために作ってある変数。keyLength はキーワードが複数ある場合に、その最大長を保持するためのオブジェクト。

eachInstance = Array.new

eachFilen = Hash.new(0)

fileN = 0

keyLength = 0

もう少し下準備。replaceChar は改行記号を \n に統一するための正規表現オブジェクト、replaceTab はタブ(\t)をシングルスペースに置き換えるためのもの、bomReplace は Ruby で BOM 付きの UTF-8 ファイルを読み込むと、BOM をそのまま読み込むので、場合によっては、kwic 表示がずれる(処理する文字数と表示される文字数に差が出る)ので、それを防ぐために取り除く。ここでわざわざ正規表現オブジェクトを作っているのは、キーワードが多い場合に、いちいち正規表現オブジェクトを作ると処理が遅くなるので、それを避けるため(実際にどれほど効果があるかはわからない・・・)。contextReg は文脈語を取り込むための物。ここでは、単純に \w+ にしているけど、それ以外のもの(- や ')を含めたいときには、ここを工夫する。

replaceChar = Regexp.new('(?:\r\n|\r|\f|\n)')

replaceTab = Regexp.new('\t')

#bomReplace = Regexp.new('\357\273\277') # これは、2.0.0 ではひつようないかもしれない。

contextReg = Regexp.new('\w+')

Mac の Mavericks 以降では 2.0.0 が標準になって、\w ではアルファベットしか引っかからなくなったので(本来の動作)、そこを

contextReg = Regexp.new('\p{Letter}+')

という表記に変えてください。これだと、文字として認識されているものだけがひっかかります。日本語環境だと、日本語の句読点は記号として扱われるようなので、日本語も文字だけが引っかかるようになるみたいです。

t1 というのは、Time.now で現在の時間を記録しておくんだけど、これと終了時の時間の差を取ることで、処理時間を表示させるための変数。なくても動く。

t1 = Time.now

次に Regexp.new で reg という正規表現オブジェクトを作るんだけど、このスクリプトでは、スラッシュ(/)を文字列の区切りとして、複数の文字列(単語だけでなくフレーズも)を検索できるようにした いので、スラッシュを正規表現の | で置き換えている。また、ワイルドカード検索ができるようにしてみる。* を 0 または 1 文字以上の文字列、? を 1 文字以上の文字列、! を任意の一文字にしてある。あと、\b で囲むことで、単語の途中にマッチしないようにしてある。それと、Regexp::IGNORECASE で大文字小文字の区別をしないように設定してある。

reg = Regexp.new("(\\b#{inWord.gsub(/ *\/ /,"|").gsub(/\*/,"\\w*").gsub(/\?/,"\\w+").gsub(/\!/,"\\w+")}\\b)",Regexp::IGNORECASE)

この辺りは、どのような検索をしたいかで変えてください。正規表現の入力で検索したい場合は次のような感じで行けるはず。

reg = Regexp.new(inWord,Regexp::IGNORECASE)

入力した文字列をそのまま検索したい場合は、入力した文字列を Regexp.escape(inWord) でエスケープさせてから正規表現オブジェクトにする。

reg = Regexp.new(Regexp.escape(inWord),Regexp::IGNORECASE)

細かいことは、Ruby のリファレンスサイトなどを参照してください。

Ruby 1.9 より前のバージョンでは、文字列はバイトでの扱いなので、正規表現でわざわざ取り出していたけれど、1.9 以降であれば文字で取り出せるので、この部分は必要なくなった。

あと、文脈の文字列を長さをそろえて取り出すために、kSpan の文字数だけマッチさせる正規表現オブジェクトを作る。これは、左文脈用と、右文脈用を別々にする。

leftExtract = Regexp.new(".{0,#{kSpan.to_s}}$")

rightExtract = Regexp.new("^.{0,#{kSpan.to_s}}")

ここからが本番の kwic の処理。ファイル・ディレクトリにちょっと書いたので、ここではファイルの処理のあたりは端折って、見つかった文字列の処理に重点を置く。Find.find(directory) のブロックで処理する。プレインテキストファイル(.txt)だけを処理したいので、File.extname(filename) が .txt 以外の場合は次のディレクトリ(ファイル)を処理する。

Find.find(fileDirectory) do | file |

next if File.extname(file).downcase != ".txt"

処理

end

処理の部分に入る。最初に、処理したファイル数を数え上げて、ファイル名を eachFname に読み込む。

fileN += 1

eachFname = File.basename(file)

そしたら、file のファイルを開いてブロックで処理する。細かいことは、File の読み書きに少し書いてある。

File.open(file) do | oneFile |

処理

end

oneFile というのにファイルの内容があるので、read でそのテキスト情報を飲み込んで、ちょっとした下処理をしている。まあ、改行記号を \n に統一して、タブ(\t)をシングルスペースにしているだけ。あと、BOM も取り除く。これは、kwic の出力の見た目の調整のため。それから、処理の高速化のために、grep(reg) で検索する正規表現 reg が含まれるパラグラフを取り出してそれを処理するようにしている。ファイルが短かったりファイル数が少ないと、特に効果はないと思うけど。

oneFile.read.gsub(replaceChar,"\n").gsub(replaceTab, " ").gsub(bomReplace,"").grep(reg) do |text|

処理

end

ここで、取り出した text という String オブジェクト(パラグラフが入っている)をそれぞれ処理していく。ここでは、scan(reg) を使ってまたまたブロックで処理することにする。

text.scan(reg) do

処理

end

まず、eachFilen にファイル名を入れて数え上げる。実際は数え上げてもその情報は使わないので、= 1 にしても同じ。keyW は、キーワードの文字列を入れる。$& はマッチした文字列全体が入っている。keyLength には、キーワードの最大長を保持するんだけど、これは、kwic の出力で見た目をそろえるため。右側の文脈が同じところから始まっていたら見やすいかなあ、と思ってそうしている。もし、それまでに保持されている長さよりも、マッチしたキーワードの長さの方が長い場合に更新する。

eachFilen[eachFname] += 1

keyW = $&

keyLength = keyW.length if keyLength < keyW.length

次に、左右の文脈の処理に入る。正規表現でマッチしたキーワードの左の文字列は $` で得られ、右側の文字列は $' で得られる。

leftText = $`

rightText = $'

Ruby 1.9 以降であれば必要がなくなった部分と、文字列が後ろから取り出せることがわかったので、この部分を修正。

まずは、左側の処理。ここでは処理速度を稼ぐために、ちょっと変なことをしている。もともとは欧文(英語)のコーパスを検索することを念頭に書いたので、2 バイト文字のことはあまり考慮していない。で、leftText に左の文脈が入っているので、それを reverse で逆にして、最初から kSpan で指定した文字数(バイト数)+ 10 を取り出して、それをまた reverse で戻している。まあ、match で処理する文字数を減らしているだけ。ASCII 文字以外(UTF-8 で 2 バイト以上)の文字が多く含まれるコーパスを扱う場合は、10 をもっと大きい数値にする。そして、match で左文脈用の正規表現オブジェクト leftExtract を使って .to_s でマッチした文字列を String オブジェクトにしている。leftWords には、並べ替えのために右から 3 つ単語を取り出している。大文字小文字の区別はいらないので、downcase にしている。reverse しているのは、この後の処理のため。

leftT = leftText.reverse[0..kSpan+10].reverse.match(leftExtract).to_s

leftWords = leftT.downcase.scan(contextReg).reverse[0..2]

文字列の取り出しは、[-n..-1] で後ろから取り出せるので、kSpan で指定した文字数を取り出す。もし文字列の長さが kSpan よりも短い場合は、nil になるので、その場合は、leftText をそのまま使うが、文字列の長さを揃えるために rjust(length) を使っている。これで、左部分のテキストの長さは kSpan に揃うはず。leftWords には、並べ替えのために右から 3 つ単語を取り出している。大文字小文字の区別はいらないので、downcase にしている。reverse しているのは、この後の処理のため。

leftT = leftText.rjust(kSpan) if (leftT = leftText[-kSpan..-1]).nil?

leftWords = leftT.downcase.scan(contextReg).reverse[0..2]

右側の文脈も同じような処理をする。ただし、reverse する必要がないので、その部分はない。

rightT = rightText.match(rightExtract).to_s

rightWords = rightT.downcase.scan(contextReg)[0..2]

最後に、ここまでの情報を eachInstance に追加していく。leftWordsrightWords.to_s を付けているのは、マッチした物がなかった場合に nil になっているので、それを "" にするため。

eachInstance << [keyW,leftWords[2].to_s,leftWords[1].to_s,leftWords[0].to_s,rightWords[0].to_s,rightWords[1].to_s,rightWords[2].to_s,eachFname,leftT,rightT]

ここで、ブロックの中の処理は終わり。

後は、結果の表示。まずは、処理時間を表示する。これを一番最後のすると、結果の表示時間も含めることができるが、ここではしていない。

print "#{Time.now - t1} seconds\n\n"

次に、全体の情報。eachInstance.length でマッチしたキーワードの数、inWord は入力した文字列、eachFilen.length はキーワードが見つかったファイル数、fileN は処理したファイル数。

print "#{eachInstance.length} instances of '#{inWord}' found in #{eachFilen.length}/#{fileN} files\n\n"

最後に、kwic の結果の表示。まず、sort_by で並べ替えをしている。rjust(n) で n 文字の右揃えで文字列を作り、ljust(n) で n 文字の左揃えの文字列を作る。ky のところで、キーワードも最大長でそろえて右側の文脈が同じところから始まるようにしているけど、.ljust(keylength) を削除すれば、余分な空白なく表示される。

eachInstance.sort_by {| v | [v[a],v[b],v[c]] }.each do | ky, l3, l2, l1, r1, r2, r3, f_n, lt, rt |

print "#{lt.rjust(kSpan)+ky.ljust(keyLength)+rt.ljust(kSpan)} | #{f_n}\n"

end

以上でおしまい。最後にスクリプト全部をまとめておく。

#! /usr/bin/ruby

$KCODE = "UTF-8"

require "find"

print "\nWhere is the folder/file?\n"

fileDirectory = gets.strip.gsub(/\\/,"") # 最後の gsub は、なくてもいい。

print "\nSearch word?\n"

inWord = gets.strip

kSpan = 60

a = 4;b = 5;c = 6

eachInstance = Array.new

eachFilen = Hash.new(0)

fileN = 0

keyLength = 0

replaceChar = Regexp.new('(?:\r\n|\r|\f|\n)')

replaceTab = Regexp.new('\t')

contextReg = Regexp.new('\w+')

t1 = Time.now

inWord = inWord.gsub(/ *\/ /,"|")

reg = Regexp.new("(\\b#{inWord.gsub(/ *\/ /,"|").gsub(/\*/,"\\w*").gsub(/\?/,"[\\w]+").gsub(/\!/,"\\w+")}\\b)",Regexp::IGNORECASE)

leftExtract = Regexp.new(".{0,#{kSpan.to_s}}$")

rightExtract = Regexp.new("^.{0,#{kSpan.to_s}}")

Find.find(fileDirectory) do | file |

next if File.extname(file).downcase != ".txt"

fileN += 1

eachFname = File.basename(file)

File.open(file) do | oneFile |

oneFile.read.gsub(/(?:\r\n|\r|\f|\n)+/,"\n").gsub(/\t/, " ").grep(reg) do |text|

text.scan(reg) do

eachFilen[eachFname] += 1

keyW = $&

keyLength = keyW.length if keyLength < keyW.length

leftText = $`

rightText = $'

leftT = leftText.reverse[0..kSpan+10].reverse.match(leftExtract).to_s leftT = leftText.rjust(kSpan) if (leftT = leftText[-kSpan..-1]).nil?

rightT = rightText.match(rightExtract).to_s

leftWords = leftT.downcase.scan(contextReg).reverse[0..2]

rightWords = rightT.downcase.scan(contextReg)[0..2]

eachInstance << [keyW,leftWords[2].to_s,leftWords[1].to_s,leftWords[0].to_s,rightWords[0].to_s,rightWords[1].to_s,rightWords[2].to_s,eachFname,leftT,rightT]

end

end

end

end

print "#{Time.now - t1} seconds\n\n"

print "#{eachInstance.length} instances of '#{inWord}' found in #{eachFilen.length}/#{fileN} files\n\n"

eachInstance.sort_by {| v | [v[a],v[b],v[c]] }.each do | ky, l3, l2, l1, r1, r2, r3, f_n, lt, rt |

print "#{lt.rjust(kSpan.to_i)+ky.ljust(keyLength.to_i)+rt.ljust(kSpan.to_i)} | #{f_n}\n"

end

こちらも、Mac の Yosemite では 2.0.0 になったので、それに合わせて修正してみた。それ以外に上のと違うのは、directory と検索文字列 [WORD TO SEARCH] をスクリプト中で決め打ちしてるところ。ファイルとして保存して実行するには、上のと同じでいいかもしれないけど、検証していないので。あと、string では grep が使えなくなったので、その部分も変更して、直接 scan で読みに行ってます。grep は、今よりもメモリや処理速度が遅かったときに書いた名残で、少しでも早く軽くしようと思って入れました。今ならなくても問題ないかと。

#!/usr/bin/ruby

# -*- mode:ruby; coding:utf-8 -*-

require "find"

fileDirectory = "files"

inWord = "[WORD TO SEARCH]"

kSpan = 60

a = 4;b = 5;c = 6

eachInstance = Array.new

eachFilen = Hash.new(0)

fileN = 0

keyLength = 0

replaceChar = Regexp.new('(?:\r\n|\r|\f|\n)')

replaceTab = Regexp.new('\t')

contextReg = Regexp.new('\p{Letter}+')

t1 = Time.now

inWord = inWord.gsub(/ *\/ /,"|")

reg = Regexp.new("(\\b#{inWord.gsub(/ *\/ /,"|").gsub(/\*/,"\\w*").gsub(/\?/,"[\\w]+").gsub(/\!/,"\\w+")}\\b)",Regexp::IGNORECASE)

leftExtract = Regexp.new(".{0,#{kSpan.to_s}}$")

rightExtract = Regexp.new("^.{0,#{kSpan.to_s}}")

Find.find(fileDirectory) do | file |

next if File.extname(file).downcase != ".txt"

fileN += 1

eachFname = File.basename(file)

File.open(file) do | oneFile |

oneFile.read.gsub(/(?:\r\n|\r|\f|\n)+/,"\n").gsub(/\t/, " ").scan(reg) do

eachFilen[eachFname] += 1

keyW = $&

keyLength = keyW.length if keyLength < keyW.length

leftText = $`

rightText = $'

leftT = leftText.reverse[0..kSpan+10].reverse.match(leftExtract).to_s

leftT = leftText.rjust(kSpan) if (leftT = leftText[-kSpan..-1]).nil?

rightT = rightText.match(rightExtract).to_s

leftWords = leftT.downcase.scan(contextReg).reverse[0..2]

rightWords = rightT.downcase.scan(contextReg)[0..2]

eachInstance << [keyW,leftWords[2].to_s,leftWords[1].to_s,leftWords[0].to_s,rightWords[0].to_s,rightWords[1].to_s,rightWords[2].to_s,eachFname,leftT,rightT]

end

end

end

print "#{Time.now - t1} seconds\n\n"

print "#{eachInstance.length} instances of '#{inWord}' found in #{eachFilen.length}/#{fileN} files\n\n"

eachInstance.sort_by {| v | [v[a],v[b],v[c]] }.each do | ky, l3, l2, l1, r1, r2, r3, f_n, lt, rt |

print "#{lt.rjust(kSpan.to_i)+ky.ljust(keyLength.to_i)+rt.ljust(kSpan.to_i)} | #{f_n}\n"

end