テキスト処理にWekaを使う(その1:文書のトークン化とTFIDF重みづけ)

テキスト分類課題などでは,文書をTF-IDF重み付けしたbag-of-wordsで表現することが多い.これをベースラインにするため,さくっとこの処理をしたい.卒論やM1のときは,この処理をわざわざ手で書いたのだが,バグが出たら大変だし,なにより面倒くさい.


論文では,最近流行りのLuceneを使っている人もいるけれど,WekaのStringToWordVectorもなかなか高性能.TFIDF重みづけまでの処理をやってみる.Luceneの方が汎用性があるから便利そうなんだけれど,とりあえずテキスト分類課題に使いたいので.

前提知識

かなり自分用メモ(+α)なので,説明不足な部分があります.あと,基本的にCUIベースで話を進めます.最後の方に気がついたのですが,GUIとずれがありますね.そこらへんは,まぁ,適当に.


Wekaについては,日本語情報があるにはあるのですが最近更新されていないので,開発者のページが一番役に立ちます.Mailing Listも活発です.

Wekaのインストールとか(適当)

とりあえずWekaのインストールはDLして解凍するだけ.気をつけるのはweka.jarにクラスパスを通しておくと便利だということくらい.


ちゃんとパスが取っているか確認.附属のirisデータを分類してみる.別に分類しなくてもいいけれど,せっかくなので.

% java weka.classifiers.bayes.NaiveBayesMultinomial -t ./weka-3-5-6/data/iris.arff

Wekaのバージョンによってパッケージ構成が変わっていることがあるので注意.以前Weka講習をした際に古いバージョンを使っている後輩がこれではまってた.


早速StringToWordVectorを使ってみる.hオプションで呼び出すとWeka APIに記載されているようなオプション一覧が出てくるので,これを眺める.

java weka.filters.unsupervised.attribute.StringToWordVector -h


動いていることが確認できたらおk.

文書のARFF形式

まず,StringToWordVectorに食わせるARFF形式について説明を行う.実はテキストからARFFを生成するTextDirectoryLoader(旧TextDirectoryToArff)というものもあるらしいが使ったことないので,後日紹介.


ARFF形式についてはここを見れば完璧に理解できるはず.


こんな感じのARFFを用意する.スパムメールと非スパムメールの内容などをbody属性で表現する.以下のような感じ.文はモンティパイソンの例のやつから.店員の発言を"spam",客の発言を"ham"にしてみた.これをspam.arffとする.

% spam.arff
@relation spam

@attribute body string
@attribute class {spam,ham}

@data
"Well, there's egg and bacon; egg sausage and bacon; egg and spam", spam
"egg bacon and spam; egg bacon sausage and spam;", spam
"spam bacon sausage and spam; spam egg spam spam bacon and spam;", spam
"spam sausage spam spam bacon spam tomato and spam;", spam
"Have you got anything without spam?", ham
"Well, there's spam egg sausage and spam, that's not got much spam in it.", spam
"I don't want ANY spam! ", ham
"Why can't she have egg bacon spam and sausage?", ham
"THAT'S got spam in it!", ham

とりあえずStringToWordVectorで変換してみる

spam.arffを単語ベクトルに変換してみる.iオプションで入力ファイルを指定.oオプションで出力ファイルを指定(出力先を指定しないで標準出力で眺めて,リダイレクトで拾ってもいいと思う).

% java weka.filters.unsupervised.attribute.StringToWordVector -i spam.arff -o spam_wv.arff

すると,こんな感じに変換される.長いので途中省略

@relation 'spam-weka.filters.unsupervised.attribute.StringToWordVector-D.,:\\\'\
\\"()?!-R1-W1000-N0-stemmerweka.core.stemmers.NullStemmer-M1'

@attribute class {spam,ham}
@attribute ANY numeric
(省略)
@attribute without numeric
@attribute you numeric

@data

{6 1,8 1,11 1,14 1,21 1,22 1,24 1,28 1}
{8 1,10 1,14 1,22 1,25 1}
{8 1,10 1,14 1,22 1,24 1,25 1}
{8 1,10 1,22 1,24 1,25 1,29 1}
{0 ham,2 1,9 1,15 1,24 1,31 1,32 1}
{6 1,8 1,14 1,15 1,17 1,18 1,19 1,20 1,21 1,22 1,24 1,27 1,28 1}
{0 ham,1 1,3 1,13 1,24 1,26 1,30 1}
{0 ham,7 1,8 1,10 1,12 1,14 1,16 1,22 1,23 1,24 1,26 1}
{0 ham,4 1,5 1,15 1,17 1,18 1,24 1}

こんな感じにトークン化してARFF化してくれる.親切なことに通常のARFFではなく,Sparse形式になっている.これは文書の場合,ほとんどが0のスパースなデータになるから,少しでも容量を少なくするための工夫.Sparse ARFF形式についてもここで解説してある.


Sparse ARFFについては知っているつもりだったけれどnominal属性のひとつ目の値の場合省略可能だということを初めて知った.上記の場合,{0 spam}は省略可能ということ.でも,見づらいから自分で書くときはやめよっと.


