🙄

cedar で最長一致でトークナイズするメモ

2024/02/23に公開

RWKV world tokenizer の情報と C++ での実装メモ
https://zenn.dev/syoyo/articles/d47afddde67886

で, hat-trie 利用でトークナイズしてみました.

別途, 最長一致ベースでの C++ 高速形態素解析 jagger の python binding に取り組んでいましたので,

高速形態素解析 Jagger の Python binding のメモ
https://zenn.dev/syoyo/articles/9ac920632ba5c9

ここで最長一致のための配列ベースの Trie 木(Prefix tree)の実装 cedar

https://www.tkl.iis.u-tokyo.ac.jp/~ynaga/cedar/

を使ってのトークナイズももやってみます!
(edar のソースコードはとてもつらぽよなので, 理解で結構時間を溶かしたよ... 🥲)

cedar は C++ 例外の利用がないので, WASM/WASI への組み込みもやりやすくなります.
(sandbox 環境でセキュアに文字列処理したいときとか)

cedar

https://www.tkl.iis.u-tokyo.ac.jp/~ynaga/cedar/

Prefix tree の配列表現で高速に prefix search するライブラリってところです.

余談

trie 木の配列表現を, なぜか NLP 界隈は「ダブル配列」(double-array)と呼んでいますが, これはひじょうにややこしい和製日本語だと思います.
英語では double-array とかで検索してもほぼヒットしないです.

ちなみに CG 界隈(?)ですと, (Q)BVH など, 配列で木構造を表現するのは昔から行われてきており, なにか特定の名前があるわけではなく, フツーに n-ary BVH とか child-sibling tree(N 文木) と呼んでいます.

https://www.uni-ulm.de/fileadmin/website_uni_ulm/iui.inst.100/institut/Papers/QBVH.pdf

N 分木(多分木)を二重連鎖木にしてオフセット配列表現するメモ
https://qiita.com/syoyo/items/6b44949148a606862db5

したがって, ここでも単に Trie 木/Prefix tree の配列表現と呼ぶようにします.

variant?

Jagger https://www.tkl.iis.u-tokyo.ac.jp/~ynaga/jagger/ では, ccedar_core.h と, cedar をちょっと改変(&簡略化?)したものがあります.

API はほぼ同じなので, たぶんどちらを使ってもよいかと思います.

ただ, ccedar_core.h 利用の Jagger では, 内部的に key となる文字列(UTF-8 文字列)を Unicode の code point 値に変換し int(4 bytes)の配列で扱っています.

https://en.wikipedia.org/wiki/UTF-8

UTF-8 ですと最大 4 bytes なので, UTF-8 のバイト列を 4 byte に padding してそのまま渡してしまってもよいところですが, Trie 木の圧縮のためでしょうか.

最長一致で取り出す

ソースコードがつらぽよでしかもドキュメントやサンプルコードが皆無に近くてつらぽよなところです.

commonPrefixSearch でいけるかと思いましたが, どうもダメで, commonPrefixPredict を使う必要がありました.

commonPrefixPredict は, 入力文字列に対して, その名の通り共通 prefix を持つキーを返します. vocab 次第で, 返ってくる候補は n 個あります.

例として, RWKV World の vocab を使います. https://zenn.dev/syoyo/articles/d47afddde67886

cedar にはいくつか API があります. まず commonPrefixSearch, commonPrefixPredict, exactMatchSearch を試します.

「である」を, 「で」「であ」「である」「である。」で処理させると, 以下になります.

で
commonPrefixSearch ():
で: found, num=1  9604:3
commonPrefixPredict ():
で: found, num=17  9604:0:19477: 42868:3:96704:あ 58050:6:149753:あっ 63636:9:173147:あった 58051:6:149754:あり 58052:6:149755:ある 42869:3:96719:き 58053:6:149759:きる 42870:3:96725:し 58054:6:149600:しょ 63637:9:173150:しょう 42871:3:96731:す 58055:6:149606:すが 42872:3:96748:の 42873:3:96749:は 58056:6:149616:はな 42874:3:96711:も
exactMatchSearch ():
id = 9604

であ
commonPrefixSearch ():
であ: found, num=2  9604:3 42868:6
commonPrefixPredict ():
であ: found, num=5  42868:0:96704: 58050:3:149753:っ 63636:6:173147:った 58051:3:149754:り 58052:3:149755:る
exactMatchSearch ():
id = 42868

である
commonPrefixSearch ():
である: found, num=3  9604:3 42868:6 58052:9
commonPrefixPredict ():
である: found, num=1  58052:0:149755:
exactMatchSearch ():
id = 58052

である。
commonPrefixSearch ():
である。: found, num=3  9604:3 42868:6 58052:9
commonPrefixPredict ():
である。: not found
exactMatchSearch ():
id = -1

