のんびり読書日記

日々の記録をつらつらと

TFIDFを使ってwikipediaの各キーワードの特徴量を抽出

以前にk-means++をPerlで書いたのですが、実際に試すデータがなかったのでそのまま放置してました。せっかくなので大きなデータで試してみたいので、今回は下準備としてwikipediaの各キーワードに対し、その特徴を表すデータを抽出したいと思います。そして今回作ったデータを使って、k-meansや階層的クラスタリングなど他の手法をいずれ試してみる予定です。

今回は特徴量としてベタにTFIDFを使うこととします。TFIDFについては、下記のページが詳しいためそちらをご参照ください。

まずWikipediaのデータをダウンロードしてきます。以下のページから、「jawiki-latest-pages-articles.xml.bz2」をダウンロードしてください。

次にダウンロードしたXMLからテキストを抽出します。自前で解析してもいいのですが、ちょっと面倒なので既存の公開されているツールを使用します。ここではWP2TXTを使用します。また今回は必要ありませんが、テキスト以外にカテゴリの情報ですとか、リンク関係などの情報を使う場合はMediaWikiのソースに含まれるツール(maintenance/importDumper.php)などを使うとよいようです。

WP2TXTをダウンロードしたら、以下のようにしてWikipediaXMLからテキストを抽出します。

% tar xvzf wp2txt-0.1.0.tar.gz
% mkdir -p ~/data/wikipedia/txt   (テキストデータ保存用)
% wp2txt-0.1.0/bin/wp2txt ~/data/wikipedia/jawiki-latest-pages-articles.xml.bz2 -d -e utf8 -o ~/wikipedia/data/txt
%  ls ~/data/wikipedia/txt/
jawiki-latest-pages-articles.xml-001.txt
jawiki-latest-pages-articles.xml-002.txt
jawiki-latest-pages-articles.xml-003.txt
jawiki-latest-pages-articles.xml-004.txt
...

よーし、これで各キーワードに対してその説明の文章を抽出できたので、この説明文の中から特に特徴的な語をTFIDFで抽出したいと思います。まずはTF(Term Frequency, 文章中の単勤の頻度)と、DF(Document Frequency, 各語がいくつの文書に出現したか)を数えます。

作成したコードは以下の通りです。先ほど作成したテキストファイルの各文をTet::MeCabを使って形態素解析して、TFとDFを数え、Tokyo CabinetのHashDBに保存します。以下のところカウントしているのは名詞だけですが、他の内容語(動詞、形容詞、形容動詞)を加えたかったり、名詞の中でもさらに品詞を絞りたい場合はちょろっと修正すればすぐにできます。

#!/usr/bin/perl
#
# count term frequency, document frequency
#
# Usage:
#  count_words.pl -d txtdir -o outdir
#

use strict;
use warnings;
use Getopt::Long;
use Text::MeCab;
use TokyoCabinet;

use constant {
    DBM_TF => 'tf.hdb',
    DBM_DF => 'df.hdb',
};

sub usage_exit {
    print <<'USAGE';
Usage:
 count_words.pl -t txtdir -o outdir
   -t, --textdir dir     directory including wikipedia texts
   -o, --output outdir   output directory
USAGE
    exit 1;
}

sub get_words {
    my ($mecab, $words, $text) = @_;
    for (my $node = $mecab->parse($text); $node; $node = $node->next) {
        my @features = split /,/, $node->feature;
        #if ($features[0] =~ /^(名詞|動詞|形容詞|形容動詞)$/) {
        if ($features[0] eq '名詞') {
            $words->{$node->surface}++;
        }
    }
}

sub add_df {
    my ($dbm, $words) = @_;
    return if !$dbm || !$words;

    foreach my $word (keys %{ $words }) {
        $dbm->addint($word, 1);
    }
}

sub add_tf {
    my ($dbm, $title, $words) = @_;
    return if !$dbm || !$title || !$words;

    my @keys = sort { $words->{$b} <=> $words->{$a} } keys %{ $words };
    my $val = join "\t", map { $_.":".$words->{$_} } @keys;
    $dbm->putasync($title, $val);  
}

sub main {
    my ($opt_txt, $opt_out);
    GetOptions(
        'textdir=s' => \$opt_txt,
        'output=s' => \$opt_out,
    );
    if (!$opt_txt || !$opt_out) {
        usage_exit();
    }

    my $tfdb = TokyoCabinet::HDB->new();
    my $dbpath = $opt_out.'/'.DBM_TF;
    if (!$tfdb->open($dbpath,
        $tfdb->OWRITER | $tfdb->OCREAT | $tfdb->OTRUNC)) {
        die 'cannot open TF dbm';
    }
    my $dfdb = TokyoCabinet::HDB->new();
    $dbpath = $opt_out.'/'.DBM_DF;
    if (!$dfdb->open($dbpath,
        $dfdb->OWRITER | $dfdb->OCREAT | $dfdb->OTRUNC)) {
        die 'cannot open DF dbm';
    }

    my $mecab = Text::MeCab->new() or die 'mecab error';

    opendir my $dh, $opt_txt or die "cannot open $opt_txt";
    my @files = grep /^jawiki-latest-pages-articles\.xml-\d+\.txt$/, readdir($dh);
    closedir $dh;
    my $cnt = 1;
    foreach my $file (@files) {
        my $path = "$opt_txt/$file";
        printf "(%d/%d) %s\n", $cnt++, scalar(@files), $path;
        open my $fh, $path or warn "cannot open $path";
        
        my ($title, $body, %words);
        while (my $line = <$fh>) {
            chomp $line;
            if ($line =~ /^\[\[(.+)\]\]$/) {
                my $newtitle = $1;
                if ($title && $body) {
                    my @sentences = split //, $body;
                    foreach my $sentence (@sentences) {
                        get_words($mecab, \%words, $sentence);
                    }
                    add_tf($tfdb, $title, \%words);
                    add_df($dfdb, \%words);
                }
                $title = $newtitle;
                $body = '';
                %words = ();
            }
            else {
                $body .= $line;
            }
        }
    }
}

