小規模データで単語の数を数えてみた (2)
大規模データで単語の数を数える - ny23の日記 でみたように,単純に高頻度の item が欲しい場合には,小規模データで単語の数を数えてみた (1) - ny23の日記 で使った sketch-based なアルゴリズムよりは,counter-based なアルゴリズムの方が(キーを陽に保存するので)使い勝手が良い.というわけで,space saving アルゴリズムを実装してみた.カウンタのデータ構造には,原著論文
に書いてある stream summary をほぼ定義通り素直に実装して利用.
// GNU GPL version 2 copyright@ny23 #include <cstdio> #include <cstdlib> #include <cstring> #include <cmath> #include <tr1/unordered_set> // easygoing implementation of stream-summary data structure template <typename T> class connector { // base class for an element in a linked list public: T * prev; T * next; void pop () { // pop self from the linked list prev->next = next; next->prev = prev; } void push (T * prev_) { // push self into the linked list (next to prev_) prev = prev_; next = prev_->next; prev->next = next->prev = static_cast <T*> (this); } bool is_singleton () const { return prev == this && next == this; } private: connector (const connector&); connector& operator= (const connector&); protected: connector (T * prev_ = 0, T * next_ = 0) : prev (prev_), next (next_) {} ~connector () {} }; class bucket_t; class item_t : public connector <item_t> { public: char * key; // keep public for coming item item_t () : connector <item_t> (), key (0), _capacity (0), _delta (0), _parent (0) {} ~item_t () { delete [] key; } bucket_t * get_parent () const { return _parent; } void set_parent (bucket_t * bucket) { _parent = bucket; } size_t get_delta () const { return _delta; } void reset_item (char* new_key, size_t new_capacity, size_t delta) { if (_capacity < new_capacity) { // avoid memory reallocation delete [] key; key = new char[new_capacity]; _capacity = new_capacity; } std::strcpy (key, new_key); _delta = delta; } private: size_t _capacity; // trade space for fast update with larger epsilon size_t _delta; bucket_t * _parent; }; class bucket_t : public connector <bucket_t> { public: bucket_t () : connector <bucket_t> (this, this), _count (0), _child (0) {}; void pop_item (item_t * item) { item->pop (); if (_child == item) _child = item->next; } void push_item (item_t * item) { item->push (_child); item->set_parent (this); } size_t get_count () const { return _count; } void set_count (size_t count) { _count = count; } item_t * get_child () const { return _child; } void set_child (item_t * item) { _child = item->next = item->prev = item; // singleton item->set_parent (this); } private: size_t _count; item_t * _child; }; // eco-friendly recycling plant for bucket objects class bucket_pool { public: void recycle_bucket (bucket_t * bucket) { bucket->next = _M; _M = bucket; } bucket_t * create_bucket () { if (! _M) return new bucket_t; // You can't get blood out of a stone. bucket_t * bucket = _M; _M = bucket->next; return bucket; } bucket_pool () : _M (0) {} ~bucket_pool () { while (_M) { bucket_t * tmp = _M->next; std::swap (_M, tmp); delete tmp; } } private: bucket_t * _M; bucket_pool (const bucket_pool&); bucket_pool& operator= (const bucket_pool&); }; struct eq_ptr { bool operator () (const item_t * const a, const item_t * const b) const { for (size_t i = 0; a->key[i] == b->key[i]; ++i) if (! a->key[i]) return true; return false; } }; struct hashfun_ptr { // FNV-1 unsigned int operator () (const item_t * const a) const { unsigned int h = 0x811c9dc5; for (char const *p = a->key; *p; ++p) { h *= 0x01000193; h ^= *p; } return h; } }; int main (int argc, char** argv) { typedef std::tr1::unordered_set <item_t *, hashfun_ptr, eq_ptr> hash_t; if (argc < 3) { std::fprintf (stderr, "Usage: %s file epsilon\n", argv[0]); std::exit (1); } FILE * fp = std::fopen (argv[1], "r"); if (! fp) { std::fprintf (stderr, "no such file: %s\n", argv[1]); std::exit (1); } char buf[1 << 18]; std::setvbuf (fp, &buf[0], _IOFBF, 1 << 18); size_t k = static_cast <size_t> (std::ceil (1.0 / std::strtod (argv[2], NULL))); // monitoring streams hash_t hash; bucket_pool bucket_pool; // trade space for faster update bucket_t * root = new bucket_t; // dummy item_t * coming_item = new item_t; size_t n = 0; #ifdef __APPLE__ size_t read = 0; while ((coming_item->key = fgetln (fp, &read)) != NULL) { #else ssize_t read = 0; size_t size = 0; while ((read = getline (&coming_item->key, &size, fp)) != -1) { #endif * (coming_item->key + read - 1) = '\0'; item_t * item = 0; // item to be updated bucket_t * bucket = root; // its bucket hash_t::iterator it = hash.find (coming_item); if (it == hash.end ()) { // not monitored if (hash.size () == k) { // replace with bucket = bucket->next; item = bucket->get_child (); // the least significant item hash.erase (hash.find (item)); } else { // new item item = new item_t; } item->reset_item (coming_item->key, read, bucket->get_count ()); hash.insert (item); } else { // monitored item = *it; bucket = item->get_parent (); } const size_t new_count = bucket->get_count () + 1; if (bucket->next->get_count () == new_count) { // push to the sibling if (bucket != root) { if (item->is_singleton ()) { // pop singleton bucket->pop (); bucket_t * tmp = bucket->prev; std::swap (bucket, tmp); bucket_pool.recycle_bucket (tmp); } else { bucket->pop_item (item); } } bucket->next->push_item (item); // move bucket to bucket->next } else if (item->is_singleton ()) { // reuse singleton bucket->set_count (new_count); } else { // prepare a new bucket if (bucket != root) bucket->pop_item (item); bucket_t * new_bucket = bucket_pool.create_bucket (); new_bucket->set_count (new_count); new_bucket->set_child (item); new_bucket->push (bucket); } ++n; } std::fclose (fp); std::fprintf (stderr, "%ld tokens, %ld space.\n", n, k); // print for (bucket_t * bucket = root->prev; bucket != root; ) { item_t * item = bucket->get_child (); do { std::fprintf (stdout, "%s %ld %ld\n", item->key, bucket->get_count (), item->get_delta ()); item_t * tmp = item; item = item->next; delete tmp; } while (item != bucket->get_child ()); bucket_t * tmp = bucket; bucket = bucket->prev; delete tmp; } delete root; delete coming_item; return 0; }
stream summary は,頻度情報を保持する bucket に,同じ頻度を共有する item(誤差情報つき)の集合を対応させたデータ構造.アルゴリズム自体は単純なのだけど,実装上は更新時の場合分けがやや煩雑*1.正直なところ,c++ のプログラムとしてはクラス設計がイマイチな気もするけど(取り敢えず動いてるからいいか).
> run ./space_saving unigram_raw.txt 0.01
実行してみる(カウンタのサイズ k を越えるところは NA と表示).
// compiled with gcc 4.6.0 20101113 (-O2 -march=core2 -m64) --------------------------------------------------------------------- ε | 0.01 | 0.001 | 0.0001 | 0.00001 | exact k | 100 | 1000 | 10000 | 100000 | 1594517 --------------------------------------------------------------------- Nε | 2790250 | 279025 | 27902 | 2790 | --------------------------------------------------------------------- memory [MiB] | 0.8 | 1.0 | 2.6 | 13.7 | 106.5 memory [MiB](key) | <0.01 | 0.06 | 0.52 | 3.46 | 28.22 memory [MiB](key)*| <0.01 | 0.07 | 0.59 | 4.20 | 39.19 time [s] | 37.5 | 34.4 | 29.9 | 41.9 | 28.9 --------------------------------------------------------------------- Rank 1-5 の | ->| ->| ->| ->| 13406476 、 | ->| ->| ->| ->| 12466310 。 | ->| ->| ->| ->| 9253307 に | ->| ->| ->| ->| 8296578 は | ->| ->| ->| ->| 7891964 Rank 10^1+1-5 と | ->| ->| ->| ->| 4144898 し | 3688148| ->| ->| ->| 3688134 ( | 2942772| ->| ->| ->| 2942768 ) | 2852888| ->| ->| ->| 2852884 年 | 2833216| ->| ->| ->| 2814393 Rank 10^2+1-5 において | NA| ->| ->| ->| 178891 名 | NA| 178428| ->| ->| 178165 上 | NA| ->| ->| ->| 178124 町 | NA| 180929| ->| ->| 177584 性 | NA| ->| ->| ->| 177481 Rank 10^3+1-5 2010 | NA| NA| ->| ->| 24094 含め | NA| NA| ->| ->| 24074 特定 | NA| NA| ->| ->| 24072 値 | NA| NA| ->| ->| 24035 飛行 | NA| NA| 24029| ->| 24022 Rank 10^4+1-5 武蔵野 | NA| NA| NA| ->| 1818 文字通り | NA| NA| NA| ->| 1818 基部 | NA| NA| NA| ->| 1818 前部 | NA| NA| NA| ->| 1818 優等 | NA| NA| NA| ->| 1818 --------------------------------------------------------------------
memory [MB] (key) は単語の字面の保存に使用するメモリのサイズを std::strlen で測ったもの.key に対して使用メモリ量が大きく見えるのは,メモリ確保が 16 bytes 単位だからかもしれない(* が 16 bytes 単位のメモリ確保を考慮した補正値).この実装では,メモリの動的な確保をなるべく避けるためにやや富豪的にメモリを使っているので,更新時間を追求するのでなければ,使用メモリはもう少し減らすことができる.
例によって誤差は Nε より大幅に抑えられている.このデータでは,頻度が top-k の item が欲しい時は,その10倍ぐらいのサイズのカウンタを用意すれば (ε = 1/10k),十分という感じ(なお,表には載せていないが,item ごとに見積もり誤差も得られる).
lossy counting の結果も載せようかと思ったけど,疲れたのでやめた.結果だけ言うと,このデータでは頻度分布が power law に従っている関係で,ε が小さくなるにつれカウンタの最大数 kmax が 1/ε よりも大きく抑えられた(例えば,ε=0.01 -> kmax=128 なのに対し,ε=0.00001 -> kmax=21677; その分,低頻度の単語については頻度の誤差が大きくなるが)*2.space saving で lossy counting のカウンタ最大数と揃えるように ε を設定すると,精度は大差ない感じ.この手の skew なデータでは,lossy counting でも space saving でもどちらでも使いたい方を使えば良いのではないかな.*3.
*1:観測された item がカウンタに登録されているか否か,更新対象の item が bucket に所属する唯一の item か否か,更新語の頻度を持つ bucket が既にあるかどうか,などの場合分けがあり,これらの組み合わせによってデータ構造に対して必要な操作が変わる.
*2:skew でないデータが入力される場合,lossy counting が必要とするカウンタのサイズは O(1/ε log Nε).
*3:大規模データで単語の数を数える - ny23の日記 のサーベイ論文で用いられている lossy counting の実装は,1/ε 単位でデータを区切って qsort というデータの skewness を無視した超富豪的実装になっているので,結果はあまり参考にしないほうが良いと思う(skew なデータを相手にする場合,ハッシュを使った方が,実装も平易だし,更新速度の点でも使用メモリの点でも有利で良いような気がする).