何番煎じか分からないけど集合知プログラミングをPHPでやってみた その5「グループを見つけ出す…の下準備…の下準備」

今回から、元書の第3章に入っていきます。第3章は"グループを見つけ出す"というタイトルになっていて、アイテムを複数のグループに分類するアルゴリズムがいくつか紹介されています。今回は、実際にグルーピングをする前段階として、フィード中の単語を数える部分を作ってみます。

…の前にphp_hadoop_streaming_frontendの修正について

以前の記事の中で、php_hadoop_streaming_frontendというのを作成して、ちょうどサンプルで単語を数えるというのをやった訳ですが、そのまま応用できそうなので、今回はそれを流用します。が、そのまま使おうとするとちょっと問題があって、少しばかり手を加えました。

Mapperの中で定義するmap関数の引数が($key,$value)となっているんですが、実は若干手を抜いていて、$valueにSTDINから取得された1行分のデータが入って、$keyには必ずnullが入るという実装にしていました。

と言うのも、Mapperに入ってくる情報は飽くまで1行ずつのデータな行指向の処理になるので、元々、$keyと$valueのセットとして値が入ってくる訳じゃないんですよね。例えば、ログファイルなんかを処理する時には、行の羅列に対して1行ずつ処理していけばいいだけなので、Mapperに渡すデータは特にkeyとvalueの組み合わせというのは必要ありません。

ただ今回は、最終的に"どの単語がどのエントリの中に含まれている"という情報が欲しいので、"key=>エントリID,value=>エントリ内容"というデータが必要になります。そうするとmapメソッドの引数の$keyが常にnullでは困るので、Mapperに渡すデータを、keyとvalueのタブ区切りというフォーマットに対応できるようにしました。

最終的にReducerが吐き出すデータもタブ区切りになっているので、Mapperがタブ区切りに対応していると、多段階のMapReduceをやりたい場合にも都合が良さそうですね。( ̄ー ̄)

ちなみに、GitHubに上げたソースコードは修正済みです。→http://github.com/stellaqua/php-hadoop-streaming-frontend/tree

元データを準備する

という事でやっと本題です。今回は最初から元書をガン無視して、はてブのHotEntryを元データのターゲットにしたいと思います。*1

まずは、はてブのHotEntryのフィードから、エントリのタイトルと概要を取得してきてデータを作成します。

<?php
require_once('XML/RSS.php');
$rss = new XML_RSS('http://b.hatena.ne.jp/hotentry.rss');
$rss->parse();
$items = $rss->getItems();
$titles = '';
$descriptions = '';
for ( $id=0; $id<count($items); $id++ ) {
    $title = preg_replace('/[\t\r\n]/', '', $items[$id]['title']);
    $description = preg_replace('/[\t\r\n]/', '', $items[$id]['description']);
    $titles .= $id."\t".$title."\n";
    $descriptions .= $id."\t".$description."\n";
}
file_put_contents(dirname(__FILE__).'/hotentry_titles.txt', $titles);
file_put_contents(dirname(__FILE__).'/hotentry_descriptions.txt', $descriptions);
?>

とりあえずデータが作れればいいので、こんなところで…。タイトルと概要でファイルを分けているのは、後でMapReduceする時に、タイトルがkeyやvalueの中に含まれると長ったらしくて困るので、連番を振ってIDで管理する為です。

単語の出現頻度のデータを作る

元書だと、データ形式に関して、"列方向に単語、行方向にブログ"という超巨大テーブルを用意して、どのブログにどの単語がいくつあるかを全て埋めていくという、なかなか恐ろしい事をやっているんですが(^^;、「さすがにそれは無いだろ。」という事で、我が道を行く事にします。

せっかくMapReduceができるようになったので、元書のベタ移植ではなく、アルゴリズムは借用させてもらいつつも、"いかにMapReduceに載せていけるか"という事に挑戦していこうと思っています。

という事で、まずはMapperから。

#!/usr/bin/php
<?php
require_once(dirname(dirname(__FILE__)).'/lib/HadoopStreaming/Mapper.php');

class Mapper extends HadoopStreaming_Mapper
{
    public function map ( $key, $value )
    {
        $value = escapeshellcmd($value);
        $result = shell_exec("echo ${value}|/usr/bin/mecab");
        $words = explode(PHP_EOL, $result, -1);
        foreach ( $words as $word ) {
            if ( $word === 'EOS' ) {
                break;
            }
            $wordinfo = split("\t|,", $word);
            if ( $wordinfo[1] === '名詞' || $wordinfo[1] === '動詞' ) {
                $wordinfo[7] = ( $wordinfo[7] !== '*' ) ? $wordinfo[7] : $wordinfo[0];
                $this->emit($wordinfo[7], $key);
            }
        }
    }
}

$mapper = new Mapper( $is_tab_separated = true );
$mapper->run();
?>

記事の冒頭で書いたフレームワークの修正で、Mapperのコンストラクタで入力がタブ区切りかどうか指定できるようになりました。(デフォルトはfalseです。) 今回はエントリIDをキーに持つデータが入力値なので、trueに設定しています。

あと、HadoopStreamingについて書いた記事では、元から分かち書きしたデータを使って単純にスペースで分割しましたが、今回はせっかくなので単語の品詞にも注目して、名詞と動詞のみ抜き出すようにしてみました。*2

これで、"単語=>[エントリID,エントリID,…]"という、各単語毎にその単語が含まれるエントリのIDが列挙される形でMapperから出力されます。

では続いてReducerに…といきたいところですが、だいぶ長くなってしまったので、続きは次回に…。

*1:元書は、筆者が用意したRSSフィードURLの一覧を使って、フィードから単語数をカウントするようになっていました。

*2:本当は名付けて.ね〜むでやっているように、"○○する"を一語とみなすとかもした方がいいんですが、今回は手を抜いて省略…。