設計ドキュメント
August 18, 2026 · View on GitHub
1. 概要
KyTea / Vaporetto と同じ「点推定(pointwise prediction)」方式を採用した、C++の分かち書き専用ライブラリ。文字境界ごとに独立した二値分類(区切る/区切らない)を分類器で行う。ラティス+Viterbiによる最小コスト法(MeCab等)とは異なり、辞書コストの設計や動的計画法を必要としない。
公開APIは単一(6節参照)で、内部に3つのバックエンドを差し替え可能な形で持つ:
- KyTea互換バックエンド(3節)— KyTeaの学習済みモデルをそのまま読み込み、KyTeaと同一の特徴抽出・線形SVM分類器で推論する。学習はサポートしない(推論のみ)。
- 独自MLPバックエンド(4節)— 線形SVMではなく、独自に設計したMLP(多層パーセプトロン)を分類器として使うバックエンド。学習エンジンも自前実装している。
- EDLAバックエンド(11節)— MLPバックエンドと同じネットワークを、誤差逆伝播ではなく誤差拡散学習法(EDLA)で学習したもの。生物学的に着想された局所学習則がこのタスクを学習できるかの検証であり、別のモデル設計ではない。
コーパスフォーマットは常にKyTeaのコーパス形式を使う(5節)。KyTea互換バックエンド・独自MLPバックエンドのいずれも、同じKyTeaコーパス形式(フル/部分アノテーション)を入力として想定する。
2. アーキテクチャ:バックエンドの抽象化
複数バックエンドを同一APIの背後に隠すため、Segmenterはバックエンドの実装詳細を知らない薄いディスパッチャである。
バックエンドの集合は「KyTea互換/独自MLP/EDLA」という決まった小さな閉じた集合であり、外部プラグインとして動的に追加する要件はない。そのため仮想関数によるオープンな拡張機構(virtual+ヒープ確保)ではなく、std::variant + std::visitによるクローズドな多態性を採る。vtableの間接呼び出しやバックエンドオブジェクトの個別ヒープ確保を避けられ、対応していないバックエンドの分岐漏れはコンパイル時に検出できる。
class KyteaBackend {
public:
Expected<Segments, Error> tokenize(std::string_view text) const;
};
class MlpBackend {
public:
Expected<Segments, Error> tokenize(std::string_view text) const;
};
class EdBackend {
public:
Expected<Segments, Error> tokenize(std::string_view text) const;
};
using AnyBackend = std::variant<kytea::KyteaBackend, mlp::MlpBackend, ed::EdBackend>;
class Segmenter {
public:
static Expected<Segmenter, Error> load(const std::filesystem::path& model_path);
static Expected<Segmenter, Error> load_kytea(const std::filesystem::path& model_path);
static Expected<Segmenter, Error> load_mlp(const std::filesystem::path& model_path);
static Expected<Segmenter, Error> load_ed(const std::filesystem::path& model_path);
Expected<Segments, Error> tokenize(std::string_view text) const {
return std::visit([&](const auto& b) { return b.tokenize(text); }, backend_);
}
std::vector<Expected<Segments, Error>>
tokenize_all(Span<const std::string_view> texts, unsigned threads = 0) const;
private:
AnyBackend backend_;
};
モデル形式の自動判別:Segmenter::load()はファイル先頭のシグネチャを見て自動的にバックエンドを選ぶ("SegmentLibMLP "で始まるヘッダ行ならMLPバックエンド(4.7節)、"SegmentLibED "ならEDLAバックエンド(11節)。それ以外はKyTeaバックエンドにフォールスルーする)。署名は長さが異なるため、load()は既知の最長署名分だけ読んでから各候補をそれぞれの長さで比較する。それより短いファイルは単にどれにも一致しない。明示的にバックエンドを指定したい場合向けにload_kytea(path) / load_mlp(path) / load_ed(path)も用意し、load()はその薄いラッパーとする。
各バックエンドクラスはtokenizeという同一シグネチャさえ満たせばよく、内部の特徴抽出・分類器・モデルパーサは完全に独立して実装できる。バックエンドに要求される共通点は「同じSegments型を返すこと」だけである。
EDLAバックエンドはこの独立性に対する意図的な例外で、MLPバックエンドのモデルパーサとスコアラーを再実装せずそのまま共有する(11.2節)。両者は重みの学習のされ方だけが違うのであり、推論経路を同一に保つことこそが、両者の比較を「別々に調整された2つの実装」ではなく学習則の測定にしている。
3. KyTea互換バックエンド
- KyTeaが出力するモデル(
train-kyteaで学習されたモデル)をそのまま読み込む。 - 素性はKyTeaと同じ3種:文字n-gram、文字種n-gram、辞書由来の単語素性。
- 推論のみをサポートする(学習エンジンは実装しない)。
- モデルのバイナリフォーマットは、KyTeaのモデルファイルを直接パースする(変換ツールを挟まない)。
- 分かち書き専用(タグ推定は行わない)。
3.1 特徴量抽出(KyTea互換・忠実再現)
分類器(LIBLINEARの線形SVM)自体は汎用的だが、素性文字列の生成ロジックと文字種分類はKyTea独自の実装であり、モデルとの互換性を保つにはここを一字一句忠実に再現する必要がある。
文字種分類(StringUtil::CharType)
6種類のタイプを、UTF-8の1文字をUnicodeコードポイントに変換した上で以下の範囲判定で決定する。判定順序も含めて重要(ローマ字→ひらがな→カタカナ→数字→漢字→その他の順で評価する)。
| タイプ | 記号 | Unicode範囲(概要) |
|---|---|---|
| ROMAJI | R | 0x41-0x5A, 0x61-0x7A(半角英字), 0xFF21-0xFF3A, 0xFF41-0xFF5A(全角英字) |
| HIRAGANA | H | 0x3040-0x3096 |
| KATAKANA | T | 0x30A0-0x30FF(ただし0x30FB中点を除く), 0xFF66-0xFF9F(半角カナ) |
| DIGIT | D | 0x30-0x39(半角数字), 0xFF10-0xFF19(全角数字) |
| KANJI | K | 0x3400-0x4DBF, 0x4E00-0x9FFF, 0xF900-0xFAFF, 0x20000-0x2A6DF, 0x2A700-0x2B73F, 0x2B740-0x2B81F, 0x2F800-0x2FA1F |
| OTHER | O | 上記以外すべて |
KyTea本体はUTF-8/EUC/SJISの3エンコーディングに対応しているが、本ライブラリはUTF-8のみを対象とする。
KyTeaのfindTypeは4バイトUTF-8(コードポイント≧U+10000、CJK拡張B以降)のコードポイント計算にバグがあり、それらの漢字を誤分類する。本ライブラリは正しいコードポイントから分類するため、この稀なケースでKyTeaと結果が分かれる(意図的な差異)。BMP内の通常の日本語テキストでは一致する。
入力の正規化(normalize)
推論時、KyTeaは入力文字列をsurface(原文)とnorm(正規化)に分け、素性計算(文字n-gram・文字種n-gram)はすべてnormに対して行う。正規化は固定テーブル(96エントリ)で、半角英数字・記号を全角へ畳む(a→a, 0→0, (→(, 半角カナ記号「」→「」等)ものである。出力の単語表層はsurface(原文のバイト列)から切り出すが、境界判定に使うスコアはnormから計算される。本ライブラリはこの固定テーブルを移植し、UTF-8デコード→コードポイント正規化→インターンの順でnorm相当のID列を作る(CharTable::encode)。
素性文字列のフォーマット
境界位置を基準に、窓幅(デフォルトcharw=3、typew同様に設定可能)の範囲で以下3種の素性文字列を生成し、モデルの辞書でIDに変換して線形分類器に渡す。
| 素性種別 | プレフィックス形式 | 例 |
|---|---|---|
| 文字n-gram | "X" + 相対位置 + 文字列そのもの | X-2, X-1, X0, X1 |
| 文字種n-gram | "T" + 相対位置 + 文字種記号の列 | T-1, T0, T1 |
| 辞書由来の単語素性 | "D" + 辞書インデックス + (L|I|R) + マッチ長 | D0L1(辞書0番、左端一致、長さ1), D1R3 |
D素性のL/I/Rは、境界に対する辞書エントリの位置関係(Left端/Inside中間/Right端)を表す。
素性IDのマッピングはハッシュではなくモデル内蔵の辞書
KyTeaのモデルは学習時の素性文字列→ID辞書をモデルファイルに埋め込んでいる。推論側は独自にハッシュ関数を実装せず、モデルファイルから素性辞書をそのまま読み込み、その辞書を引いてIDを求める。未知の素性文字列(学習時に出現しなかったもの)はモデルに存在しないため、その素性は単純にスキップする。
3.2 モデルファイルフォーマット
ヘッダ行
KyTea <version> <T|B> <encoding>
例:KyTea 0.4.0 B utf8。versionは量子化ビルドでは"0.4.0"。フォーマット文字はT=テキスト、B=バイナリ。本ライブラリは0.4.0系のバイナリ形式のみを対象とする。2節のバックエンド自動判別が見るのはこの行ではなくMLP側のシグネチャで、"SegmentLibMLP "で始まらないファイルがこのパーサに回ってくる。
ファイル全体のセクション順序
- Config:
do_ws,do_tags,numTags,charWindow,charN,typeWindow,typeN,dictionaryN, バイアス有無,epsilon,solverType、および文字マップ - 分かち書きモデル(
wsModel_):KyteaModel1個 - タグモデル:
numTags個ぶん(本ライブラリはスキップのみ) - 単語辞書:
Dictionary<ModelTagEntry>(本ライブラリはchar_length/in_dictのみ保持) - サブワード辞書:
Dictionary<ProbTagEntry>(本ライブラリはスキップのみ) - 言語モデル(LM):
numTags個ぶん(本ライブラリはスキップのみ)
本ライブラリは分かち書き専用。上記6セクションのうちWSに必要なのは1(numTags/do_tagsはセクション3・単語辞書エントリ内のタグ情報のバイト長を知るためだけに必要)・2・4の一部(char_length/in_dictのみ)で、3・5・6と単語辞書エントリ内のタグ候補・per-wordタグモデルはファイル上のバイト列としては存在するが、値として保持せず読み飛ばす(kytea/model.hのskip_*系関数)。ファイル形式自体はKyTea側が定めた不変の仕様であり、本ライブラリの実装はどこを保持しどこを読み飛ばすかを選ぶだけである。
KyteaModel(分類器1個)のシリアライズ
- クラス数(
int32_t。0または2未満なら「モデルなし」を意味しそこで終了) - ソルバー種別(
char1バイトの列挙値。デフォルトはL2R_L2LOSS_SVC_DUAL=L2正則化L2-lossの線形SVM(dual)) - 各クラスのラベル(
int32_t×クラス数) - バイアス有無(
bool) multiplier(double。量子化された重みを実数に戻すためのスケール係数)FeatureLookup(後述)
FeatureLookup=推論用に事前コンパイルされた素性→重みの直接マッピング
KyTeaは学習時の素性文字列→ID辞書をそのままモデルファイルに書き出すのではなく、推論用に最適化されたFeatureLookup構造を別途構築して書き出す。中身は次の7要素:
| フィールド | 型 | 内容 |
|---|---|---|
charDict | Dictionary<FeatVec> | 文字n-gram → クラス別重みベクトル |
typeDict | Dictionary<FeatVec> | 文字種n-gram → クラス別重みベクトル |
selfDict | Dictionary<FeatVec> | 辞書由来の自己文字列素性 → クラス別重みベクトル |
dictVector | FeatVec | 辞書関連の追加固定長重み |
biases | FeatVec | バイアス項 |
tagDictVector / tagUnkVector | FeatVec | タグ推定関連の重み(本ライブラリは未使用) |
Dictionary<FeatVec>はAho-Corasickオートマトン(DictionaryStateの配列:failureリンク、gotos(文字→次状態、ソート済みで二分探索)、output(この状態で確定する素性のインデックス列))である。
Dictionary<Entry>のバイナリレイアウト(charDict/typeDict/selfDict(Entry=FeatVec)だけでなく、単語辞書(Entry=ModelTagEntry)・サブワード辞書(Entry=ProbTagEntry)にも共通の枠組み):
辞書数 : unsigned char(1バイト。0なら「辞書なし」を意味し以降のフィールドは書かれない)
状態数 : uint32_t
[状態数ぶん] 各状態(DictionaryState):
failure : uint32_t // 失敗遷移先の状態インデックス
gotos数 : uint32_t
[gotos数ぶん]
文字 : KyteaChar (=unsigned short, 2バイト)
遷移先 : uint32_t
output数 : uint32_t
[output数ぶん]
値 : uint32_t // この状態で確定するエントリのインデックス
isBranch : bool(1バイト)
エントリ数 : uint32_t
[エントリ数ぶん] writeEntry<Entry>(...) // Entry型ごとに異なる(下記)
gotosは文字(KyteaChar)でソートされており、読み込み側はDictionaryState::step()で二分探索する。
writeEntry<Entry>はEntry型ごとに異なる:
Entry = FeatVec(charDict/typeDict/selfDict用):uint32_tの要素数 →FeatVal(既定int16_t)× 要素数。先頭に素性文字列などの識別子は一切含まれない(対応する文字列はDictionaryStateの遷移パス自体が表現している)。Entry = ModelTagEntry(単語辞書用):word(KyteaString)→ タグレベルごとの情報(本ライブラリはスキップ)→inDict(unsigned char、辞書所属ビットマスク)→ タグレベルごとのKyteaModel(本ライブラリはスキップ)。Entry = ProbTagEntry(サブワード辞書用):本ライブラリはスキップ。
KyteaString(=可変長文字列)のバイナリ表現はNUL終端のバイト列として保存される。長さプレフィックスは持たず、読み込み側は\0まで読み進める実装になっている。
**KyteaCharの型はunsigned short(2バイト・符号なし)**であり、KyTea内部では文字はUTF-8のまま保持せず、いったんこの2バイト整数の内部表現にマッピングしてから扱っている。Aho-Corasickオートマトンの遷移はKyteaChar単位で構築されているため、UTF-8の生バイト列ではなく、モデルに埋め込まれた文字→2バイト整数のマッピングを使ってオートマトンを辿る必要がある。
KyteaCharは固定のUnicodeコードポイントではなく、モデルごとに学習時の文字出現順で採番される「文字のインターン表」のIDである。
- ID
0は空文字列用の予約済みセンチネル。実際の文字はID1から始まる。 - Config内の「文字マップ」フィールドの中身は、学習時にモデルが見た全ての異なり文字を、ID順(=初出順)に並べて連結しただけの1本のUTF-8文字列である。
- モデルローダは、この文字列を読み込んだらUTF-8として先頭から1文字ずつデコードし、出現順に
1始まりのIDを割り振ってchar(UTF-8) → KyteaCharの対応表を構築する。入力テキストを推論する際も、この対応表を引いてUTF-8文字をKyteaChar列に変換してからAho-Corasickオートマトンを辿る。
未知文字(学習語彙にない文字)の扱い:KyTea本体は推論時、未知文字にcharTypes_.size()(=学習時最大ID+1以降)の新規IDを動的に採番する。本ライブラリは未知文字を一律kNoChar(0)に落とす。両者はID割り当てが異なるが、分かち書きの出力は完全に等価である(calculateWSのWS素性は3種すべてがAho-Corasickマッチで、いずれも学習時ID 1..Kでキー付けされたオートマトンを辿るため、未知文字のIDが0でもK+1でもどの遷移にも存在しない)。文字種n-gramへの寄与は、文字種をコードポイントから直接分類しており文字IDに依存しないため影響を受けない。ただし4バイトUTF-8のCJK拡張B漢字(例:𠮷 U+20BB7)は別軸——これは未知文字処理ではなくfindTypeバグ(正しくKanji分類する本ライブラリと、誤分類するKyTeaで型n-gramが分岐する)による意図的な差異。
推論は「文字列→ID→重み配列引き」ではなく「Aho-Corasickでマッチした瞬間に重みベクトルを直接得る」設計。モデルローダは、素性文字列を独自に生成してハッシュ/ID変換するのではなく、このFeatureLookup(3つのAho-Corasickオートマトン+4つの重みベクトル)をそのままファイルからパースし、同じ構造で持つ。
重みの型(FeatVal)はデフォルトでint16_t量子化。配布されている学習済みモデルは通常量子化ビルド(FeatVal = int16_t)で作られている。読み込み時はint16_tの重みをmultiplier(double)倍して実数の重みに戻す。本ライブラリは量子化モデル(int16_t)のみをサポートする(非量子化モデルはヘッダのバージョン文字列"0.4.0NQ"で検出しエラーとする)。
3.3 推論アルゴリズム(単語分割、Kytea::calculateWSの再現)
KyteaBackend::tokenizeが実行する計算。
文の文字数をNとすると、境界はN-1個ある(各文字の直後、最後の文字を除く)。各境界i(0 <= i < N-1)についてスコアscore[i]を次の順で積み上げる:
- 初期値:
score[i] = biases[0](FeatureLookup::biases_の先頭要素。全境界で共通の定数) - 文字n-gramスコアの加算:正規化済み文字列(
sent.norm)に対してcharDict(Aho-Corasick)でマッチした全ての文字n-gramについて、addNgramScoresのロジックで加算する。1つのn-gramマッチは、その出現位置を中心に窓の中に入る複数の境界に同時に寄与する(FeatVecは窓幅window*2分のスコアを1本のベクトルとして持ち、マッチ位置posからbase_pos = pos - windowを起点にscore[base_pos + j] += vec[j](jは有効範囲にクリップ)という形で分配される)。 - 文字種n-gramスコアの加算:文字列を文字種記号の列(
R/H/T/D/K/O)に変換したものに対して、typeDictで同様に加算する。 - 辞書由来(D素性)スコアの加算:
dict_->match(sent.norm)で単語辞書とのAho-Corasickマッチを取り、addDictionaryScoresで加算する。インデックス計算(len=score.size()、max=config.getDictionaryN()、マッチした語の文字長wlen、lablen=min(wlen,max)-1):- マッチ語の左端の境界(
end-wlen番目、ただしend>=wlenの場合のみ)にdictVector[辞書番号*dictLen + (end-wlen)*3*max + lablen*3 + 0]を加算 - マッチ語の内部の各境界(
end-wlen+1 <= k < end)に... + k*3*max + lablen*3 + 1を加算 - マッチ語の右端の境界(
end番目、ただしend != lenの場合のみ)に... + end*3*max + lablen*3 + 2を加算
- マッチ語の左端の境界(
- ハードな制約(
-wsconst相当)の上書き:config.getWsConstraint()に指定された文字種記号が、隣接する2文字の文字種が同一のケースに含まれる場合、その境界のスコアを強制的に「境界なし」側に上書きする(配布jpモデルのwsConstraintは通常空で既定出力には無影響。本ライブラリは未実装)。 - 最終スコア:
wsConfs[i] = score[i] * wsModel_->getMultiplier() - 境界判定:
wsConfs[i] > 0なら境界あり、そうでなければ境界なし。
つまり実装している推論ロジックは、**「biasを初期値に、charDict・typeDict・dictVectorの3種類のAho-Corasickマッチスコアを加算し、multiplierをかけて0と比較する」**という単純な線形和である。SVMの学習部分(LIBLINEAR)は実装していない。
4. 独自MLPバックエンド
3節の線形SVMに代えて、独自に設計したMLP(多層パーセプトロン)を分類器として使うバックエンド。分割方式そのものは3節と同じポイントワイズ二値分類(各境界候補について「区切る/区切らない」を独立に判定)で、公開APIも同じtokenizeを満たす。KyTea/Vaporettoが手で列挙する素性(文字n-gram・文字種n-gram・辞書素性)の交互作用を、窓内の埋め込み表現+隠れ層に自動獲得させる。
学習データはKyTeaコーパス形式(5節)をそのまま使う。学習エンジン(順伝播・逆伝播・最適化)は本ライブラリで実装している。
4.1 設計思想と、KyTea/Vaporettoとの対比
| KyTea(3節)/Vaporetto(外部比較のみ、本ライブラリのバックエンドではない) | 本MLPバックエンド(4節) | |
|---|---|---|
| 分類方式 | ポイントワイズ二値分類 | 同左 |
| 分類器 | 線形SVM(重みの線形和) | MLP(非線形・多層) |
| 素性 | 文字n-gram・文字種n-gram・辞書素性を手で設計・列挙 | 窓内の埋め込みを連結し、交互作用をネットワークが学習 |
| 文字種特徴 | ヒューリスティックな6種分類(3.1節)を明示的に使用 | 使わない |
| 原子単位 | コードポイント単位の「文字」 | EGC(書記素クラスタ)単位(4.2節) |
| 語彙・OOV | モデル内蔵の素性辞書、未知素性はスキップ | 埋め込みはコードポイント語彙、EGCは合成で表現し原理的にOOVなし(4.3節) |
文字種特徴を明示的に持たないのは意図的な設計判断である。未知・低頻度への汎化が問題になった場合に限り、General Unicode Property(General Category等)を補助入力として導入する余地を残す。
4.2 原子単位・境界候補・窓の数え方=EGC
分類の原子単位を、コードポイントではなく**EGC(Extended Grapheme Cluster、UAX #29)**とする。
- 境界候補はEGCの隙間のみ。EGCの内部(例:
か+濁点が= U+304B U+3099、絵文字ZWJ連結👨👩👦= 6コードポイント)では決して区切らない。 - 窓は「EGCの個数」で数える。コードポイント個数で窓を測ると、
👨👩👦1個だけで左右window=5の窓を食い潰してしまう。EGC単位なら👨👩👦は1トークンとして扱え、残りの枠を前後の実際の語に使える。
文のEGC列をe[0..M-1]とすると、境界候補はM-1個(各EGCの直後、末尾を除く)。各境界i(0 <= i < M-1)について独立に二値分類する。
4.3 EGCの表現=構成コードポイントからの合成的埋め込み
各EGCをそのまま語彙IDに引く(フラットなEGC-id埋め込み)方式は採らない。代わりに、EGCを構成コードポイント列に分解し、コードポイント埋め込みをpoolingして1本のEGCベクトルを合成する。
EGC → 構成コードポイント列に分解
各コードポイント → 埋め込み(語彙は「学習時に出現したコードポイント」。小さく有界)
→ pooling → EGCベクトル(次元 d)
語彙構築には頻度閾値を設ける(出現2回未満のコードポイントは語彙に入れずUNKに落とす)。低頻度コードポイントを訓練中UNKとして流すことで、UNK埋め込みが「稀な文字の平均的な振る舞い」を学習する。
日本語・中国語は1 EGC≒1コードポイントがほとんどなので、poolingは大半が恒等に縮退し、実質「コードポイント埋め込み」として振る舞う。結合列・タイ語・ミャンマー語・絵文字でのみpoolingが実効的に働く。
4.4 ネットワーク構成
「窓内の各EGCベクトル+辞書素性を連結し、隠れ1層のMLPに通して二値判定する」ポイントワイズ分類器。構成は速度要件(4.6節の第1層事前計算)から逆算して決定した。
境界 i について:
窓 = e[i-w+1 … i+w] の EGC 列(左右各 w、計 2w 個。端は PAD トークン)
各 EGC → 4.3節の合成的埋め込み(次元 d、pooling は mean)
f_dict = 辞書マッチ二値素性(下記)
h = ReLU( W1 · concat(2w × d) + W_dict · f_dict + b1 ) # 隠れ層 1 層
y = w2 · h + b2 # スカラー
y > 0 なら境界
確定値:
| 項目 | 値 | 根拠 |
|---|---|---|
窓幅 w | 5(左右各5、計10 EGC) | 第1層事前計算方式ではw拡大のコストが「表引き加算1回/EGC」と線形でほぼタダ |
| 埋め込み次元 `d$ | 64 | コードポイント語彙~1万 \times 64で軽量 |
| \text{pooling} | \text{mean}(変更不可) | \text{mean}は線形なので$W1_j·mean(e_c) = mean(W1_j·e_c)`が成り立ち、第1層事前計算(4.6節)と両立する |
| 隠れ層 | 1層、幅H=256、ReLU | ポイントワイズ分類では深さの効果が薄く、推論コストは隠れ層以降が支配的になるため浅く保つ |
| 出力 | 1ユニット、推論時はyの符号判定(sigmoid省略) | p>0.5 ⇔ y>0 |
辞書マッチ二値素性f_dict:辞書(単語リスト)とのマッチは、エントリから構築したFST(4.7節フィールド17)に対する各クラスタ先頭からの共通接頭辞検索で取り、境界iを跨ぐ/接するマッチについて、位置関係3種(L/I/R)×マッチ長バケット(min(EGC長, 4)$の4段階)の計12個の二値素性を立てる(複数辞書対応時は辞書ごとに12個)。同一(位置関係 \times 長さバケット)に複数の辞書語がマッチしても素性は1のまま(二値\text{clamp})。辞書なしでも動作する($f_dict全ゼロ)。
4.5 学習
- 損失:境界ごとの二値クロスエントロピー(BCE)。
sigmoid(y)は損失計算でのみ使用。 - 教師信号のマスク:部分アノテーション(5.2節)の「不明」位置は損失計算から除外する。intra-EGC位置はそもそも境界候補でないため損失にも現れない。
- アノテーションとEGCの衝突処理:EGC内部に境界ありが来た文は、警告を出してその文ごとスキップする。
- 入力の正規化:KyTeaと同じ半角→全角固定テーブル正規化をかけてからEGC分割・語彙化する(
CharTableの正規化テーブルを共用)。 - 学習後の量子化:重み・埋め込みは学習後にint16へ量子化する(PTQ)。
4.6 推論・C++実装方針:第1層の事前計算(NNUE方式)
第1層は純粋な線形変換なので、窓位置ごとに分解できる:
$ \text{W1} · \text{concat}(\text{v\_1}, …, \text{v\_2w}) = Σ\text{\_j} \text{W1\_j} · \text{v\_j} (\text{W1\_j} は窓位置 \text{j} に対応する 256 \times \text{d} のスライス) $
したがって**(EGC, 窓位置j) → W1_j·v(EGC) ∈ R^256を事前に表引き化**する。mean poolingの線形性(4.4節)により、合成的EGC埋め込みもこの表に折り込める。辞書二値素性も同様にW_dictの列ベクトル表引きになる。推論時の1境界の計算は:
acc = b1
acc += table[egc_j, j] を 2w 回(表引き+256次元ベクトル加算)
acc += dict_col[k] をアクティブ辞書素性ぶん(0〜数回)
h = ReLU(acc)
y = dot(w2, h) + b2 (256次元内積 1 回)
境界 ⇔ y > 0
テーブル・アキュムレータの数値表現:Model::load/load_from_bytesはTablePrecision::{Int32,Int16}を選べる(既定はInt16)。
- Int16:
kAccShift=9(→、4倍ヘッドルーム)、requant_i16=丸め付き右シフト+飽和が全int16量(table/dict_col/b1、b2は同シフトのint64)の変換。アキュムレータは飽和add(vqaddq_s16)。 - Int32:検証用参照経路として残置。学習側
int16_decisionとのbit-exact契約はInt32が担う。
事前計算テーブル:頻出EGC(≒頻出コードポイント)について構築。稀なEGCは「コードポイント埋め込み→mean→W1_jを掛ける」合成経路にフォールバックする。
SIMDカーネル:include/segmentlib/mlp/kernels.h$(ヘッダオンリー)に\text{add}/\text{relu}/\text{dot} \times \text{int32}/\text{int16}の6カーネル+$add_widen_i16_i32、およびInt16経路が実際に走らせる融合カーネルfused_score_i16(4.8節)。kernels::scalar::*が常時コンパイルされるoracle、ディスパッチはコンパイル時(AArch64→NEON、x86は__AVX2__定義時のみAVX2、他はscalar)。NEON・AVX2ともbit一致テストで実機検証済み(NEON=ローカルARM実機、AVX2=CI ubuntu-24.04実機+Windows MSVC実機)。
thread_localスクラッチ:mlp/mlp_backend.hのdetail::scratch()アクセサが持つScratch{EncodedEgc,Workspace,scores}、per-call割当ゼロ。名前空間スコープではなく関数内に置くのは、ヘッダが多数の翻訳単位にincludeされてもプログラム全体で1つの実体でなければならないため。
トークン化(UTF-8 → EGC分割)はUAX #29に従う。順伝播(推論)のみをライブラリ本体が実装し、学習は別コンポーネント(SEGMENTLIB_BUILD_TRAININGオプトイン)で行い、モデルファイル(4.7節)で受け渡す。
4.7 モデルファイル形式(独自設計)
シリアライズ形式は独自設計。BinaryReader(bytes/binary_reader.h)のプリミティブ(リトルエンディアン固定幅整数・NUL終端文字列・\n終端ヘッダ行)でそのまま読めることを設計制約とし、KyTeaバックエンドと同じ読み取り基盤を共用する。事前計算テーブル(4.6節)はファイルに含めず、ロード時に構築する。辞書マッチャは逆に、ロード時に構築しないためにコンパイル済みFSTとしてファイルに含める。
ヘッダ行(ASCII、\n終端)
SegmentLibMLP <version>\n
例:SegmentLibMLP 1\n。この"SegmentLibMLP "シグネチャを2節のバックエンド自動判別に使う(KyTeaの"KyTea "シグネチャと排他)。未知のバージョンはローダがエラーにする。
ヘッダ行に続くバイナリ本体(すべてリトルエンディアン):
| # | フィールド | 型 | 内容 |
|---|---|---|---|
| Config | |||
| 1 | char_window w | uint8 | 片側窓幅(EGC個数)。4.4節でw=5 |
| 2 | embed_dim d | uint16 | コードポイント埋め込み次元。4.4節で64 |
| 3 | hidden H | uint16 | 隠れ層幅。4.4節で256 |
| 4 | num_dicts | uint8 | 辞書数(0可。0ならW_dict・辞書セクションは書かれない) |
| 4b | unicode_version | `uint16$ | 学習時の\text{EGC}分割に使った\text{Unicode}バージョン(メジャー \times 100+マイナー)。不一致ならローダが警告する |
| \text{Scales}(量子化スケール) | |||
| 5 | $emb_scale` | double | 埋め込みint16→実数の係数 |
| 6 | w1_scale | double | W1のint16→実数の係数 |
| 7 | wdict_scale | double | W_dictの係数(num_dicts>0のときのみ) |
| 8 | w2_scale | double | w2の係数 |
| 8b | acc_scale | double | 加算器(第1層活性)の整数スケール。学習後に検証データで活性分布をキャリブレーションして選ぶ |
| Vocabulary(コードポイント語彙) | |||
| 9 | vocab_size V | uint32 | 埋め込み行数。行0=PAD、行1=UNK(未知コードポイント)を含む |
| 10 | codepoints | uint32 × (V-2) | 行2..V-1に対応するコードポイントを昇順で格納。推論時は入力コードポイントをこの配列で二分探索し行番号を得る |
| Embedding | |||
| 11 | embedding | int16 × (V·d) | 埋め込みテーブル。行優先(行0=PAD、行1=UNK、行2以降=codepoints順) |
| Layer 1 | |||
| 12 | W1 | int16 × (H · 2w · d) | 第1層重み。行優先でW1[h][j*d + c] |
| 13 | W_dict | int16 × (H · num_dicts · 12) | 辞書二値素性の重み。num_dicts>0のときのみ |
| 14 | b1 | double × H | 第1層バイアス(非量子化) |
| Layer 2 | |||
| 15 | w2 | int16 × H | 出力層重み |
| 16 | b2 | double | 出力層バイアス(非量子化) |
Dictionaries(num_dicts>0のときのみ) | |||
| 17a | fst_size | uint32 | コンパイル済みFSTのバイト長 |
| 17b | fst | uint8 × fst_size | cpp-fstlibのバイトコード。各エントリの正規化UTF-8バイト列をキーとし、出力はチャンネル集合のid。そのまま使う(ローダは展開も再構築もしない)。語リストではなくコンパイル済みマッチャを持つことで、57万語のUniDic辞書が2.1MB・ロード135msに収まる(語リストだけなら7.6MB・510ms) |
| 17c | set_count | uint32 | 相異なるチャンネル集合の個数 |
| 17d | set_offsets | uint32 × (set_count+1) | set_dictsへのCSRオフセット。昇順かつ先頭0であることをローダが検査する |
| 17e | set_dicts | uint8 × set_offsets[set_count] | 各集合の要素となる辞書チャンネル。いずれもnum_dicts未満であること |
ロード時の処理:(1) 語彙・埋め込み・重みを読み、(2) 位置別事前計算テーブルと辞書素性の列ベクトルの展開を構築、(3) 辞書マッチャをファイル中のFSTに向ける、(4) b1/b2をアキュムレータ整数スケールへ量子化。
4.8 評価結果(現在の実測値)
比較は入手可能なコーパス(UD_Japanese-GSD、CC BY-SA 4.0)でKyTeaとVaporettoを再学習し、同一データ・辞書なしで学習したMLPバックエンドと突き合わせる形で行っている(配布モデルの学習コーパスは入手不可のため)。3者とも corpus/ud-gsd/train.kytea.txt で学習、辞書素性なし。KyTeaは実バイナリ(kytea -notags)と自前バックエンドがバイト一致することを確認済み。
精度(scripts/eval_segmentation.py、境界F値。3者とも同一eval・同一goldで計測):
| テストセット(ジャンル) | 境界数 | KyTea F1 | Vaporetto F1 | MLP F1 | MLP差(vs KyTea) |
|---|---|---|---|---|---|
| GSD test(Wikipedia、in-domain) | 12,491 | 98.87% | 98.79% | 98.00% | −0.87pt |
| PUD(news/Wikipedia対訳、out-of-domain) | 27,788 | 99.24% | 99.17% | 98.57% | −0.67pt |
| GSD+PUD合算(2ジャンル) | 40,279 | 99.13% | 99.05% | 98.39% | −0.74pt |
辞書なし・既定構成(w=5, d=64, H=256, patience=15, seed=42)での結果。量子化による判定反転はUD-GSD実モデルでdev+train 290,024境界中0件。seed起因のF1変動は±0.05pt程度(5 seedで実測)。辞書なしでは本MLPは線形モデル(KyTea/Vaporetto)に約0.7〜0.9pt負ける。KyTeaとVaporettoはほぼ互角。
最適化手法という手が1つ開いている。実測済みだが未採用。 11.6節の素のSGD対照(EDLA比較のために追加したもの)が、学習率を詰めればこのタスクでAdamを上回ることが判明した。出荷構成(UniDic辞書あり)で5 seed:
| 最適化手法 | GSD test F1 | PUD test F1 |
|---|---|---|
| Adam, lr 1e-3(既定) | 99.13% (0.038) | 99.30% (0.019) |
| Adam, lr 3e-3(dev選択) | 99.12% (0.068) | 99.31% (0.035) |
| SGD, lr 5.0(dev選択) | 99.31% (0.034) | 99.46% (0.022) |
この結論を出す前にAdam側の学習率も同じ手順でスイープしている。片側だけを調整するのは、このプロジェクトの比較がまさに避けようとしている誤りだからである。そしてその検証こそが結果を興味深くしている。Adamをチューニングするとdevは改善する(0.9897→0.9905)が testは改善せず、SGDのより小さなdevの優位(0.9907)の方がtestに転写される。現在の既定に対する+0.19/+0.16ptはseed分散の約5倍で、推論コストはゼロ。同じ辞書を与えたKyTea(99.43/99.54)との残差を−0.30/−0.24ptから−0.12/−0.08ptへ縮める。
採用ではなく記録に留めてある。既定を切り替えるとは同梱参照モデルの再訓練、その出力を固定しているゴールデンフィクスチャの再生成、そしてリリース(docs/RELEASING.md)を意味し、測定の副作用ではなく意図的な行為であるべきだからである。--optimizer sgd --lr 5.0で再現できる。
この構成に対して2つの手を評価し、決着させた(いずれも5 seedで実測)。早期終了のpatienceは5→15に引き上げて採用:dev F1は十数エポック規模の踊り場を挟みながら伸び続けるため、patience 5は踊り場で打ち切ってGSD test F1を0.11pt失っていた。patience 15のコストは学習時間だけ(約22秒→60秒)で、推論側には何も乗らない。ネットワークの容量増(d=96, H=512)は不採用:GSD +0.23pt / PUD +0.10ptと引き換えに推論速度が45%減(5.1→2.8 M chars/sec)、モデルは627KB→1511KB、ロードは130ms→467ms。これは以前に不採用とした辞書素性(−48%でGSD +0.45pt)と同じ速度コストで、精度の伸びは半分、モデルの肥大は12倍にあたる。
この差の大半は辞書で埋まり、本プロジェクトが構築・評価する参照モデルはそれを使う構成に変えた(トレーナの --dict 自体はオプトインのまま。語リストは利用者が渡すファイルである)。学習コーパスから自己抽出した辞書でもGSD +0.42ptだが、外部辞書の方が遥かに効く。UD_Japaneseの分割基準がUniDicの短単位であり、UniDicはその語彙そのものだからである。以下はすべて同一の辞書なしベースラインに対する5 seed平均で、採用した辞書は scripts/fetch_unidic_dict.sh で再現できる。
| 辞書 | 語数 | GSD F1 | PUD F1 | モデル | ロード | 速度 |
|---|---|---|---|---|---|---|
| なし | 0 | 98.07% | 98.59% | 0.6 MB | 130 ms | 5.0 M chars/sec |
| 学習コーパス、頻度2以上 | 8,726 | 98.49% | 98.90% | 0.7 MB | 130 ms | 2.9 |
| IPAdic | 325,869 | 98.81% | 99.09% | 1.7 MB | 137 ms | 2.4 |
| UniDic、2〜4文字 | 353,968 | 99.06% | 99.19% | 1.7 MB | 133 ms | 2.6 |
| UniDic、2文字以上(既定) | 565,302 | 99.12% | 99.30% | 2.1 MB | 135 ms | 2.4 |
| UniDic、全部 | 570,144 | 99.14% | 99.29% | 2.1 MB | 134 ms | 2.3 |
効くフィルタは1つ、1文字エントリの除去である。UniDicの1%にすぎないが実文中で絶えずマッチする一方、該当文字は文字窓が既に直接見ているため、除いても精度は測定可能な範囲で変わらず速度が約12%戻る。もう1つの候補だった語長4文字での打ち切りは割に合わない。長いエントリの保存コストになるはずの接頭辞・接尾辞をFSTが共有するため、打ち切りの節約は0.4MBにとどまり、GSD −0.06pt / PUD −0.11ptに見合わない。同じ共有のおかげでロード時間も辞書サイズにほとんど依存しない(上表のどの行も130〜137ms)。
留保が2つ。線形モデルとの順位は変わらない。同じUniDic辞書を与えるとKyTeaはGSD 99.43% / PUD 99.54%に達し、依然として上である。また --dict は繰り返し指定でき辞書を別チャンネルとして重ねられるが、既定には採用しなかった。UniDic + IPAdic + コーパス辞書の3チャンネルは +0.10ptに対し速度32%減・モデル11.4MBになる。逆に語リストを1チャンネルに合併しても何も起きない(コーパス辞書の95%が既にUniDicに含まれるため)。
速度(M1 Pro、bench/bench_segment、int16+NEON、best-of-8、辞書なし):
| ジャンル | MLP | 実KyTea | 比 |
|---|---|---|---|
| GSD train(Wikipedia、277K字) | 5.11 M chars/sec | 1.40 M chars/sec | 3.65x |
| PUD test(news対訳、48K字) | 5.60 M chars/sec | 1.58 M chars/sec | 3.54x |
分割速度はジャンルに非感受(スコア計算はEGC窓の整数演算のみで語彙・文体に依存しない)。
4.9 学習側の設計(C++自前実装)
学習エンジンは本ライブラリで自前実装する(外部フレームワーク非依存)。学習はfp32、推論はint16(4.6節)で、両者はモデルファイル(4.7節)で受け渡す。推論(ライブラリ本体の成果物)は順伝播のみで、学習コンポーネントはビルド上分離する(SEGMENTLIB_BUILD_TRAININGオプトイン。推論バイナリにBLAS/CUDAを要求しない)。
学習パイプライン
1. コーパス読込(5節, KyTeaフル/部分アノテーション)
2. 正規化(KyTea互換の半角→全角, CharTable共用, 4.5節)
3. EGC分割(UAX #29)
3b. 語彙構築: 出現コードポイントを頻度集計し、閾値未満は UNK に落とす
4. 例の生成: 各境界 i →
- 窓 [i-w+1 … i+w] の各EGC → 構成コードポイント行ID列(端はPAD)
- 辞書マッチ二値素性 f_dict(辞書FST, 4.4節)
- ラベル(境界=1/非境界=0)
- マスク(部分アノテーションの不明位置は損失から除外, 4.5節)
5. ミニバッチ化: 埋め込みgather → mean pooling → concat(2w·d)
6. 順伝播 → BCE(マスク付き) → 逆伝播(埋め込みは疎勾配)
7. 最適化: Adam
8. 収束後: PTQ int16 量子化 → 検証(判定反転チェック, 4.5節)→ 5.7形式で書き出し
逆伝播の要点
- 第1層・第2層:標準的なdense層の勾配。行列積が主計算で、
ComputeBackendのGEMMに委譲する。 - mean pooling → 埋め込み:EGCベクトルが構成コードポイントのmeanなので、EGCベクトルへの勾配は各構成コードポイント行へ
1/(コードポイント数)で分配される。埋め込みテーブルの勾配は疎(バッチに現れた行のみ)。Adamの1次・2次モーメントも現れた行だけ更新する。 - 辞書二値素性
f_dictへのW_dict勾配も、立っている素性の列のみの疎更新。
最適化器:Adam。
量子化:PTQ(Post-Training Quantization)で確定。QATは採用しない。
計算バックエンド抽象(ComputeBackend)
行列積・活性化・要素演算・勾配だけをこの層に閉じ込め、プラットフォームごとに実装を差し替える。CPU実装はBLASインターフェース(cblas_sgemm等)に対して書き、リンクするBLASを切り替えるだけで全OSに載る。
| プラットフォーム | 第一候補 | CPU実装(BLAS) | GPU実装(任意) |
|---|---|---|---|
| macOS (Apple Silicon) | CPU/AMX | Accelerate | Metal/MPSGraph(通常不要) |
| Linux + NVIDIA | GPU | OpenBLAS/MKL | cuBLAS |
| Linux (GPUなし) | CPU | OpenBLAS/BLIS | — |
| Windows + NVIDIA | GPU | OpenBLAS/MKL | cuBLAS |
| Windows (GPUなし) | CPU | OpenBLAS | — |
サポート方針:macOS・Linuxを一級サポート、Windowsはbest-effort。BLASは学習ターゲット(segmentlib_train)のみリンクする。
推論側の可搬性(学習とは別軸):推論はBLASではなく手書きSIMDのint16 NNUE方式(4.6節)で、NEON(Apple/ARM)+ AVX2(x86 = Linux/Windows共通)+ スカラーfallbackの3実装で全OSを覆う。x86のAVX2パスはLinux/Windowsで同一。
5. コーパス仕様
デフォルトの区切り文字:
| 用途 | 文字 | デフォルト |
|---|---|---|
| 単語境界(フル)/ 不明境界(部分) | wordBound_ / unkBound_ | 半角スペース " " |
| タグ境界 | tagBound_ | / |
| タグ候補区切り | elemBound_ | & |
| エスケープ | escape_ | \ |
| 非境界(部分) | noBound_ | - |
| 境界(部分) | hasBound_ | | |
| スキップ(部分) | skipBound_ | ? |
両バックエンド(3〜4節)の学習データは、このKyTeaコーパス形式に統一する。
5.1 フルアノテーション形式
word1/tag0a&tag0b/tag1a word2/tag0 word3 ...
- 単語は半角スペースで区切る。
/が出現するたびにタグの「レベル」が1つ進む。- 同一レベル内で複数候補のタグを持たせたい場合は
&で連結する(学習時は先頭候補が正解ラベルとして使われる)。 - 単語・タグ中に区切り文字自体(スペース、
/、&、\)を含めたい場合は\でエスケープする。 - 実例:
コーパス/ko:pasu の/no 文/buN で/de す/su 。/. - 本ライブラリの学習パイプライン(MLPバックエンド)はタグ情報を使わず境界情報のみを使う。
5.2 部分アノテーション形式
ヴ-ェ-ネ-ツ-ィ-ア|は|イ-タ-リ-ア|に|あ り ま す|。
- 文字を1文字ずつ並べ、隣接文字の間に以下いずれかの記号を置いて境界情報を表す:
-(noBound):境界ではない(同じ単語内)。教師信号として確定的に使う。|(hasBound):境界である。教師信号として確定的に使う。単語の終端も兼ねる。(unkBound)/?(skipBound):不明。読み込み時はいずれも「教師信号なし」として扱われる。
- タグは各語の末尾、
|の直前に/tag0&tag1/tag2...の形式でフルアノテーションと同じ文法で付与できる(本ライブラリの学習は境界情報のみ使用)。 - エスケープ文字・タグ境界・タグ候補区切りはフルアノテーションと共通(
\,/,&)。
6. C++ API
6.1 基本方針
- 可変オブジェクトを使い回す副作用ベースのAPIではなく、入力を受け取り結果を値として返す関数型のAPIを基本とする。
- 入力は所有権を必要としないため
std::string_viewを受け取る。 - エラーは
Expected<T,E>(support/expected.h)で表現する。std::expectedはC++23で、 推論がターゲットとするC++17では使えないため、同形の最小実装を自前で持つ。 - オフセットはUTF-8バイトオフセットを採用する。
- バックエンド(KyTea互換/独自MLP)の違いはこのAPI層には一切露出しない。呼び出し側は
Segmenter::load()で読み込んだモデルファイルの種類を意識せず、同じtokenize()を呼ぶだけでよい。
6.2 型
本ライブラリは分かち書き専用。Segmentsは語スパン(開始・終了のUTF-8バイトオフセット)のペア列という最小の形をとる——専用のSegment構造体は導入せず、生の型のエイリアスとする。
using Segments = std::vector<std::pair<std::size_t, std::size_t>>; // (start, end) のペア列
enum class ErrorCode {
InvalidUtf8,
ModelNotLoaded,
UnsupportedModelFormat,
MalformedModel,
MalformedCorpus,
IoError,
};
struct Error {
ErrorCode code;
std::string_view message; // 静的文字列を想定。動的なメッセージは持たせない
};
6.3 Segmenter
class Segmenter {
public:
static Expected<Segmenter, Error> load(const std::filesystem::path& model_path);
static Expected<Segmenter, Error> load_kytea(const std::filesystem::path& model_path);
static Expected<Segmenter, Error> load_mlp(const std::filesystem::path& model_path);
// Move-only: a Segmenter owns its model outright, so an implicit copy
// would silently duplicate all of it (~400MB for the distributed KyTea
// model). Share one with `const Segmenter&`.
Segmenter(const Segmenter&) = delete;
Segmenter& operator=(const Segmenter&) = delete;
Segmenter(Segmenter&&) = default;
Segmenter& operator=(Segmenter&&) = default;
Expected<Segments, Error> tokenize(std::string_view text) const;
std::vector<Expected<Segments, Error>> tokenize_all(
Span<const std::string_view> texts, unsigned threads = 0) const;
};
- 分かち書きのみを行う
tokenize()一本。多数の入力を並列に分かち書きするtokenize_all()(threads==0でハードウェア並列数、9節:M1 Pro 8スレッドで44.98 M chars/sec、単スレッド比7.96×)。 - 空文字列の入力は「エラーではなく空の結果」として扱う(
Segments{})。ErrorはUTF-8不正やモデル未読込・未対応形式などの実際の異常系のみに使う。 - ロード済みの
Segmenterは不変で、呼び出しごとの作業領域はthread_localなので、同一のconst Segmenter&に対して任意個のスレッドから同時にtokenize()/tokenize_all()を呼べる。共有はコピーではなくこの形で行う。
7. CLIインターフェース
コマンド名はsegmenter。git/cargoのようなサブコマンド構成。
segmenter predict --model model.bin < input.txt > output.txt
segmenter train --backend mlp --corpus corpus.txt --model-out model.bin
7.1 segmenter predict(推論)
KyTea / Vaporettoと同様、標準入力からテキストを読み、標準出力に分かち書き結果を書き出すフィルタ型。
オプション
| オプション | 説明 |
|---|---|
--model <path> | モデルファイルのパス(必須)。KyTea互換/独自MLPのいずれかを自動判別する(2節) |
--threads <n> | 並列実行のスレッド数(0=hardware_concurrency()、既定。上限1024) |
出力フォーマット
KyTeaの分かち書き出力形式(スペース区切りの単語列)を踏襲する。
コーパス の 文 で す 。
表層語のエスケープ(showEscapedString):区切り文字(スペース・/・&・エスケープ文字\自身)が単語表層に含まれる場合、\で前置してエスケープする(例:入力Hello World → Hello \ World、2024/12/31 → 2024 \/ 12 \/ 31)。実装はappend_full_line(include/segmentlib/output.h)に集約し、CLIとgoldenテストで共有。
入力の扱い:入力は常にUTF-8固定。不正なUTF-8バイト列はCharTable::encode/Vocab::encodeがErrorCode::InvalidUtf8を返し、CLIは該当行以前の出力をflushした上で即座に中断する(exit code 1・stderrにメッセージ)。
7.2 segmenter train(学習)
segmenter train --backend mlp \
--corpus full1.txt --corpus full2.txt \
--partial-corpus part1.txt \
--dict dict.txt \
--model-out model.bin \
[--char-window 5] [--embed-dim 64] [--hidden 256] [--min-count 2] \
[--epochs 100] [--batch-size 256] [--patience 15] [--lr 1e-3] [--seed 42]
--backend mlpと--backend edを実装している(いずれもSEGMENTLIB_BUILD_TRAINING=ONビルドが必要。OFFビルドではtrainはスタブ)。--backend kytea/--backend vaporettoは明示的な「not implemented」エラーを返す(train_command.cpp)。
以下のオプションは--lrを含め、どちらのバックエンドでも同じ意味を持つ。EDLA学習器は同じミニバッチ上で同じAdamを使い、更新を作る規則だけが異なる(11.3節)。フラグを揃えた両者の実行が、その差を学習則に帰属させられるのはこのためである。
| オプション | 説明 |
|---|---|
--backend mlp|ed | 学習するバックエンド(必須) |
--corpus <path> | フルアノテーションコーパス(5.1節)。複数回指定可能 |
--partial-corpus <path> | 部分アノテーションコーパス(5.2節)。複数回指定可能 |
--dict <path> | 辞書ファイル。複数回指定可能 |
--dev-corpus <path> | 検証用コーパス(早期終了・量子化キャリブレーションに使用) |
--model-out <path> | 学習済みモデルの出力先(必須) |
--char-window <int> | EGC窓幅(既定5) |
--embed-dim <int> | 埋め込み次元(既定64) |
--hidden <int> | 隠れ層幅(既定256) |
--min-count <int> | コードポイント語彙の頻度閾値(既定2) |
--epochs <int> | エポック数(既定100) |
--batch-size <int> | バッチサイズ(既定256) |
--patience <int> | 早期終了の忍耐値(既定15) |
--lr <float> | 学習率(既定1e-3) |
--optimizer <adam|sgd> | 最適化手法(既定adam。sgdは11.6節のスケール保存対照) |
--seed <int> | 乱数シード(既定42) |
--ed-embedding-update <hybrid|pure> | EDLA専用:埋め込みの更新方法(既定hybrid、11.3節) |
--ed-learn-feedback | EDLA専用:フィードバックを固定極性ではなく学習する(Kolen-Pollack、11.7節) |
--ed-feedback-decay <float> | EDLA専用:上記の共有減衰(既定0.01) |
--ed-feedback-init <float> | EDLA専用:フィードバックの初期値の大きさ(既定1.0) |
KyTea互換モデルの学習が必要な場合は、本物のtrain-kytea(Homebrew配布)を外部ツールとして直接呼ぶ(本ライブラリの評価パイプライン、4.8節がその実例)。
8. 実装モジュール構成
8.1 レイヤ構成(下位→上位、上位は下位にのみ依存)
include/segmentlib/
├── bytes/
│ ├── binary_reader.h # (L1) プリミティブなバイナリ読み取りカーソル
│ └── binary_writer.h # (L1) 対になる書き出しカーソル(学習側のexporterが使う)
├── unicode/
│ ├── utf8.h # (L1) UTF-8デコード/エンコードの純粋関数群
│ ├── egc.h # (L1) UAX #29 EGC分割
│ └── normalize.h # (L1) KyTea互換の半角→全角正規化
├── kytea/
│ ├── char_table.h # (L2) 文字種分類 + 文字インターン表
│ ├── automaton.h # (L2) Aho-Corasickランタイム表現(double-array)
│ ├── model.h # (L3) Config/KyteaModel/FeatureLookupのデータ型 + load()
│ ├── scorer.h # (L4) calculateWSのスコア計算アルゴリズム
│ └── kytea_backend.h # (L5) Backend I/F実装(tokenize)
├── mlp/
│ ├── vocab.h # (L2) コードポイント語彙 + EGCエンコード
│ ├── dictionary.h # (L2) 辞書二値素性のFSTマッチャ(cpp-fstlib)
│ ├── kernels.h # (L2) SIMDカーネル(add/relu/dot、NEON/AVX2/scalar)
│ ├── precompute.h # (L3) 位置別事前計算テーブル
│ ├── model.h # (L3) モデルのデータ型 + load()
│ ├── scorer.h # (L4) 推論スコア計算
│ └── mlp_backend.h # (L5) Backend I/F実装(tokenize)
├── ed/ # EDLA:mlpと同じネットワークを別の規則で学習(11節)
│ ├── model.h # (L3) "SegmentLibED"署名。本体のparseはmlp::Modelが行う
│ └── ed_backend.h # (L5) Backend I/F実装(mlp::tokenize_withでスコアリング)
├── support/ # 後発規格の機能をC++17で代替するもの
│ ├── expected.h # Expected<T,E> / Unexpected<E>(std::expectedはC++23)
│ ├── span.h # Span<T>(std::spanはC++20)
│ ├── endian.h # バイト順とbyteswap(std::endian/byteswapはC++20/23)
│ └── attributes.h # SEGMENTLIB_FLATTEN(ヘッダオンリー版ホットパスのインライン化ヒント)
├── segmenter.h # (L6) 公開API(6節)
├── output.h # 出力整形(append_full_line)
└── types.h # Segments/Error(6.2節、依存なし)
src/ # 推論はヘッダオンリー。ここでコンパイルされるのは学習とCLIのみ
├── mlp/train/ # 学習コンポーネント(SEGMENTLIB_BUILD_TRAINING時のみビルド)
│ ├── corpus.{h,cpp} # KyTeaコーパスパーサ
│ ├── example.{h,cpp} # 学習例生成
│ ├── dataset.{h,cpp} # ミニバッチ化
│ ├── net.{h,cpp} # 順伝播・逆伝播
│ ├── adam.{h,cpp} # Adamオプティマイザ
│ ├── trainer.{h,cpp} # 学習ループ
│ ├── quantize.{h,cpp} # PTQ int16量子化
│ ├── exporter.{h,cpp} # モデルファイル書き出し
│ ├── compute_backend.h # BLAS抽象
│ └── cpu_blas.cpp # CPU(Accelerate/OpenBLAS)実装
├── ed/train/ # EDLA学習器:net.cppの逆伝播だけを置き換え、他は再利用
│ ├── edla.{h,cpp} # 局所更新則・Dale則・極性
│ └── trainer.{h,cpp} # 学習ループ(mlp/train/trainer.cppと同型)
└── cli/
├── main.cpp # `predict`/`train` のサブコマンド振り分け
├── predict_command.cpp # predictサブコマンド本体
└── train_command.cpp # trainサブコマンド本体(--backend mlp と ed を実装)
8.2 各モジュールの責務
L1: bytes::BinaryReader — Span<const std::byte>を読み進める薄いカーソル。read<T>()、read_cstring()を提供する。パースエラーは内部では例外(軽量なParseError)で投げっぱなしにし、model.hのload()という境界だけでExpectedに変換する。
L1: unicode::utf8 — UTF-8の1コードポイントをデコードする純粋関数。
L2: kytea::CharTable — 「文字インターン表」(ID0=センチネル、実文字は1始まりの初出順)を保持する値型。decode/encodeと文字種分類(classify)を提供する。
L2: kytea::Automaton<Payload> — Dictionary<Entry>のランタイム表現。std::vector<State>とstd::vector<Payload>をフラットに持つ値型(canonical double-array)。モデルファイルのデシリアライズ結果を受け取るだけで、Aho-Corasickを構築するロジックは持たない(ファイルには構築済みオートマトンがそのまま入っているため)。
L3: kytea::Model — Config/KyteaModel/FeatureLookupに対応するイミュータブルな値型。static auto load(std::filesystem::path) -> Expected<Model, Error>が唯一の構築経路。保持するのはcharDict/typeDict/dictVector/biases/multiplier/word_dict(char_length/in_dictのみ)——分かち書きに必要な最小限。単語辞書のper-wordタグ情報・サブワード辞書・言語モデル・タグモデルは、ファイル上に存在するので読み飛ばして正しく後続位置までシークするが、値としては保持しない。
L4: kytea::scorer — calculateWSのアルゴリズムをそのまま関数として実装する。Model・CharTable・入力テキストを受け取り、境界ごとのスコア列を返す純粋関数。
L5: kytea::KyteaBackend — 2節で定義したtokenizeシグネチャを満たすクラス。中身はModelを保持し、scorerの関数を呼ぶだけの薄いアダプタ。
L6: Segmenter — 2節の設計そのまま。std::variant<KyteaBackend, MlpBackend>。
CLI層 — predict_command.cppは「引数パース→Segmenter::load→stdinを1行ずつ読んでtokenize→出力」というだけの薄いループ。ビジネスロジックを一切持たない。
8.3 ディレクトリ構成
cpp-segmentlib/
├── CMakeLists.txt # トップレベル。C++23、サブディレクトリを束ねる
├── include/segmentlib/ # 公開ヘッダ(8.1節の構成)
├── src/ # 実装(8.1節の構成)
│ ├── CMakeLists.txt # segmentlib(ライブラリ)のターゲット定義
│ └── cli/
│ └── CMakeLists.txt # segmenter(実行ファイル)のターゲット定義
├── tests/
│ ├── CMakeLists.txt
│ ├── unit/ # モジュール単位のテスト
│ ├── golden/ # KyTea実バイナリの実出力と突き合わせるテスト
│ │ ├── golden_test.cpp
│ │ └── fixtures/
│ │ ├── input.txt
│ │ └── expected.txt # `kytea -notags`の出力
│ └── consumer/ # 利用側プロジェクトの模擬。check_consumer.shが
│ # ビルドする。本体ビルドには含まれない
├── models/
│ ├── mlp/ # コミット済みMLP参照モデル + NOTICE(CC BY-SA 4.0)
│ └── kytea/ # (gitignore) fetchするKyTeaモデル
├── corpus/ # (gitignore) 評価用コーパス
├── bench/ # 推論ベンチ(9節)
│ ├── setup.sh / run.sh / README.md
│ ├── bench_segment.cpp # 自ライブラリ in-process
│ ├── bench_kytea.cpp # libkytea in-process
│ └── {.vendor,corpus,results}/ # (gitignore)
├── scripts/
│ ├── fetch_kytea_model.sh
│ ├── fetch_ud_gsd_corpus.sh
│ ├── fetch_ud_pud_corpus.sh
│ ├── convert_ud_gsd_corpus.py
│ ├── eval_segmentation.py
│ ├── extract_dict.py
│ ├── check_consumer.sh
│ ├── gen_egc_table.py
│ └── strip_kytea_tags.py
├── docs/
│ └── design.ja.md / design.md
├── .github/workflows/ci.yml
└── .gitignore
設計判断
- ビルドシステムはCMake。
- 推論経路の依存は vendoring した1つだけ:
third_party/cpp-fstlib(ヘッダオンリー、MIT)。辞書マッチャ(4.4節)が使うFSTで、mlp/dictionary.hがfst::mapを直接持ちincludeするため、ヘッダオンリーの推論経路を使う側も一緒にコンパイルすることになり、includeパスにthird_party/が必要(segmentlibCMakeターゲットがSYSTEM interface includeとして持つ)。includeは"cpp-fstlib/fstlib.h"と書き、includeパスはthird_party/にしてある。利用側プロジェクトがこれを隠すにはfstlib.hというファイル名だけでなくディレクトリ名の一致まで必要になる。それ以外は標準ライブラリのみ。学習経路(SEGMENTLIB_BUILD_TRAINING)のみBLASをリンクする。 - テストフレームワークは
doctest(ヘッダオンリー、CMakeのFetchContentで取得)。 models/kytea/・corpus/はgit管理しない:.gitignoreに追加し、scripts/fetch_*.shのようなダウンロードスクリプトのみをリポジトリに置く。例外はmodels/mlp/(学習済みMLP参照モデル、約2.1MB、本プロジェクト自身の成果物)で、NOTICEと一緒にコミットし、cloneした直後にダウンロードも学習も無しで分かち書きが動くようにする。その出力は回帰テストで固定される。再学習はリリース時の行為(docs/RELEASING.md)。golden/テストは固定データ方式:既知の入力文とKyTea実行結果のペアをtests/golden/fixtures/に固定データとしてコミットする。モデル未取得時はテストがスキップされる(CI耐性)。
8.4 ビルド/ツールチェーン要件
- 推論はC++17だけで足りる。ヘッダオンリーであり、本来使いたい後発規格の語彙型(
std::expected=C++23、std::span・std::endian/std::byteswap=C++20)はinclude/segmentlib/support/の最小実装で置き換えてある。C++17のプロジェクトにヘッダを置くだけで使えるのはこのため。scripts/check_consumer.sh build-consumer 17がC++17+-pedantic-errorsで利用側をビルドしており、公開ヘッダに後発規格の構文が紛れ込んだときに落ちる唯一のビルドがこれ。 - 学習とCLIはC++23が必要なまま(
SEGMENTLIB_BUILD_TRAINING・SEGMENTLIB_BUILD_CLI)。テストスイートも同様。C++17を約束するのは推論ヘッダだけ。 - macOS:AppleClang(Xcode標準)でビルド可能。
- Linux:GCC 14以降。
- Windows:MSVC、best-effort。
- ビルド手順:
ベンチマーク計測時は必ずcmake -S . -B build -G Ninja cmake --build build ctest --test-dir build --output-on-failure-DCMAKE_BUILD_TYPE=Releaseを指定する(Debug構成では推論速度が数十倍遅くなる)。 - 他プロジェクトからの利用:
add_subdirectory()またはFetchContentで取り込み、segmentlibターゲットにリンクする(公開includeパスは自動で伝播)。開発用ターゲット——テストスイート(doctestをネットワーク取得する)・ベンチマーク・segmenterCLI——は、単体ビルド時はON、segmentlibが最上位プロジェクトでないときはOFFが既定なので、利用側のallでビルドされるのは静的ライブラリ1つだけ。CMAKE_BUILD_TYPEのRelease既定も同様に単体ビルド時のみ(FetchContent配下ではキャッシュは利用側のものだから)。install/exportセットは持たないためfind_package(segmentlib)は非対応。 - CI:
.github/workflows/ci.yml。macOS arm64(NEON)・Linux x86_64(AVX2/scalar、GCC14+OpenBLAS)・Windows(MSVC、AVX2、best-effort)に加え、ASan+UBSanジョブ(assertion有効なのはこれだけ)、KyTeaモデルを取得(キャッシュ付き)してSEGMENTLIB_REQUIRE_GOLDENを立てるgoldenジョブ(この環境変数によりモデル未取得はスキップではなく失敗になる)、そしてtests/consumer/を作業ツリーに対してビルドし「利用側のビルドに入るのはライブラリだけ(テスト/ベンチ/CLIの成果物なし・doctest取得なし・ビルド種別を勝手に決めない)」を検査するconsumerジョブ(scripts/check_consumer.sh)の計7ジョブ。全ジョブ-DSEGMENTLIB_WARNINGS_AS_ERRORS=ONでビルド。
9. ベンチマーク(推論速度)
bench/。KyTea / Vaporettoとの推論速度比較(KyTeaバックエンドについて)。
9.1 設計原則
- 同一モデル:3ツールとも同じ重みで動かす。
- 正確性ゲートを先に通す:タイミング前に出力を
diff。segmentlibはKyTeaとバイト一致、Vaporettoの相違率を報告する。 - 純粋推論はin-processで測る:モデルを1回ロードし、tokenizeループのみを計測(ロード・I/Oを除外)。専用ハーネス(
bench_segment=自ライブラリ、bench_kytea=libkyteaリンク)を使う。 - 条件固定:シングルスレッド・ウォームアップ後best-of-N。指標はUnicodeコードポイント/秒。
- コーパス:青空文庫の実在作品(漱石・太宰・芥川・宮沢、約71万文字)。
9.2 最新結果(Apple M1 Pro、青空文庫71万字、best-of-5)
正確性ゲート
- segmentlib vs KyTea:0 / 20822 行相違(100%一致)
- Vaporetto vs KyTea:92 / 20822 行相違(99.56%一致)
純粋推論速度(in-process、ロード・I/O除外、シングルスレッド)
| ツール | ロード | M文字/秒 | 対KyTea |
|---|---|---|---|
| segmentlib | 412 ms | 6.21 | 約5.3× |
| KyTea | 964 ms | 1.17 | 1.00× |
| Vaporetto | 数秒(daachorse構築) | 8.68 | 約7.4× |
segmentlibの並列スループット(tokenize_all、best-of-5)
| スレッド数 | M文字/秒 | 単スレッド比 | 対KyTea |
|---|---|---|---|
| 1 | 5.65 | 1.00× | 4.8× |
| 2 | 12.91 | 2.28× | 11.0× |
| 4 | 25.91 | 4.59× | 22.1× |
| 8 | 44.98 | 7.96× | 38.4× |
知見:segmentlibはKyTeaとバイト完全一致しながらシングルスレッド推論5倍以上、ロードも速い。Vaporettoはシングルスレッドでは依然最速(出力は厳密には非一致、0.44%)だが、segmentlibはマルチスレッド化でVaporettoのシングルスレッドを上回る(8スレッドで約5.2倍)。
10. 既知の制限・未実装機能
- タグ推定(品詞・読み):行わない。分かち書き専用。
- KyTea互換の学習エンジン:実装しない。
segmenter train --backend kyteaは明示的エラーを返す。KyTea互換モデルが必要な場合は本物のtrain-kyteaを使う。 - Vaporetto互換バックエンド:作らない。Vaporettoは外部ベンチマーク比較対象としてのみ使用する。
--encode:実装しない。入力は常にUTF-8固定。--backend(predict時の明示指定)・--scores(境界スコア出力):未実装(自動判別で用が足りている)。- 複数候補+信頼度出力(KyTeaの
-out conf/-tagmax相当):実装しない。 - 部分アノテーション入力のハード制約(
-wsconst相当):未実装。配布jpモデルのwsConstraintは通常空で既定出力には影響しない。 - pmr版API(
std::pmr::memory_resource注入):未実装。ベンチマークで実際にボトルネックになった場合に検討する。 - MLPバックエンドのAVX2:CIで実機検証済み(GitHub Actions ubuntu-24.04実機+Windows MSVC実機)。
- EDLAバックエンドの2層以上:未実装。
PrecomputeTableは埋め込み→隠れ層が単一の線形ホップであることを前提にしているため、多層版は学習側だけでなく推論カーネルの作り直しを要する(11.3節)。 - EDLAモデルの同梱:行わない。
models/はリリース管理対象の成果物を置く場所であり、EDLAモデルはjust model-edでローカルに再現する研究成果物とする(11.5節)。
11. EDLAバックエンド
11.1 何であり、なぜここにあるか
誤差拡散学習法(EDLA, Error Diffusion Learning Algorithm)は1999年に金子勇が考案し、Fujita, arXiv:2504.14814 が体系的に評価した、誤差逆伝播を使わない学習法である。出力層で計算した単一の大域誤差信号を下位の全層へそのままブロードキャストし、各ユニットはそれを局所的に手に入る量(自分自身の前活性、自分のシナプスに届く活動、固定された興奮性/抑制性のタグ)だけを使って重み変化に変換する。どの層も他層の重みを読まず、微分の連鎖が後ろ向きに伝播することもない。
このバックエンドは4節のネットワークを、誤差逆伝播の代わりにこの規則で学習する。このタスクが試す場として公平である理由は2つある。
- 分類器がもともと二値である。 EDLAが素で扱えるのはスカラー出力1本であり、論文の多クラス実験では独立したK個のネットワークと同等MLPの約4K倍のパラメータを要する。ポイントワイズな境界予測は構造的に出力1本なので、このコストが発生しない。
- ネットワークがもともと浅い。 論文の中心的な知見は「1隠れ層ではEDLAは誤差逆伝播に近く、深くすると急速に劣化する」である。4.4節のネットワークはちょうど1隠れ層なので、EDLAが機能すると期待される領域に乗るためのアーキテクチャ上の譲歩が要らない。
2点目は両刃であり、埋もれさせずここに明記する。このベンチマークは、論文が特定したEDLAにとって最も有利な領域に位置している。 ここでの差が小さいことは期待どおりの結果であって、規則がより深いネットワークへ一般化する証拠ではない。同じ論文はその場合に差が広がることを予測し、実測している。
11.2 MLPバックエンドとの関係
EDLAバックエンドはMLPバックエンドのモデルパーサ、事前計算テーブル、スコアラーをそのまま共有する(ed/model.hがmlp::Modelを内包し、ed::EdBackend::tokenizeはmlp::tokenize_withを呼ぶ)。2節の「バックエンド同士はSegments型以外何も共有しない」という方針を意図的に外している唯一の箇所である。
理由は実験そのものにある。2つのバックエンドは学習則という1変数だけが違うようにしたいので、推論経路は作り直すのではなくバイト単位で同一に保つ。スコアラーを複製すれば乖離しうるし、その乖離は測ろうとしているものと区別のつかない精度差として現れる。この性質は単体テストで直接固定してある(同じ重みを両方の署名で書き出すと分割結果が完全一致する、ed_model_test.cpp)。
学習側の共有も同程度に広い。コーパス解析、語彙・訓練例生成、ミニバッチ化、順伝播、Adam、PTQ量子化、シリアライザはすべて4.9節のコードのまま。置き換えるのはNet::backwardだけである。
11.3 学習則
1事例の大域誤差信号を d = t − sigmoid(y) と書く。出力層を離れる量はこれだけである。
出力層(w2, b2)。損失から線形1ホップなので厳密な勾配がすでに局所的(シナプス前活動と大域信号の積)であり、EDLAが近似すべきものがない。誤差逆伝播と同一。
隠れ層(W1, W_dict, b1)。各ユニットjは固定極性p_jを持つ。前半H/2個が+1、残りが−1で、Hのみから導出されモデルファイルには保存しない。更新は
Da_j = g'(a_j) · p_j · d
であり、誤差逆伝播であれば g'(a_j) · w2_j · d を使う箇所である。この置き換えはまさに「下流の重みの大きさを捨て、符号だけを残す」ことであり、w2の後ろ向きの読み出しを取り除くのはこれである。論文の正/負の誤差チャネルは同じ式の別表現で、dをd+ = max(d,0)とd− = max(−d,0)に分け、興奮性ユニットにd+、抑制性ユニットにd−を与えるとp_j·dが再構成される。
この置き換えが「置き換えた重みの符号」を代表するためには、sign(w2_j)が実際にp_jと一致していなければならない。そこでDale則を課す。w2は自分の極性の側で初期化し、最適化ステップのたびにその側へクランプし直す。ちょうど0にクランプされたユニットは毎エポック数えて報告する(pinned N/H)。極性分割が失った容量そのものだからである。
生のロジットではなくsigmoid(y)を使うのは、ブロードキャストを[-1,1]に有界化するためである。単一のスカラーが下流の減衰なしに全層へ同時に届くので、非有界だとマージンの大きい1事例がネットワーク全体を動かしてしまう。論文がより深いEDLAネットワークで報告する活性化の暴走がこれにあたる。
埋め込み。 論文のネットワークは固定の入力特徴を取るので、学習される入力埋め込みはEDLAが規定する範囲の外にある。これは拡張であり、その2通りの読み方を暗黙の決定ではなく学習フラグにしてある。
--ed-embedding-update hybrid(既定):誤差逆伝播が使うのと同じ1ホップ写像dX = dA · W1を再利用する。このホップは非線形を跨がないので活性化微分の連鎖を作らず(EDLAが否定しているのはそれである)、行の更新が「窓に実際にどの文字がいたか」に依存するようにするのはこれである。--ed-embedding-update pure:大域信号を埋め込みへ、次元ごとの極性でゲートしたそれ自身の層として拡散する。「後ろ向きの写像をどこにも持たない」ことに忠実だが、更新方向はバッチ内のどの文字についても同一になる。その代償は11.6節で測る。
最適化手法。 既定はMLPバックエンドと同じAdamを、同じミニバッチ上で同じ--lrで使う。Adamのパラメータごとのステップはそのパラメータ自身の履歴のみから計算されるので、局所性という性質を壊さない。--optimizer sgdで素のSGDを選べる。これが存在するのは、Adamが両規則の差を「混入させない」のではなく隠してしまうことが判明したためで、11.6節がそれを測定している。Gradientsは入れ物として再利用している。ここでの値は損失の偏微分ではなく、Adamの減算が期待する符号を持たせた局所更新方向である。
深さ。 1隠れ層固定。論文の安定性に関する知見に加え、PrecomputeTable(4.6節)が埋め込み→隠れ層を単一の線形ホップと仮定して(行, スロット)ごとのテーブルに畳み込んでいるため、2層目は学習側の変更だけでなく新しい推論カーネルを必要とする。
11.4 モデル形式
"SegmentLibED 1\n" に続けて4.7節の本体をそのまま置く。EDLAモデルが記録するテンソルは同一である(ネットワークが同一なので)。したがって両者を区別するのはヘッダ行だけであり、それが存在するのは「ファイルがどの規則で作られたかを述べる」ためと「ローダが互いのモデルを拒否する」ためである。
極性配列はHから導出されるため保存しない。学習器もローダも同じ関数から再計算する。
署名の長さが異なる点("SegmentLibED "は13バイト、"SegmentLibMLP "は14バイト)に注意。Segmenter::loadが既知の最長署名を読んでから各候補をそれぞれの長さで比較するのはこのためである(2節)。
11.5 学習
segmenter train --backend ed \
--corpus corpus/ud-gsd/train.kytea.txt \
--dev-corpus corpus/ud-gsd/dev.kytea.txt \
--model-out corpus/ud-gsd/ed.mod
7.2節のオプションは--lrを含めすべて同じ意味で通用する。just model-edが上記を参照コーパス・参照辞書で実行する。得られたモデルは同梱しない。models/はゴールデンフィクスチャが出力を固定するリリース管理対象の成果物を置く場所であり、EDLAモデルはローカルの研究成果物に留める。
11.6 評価
プロトコル。 両バックエンドをcorpus/ud-gsd/train.kytea.txt(UD_Japanese-GSD、リリースタグr2.18)で学習し、早期終了にcorpus/ud-gsd/dev.kytea.txtを使用。ネットワーク規模とハイパーパラメータは同一(w=5, d=64, H=256, min-count 2, batch 256, lr 1e-3, patience 15)、辞書なし、seedは42〜46。精度はscripts/eval_segmentation.pyによる境界F1で、他バックエンドと同じゴールドファイルに対して測定。速度はApple M1 Pro、推論は消費者向け構成のビルド(build-release、学習・テストなし)、学習は早期終了を無効にして10エポック固定とし、収束までではなく単位仕事量あたりで2つの規則を比較している。
精度(5 seed平均、括弧内は標準偏差):
| 構成 | GSD test F1 | PUD test F1 | MLP比(GSD) |
|---|---|---|---|
| MLP(誤差逆伝播) | 98.00% (0.047) | 98.56% (0.029) | — |
| EDLA, hybrid埋め込み | 98.00% (0.067) | 98.56% (0.040) | −0.00pt |
| EDLA, pure埋め込み | 95.73% (0.083) | 96.67% (0.157) | −2.27pt |
コスト(辞書なし。辞書構成で異なる箇所は括弧内):
| MLP | EDLA | |
|---|---|---|
| モデルサイズ | 642,348 B | 642,347 B |
| ロード時間 | 129 ms | 129 ms |
| 推論・1スレッド | 5.72 M chars/sec | 5.79 M chars/sec |
| 学習 | 1.00 s/epoch(UniDicで1.18) | 1.02 s/epoch(UniDicで1.10) |
モデルサイズ・ロード時間・推論速度が同じなのは構造上の必然である。ネットワーク、ファイル形式、スコアリングコードが共有されており、1バイトのサイズ差は署名文字列("SegmentLibED "が1バイト短い)そのものである。推論の実測値は約1%違うが、この機材での実行ごとのノイズの範囲。学習コストが同じなのは両規則が同じGEMMを発行するためで、EDLAの隠れ層の項はわずかに安い(誤差逆伝播がw2を掛ける箇所が符号反転で済む)が、時間が消費されているのはそこではない。
この数値の読み方について。 このタスクの1隠れ層において、EDLAは誤差逆伝播と区別がつかない。差はどちらのseed分散よりも小さい。これは元論文自身の浅いネットワークでの測定(MNIST 1隠れ層:EDLA 97.5%対誤差逆伝播98.2%)よりも強い結果であり、だからこそ甘くではなく厳しく見るべきで、実際に割り引くべき具体的な理由がある。
Adamという交絡。 Dale則がsign(w2_j) = p_jを強制しているため、EDLAの隠れ層更新と誤差逆伝播のそれはちょうど|w2_j|だけ違う。
BP: Da_j = g'(a_j) · w2_j · d = g'(a_j) · p_j · |w2_j| · d
EDLA: Da_j = g'(a_j) · p_j · d
|w2_j|はユニットjに入る全て(W1とW_dictのその行、およびb1[j])にわたって定数である。Adamは各パラメータのステップをそのパラメータ自身の勾配のRMSで割るが、この比は勾配を定数倍しても不変である。したがってw2の変化が緩やかであれば、Adamは2つの規則の差をほぼ厳密に打ち消し、下位層はどちらでも同じ更新を受け取ることになる。
つまり報告した同等性が言っているのは「Adam付きEDLAはAdam付き誤差逆伝播と一致する」であって、「EDLAは誤差逆伝播と一致する」より弱い。Adamは最適化手法が比較に混入しないよう選んだものだが(11.3節)、結果的には逆方向に交絡させていた。このアーキテクチャで2つの規則を分けているものの大半を、正規化で消してしまうからである。打ち消しを生き延びるのは埋め込み経路(dX = dA · W1はjについて和を取るので、ユニットごとのスケールが括り出せない)とw2自身の時間変化である。実際、規則が完全に等価ではないことと整合して、同一seedから2つの規則で学習したモデルはGSD testの543行中532行で異なる分割を出す。等しいスコアに達しつつ、本当に別の解に到達している。
素のSGDによる対照実験(実測済み)。 上の交絡が要求していた対照を実行した。--optimizer sgd(モーメンタムなし・weight decayなし。比較を均すものを他に何も持たない)で、学習率をバックエンドごとにdev(seed 42)でスイープし、各自のベスト値で5 seed回した。
規則の差が最初に現れるのはスイープそのものである。誤差逆伝播はlr = 5.0まで改善を続け(dev 0.9815)、10でようやく劣化する。EDLAはlr = 1.0がピーク(dev 0.9791)で、1.5を超えると下がり、3.0で完全に崩壊する(dev 0.7502)。安定域がおよそ5倍狭い。これは|w2_j|のスケーリングとAdamの正規化を同時に失った場合の予測そのものであり、「EDLAはReLUでは学習率を小さくする必要がある」という論文の観察とも一致する。
各自のベスト学習率での結果(5 seed):
| 構成 | GSD test F1 | PUD test F1 | BP比(GSD) |
|---|---|---|---|
| MLP + SGD, lr 5.0 | 98.27% (0.111) | 98.75% (0.063) | — |
| EDLA + SGD, lr 1.0 | 98.11% (0.036) | 98.67% (0.023) | −0.16pt |
最適化手法が勾配のスケールを正規化しなくなった途端、EDLAは誤差逆伝播に0.09〜0.16pt劣る。小さいがseedをまたいで一貫しており(差はどちらの構成のseed分散よりも大きい)、方向も大きさも浅いネットワークに関する文献の予測どおりである(同じ深さでの論文のMNISTギャップ0.7ptよりは小さい)。Adamでの同点ではなくこちらが、1隠れ層における学習則の正直な測定である。信用割り当て規則はここで約0.1〜0.2ptのコストを持ち、Adamがそれを隠していた。 副次的な観察が2つ:EDLAのseed分散は誤差逆伝播の約1/3であり、早期終了も早い(20〜30エポック対36〜76)。狭いベスト学習率において、より速く、より低いプラトーに到達する。
なお、どちらのバックエンドもチューニングされたSGDの方がAdamの既定値より高い(98.27/98.11対どちらも98.00)。これは規則ではなくこのタスクの最適化地形についての観察だが、4.8節の参照値がAdamベースであることを考えると、それ自体のプロトコルに則ったスイープに値する伸び代を示唆するため、ここに記録する。
pure拡散のアブレーションについて。 埋め込みを拡散だけで更新するとGSDで2.27pt、PUDで1.89ptを失う。ここで測定された唯一の大きな効果であり、hybridを既定にしている理由である。原因はチューニング不足ではなく構造的なものだ。この規則ではdv_cはバッチの誤差符号と次元の極性にしか依存せず、窓にどの文字がいたかには決して依存しないので、バッチに現れた行がすべて同じ方向へ動き、テーブルは文字を区別することを学習できない。なおこれは論文が規定していない部分(論文のネットワークは固定入力を取る)なので、失敗しているのはここで行った拡張であって、公表されたEDLAではない点は明記しておく。
論文との位置関係。 この結果は「浅いネットワークではEDLAは誤差逆伝播に近い」というFujitaの知見と整合し、同じ論文が差の拡大を測定している深さの領域(CIFAR-10・4隠れ層:35.5%対55.2%)については何も言っていない。このバックエンドは1隠れ層であり、そこには言及できない。
11.7 学習されるフィードバック(Kolen-Pollack)
素のSGD対照が露わにした0.16ptは、実は信用割り当ての差ではなかった。学習率を揃えれば2つの規則は互角であり(lr 1.0:誤差逆伝播 dev 0.9790、EDLA 0.9791)、差が生じるのは誤差逆伝播だけがlr 5.0を使えるからである。固定極性EDLAは1.0を超えると崩壊する。つまり質の欠損ではなく安定性の上限であり、そうと分かればはるかに攻めやすい対象になる。
--ed-learn-feedbackはKolen-Pollack(Akrout et al. 2019, arXiv:1904.05391)を実装する。固定のp_jが、w2_jと同じ更新・同じ減衰を受ける学習可能なユニット別フィードバックb_jになる。各重みは自分の値だけで縮むので、両者の差は毎ステップ(1 - decay)倍され、bはw2を一度も読むことなくそこへ収束する。重み転送が不在のまま保たれること、それが要点である。収束は仮定せず測定している。学習器が毎エポック|b-w2|/|w2|を出力し、2.017 → 0.596 → 0.189 → 0.062 → 0.000と落ちる。
収束それ自体では何も変わらなかった(lr 1.0でdev 0.9785、3.0では依然崩壊)。原因は過渡期にある。bは±1から始まるのにw2は1/sqrt(H)≈0.06付近で初期化されるため、最初の更新が誤差逆伝播の約16倍になり、収束する前に発散する。--ed-feedback-initはその開始値を設定する。初期化器の公開定数を使うのであって学習済みの重みの値ではないので、これも重み転送ではない。
両方を入れると(--ed-feedback-decay 0.001 --ed-feedback-init 0.0625)上限が外れる。固定極性が0.7502と0.0000を出すlr 3.0(dev 0.9797)と5.0(dev 0.9812)でEDLAが学習する。各構成のベスト学習率で5 seed:
| 構成 | GSD test F1 | PUD test F1 | BP比(GSD) |
|---|---|---|---|
| MLP + SGD, lr 5.0 | 98.27% (0.111) | 98.75% (0.063) | — |
| EDLA 固定極性 + SGD, lr 1.0 | 98.11% (0.036) | 98.67% (0.023) | −0.16pt |
| EDLA + Kolen-Pollack + SGD, lr 5.0 | 98.29% (0.019) | 98.78% (0.024) | +0.02pt |
フィードバックを学習させると差は完全に消える。両テストセットで+0.02pt、seedノイズに十分収まる範囲であり、しかも誤差逆伝播の1/3のseed分散(sd 0.019対0.111)で達成している。このタスクの1隠れ層においては、下流の重みを一度も読まずDale則に従う規則が、誤差逆伝播と正面から並んだということである。固定極性の0.16ptは|w2_j|を捨てた代償であり、Kolen-Pollackは重み転送を取り戻すことなくそれを回収する。
一般化の範囲について注意が2つ。これは依然として元論文がEDLAにとって最も有利とする浅い領域であり、Kolen-Pollackが深いネットワークの差を埋めるというAkrout自身の証拠(ImageNet ResNet-18:top-1誤り29.2%対誤差逆伝播30.1%)は、このネットワークが2隠れ層になったときに改めて検証すべきものである。また既定は固定極性のまま変えていない。それが論文の記述する規則であり、--backend edが学習するのはそれで、学習されるフィードバックは黙って差し替えるのではなく文書化された拡張として置いてある。