こんにちは。QuantumCore(リザバーコンピューティングでエッジAIを作っている会社)のCEOをやっています。
OSSでは太古の時代にはOpenNap(Napster互換プロトコル)関連のコミッターをやってWinMXブームを下支えしたりニコニコ動画ブラウザ作ってたりした Shin1985 というハンドルネームで活動していて、今回紹介する日本語トークナイザ mmjp を個人開発で公開しています。
https://github.com/shin1985/mmjp
mmjpは一言でいうと、
- 形態素解析っぽい"単語境界の賢さ" と
- サブワード分割の"辞書・圧縮性"
を、超軽量C実装で合体させたものです。
原型(DoubleArray / SuffixArray / npycrfのライト実装)は 約15-16年前大学院生だった時に自然言語処理の研究 で個人で作っていて、今回「マイコンで現実に使えるところまで」を意識し完成させました。
この記事の立ち位置
- 書き手は「QuantumCoreのCEO」だけど、mmjp自体は 個人(Shin1985)として公開しているOSS
- 会社の公式プロダクト紹介ではなく、「エッジAI屋として、こういう前処理が欲しかった」をまとめたものです
なぜ今さら日本語トークナイザを作り直したのか
エッジ × SLM で「トークナイザの質」が効いてくる
最近、ロボットやヒューマノイドに音声対話機能を載せたい、という相談が増えています。
クラウドに投げれば済む話ですが、コストやレイテンシ、プライバシーの問題で エッジで完結させたい ケースも多い。
そうなると、フルスペックのLLM(数十B〜数百Bパラメータ)は載らないので、SLM(Small Language Model、数百M〜数Bパラメータ) を使うことになります。
ここで問題になるのが トークナイザの粒度 です。
大規模LLMは、コンテキスト長も長いし、パラメータ数も多いので、多少変な分割でも文脈で補完できます。
でもSLMは違います:
| 大規模LLM | SLM(エッジ向け) | |
|---|---|---|
| コンテキスト長 | 長い(8K〜128K+) | 短い(512〜4K程度) |
| パラメータ数 | 多い(補完力が高い) | 少ない(分割の質が推論に直結) |
| トークン効率の重要度 | 中 | 高(1トークンの重みが大きい) |
SentencePiece/BPEは大規模コーパスで学習すると、頻出する部分文字列をまとめてトークン化します。
英語ではうまくいくことが多いですが、日本語だと:
- 「住んでいます」→「住」「ん」「で」「います」のように、意味の切れ目と無関係に分割されることがある
- ドメイン固有の語彙(製品名、型番など)がバラバラに分割される
- 分割パターンが学習コーパスに依存して、入力によって挙動が読みにくい
SLMのような「小さな脳」で日本語を扱うには、形態素に近い、意味のある粒度 で分割してあげたほうが、モデルの負担が減ります。
でも形態素解析は重い
じゃあ形態素解析を使えばいいかというと、これはこれで問題があります:
- 辞書がでかい(MeCab + IPAdicで数十MB〜)
- 依存ライブラリが多い
- MCUや組み込み環境では動かしにくい
エッジに載せるには 軽さ が必要。でも 粒度の質 も欲しい。
mmjpの狙い:「軽いのに形態素っぽい粒度」
15-16年前、まだサブワード分割が今ほど一般的じゃなかった頃に「形態素解析の精度」と「サブワード分割の柔軟性」の両方が欲しくて工藤さんや持橋先生や森先生の論文を読みながらmmjpの原型を作り始めました。
- CRF(条件付き確率場) で文字種の並びから「ここが単語境界っぽい」を推定
- Unigram LM で辞書にある語彙を優先的に拾う
- 両者を足してViterbiで最適分割
この組み合わせで、辞書にない語にも対応しつつ、形態素に近い粒度 を出せます。
今回やった"完成"の意味は、アルゴリズムというより 実装の整備 です:
- C99 / 依存ライブラリなし
- 推論時
mallocなし - 固定小数点(Q8.8)中心
- MCU export(model.bin → Cヘッダ) を追加して、マイコンに載せられる形にした
「テキスト前処理を含めて、エッジで完結させたい」側の人に刺さるように、最後の詰めをしました。
再現性(決定的な分割)
もう一つ重要なのが 再現性 です。
mmjpのデフォルト(Viterbiモード)は 決定的 です。同じ入力には必ず同じ出力が返ります。
これは推論時には重要で、入力が同じなのに分割が変わると、モデルの挙動が不安定になります。
一方で、学習データを作るときは 確率的な分割(Subword Regularization) で揺らしたほうが頑健性が上がる。
mmjpは両方できます:
- 推論時:Viterbiで決定的に分割
- 学習時:FFBSサンプリングやN-bestで分割を揺らしてデータ拡張
mmjpの設計思想
| 特徴 | 狙い |
|---|---|
| C99 / 依存ライブラリなし(標準Cのみ) | どんな環境でもビルドできる |
推論時 malloc なし(ワーク領域を先に確保して使い回す設計) |
組み込みでメモリ管理しやすい |
| スコアは 固定小数点(Q8.8)中心 | FPUがない/遅い環境でも戦える |
| CRF + Unigram LM のハイブリッド推論 | 精度と軽さの両立 |
| Lossless Tokenization | 空白・改行まで完全に保存、detokで復元 |
| Subword Regularization(FFBS / N-best) | 学習データの水増し・頑健化 |
| MCU export(model.bin → Cヘッダ) | Flash/ROMに埋め込める |
同梱モデル models/mmjp_wiki.bin は 約275KB。
エッジに置ける日本語トークナイザとして、このサイズ感は重要でした。
仕組み("いいとこ取り"の中身)
mmjpの推論は大きく2つのスコアを足しています。
1) 文字種ベースCRF:境界を"それっぽく"する
日本語は「ひらがな/カタカナ/漢字/英数/記号」といった 文字種の並び だけでも、境界のヒントがかなり出ます。
mmjpはここを 2状態CRF(開始=1 / 内部=0) で軽量に実装しています。
- 特徴は「前/現在/次の文字種」や「ペア(前+現在、現在+次)」など軽量テンプレ
- 辞書にない未知語でも 文字の並びの雰囲気 で境界を作れる
2) Unigram LM:サブワード辞書で"語彙っぽい"塊を拾う
辞書(DoubleArray trie)に載っているピースの確率(対数確率)で、「この区間を1トークンにすると自然?」を評価します。
SentencePiece Unigramに近い系統です。
合体してViterbi
最終的に、ラティス上で
score(segmentation) =
sum_boundary( CRF_score_at_boundary )
+ lambda0 * sum_piece( logp(piece) )
を計算し、Viterbiで最適な分割を取ります。
lambda0 を変えると「辞書寄りで長め」or「CRF寄りで細かめ」という性格が変わります(後述のベンチ参照)。
クイックスタート
ビルド(Cツール)
cd tools
# 学習ツール
gcc -O3 -std=c99 \
-I.. -I../double_array -I../npycrf_lite -I../suffix_array -I../unilm_mdl \
-o mmjp_train mmjp_train.c mmjp_model.c \
../suffix_array/sa_utf8.c ../unilm_mdl/unilm_mdl.c \
../double_array/double_array_trie.c ../npycrf_lite/npycrf_lite.c \
../mmjp_lossless.c -lm
# 推論ツール
gcc -O3 -std=c99 \
-I.. -I../double_array -I../npycrf_lite \
-o mmjp_tokenize mmjp_tokenize.c mmjp_model.c \
../double_array/double_array_trie.c ../npycrf_lite/npycrf_lite.c \
../mmjp_lossless.c -lm
# MCU export
gcc -O3 -std=c99 \
-I.. -I../double_array -I../npycrf_lite \
-o mmjp_export_c mmjp_export_c.c mmjp_model.c \
../double_array/double_array_trie.c ../npycrf_lite/npycrf_lite.c \
../mmjp_lossless.c -lm
同梱モデルでトークナイズ
echo '東京都に住んでいます。' | ./tools/mmjp_tokenize --model models/mmjp_wiki.bin
出力:
東京都 に 住ん でい ます 。
「形態素解析ほど重くなく、でも"日本語っぽい切れ方"」というラインです。
ベンチマーク:速度・メモリ・精度
エッジ用途だと「精度」より先に "何KB/何MBで動く?" が意思決定では超重要です。
"ARMのMクラスかAクラスかJetsonか!?" とだいたいその辺でSRAMが決まりますもんね。
実測値を載せます。(Mクラスも狙えるのでMBクラスと落胆せずに次も読んでね!)
同梱モデルの学習・評価条件
- Wikipedia日本語 20k文(教師なし)
- Janomeの分割を銀ラベルにしてCRFを教師あり最適化(train 800文)
- 評価は銀ラベル 181文で 単語開始境界F1
他手法との比較(学習)
| method | Elapsed | Max RSS |
|---|---|---|
| mmjp + supervised CRF | 0:07.25 | 13MB |
| SentencePiece Unigram | 0:14.52 | 154MB |
| SentencePiece BPE | 0:10.48 | 157MB |
他手法との比較(推論:20k文)
| method | Elapsed | Max RSS |
|---|---|---|
| mmjp + CRF | 0:00.40 | 3.5MB |
| SentencePiece Unigram (Python) | 0:01.77 | 37.7MB |
| SentencePiece BPE (Python) | 0:02.04 | 39.5MB |
※ mmjpはC CLI、SentencePieceはPython API経由の計測(戻り値生成コスト込み)なので、測り方の差はあります
モデルサイズ
| model | size |
|---|---|
| mmjp_wiki.bin | 275KB |
| spm_unigram_8k.model | 約120KB |
精度(境界F1)
| method | Precision | Recall | F1 |
|---|---|---|---|
| mmjp + supervised CRF | 0.930 | 0.740 | 0.824 |
| SentencePiece Unigram | 0.788 | 0.814 | 0.801 |
| SentencePiece BPE | 0.771 | 0.782 | 0.776 |
※ 評価はJanome由来の銀ラベルなので、F1は「Janomeにどれだけ寄るか」の指標です
lambda0 sweep(精度と粒度のトレードオフ)
lambda0(CRFとLMの混合比)を変えたときの結果:
| lambda0 | F1 | avg tokens/sent | 解釈 |
|---|---|---|---|
| 0.047 | 0.839 | 29.3 | 精度重視(細かめ) |
| 0.148 | 0.838 | 27.7 | バランス |
| 0.195 | 0.838 | 26.8 | 同梱モデル相当 |
チューニング指針:
| 目的 | lambda0の目安 |
|---|---|
| 精度重視(境界に寄せたい) | ≒ 0.05 |
| バランス(トークン数も抑えたい) | ≒ 0.15〜0.20 |
| 圧縮寄り(トークンを減らしたい) | ≒ 0.3〜(F1は落ちる) |
MCU export を追試した(SRAMが"KBオーダー"で済むか?)
「MCU exportがある」だけでなく、実際にSRAMがKBオーダーで済む ところまで確認しました。
1) model.bin → Cヘッダへ変換
./tools/mmjp_export_c --model models/mmjp_wiki.bin --out mmjp_wiki_model.h --symbol mmjp_wiki
生成される mmjp_wiki_model.h は static const 配列 + npycrf_model_t を吐くので、Flash/ROMに載せやすい形式です。
2) malloc無しで推論する最小コード例
ポイントは3つ:
-
npycrf_workbuf_size(max_n_cp, max_word_len)で必要SRAMを見積もる - そのサイズの
workbuf[]を静的に確保 -
npycrf_work_init()→npycrf_decode()で分割
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "mmjp_wiki_model.h" // mmjp_export_cが生成
#define MAX_N_CP 256
#define MAX_WORD_LEN 8
static uint8_t workbuf[16000]; // 必要量より少し大きめに確保
static uint16_t b_cp[MAX_N_CP + 1];
static uint16_t b_bytes[MAX_N_CP + 1];
int main(void) {
npycrf_work_t w;
size_t need = npycrf_workbuf_size(MAX_N_CP, MAX_WORD_LEN);
npycrf_work_init(&w, workbuf, sizeof(workbuf), MAX_N_CP, MAX_WORD_LEN);
const char *s = "東京都に住んでいます。";
size_t b_count = 0;
npycrf_decode(&mmjp_wiki_model, (const uint8_t*)s, strlen(s),
&w, b_cp, MAX_N_CP + 1, &b_count, NULL);
npycrf_boundaries_cp_to_bytes(w.cp_off, b_cp, b_count, b_bytes);
for (size_t i = 0; i + 1 < b_count; i++) {
fwrite(s + b_bytes[i], 1, (size_t)(b_bytes[i+1] - b_bytes[i]), stdout);
if (i + 2 < b_count) putchar(' ');
}
putchar('\n');
return 0;
}
出力:
東京都 に 住ん でい ます 。
3) SRAM必要量(npycrf_workbuf_size 実測)
MAX_WORD_LEN=8(同梱モデル設定)として、max_n_cp(最大文字数)を変えたときの必要ワーク領域:
| max_n_cp | workbuf必要量 |
|---|---|
| 64 | 約3.8KB |
| 128 | 約7.3KB |
| 256 | 約14.1KB |
| 512 | 約27.9KB |
SRAMは"KBオーダー"で実装可能 です。
出力境界配列などの追加バッファも O(max_n_cp) で小さめ。
4) ELFのsizeで見る(Flash/ROMとSRAMの感覚)
上の最小デモを workbuf[16000] でビルドした場合:
text data bss dec hex
295461 824 17152 313437 4c85d
MCU目線での解釈:
| セグメント | サイズ | 意味 |
|---|---|---|
| text | 約289KB | コード + const配列 ≒ Flash/ROM |
| bss | 約17KB | 静的ワーク領域 ≒ SRAM |
「Flashは数百KB欲しいが、SRAMは十数KBで回る」 という見立てが立ちます。
参考:Sampling / N-best をMCUでやるなら
追加バッファもAPIで見積もれます(ただしサンプリングは double と exp/log を使うので、MCUでは基本オフ推奨):
| バッファ | max_n_cp=256, L=8 での必要量 |
|---|---|
| workbuf | 約14KB |
| samplebuf | 約18KB |
| nbestbuf (nbest=8) | 約108KB |
MCUでは基本 ベスト(Viterbi)だけ を使う運用が現実的です。
Lossless Tokenization(可逆トークナイズ)
ログ/チャット/コマンド列みたいなエッジ入力は、空白・改行が意味を持ちがちです。
mmjpは空白・改行をメタ文字にして 完全復元(round-trip) できます。
| 元文字 | メタ文字 |
|---|---|
| スペース |
▁(U+2581) |
| タブ | ▂ |
| LF | ▃ |
| CR | ▄ |
| エスケープ | ▀ |
Losslessモデルの学習
./tools/mmjp_train \
--corpus your_corpus.txt \
--out model.bin \
--lossless_ws 1 \
--lossless_eol 1
round-trip(tokenize → detok)
# tokenize
cat input.txt | ./tools/mmjp_tokenize \
--model model.bin --lossless_ws -1 --read_all 1 > tokens.txt
# detok(完全復元)
cat tokens.txt | ./tools/mmjp_tokenize \
--model model.bin --detok --lossless_ws -1 --read_all 1 > restored.txt
input.txt と restored.txt が一致します。
Subword Regularization(確率的分割)
学習データ生成で地味に効きます。
内部ラティスがあるので、FFBSで"揺らいだ分割"が作れます。
FFBSサンプリング
echo '東京都に住んでいます。' | ./tools/mmjp_tokenize \
--model models/mmjp_wiki.bin \
--sample --temperature 1.2 --nsamples 5
N-best
echo '東京都に住んでいます。' | ./tools/mmjp_tokenize \
--model models/mmjp_wiki.bin \
--nbest 5
出力例:
東京都 に 住ん でい ます 。
東京都 に 住ん でいます 。
東京都 に 住んで います 。
東京都 に 住んでい ます 。
東京都 に 住 んでい ます 。
QuantumCoreの案件では「クラウドでデータ生成 → エッジに小さなモデル配布」という導線が多いので、学習データ生成にそのまま使える のは実際かなり重宝しています。
自前ドメイン語彙で学習
エッジ案件の日本語は、製品名・型番・略語・社内用語が多い。
mmjpは mmjp_train で 自前コーパスからモデル生成 できます。
./tools/mmjp_train \
--corpus your_corpus.txt \
--out model.bin \
--vocab 8000 \
--max_piece_len 8
CRFは以下の方法でチューニング可能:
- 設定ファイルで重み上書き
- 教師あり(空白区切りの正解分割)で最適化
- 教師なし(LMのViterbi結果を疑似ラベルにして最適化)
Pythonから使う
Pythonバインディング(C拡張)も同梱しています。
# Ubuntu/Debianの場合、先にこれが必要なことが多い
sudo apt-get install python3-dev build-essential
pip install .
import mmjp
m = mmjp.Model("models/mmjp_wiki.bin")
print(m.tokenize("東京都に住んでいます。"))
print(m.sample("東京都に住んでいます。", temperature=1.2, seed=1))
print(m.nbest("東京都に住んでいます。", nbest=5))
print(m.tokenize_with_offsets("東京都に住んでいます。", unit="byte"))
QuantumCore的ユースケース
最後に「なぜCEOがトークナイザを作ってるの?」に答えると、理由は単純で、
エッジAIで"日本語テキストを時系列信号"として扱うには、小さく・速く・移植しやすい前処理が欲しい
からです。
リザバーコンピューティングは軽量に時系列処理ができるので、以下のような流れがやりやすい:
1) エッジで日本語ログをそのまま特徴量に
- Losslessで改行/空白まで保持してトークン化
- 後段で Reservoir(リザバー) に流して異常検知/予兆/クラスタリング
- 前処理が軽いほど、オンデバイスで完結しやすい
2) SLMの前段トークナイザとして
- 形態素に近い粒度で分割 → SLMの負担を軽減
- コンテキスト長が短い環境でトークン効率が効いてくる
3) 学習データ生成でSubword Regularization
-
--sampleや--nbestで分割を揺らして頑健性UP - ドメインテキストの水増しが「分割の揺らぎ」だけでできる
mmjpは「形態素解析の全部」を目指すというより、エッジで回せる現実的な分割器として作っています。
ライセンス
- コード:MIT License
-
モデル:学習コーパスのライセンスに依存
- 同梱
mmjp_wiki.binは Wikipedia(CC BY-SA)で学習
- 同梱
商用利用・再配布を考える場合は、ここだけは必ず確認してください。
まとめ
mmjpは、
| 要点 | 内容 |
|---|---|
| "形態素 vs サブワード"の対立を潰す | CRF × Unigram LM のハイブリッド |
| エッジ前提の実装思想 | malloc無し・固定小数点・小モデル・lossless |
| SentencePieceより軽い | 推論RSS 3.5MB vs 37MB、速度4倍以上 |
| MCU export追試済み | SRAMは十数KBで回る、Flashは数百KB |
17年前に「形態素解析とサブワードのいいとこどりができないか」と思って作り始めたものが、SLM時代のエッジ向けとして形になりました。
「とりあえず mmjp_tokenize で動かす → 自前コーパスで mmjp_train」の流れで試してもらえると嬉しいです。
個人OSS(Shin1985)として公開しているので、Issue/PR歓迎です。
QuantumCoreの方では、このOSS向けにWikipediaデータではなく実践の議事録サービスの運用で培った5万時間分の会議議事データ(マスク済み/オプトイン済)から学習したモデルも提供しています。
またリザバーコンピューティングを使ったエッジで動くSLMも提供しています。
今後の予定
反応があれば、以下のような続編も書こうと思っています:
- Bare-metal / RTOSでの最小統合サンプル
- ドメイン語彙コーパスの作り方(ログ/FAQ/マニュアル)
- リザバーコンピューティングとの組み合わせ方
フィードバックはGitHubでお待ちしています。