vocab には「で」「であ」「である」があります.

今回は最長一致のがほしいので, commonPrefixPredict で候補がある間は処理を続け, 候補がなくなったら一つ前の状態の id を採用すれば最長一致ができます!

commonPrefixSearch, exactMatchSearch ではダメなのか

たとえば aa, aaaa がのある vocab の場合,

a
commonPrefixSearch ():
a: not found
commonPrefixPredict ():
a: found, num=2  0:1:256:a 1:3:258:aaa
exactMatch ():
id = -1

aa
commonPrefixSearch ():
aa: found, num=1  0:2
commonPrefixPredict ():
aa: found, num=2  0:0:256: 1:2:258:aa
exactMatch ():
id = 0

aaa
commonPrefixSearch ():
aaa: found, num=1  0:2
commonPrefixPredict ():
aaa: found, num=1  1:1:258:a
exactMatch ():
id = -1

a を与えると commonPrefixSearch は not found を返します. 入力文字が aa だった場合に, 最初の文字の処理で not found になってしまいうまく見つけてくれません.
exactMatch も同様です.

commonPrefixPredict ではダメか

commonPrefixPredict は, 候補が多いとバッファを多くとらないといけない & iterate 回数が増えて性能が出ない可能性があります.

また, jagger に付属の ccedar では commonPrefixPredictがありません!

https://github.com/lighttransport/jagger-python/blob/main/jagger/ccedar_core.h

traverse を使う

しょうがないので, 最終的には traverse を使うことにしました.
traverse は trie 木の subtree をたどる機能のようです(ソースコードがややこしすぎて読解できない).

  static int lps(trie_t *ptrie, const char *line) {
    size_t len = strlen(line);
  
    size_t prev_from{0};
    int prev_n{-1};
  
    size_t from{0};
    for (size_t i = 0; i < len; i++) {
      size_t pos = 0;
      // traverse(&line[i], from, pos, 1) でもよい
      int n = ptrie->traverse(&line[i], from, pos, pos + 1);
      if (n == trie_t::CEDAR_NO_VALUE) {
        std::cout << "i " << i << "no value\n";
        continue;
      }
      if (n == trie_t::CEDAR_NO_PATH) {
        std::cout << "i " << i << "no path\n";
        break;
      }
      prev_n = n;
      prev_from = from;
    }
  
    std::cout << "--- lps ---\n";
    std::cout << "prev_n " << prev_n << "\n";
    std::cout << "prev_from = " << prev_from << "\n";
    std::cout << "from = " << from << "\n";

    return prev_n;
  }

とりあえず 1 文字づつ処理させて, traverse していきます(from(trie 木の subtree(child, sibling index))の index は traverse を呼ぶとアップデートされます.

a
--- lps ---
prev_n -1
prev_from = 0
from = 97

aa
i 0no value
--- lps ---
prev_n 0
prev_from = 256
from = 256

aaa
i 0no value
i 2no value
--- lps ---
prev_n 0
prev_from = 256
from = 352

aaaa
i 0no value
i 2no value
--- lps ---
prev_n 1
prev_from = 258
from = 258

aaaaa
i 0no value
i 2no value
i 4no path
--- lps ---
prev_n 1
prev_from = 258
from = 258

b
i 0no path
--- lps ---
prev_n -1
prev_from = 0
from = 0

prev_n にマッチしているのが返ります!

  • CEDAR_NO_PATH になったら共通 prefix を持つキーはなにも無いので, early terminate している
  • from == prev_from であったら exactMatch しているはずなので, この時点で loop を抜けてもよいであろう.

これで, (たぶん計算量抑えて)最長一致ができます!

さらなる高みへ

exactMatchSearch の利用

LLM でのテキスト生成のためのトークナイズですと, 「ああ」「ああいう」が vocab にあったときにどちらも「ああ」で切り出しても問題が少ないみたいなケースでは, exactMatchSearch 利用でもいいかもしれません(多少コードが簡略化される).

自前実装

構成としてはレイトレでの BVH 構築とかなり同じなので, レイトレで BVH 実装の経験ある方はすんなりいけるでしょう...

レイトレ BVH 始めてのひとは

冬到来! NanoRT ではじめよう C++ レイトレーシング
https://qiita.com/syoyo/items/1aae159f9b262fbd4aa3

を参考にしてレイトレ BVH 極めようね.

n 分木処理は IP アドレスのルーティングのやりかたも参考になるよ.

Poptrie: A Compressed Trie with Population Count for Fast and Scalable Software IP Routing Table Lookup (H. Asai et al., ACM SIGCOMM 2015)の日本語解説記事です。気が向いたので書きます。
https://ja.tech.jar.jp/network/algorithms/poptrie/intro.html

Discussion