TFIDF重みづけで変換する

このままだと単純に出現回数を数えているだけなので,これをTF-IDF重み付けしたい.
StringToWordVectorは様々なオプションを設定することができる.


より高度な使い方をするために,hオプションで見られるオプションについて簡単に解説.自分の復習のために和訳しただけ.使ったことないオプションもあるので,妄想も含みます.誤訳の可能性があるため,原文追記.変なメモが追加されているのは気にしないでくださいまし.

 Valid options are:
 -C
  Output word counts rather than boolean word presence.
  出現したかのtrue/falseではなく,出現回数を出力(NBなら二項モデルを使うか,多項モデルを使うか)

 -R <index1,index2-index4,...>
  Specify list of string attributes to convert to words (as weka Range).
  (default: select all string attributes)
  指定された@attributeを変換するよ,というオプション
  デフォルトではstring型の@attributeは全部変換しちゃいます.
  e.g., -R 1(1つ目のattributeを変換)
  複数@attribute stringを用意するケースがあまり思いつかないけれど,
  複雑なことをする場合にお使いください.

 -V
  Invert matching sense of column indexes.
  意味がわかりませんでした.

 -P <attribute name prefix>
  Specify a prefix for the created attribute names.
  (default: "")
  作成される属性名に接頭語を付与したければどうぞ
  ファイルサイズが大きくなるくらいしか効果が期待できない.

 -W <number of words to keep>
  Specify approximate number of word fields to create.
  Surplus words will be discarded..
  (default: 1000)
  単語の出現回数の上限を設定.設定した回数以上出現した単語は除去される.

 -T
  Transform the word frequencies into log(1+fij)
  where fij is the frequency of word i in jth document(instance).
  TFの計算.今流行りのlog(1+fij)で計算する.

 -I
  Transform each word frequency into:
  fij*log(num of Documents/num of documents containing word i)
  where fij if frequency of word i in jth document(instance)
  IDFの計算.log(N/Di)という普通のIDF

 -N
  Whether to 0=not normalize/1=normalize all data/2=normalize test data only
  to average length of training documents (default 0=don't normalize).
  文書の長さで正規化をするかしないか.分類器によりますが,文書長の偏りで精度が
  落ちるので,やっておきましょう.(Complement NBなどは正規してくれるけれど)
  2の意味がいまいちよくわからん

 -L
  Convert all tokens to lowercase before adding to the dictionary.
  処理をする前に全ての単語を小文字にします.しといたほうがいい.

 -S
  Ignore words that are in the stoplist.
  不要語の除去.必須でしょう

 -stemmer <spec>
  The stemmering algorihtm (classname plus parameters) to use.
  ステミング(接辞処理).アルゴリズムも指定できるなんてすばらしい!
  Weka GUIで設定されるSnowball stemmersは実装されていないので注意!
  e.g., -stemmer weka.core.stemmers.LovinsStemmer
  Lovinsアルゴリズムは動くっぽい.IteratedLovinsStemmerってナンダ??
  <http://weka.sourceforge.net/wekadoc/index.php/en:Stemmers_(3.5.2)>を参考に

 -M <int>
  The minimum term frequency (default = 1).
  単語の出現回数の下限を設定.-Wオプションと組み合わせて除去を行いましょう.
  TFIDFを計算する場合でも,頻度でカットされてから計算されるみたい.

 -O
  If this is set, the maximum number of words and the 
  minimum term frequency is not enforced on a per-class 
  basis but based on the documents in all the classes 
  (even if a class attribute is set).
  これを設定しないと上述の-W, -Mによる上限,下限の設定は
  「クラスごと」の頻度になってしまいます.
  課題によっては異なるけれど,基本的には-Oオプションを使いましょう.

 -stopwords <file>
  A file containing stopwords to override the default ones.
  Using this option automatically sets the flag ('-S') to use the
  stoplist if the file exists.
  Format: one stopword per line, lines starting with '#'
  are interpreted as comments and ignored.
  ストップワードリストを自分で用意した場合はどうぞ.なんて便利!
  ファイルは一行いちstop word.#で開始される行は無視されます.

 -tokenizer <spec>
  The tokenizing algorihtm (classname plus parameters) to use.
  (default: weka.core.tokenizers.WordTokenizer)
  トークン化アルゴリズムを選択することができます.
  英語の場合は気にする必要ないでしょう.
  日本語のことは考えていないので今回はdefaultでいきます.


というわけで,TF-IDF重みづけをするにはこんな感じでいい.以下の処理をするコマンドを具体例として示す.

  • 単語の頻度をカウント(-C)
  • 全ての単語を小文字に変換(-L)
  • TF-IDF重みづけ(-T -I)
  • 文書の長さで正規化(-N 1)
  • 全ての文書において(-O)最低頻度が1(-M 1), 最高頻度が5(-W 5)
  • 不要語の除去(-S)
  • ステミング(-stemmer weka.core.stemmers.LovinsStemmer)
  • 入力ファイル=spam.arff
  • 出力ファイル=spam-tfidf.arff
% java weka.filters.unsupervised.attribute.StringToWordVector -C -L -T -I -N 1 -O -M 1 -W 5 -S -stemmer weka.core.stemmers.LovinsStemmer -i spam.arff -o spam-tfidf.arff

えいやっ.ちゃんと動いたっぽい.最終的な出力結果

% spam-tfidf.arff
@relation 'spam-weka.filters.unsupervised.attribute.StringToWordVector-D.,:\\\'\\\"()?!-R1-W5-C-T-I-N1-L-S-stemmerweka.core.stemmers.LovinsStemmer-M1-O
'

@attribute class {spam,ham}
@attribute bacon numeric
@attribute bacon; numeric
@attribute hav numeric
@attribute saus numeric
@attribute spam numeric
@attribute spam; numeric
@attribute ther numeric
@attribute wel numeric

@data

{2 1.023558,4 0.119172,5 0.034618,7 0.442068,8 0.442068}
{1 0.703842,4 0.222037,6 0.953534}
{1 0.698475,4 0.220344,5 0.148621,6 0.946263}
{1 0.67415,4 0.337075,5 0.227355,6 0.913309}
{0 ham,3 1.202106,5 0.094136}
{4 0.224457,5 0.130405,7 0.832627,8 0.832627}
{0 ham,5 1.205786}
{0 ham,1 0.555525,3 1.030364,4 0.277763,5 0.080687}
{0 ham,5 1.205786}

形式を整える

「分類マダー?」とチンチン器を叩く音が聞こえてきそうだが,まだあわてる時間じゃない.変換したものを見ると,class attributeが最初になってしまっている.分類器はデフォルトでは最後のattributeをclass attributeとして読み込むので,このままでは具合が悪い(オプションで指定すれば平気だけれど,面倒でしょ?).


ARFFファイルをいじくり回すフィルタはいくらでも用意されているので,こんな感じで変換をする.

weka.filters.unsupervised.attribute.Reorder -R 2-last,first

これで最初の属性を一番最後に,それ以外を繰り上げますよ.という変換ができる.もちろんこれくらいなら自作スクリプトで書けそうだけれど,既にあるのでこちらを使いましょう.以下,具体例

% java weka.filters.unsupervised.attribute.Reorder -R 2-last,first -i spam-tfidf.arff -o spam-tfidf2.arff

こうやっていくつかの種類のフィルタを使っていると,オプション名とその意味が統一されていることに気がつく.だいぶ慣れてきた.

分類してみる

これで分類できる状態(のはず)なので,おそるおそる分類してみる.10個もインスタンスがないので,2-fold cross-validationのオプション設定.

% java weka.classifiers.bayes.NaiveBayesMultinomial -t spam-tfidf2.arff -x 2 -o

できた!

=== Stratified cross-validation ===

Correctly Classified Instances           9              100      %
Incorrectly Classified Instances         0                0      %
Kappa statistic                          1
Mean absolute error                      0.102
Root mean squared error                  0.1812
Relative absolute error                 20.4084 %
Root relative squared error             36.0762 %
Total Number of Instances                9


=== Confusion Matrix ===

 a b   <-- classified as
 5 0 | a = spam
 0 4 | b = ham

ちなみに上の結果はtest errorです.完璧な汎化性能ですな.さすがフルチューニング.

お疲れ様でした.これで文書データを分類評価可能な状態に変換することができた.終わってみれば一瞬のことで,説明されれば30分もかからずにできるようになるでしょう.

変換プログラムとかを自分の手で書くのもためになるさ.きっと.


結論

結局日曜日を半日費やし,確認をしながらこれを書いていた.終わってみれば,日本語資料の少ないWekaの良い資料になったのではないかと思っている.思わずには費やした時間が報われない.

やる気の神様が降りてきたらきちんとまとめてPDFとかで公開しよう.来世紀までにはやる予定.

過去に自分がつくろうとしていた資料や知りたかった情報がWekaWikiやらMLやらで既出なのを知ると,英語話者が本当にうらやましくなる.英語が基本ですね.

今後の課題

spam-tfidf.arffを眺めればわかると思うが,単語のトークン化で失敗している部分がある.

Weka GUIにはtokenizerという項目があるけれど,ヘルプには出てこないし(寝ぼけてました)CUIからオプション指定するとエラーが出た.面倒くさくなってきたので調査不足.Tokenizerもかなり重要だと思うのでspamspam;が存在している.;でsplitしていないっぽい.
もう今日はこれ以上調べたくないので宿題.


また,これだとWeka内部でしか使えないやん!と思うかもしれないけれど,通常のARFFだとデータ部分がコンマ区切りになっているので,SparseToNonSparseフィルタを使って通常のARFFに変換すれば,他のプログラムからも使いやすくなるはず.それでも一段階の変換プログラムは書く必要があるけれど.


生テキストからARFF生成できたらもっと嬉しい.Wekaには既に用意されているみたいなので,次回までには勉強しておきます.