Ruby
litsea-ruby は magnus と rb-sys を用いて Litsea を Ruby 3.1 以降へ公開するバインディングです。RubyGems では litsea として配布します。
インストール
gem install litsea
gem はソース配布です。gem install の実行時に、同梱の Rust ソースからネイティブ拡張をコンパイルします(1〜2 分かかります)。次のものが必要です。
- Ruby 3.1 以降と、ネイティブ gem のビルドに使うツール(C コンパイラと
make) - Rust ツールチェーン 1.87 以降(rustup でインストール)
- libclang(rb-sys が Ruby のバインディング生成に使用): Debian / Ubuntu は
libclang-dev、macOS は Xcode Command Line Tools
gem は crates.io 上の同じバージョンの litsea と litsea-binding-core に対してコンパイルされます(この 2 つはバージョンを完全に固定しています)。そのため Litsea.version は常に gem のバージョンと一致します。gem には Cargo.lock が含まれないため、それ以外の Rust の依存はインストール時に互換性のある最新のリリースに解決されます。
Note: 0.13.0 から #247 の修正までにリリースされた gem は、インストール時のコンパイルに失敗します(変更履歴を参照)。
gem install litseaは最新のリリースを選ぶため、影響があるのはそれらのバージョンを指定した場合だけです。
モデルの入手
gem にモデルは含まれません。models/ から取得してパスを渡してください(事前学習済みモデルを参照)。モデル自身が種別を持つため、フラグの指定は不要で、読み込んだモデルで何ができるかは has_pos? が示します。
分割
require "litsea"
seg = Litsea::Segmenter.open(:japanese, "models/japanese.model")
seg.segment("これはテストです。")
# => ["これ", "は", "テスト", "です", "。"]
言語は Symbol でも String でも指定でき、ISO 639-1 コードも使えます(:ja / "japanese")。
空白区切りの言語では空白自体が 1 トークンとして返るため、トークンを連結すると常に入力が復元されます。
Litsea::Segmenter.open(:korean, "models/korean.model").segment("안녕하세요 반갑습니다")
# => ["안녕하세요", " ", "반갑습니다"]
POS タグ付け
seg = Litsea::Segmenter.open(:japanese, "models/japanese_pos.model")
seg.segment_with_pos("これはテストです。").each do |token|
puts "#{token.surface}\t#{token.pos}\t[#{token.start}..#{token.end}]"
end
# これ PRON [0..6]
# は ADP [6..9]
# テスト NOUN [9..18]
# です AUX [18..24]
# 。 PUNCT [24..27]
start と end はバイトオフセットです。Ruby の String#[] は文字単位なので、切り出しには byteslice を使ってください。
text.byteslice(token.start, token.end - token.start) # == token.surface
ユーザー辞書
すべてのコンストラクタは、キーワード引数を 2 つ受け取ります(#263)。user_words: は、現れた場所で必ず 1 トークンとして出力する語の配列、user_words_gate: は、モデルの margin が十分小さい場所にだけ語を適用する flip ratio(0.0〜1.0)です。
seg = Litsea::Segmenter.open(:japanese, "models/japanese.model", user_words: ["雪だるま", "新幹線"])
seg.segment("雪だるまを作る")
# => ["雪だるま", "を", "作る"] (辞書なしでは ["雪", "だる", "ま", "を", "作る"])
登録語には両端に境界が置かれ、内側には置かれません。重なった出現箇所は leftmost-longest で解決し、1 文字の語は無視します。POS モデルでは、登録語も他の語と同じようにタグ付けされます(japanese_pos.model では 雪だるま/NOUN)。照合は文脈を見ないため、短い断片を語として登録すると、それを含む他の語をすべて壊します。user_words_gate: 0.0 は、モデルがすでに同意している場所だけに語を適用します。0.0〜1.0 の外の gate は Litsea::InvalidArgumentError を、未知のキーワードは ArgumentError を送出します。意味論の全体は Segmenter を参照してください。
API
| 呼び出し | 戻り値 |
|---|---|
Litsea::Segmenter.open(language, path, user_words: nil, user_words_gate: nil) | セグメンタ |
Litsea::Segmenter.from_bytes(language, data, user_words: nil, user_words_gate: nil) | セグメンタ(バイナリ String も可) |
Litsea::Segmenter.from_uri(language, uri, user_words: nil, user_words_gate: nil) | セグメンタ |
#segment(text) | Array<String> |
#segment_batch(texts) | Array<Array<String>> |
#segment_tokens(text) | バイトオフセット付き Array<Litsea::Token> |
#segment_with_pos(text) | タグとオフセット付き Array<Litsea::Token> |
#segment_with_pos_batch(texts) | Array<Array<Litsea::Token>> |
Litsea::Extractor.new(language)#extract(...) | nil |
Litsea::Extractor.new(language)#extract_two_stage(...) | nil |
Litsea::Trainer.new(threshold, iterations, features)#train(model, cancel:) | BinaryMetrics |
Litsea::PerceptronTrainer.new(epochs, features)#train(model, cancel:) | MulticlassMetrics |
Litsea::TwoStageTrainer.new(epochs, prefix, dominance:)#train(model, cancel:) | TwoStageMetrics |
GVL の解放
モデルの読み込み・特徴量抽出・学習といった時間のかかる処理は、GVL(Global VM Lock)を解放した状態で実行されます。そのため他の Ruby スレッドが動き続け、キャンセルが意味を持ちます。
cancel = Litsea::CancelToken.new
Thread.new { sleep 60; cancel.cancel }
metrics = Litsea::Trainer.new(0.01, 100_000, "features.txt").train("japanese.model", cancel: cancel)
キャンセルはエラーではありません。次のチェックポイントで停止し、部分的に学習されたモデルを保存してメトリクスを返します。バインディングはシグナルハンドラを登録しません。
rb_thread_call_without_gvl は magnus も rb-sys もラップしていません(magnus は未バインドの C 関数一覧に挙げており、この関数は rb-sys が生成するバインディングの対象外ヘッダで宣言されています)。そのため本バインディングは src/gvl.rs で自ら宣言し、パニックが C フレームを越えて巻き戻らないよう extern "C" のトランポリンで捕捉しています。この主張は 2 つのテストで担保しています。1 つは学習中に別の Ruby スレッドが動き続けること、もう 1 つは別スレッドからのキャンセルが学習ウィンドウ内に収まることです。GVL 解放を外すと両方とも失敗します。
1 文の分割は GVL を保持したままです。解放のコストの方が処理そのものより大きいためです。
TwoStageTrainer は 1 度しか使えません。学習時に stage 1 が AdaBoost モデルへ collapse され、トレーナが消費されるためです。状態は available? が示し、2 回目の train は例外を発生させます。
エラー
すべてのエラーは Litsea::Error を継承するため、1 つの rescue で捕捉できます(Python・PHP バインディングと同じ階層です)。
| エラー | 発生条件 |
|---|---|
Litsea::InvalidArgumentError | 未知の言語名、未知の feature set、使用済みトレーナ、0.0〜1.0 の外の user_words_gate: |
Litsea::ModelError | ダウンロード失敗、または旧 joint POS モデル |
Litsea::IoError | ファイルの読み書き失敗 |
Litsea::ParseError | モデルまたは学習データの形式不正 |
Litsea::UnsupportedError | このビルドでは利用できないスキームや操作 |
Litsea::PosUnavailableError | 分割専用モデルに対する POS タグ付けの要求 |
開発
make test-litsea-ruby # cargo test + rake compile + rake test
make lint-litsea-ruby # clippy + rubocop
make build-litsea-ruby # リリースビルド
make package-litsea-ruby # ソース gem を litsea-ruby/pkg/ に出力
make test-litsea-ruby-gem # その gem をクリーンなコンテナで gem install(Docker が必要)
有効な Ruby で bundle が使える必要があります。バージョン管理ツールの shim が存在していても、選択中のインタプリタに bundler が無い場合があるため、Makefile がその旨を検出して案内します。パリティテストは litsea CLI をビルドし、その出力とバインディングの出力を突き合わせます。
gem は gem build ではなく make package-litsea-ruby(bundle exec rake build)でビルドしてください。クレート自身の Cargo.toml は Cargo ワークスペースから設定を継承しており、このリポジトリの中でしか解決できません。このタスクは cargo package が正規化したクレートから gem をビルドします。gem とクレートのバージョンが異なる場合や、litsea と litsea-binding-core がそのバージョンに固定されていない場合は、ビルドを中止します。make test-litsea-ruby-gem は、素の ruby:3.3 コンテナでその gem を gem install し、テキストを分割します。litsea のクレートはチェックアウトからコンパイルするため、crates.io に未公開のバージョンでも実行できます。