単語頻度を数える

ここでは、テキストファイルに含まれる単語数を数えるスクリプトを作ってみる。

ただ、半角のスペースで split を使って分けて数えるだけなら、たのしい Ruby とかにもあるように、とても簡単なスクリプトになる。でも、それだと、記号と単語の区別はなく言語分析には全く向かないので、そういうことを考慮したものにしたい。

あと、ここにあるスクリプトは 1.8.7 で書いたので、1.9.x を使っている方は文字コードあたりの変更が必要かもしれない。ここでは、文字コードを UTF-8 に限定して扱うので、$KCODE = "UTF-8" をはじめに指定する。ほかの文字コードを扱いたい場合は変更する。Yosemite からは 2.0.0 なので、文字コードの指定の方法が別になるほか、ちょっと変更を加えたスクリプトも最後に追加しました。

ファイルの読み込み

まずは、単語を数えるファイルを選ぶ。細かい説明はFile の読み書きファイル・ディレクトリにあるので、簡単に進める。

複数のファイルに対応できるように、Find.find(dir) を使うので、find モジュールを読み込む。

require "find"

これで、Find.find(dir) が使えるが、この dir には directory のパスを指定する。まあ、簡単なスクリプトなので、

directory = "...."

で、... に最初からパスを入れる(相対もしくは絶対)なんて方法でもいいけど、

print "enter the path: "

directory = gets.chomp

なんてして、入力させてもいい。

ここでは、説明が楽、ということで、最初から指定する方法でいく。

directory = "files"

で、スクリプトが保存されているフォルダに、テキストファイルが入っている files というフォルダがあるという形にする。扱うファイルが一つの場合は、そのファイル名を拡張子を含めていれればいい。たとえば、sample.txt なら、

directory = "sample.txt"

次に、指定したフォルダにあるすべてのディレクトリから、txt の拡張子を持ったもの(プレインテキスト)だけを開いて、中のテキストを処理するために、 Find.find(dir) をブロックで使う。細かいことはファイル・ディレクトリを参照。

簡単に説明すると、ブロックで file に files に含まれるディレクトリ(files フォルダ自身を含む)が入って一つずつ処理されるので、それから、Fine.extname(dir) で拡張子を抜き出して(ピリオドも含まれる)、それが .txt にマッチしたばあいに処理を行う。正規表現のところの i は IGNORECASE で大文字小文字の区別をしないようにしている。

Find.find(directory) do | path |

if /\.txt/i =~ File.extname(path)

ここに単語を数える処理を入れる end end

ただ、この場合みたいにファイルの拡張子が一つの場合は、正規表現でなく単に if File.extname(path).downcase == ".txt" なんてしてもいい。

Find.find(directory) do | path |

if File.extname(path).downcase == ".txt"

ここに単語を数える処理を入れる

end

end

File.extname(path) で拡張子が得られるので、それを .downcase で小文字にして、それが .txt であれば追加、ということにする。ただし、正規表現にしておくと、後で他のファイルタイプも扱いたいときに融通が利く。

たとえば、.txt と .csv に対応させたいときは、

if /\.(?:txt|csv)/i =~ File.extname(path)

なんてすればいい。

で、戻って、試しに処理のところに p path といれると、.txt のつくファイル名が表示されるはず(require "find" を忘れないように)。

ここでは、ファイルを開いてテキストを抜き出したいので、file.open(dir) でファイルを開く。まあ、オプションをつけるなら file.open(dir,"r") とする。開くときは、File の読み書きにあるようにブロックにする。ここで、read を使って、ファイルの中身を読み込む。content = file.read とすると、content にファイルの中身が入る。

File.open(path,"r") do |file|

content = file.read

end

ここまでをまとめると、次のようになる。

Find.find(directory) do | path |

if /\.txt/i =~ File.extname(path)

File.open(path,"r") do |file|

content = file.read

end

end

end

ここでは content にテキストを読み込んでいるが、実際には String オブジェクトにしないで、ブロックにして単語数を数える処理をする。

単語数を数える

ファイルの中身のテキストを読み込んだら、次は単語数を数える。これにはハッシュを使う。

まず、ハッシュを宣言する(?)。word という名前にしておく。これは、後で表示の処理をするために、Find.find(dir) のブロックの外(スクリプトの最初)でする。

words = Hash.new(0)

最後の括弧の中に 0 があるのは、キーがない場合に値が 0 であるとしている。これは、ハッシュで数え上げていくときに、最初の値が 0 でないと都合が悪いため(1 ずつ加えていくので最初が 0 じゃないとエラーになる。何も指定しないと初期値が nil になっているはず)。

次に、ファイルを読み込んだときの file.read で、テキストが読み込まれているので、これに対して処理をする。単語ごとに区切って配列にして、それをブロック処理する方法をとる。

ここで、単語をどう扱うかを決める。

split(/\W+/) : Ruby が文字として扱わない(スペース、改行、記号など)もので分割してそれ単語としてを一つずつ処理する。

scan(/\w+/) : Ruby が文字として扱うもの(数字、アルファベット、_ など)を単語としてマッチさせ、それを一つずつ処理する。

処理をさせる場合は、split を使った方が速い。ただ、それだと融通(応用)が利かない。scan を使った場合、正規表現を工夫することで、記号を単語に含めたりもできる。

例えば、student's を1つの単語として扱いたい場合、scan(/\w+(?:\'\w+)*/) などとすることで、student と student's を区別できたりする。まあ、このあたりは厳密にやると大変なことになるので、それなりに適当に。どちらを使うにせよ、ブロックにして処理する。

その処理は、ハッシュの words に単語をキーにして 1 ずつ足していく(ここで八種の初期値に 0 を指定しておかないとエラーが出る)