main();

__END__

実際に実行してみます。TFは下のようなテキスト形式で保存されています。DFはバイナリ形式の整数です。

% mkdir -p ~/data/wikipedia/tfidf
% ./count_words.pl -t ~/data/wikipedia/txt -o ~/data/wikipedia/tfidf
(1/153) /home/mizuki/data/wikipedia/txt/jawiki-latest-pages-articles.xml-141.txt
(2/153) /home/mizuki/data/wikipedia/txt/jawiki-latest-pages-articles.xml-061.txt
(3/153) /home/mizuki/data/wikipedia/txt/jawiki-latest-pages-articles.xml-041.txt
(4/153) /home/mizuki/data/wikipedia/txt/jawiki-latest-pages-articles.xml-134.txt
...
% ls ~/data/wikipedia/tfidf
df.hdb      tf.hdb
% tchmgr list tf.hdb | head
宝井誠明    年:3    シコ:2  型:2    映画:2  -:2 1992:2  月:2    春雄:1  フロム・ファーストプロダクション:1  2:1 1:1 東京:1  >出演:1 役:1    所属:1  明:1    日:1    出身:1  山本:1  11:1    1975:1  俳優:1  デビュー:1  宝井:1  特技:1  *:1 誠:1    都:1    テニス:1    野球:1  まさ:1  O:1 ドラマ:1    CM:1    サッカー:1  数々:1  血液:1
上原カエラ  cm:4    AV:2    年:2    月:2    女優:1  ダンス:1    90:1    165:1   ら:1    日本:1  2:1 サイズ:1    W:1 57:1    2008:1  B:1 14:1    H:1 日:1    ホット:1    デビュー:1  スリー:1    特技:1  -:1 87:1    カエラ:1    身長:1  趣味:1  ヨガ:1  上
原:1  1988:1  8:1 37:1    Impression:1    First:1
リチャード・シモンズのスリムな料理SHOW  料理:5  番組:3  リチャード:3    ダイエット:2    放送:2  ジョーク:1  貴史:1  計算:1  >ヘルシー:1 主:1    SHOW:1  タレント:1  有名:1  ・・・:1   ギャグ:1    スリム:1    約束:1  ジョージ:1  オカマ:1    吉田:1  時:1    シモンズ:1  系:1    アメリカ:1  松尾:1  口調:1  エキスパート:1  進行:1  リチャード・シモンズ:1  中心:1  カロリー:1  他:1    CS:1    教祖:1  FOOD:1  チャンネル:1
...

例えば、キーワード「宝井誠明」の説明文中では「年」「シコ」「型」などが多く現れています。ちょっとゴミが多いですかねー。まあその辺はIDFを使ったときに消えると信じて続けます。本当はこの段階で数字とか「ため」みたいなあまり意味のない語はフィルタリングした方がよさそうですが。

次に上で数え上げたTFとDFを使って、各キーワードの説明文中の語のTFIDFを計算します。そしてTFIDF値の高い上位50語を保存します。作成したコードは以下の通りです。

#!/usr/bin/perl
#
# TFIDF
#
# Usage:
#  tfidf.pl -t tfdb -d dfdb -o tfidfdb
#

use strict;
use warnings;
use Encode qw(encode decode);
use Getopt::Long;
use Text::MeCab;
use TokyoCabinet;

use constant {
    DBM_TFIDF => 'tfidf.hdb',
    MAX_WORD  => 50,
};

sub usage_exit {
    print <<'USAGE';
Usage:
 tfidf.pl -t tfdb -d dfdb -o tfidfdb
   -t, --tfdb dbm      Term frequency(TF) dbm
   -d, --dfdb dbm      Document frequency(DF) dbm
   -o, --output dbm    TFIDF dbm     
USAGE
    exit 1;
}

sub calc_tfidf {
    my ($tf, $df, $total) = @_;
    my $idf = log($total / ($df + 1));
    return $tf * $idf;
}

