9月17日、Peter Vijehが「I had Gemini train its own replacement for $9」と題した記事を公開した。この記事では、GeminiにRedditコメントをラベリングさせ、そのデータでGLiNERをファインチューニングすることで、継続的な推論コストをゼロに抑える実践的な手法について詳しく紹介されている。ラベリングコスト自体は$9発生しているが、一度きりの初期投資として割り切り、以後の推論をローカルモデルで賄う設計だ。
発端:料理ナイフのオタクとNERの問題
筆者は高級シェフズナイフの愛好家で、Redditのスレッドをスクレイピングしてブランド名、モデル名、鋼材名を抽出するツールを作っていた。この「テキストから固有名詞を抽出する」タスクは固有表現認識(Named Entity Recognition、NER)と呼ばれる古典的なNLPの問題だ。
当初はGeminiを1コメントあたり1回APIコールする形で処理していた。「Mazaki in white #2、昔のFibroxより全然いい」というコメントから、Mazaki(ブランド)、Fibrox(モデル)、white #2(鋼材)を正確に抽出できていた。ただし、スクレイパーが新着コメントを取り込むたびに課金が走るため、コストはコミュニティの投稿量に比例して膨らんでいく構造だった。
代替候補として浮上したのがGLiNERだ。DeBERTa-v3をベースにしたオープンソースのNERモデルで、ローカルで動作する。ゼロショット(追加学習なし)で試したところ、F1スコアは約0.65。Geminiと比べると精度が足りない。
そこで考えたのが「Geminiに一度だけラベリングを任せ、そのデータでGLiNERをファインチューニングする」というアプローチだ。ラベリングは初回のみ課金が発生するが、以降の推論はすべてローカルで完結する。
$9のラベリングで4,290件を処理
GeminiはOpenRouter経由でtemperature 0、25分で4,290件のコメントをラベリングし、コストは**$9(1件あたり約$0.0021)**だった。
ここで重要な設計判断がある。Geminiに文字オフセット(何文字目から何文字目か)を返させなかった点だ。LLMは文字数を数えるのが苦手で、2〜3文字ずれたスパンを返してくることが多い。代わりに「正確な部分文字列とラベル」を返させ、オフセットはコード側で計算する。
// モデルは文字列を返す。オフセットはコードで計算する。
{ "entities": [
{ "text": "Benchmade", "label": "knife brand" },
{ "text": "940", "label": "knife model" },
{ "text": "S30V", "label": "knife steel" }
] }
また、VG-10やCPM-154、1.4116のようにハイフンや数字を含む鋼材名が汎用トークナイザーで分割されないよう、正規表現で対処している。
訓練データの約30%は「製品名が含まれないコメント」だ。gyuto(牛刀)、carbon、handle、patinaといった、製品名と間違えやすいワードを含むがエンティティのないコメントをネガティブサンプルとして加えている。検証用に225件を学習前に固定し、その後一切触れなかった。
5回失敗して気づいた「words_mask」の罠
ここが記事の核心だ。10回の訓練のうち5回はまったく学習できなかった。最初の3回はHugging Face Trainerの設定ミスで、ありがちなものだ。
| 実行 | 問題 | 修正 |
|---|---|---|
| 1 | GLiNERのデフォルトmax_steps=10000がnum_train_epochs=3を上書きし、39エポック学習した |
max_stepsを明示的に設定 |
| 2 | eval_strategyなしでload_best_model_at_endを使うとエラー |
eval_strategy="steps"を設定 |
| 3 | Trainerが保存したstate-dictのキーに"model."プレフィックスがなかった |
保存時にプレフィックスを付与 |
| 4 | ネガティブサンプルにner_labelsが未設定 |
全サンプルにラベルリストを設定 |
| 4〜5 | words_maskをattention maskと同じ方法で作成 |
単語インデックスを正しく設定 |
4・5回目の失敗が最も発見が難しかった。GLiNERのトークナイザーがRedditの壊れた絵文字でクラッシュしたため、筆者はパッチを当てた。その際にwords_maskというテンソルを自前で埋める必要があった。
このテンソルはattention_maskと形状が同じだ。attention maskは実トークンを1、パディングを0で埋める。筆者もそう埋めた。名前からも形状からも、それ以外の解釈は難しい。
ところがこれは「マスク」ではなく「単語インデックス」だった。 スペシャルトークンとパディングを0、最初のサブトークンから1、2、3と増やす形式で、スパンスコアリングヘッドがサブトークンを単語に集約するために使う。
# 筆者が書いたコード # GLiNERが期待するもの
words_mask = [1,1,1,1,1] words_mask = [0,1,2,2,3]
# [CLS] Mazaki wh ##ite #2
全部1で埋めると「コメント全体が1つの巨大な単語」として扱われる。モデルはその中にスパンを見つけようとするが当然できず、lossは130付近から70付近へ下がったところで横ばいになった。クラッシュも警告もNaNも出ない。gradientは普通のサイズで、チェックポイントも正常に保存される。ただF1がゼロ付近をさまよう。
筆者がドキュメントではなく訓練ループのコードを読んで初めて気づいた問題だ。この経験から筆者は「ラベルの質を上げるより、手動で構築したテンソル全てにアサーションを書く方を選ぶ」と述べている。
最終結果:0.83 F1、$11.50で完結
words_maskを修正した6回目以降は順調に収束した。
- GLiNER medium(209M):F1 0.800
- GLiNER large v2.5(459M):F1 0.83(Tesla T4で24分)
- 鋼材の再現率(recall):
MagnaCut、S35VN、HAP40のような鋼材名はブランド名より信頼スコアが低く出るため、クラスごとに閾値を設定したことで0.787→0.911に改善
総コストはGeminiラベリング$9+GPU時間約$2.50。ラベリングは一回限りの支出であり、以後の推論コストは発生しない。このモデルはNew Knife Dayというサービスを動かしており、Redditでどんなナイフが話題になっているかを追跡している。
詳細はI had Gemini train its own replacement for $9を参照していただきたい。