file.read.downcase.split(/\W+/).each do |word|

words[word] += 1

end

もしくは

file.read.downcase.scan(/\w+/) do |word|

words[word] += 1

end

split の方は each で明示しないとブロックにならないようだが、scan はなくても大丈夫。downcase としてあるのは全部小文字にして、大文字小文字の区別をなくすため。区別するには削除する。

結果の処理

さて、ここまでのところで、words というハッシュに単語をキーとして頻度が値になって情報が入っている。これをそのまま表示させても意味がないので、頻度の多い順に並べ替えることにする。Ruby のハッシュには並び順という概念がないようなので、sort_by という配列のメソッドを使って、並べ替えて配列にする。

sort_by は単語と頻度を取り出してブロックのように処理する訳だが、頻度順にするには頻度を負の値にして順にすればいい。ただ、それだけだと、同じ頻度の単語の並びがバラバラになるので、それはアルファベット順にしたい。どうするかというと、並べ替えの指定を配列にして、一つ目が頻度の負の値、二つめが単語のアルファベット順になるようにする。

words.sort_by{|word,count| [-count,word])

ここでは、word がキーで count が値。この sort_by を使うと、結果は配列(Array オブジェクト)になって、頻度の多い順で同じ頻度ではアルファベット順に並んでいるはず。

次に、これを表示させる訳だが、このままブロック処理にする。

words.sort_by{|word,count| [-count,word]).each do |word,count|

ここに表示処理を入れる

end

表示処理は、単語と頻度の間にタブ記号を入れる、単語の部分を指定した文字数で表示する、などいろいろあるが、ここではこの2つをやってみる。

間にタブ記号を入れる場合は、単に \t を入れればいい。あと、print で表示する際にダブルクウォーテーションに囲まれた中で引数をつかうには #{...} を使う。改行記号は、Unix 標準というか、OSX の標準の \n にしておく。

print "#{word}\t#{count}\n"

単語の部分の文字列を指定する場合は、ljust(n) で n に表示する文字数を入れる(最初の一文字はアルファベットの小文字のエル)。これは、指定した文字数で与えられた文字列を左寄せで返す。右寄せのときは rjust(n) を使う。ただし、表示には等幅フォントを使わないとちゃんとそろわない。

print "#{word.ljust(20)}#{count}\n"

このあたりは、結果をどう使うかによって変える。見た目がいいのは文字数を指定する方法だけど、タブ区切りだと後々使いやすかったりする。

ついでに、一番始めに列のタイトルを表示してみる。

print "WORD\tFREQUENCY\n"

これをまとめると(タブの場合)

print "WORD\tFREQUENCY\n"

words.sort_by{|word,count| [-count,word]}.each do |word,count|

print "#{word}\t#{count}\n"

end

さて、これだと、ターミナルから実行したらターミナルに、TextMate などのプログラムから実行するとその表示ウィンドウに結果が表示される。それはそれでいいけど、結果を保存したい場合はどうするか。File.open を使ってファイルに書き込む。細かいことは File の読み書きに少しメモしてある。

書き込みは w を指定してブロックにする。

File.open("output.txt","w") do |output|

処理 end

この処理に、output に対して print で表示させるときと同じように処理する。

output.print "WORD\tFREQUENCY\n"

words.sort_by{|word,count| [-count,word]}.each do |word,count|

output.print "#{word}\t#{count}\n"

end

ただ、保存されるデータが ASCII で対応できる文字だけならいいが、それ以外のものが含まれている場合は、スクリプトでの文字コードが UTF-8 なためか、テキストエディットが文字コードを判別できずファイルを開けなかった。まあ、OS X の自動文字列判別が使えないと言ってしまえばそれまでだけど。ということで、確実に UTF-8 だとわからせるために、不本意ながら BOM をつけることにする。いらないと思ったら飛ばしてください。

output.print "\357\273\277"

まとめると、

File.open("output.txt","w") do |output|

output.print "\357\273\277"

output.print "WORD\tFREQUENCY\n"

words.sort_by{|word,count| [-count,word]}.each do |word,count|

output.print "#{word}\t#{count}\n"

end

end

スクリプト

最後に、スクリプトを全部まとめておく。ここでは、split の方にして、結果を表示させるようにしてある。ファイルに保存したい場合は、最後のところを(print から最後まで)上のスクリプトで置き換えてください。

#!/usr/bin/ruby

$KCODE = "UTF-8"

require 'find'

directory = "files"

words = Hash.new(0)

Find.find(directory) do |path|

if /\.txt/i =~ File.extname(path)

File.open(path,"r") do |file|

file.read.downcase.split(/\W+/).each do |word|

words[word] += 1

end

end

end

end

print "WORD\tFREQUENCY\n"

words.sort_by{|word,count| [-count,word]}.each do |word,count|

print "#{word}\t#{count}\n"

end

Yosemite での標準の 2.0.0 だと、$KCODE は使えないので、別の方法で指定する。あと、Mac だと、1.8.7 では \w で日本語などの文字も引っかかってたけど、2.0.0 だと UTF-8 のテキストの場合、\w はちゃんとアルファベットしか引っかからないので、\p{Letter} で、文字をつかむようにしてある。ということで、Mac の Yosemite でも動くスクリプトはこちら(多分)。

#!/usr/bin/ruby # -*- mode:ruby; coding:utf-8 -*-

require 'find'

directory = "files"

words = Hash.new(0)

Find.find(directory) do |path|

if /\.txt/i =~ File.extname(path)

File.open(path,"r") do |file|

file.read.downcase.scan(/\p{Letter}+/) do |word| words[word] += 1

end end end end

print "WORD\tFREQUENCY\n"

words.sort_by{|word,count| [-count,word]}.each do |word,count|

print "#{word}\t#{count}\n"

end