sub main {
    my ($opt_tf, $opt_df, $opt_tfidf);
    GetOptions(
        'tfdb=s'   => \$opt_tf,
        'dfdb=s'   => \$opt_df,
        'output=s' => \$opt_tfidf,
    );
    if (!$opt_tf || !$opt_df || !$opt_tfidf) {
        usage_exit();
    }

    my $tfdb = TokyoCabinet::HDB->new();
    if (!$tfdb->open($opt_tf, $tfdb->OREADER)) {
        die 'cannot open TF dbm';
    }
    my $dfdb = TokyoCabinet::HDB->new();
    if (!$dfdb->open($opt_df, $dfdb->OREADER)) {
        die 'cannot open DF dbm';
    }
    my $tfidfdb = TokyoCabinet::HDB->new();
    if (!$tfidfdb->open($opt_tfidf,
        $tfidfdb->OWRITER | $tfidfdb->OCREAT | $tfidfdb->OTRUNC)) {
        die 'cannot open output dbm';
    }

    my $total = $tfdb->rnum();
    $tfdb->iterinit();
    while (my $title = $tfdb->iternext()) {
        my $tfval = $tfdb->get($title);
        next if !$tfval;
        my %tfidf;
        foreach my $item (split /\t/, $tfval) {
            my $itemdec = decode 'utf8', $item;
            my ($word, $tf) = split /:/, $itemdec;
            $word = encode 'utf8', $word;
            my $dfval = $dfdb->get($word);
            my $df = $dfval ? unpack 'n', $dfval : 0;
            $tfidf{$word} = calc_tfidf($tf, $df, $total);
        }
        my @words = sort { $tfidf{$b} <=> $tfidf{$a} } keys %tfidf;
        @words = @words[0 .. MAX_WORD-1] if scalar(@words) > MAX_WORD;
        next if !@words;
        my $tfidfval = join "\t", map { $_.":".int($tfidf{$_} * 100) } @words;
        #print "$title\t$tfidfval\n";
        $tfidfdb->putasync($title, $tfidfval);
    }
}

main();

__END__

それでは実行して、各キーワードのTFIDFを求めてみます。

% ./tfidf.pl -t ~/data/wikipedia/tf.hdb -d ~/data/wikipedia/df.hdb -o ~/data/wikipedia/tfidf.hdb
% tchmgr get ~/data/wikipedia/tfidf.hdb SMAP
アーロン・ロジャース    指名:9952       QB:4464 パス:3605       ファーヴ:3340   ロジャース:3178 年:2976 グリーンベイ・パッカ ーズ:2665       こと:2581       パッカーズ:2367 全体:2216       カリフォルニア:1976     彼:1873 1:1683  先発:1531       シー ズン:1521       .:1474  %、:1412        2:1353  アレックス:1335 オークランド・レイダーズ:1332   ニューイングランド・ペイトリ オッツ:1332     アメリカンフットボール:1332     8:1199  ドラフト:1182   権:1156 位:1138 大学:1082       中:1080 インターセプ ト:1047 ヤード:1034     引退:1010       タッチダウン:967        NFL:954 時:950  トレード:929    成功:903        出身:901     戦:901  63:844  28:838  負傷:836        入団:817        会場:817        ヤーテージ:777  ミネソタ・バイキングス:777      がち:769     プレー:743      スミス:734      回:721  ワーナー:708
藤原雅長        年:5208 雅:1285 藤原:1271       近臣:1263       位:1138 従:1118 任:1110 長:957  元年:903        3:861   7:813        院:798  近衛:782        五位上民部権大輔:777    嘉:770  視:764  4:760   下:747  日:747  左:713  隆信:708        月:689       わら:638        叙:629  保:600  8:599   能:595  参議:572        守:556  蔵人:521        久安:515        五:507  少将:506     26:503  顕能:500        在任:460        2:451   ふじ:448        中将:448        19:445  処分:431        久:425  死去:424     任命:422        兼:416  教:406  後期:403        長男:401        この間:377      辞任:377
カイロの紫のバラ        映画:4659       セシリア:2333   トム:1738       カイロ:1250     ニュージャージー:1110   撮影:1069    ギル:1057       賞:1041 の:1026 紫:1021 バラ:971        生活:901        ジェフ・ダニエルズ:777  ラリタン・ディナー:777  バー トランドアイランド・アミューズメントパーク:777  サウス・アムボイ:777    館:690  ため:663        役:655  夢中:627        製作:593     モンク:585      アイエロ:583    ミア・ファロー:478      普通:467        監督:462        ハット:460      恋人:455     フレッド・アステア:434  of:433  彼女:430        films:428       Cairo:428       失業:422        作:412  TIME:409        英国:401     ALL:397 年代:395        夫:382  Rose:377        中:360  タイム:358      ロジャース:353  The:351 悲惨:350        ウェ イトレス:350    州:346  席:344  上映:334
...

ゴミっぽい語がまだいくつか入っていますが、比較的特徴的な単語が上位にくるようになりました。

さて、これで各キーワードに対して、その特徴を表すベクトルが抽出できたことになりますので、次はこの特徴ベクトルを使ってまた遊びたいと思います。とりあえずはこの前のk-meansに入力を今回のDBMを使うようにして、なおかつC++で書き直して試してみますかねー。

そうそう、遊ぶにしてもできれば真面目にやりたいので、そろそろIIRをちゃんと読もうかな…。