9月17日、Peter Vijehが「I had Gemini train its own replacement for $9」と題した記事を公開した。Geminiに4,000件超のデータを一度だけラベリングさせ、そのラベルで小型モデルをファインチューニングすることでAPIコストをほぼゼロに抑えた実験の記録だ。
かかった費用はラベリング**$9、GPU代$2.50。達成したF1スコアは0.83**。そして数日を溶かしたのは、1本の壊れたテンソルだった。
「LLMで後継モデルを育てる」という戦略
LLMを使って教師データを生成し、小型モデルをファインチューニングする手法は「LLM-as-annotator」あるいは知識蒸留(Knowledge Distillation)的アプローチとして近年注目を集めている。LLMの精度をある程度引き継ぎながら推論コストをほぼゼロにできる点が魅力だ。GPT-4やGeminiのような大規模モデルはアノテーターとして優秀だが、本番推論に使い続けるにはコストが高すぎる、という課題への現実的な解の一つとして実践者の間で広がっている。
著者はRedditで高級シェフズナイフについての議論をスクレイピングし、ブランド名・モデル名・鋼材名を抽出するシステムを運用していた。当初はGeminiのAPIをコメント1件ごとに叩いていたが、投稿数に比例してコストが膨らむ構造に限界があった。
代替として試したのが**GLiNERだ。GLiNERはBERTベースの固有表現認識(NER)モデルで、抽出したいエンティティの種類を推論時にテキストで自由に指定できる汎用設計が特徴。「knife brand」「knife steel」のように自然言語でラベル名を渡すだけでゼロショット抽出が可能だ。ゼロショットで試したところF1は約0.65**。Geminiとの差を埋めるためにとった戦略が「Geminiにラベリングさせ、そのデータでGLiNERをファインチューニングする」というものだった。
手順はシンプルだ:
- Geminiに4,290件のコメントを一括ラベリングさせる(コスト:**$9**、1件あたり約$0.0021)
- そのラベルでGLiNER large v2.5(459Mパラメータ)をファインチューニング
- 以降はローカルで推論、Geminiには二度と課金しない
結果としてF1 0.83を達成。GPUはTesla T4を使い、トレーニング時間は24分だった。
数日を溶かしたwords_maskの罠
10回のトレーニングのうち、5回はモデルが全く学習しなかった。最初の数回はHugging Face Trainerの設定ミスによるよくある落とし穴だ:
| Run | 問題 | 対処 |
|---|---|---|
| 1 | max_steps=10000がデフォルトでnum_train_epochs=3を上書きし、39エポック訓練してしまう |
max_stepsを明示的に設定 |
| 2 | load_best_model_at_end使用時にeval_strategy未設定でエラー |
eval_strategy="steps"を追加 |
| 3 | Trainerが保存したstate-dictのキーにmodel.プレフィックスが付かない |
保存時にプレフィックスを追加 |
| 4 | ネガティブサンプルにner_labels未設定 |
全サンプルにラベルリストを設定 |
| 4–5 | words_maskの実装ミス(後述) |
インクリメンタルな単語インデックスへ変更 |
記事のハイライトはRun 4・5の原因となったwords_maskのバグだ。
GLiNERはReddit絵文字などのテキストでクラッシュするケースがあるため、著者はトークナイズ処理に自前のパッチを当てていた。その中でwords_maskというテンソルを手動で構築する必要があったのだが、名前も形状もattention maskと同じだったため、著者は自然に「実トークンを1、パディングを0」で埋めた。
# 著者が書いたコード(誤り)
words_mask = [1, 1, 1, 1, 1]
# GLiNERが期待する値(単語インデックス)
words_mask = [0, 1, 2, 2, 3]
# [CLS] Mazaki wh ##ite #2
words_maskの正体は単語インデックスだった。スパンスコアリングヘッドがサブトークンを単語単位にプールするためのテンソルであり、全要素が1だと「コメント全体が1単語」と解釈される。モデルは巨大な1トークンの中からブランド名を探すことになり、当然学習できない。
訓練は完走した。ロスは130から始まり70付近で横ばい。クラッシュもなし、警告もなし、NaNもなし、eval F1はゼロ付近。
「ロスが横ばい」の原因は「データが難しい」場合でも「テンソルが壊れている」場合でも見た目が同じ、というのが著者の教訓だ。
「ラベルセットを改善するかどうかより、自前で構築したテンソルにアサーションを入れるかを選ぶなら、アサーションを選ぶ」
精度を上げた工夫
words_maskを修正したRun 6以降は安定して学習が進んだ。残りのRunはバリデーションセット(225件、トレーニング開始前に固定)を使ったチューニングにあてた。
- モデルサイズ:209M(medium)で0.800、459M(large)で0.83
- 閾値のクラス別設定:鋼材名(MagnaCut、S35VN、HAP40など)はブランドより信頼スコアが低く出るため、グローバル閾値ではドロップされやすい。クラスごとに閾値を設けることで鋼材のRecallが0.787から0.911に改善した
- ネガティブサンプル:「gyuto」「carbon」「patina」など偽陽性トリガーを含むがプロダクト名のないコメントをトレーニングセットの約30%に追加。51件→510件に増やしたところF1が下がったため、Run 10では51件に戻した
- 早期停止:2,000件程度のデータではEpoch 2でボトムを打ち、その後過学習が始まる
なお、バリデーションセットをランダム分割にしていた初期RunではF1が0.879を記録したが、著者はこれを正式な結果として採用していない。分割の仕方によってF1が大きく変動し、改善の帰属が不明瞭になるためだ。
Geminiへのプロンプト設計
実用的な工夫として、Geminiに文字オフセットを返させないという判断がある。LLMは文字位置のカウントが不安定で、2〜3文字ずれたスパンを返すことがある。プロンプトでは「元テキストに含まれる部分文字列そのもの」を返すよう指示し、オフセットの計算はTypeScript側で行った。
{ "entities": [
{ "text": "Benchmade", "label": "knife brand" },
{ "text": "940", "label": "knife model" },
{ "text": "S30V", "label": "knife steel" }
] }
文字列がコメント中に見つからない場合はエンティティをドロップしてログに記録する設計だ。また、VG-10・CPM-154・1.4116のようなハイフンや記号を含む鋼材名は正規表現でトークン分割時に保護している。
結果まとめ
| 項目 | 内容 |
|---|---|
| 問題 | RedditコメントからナイフのブランドIモデル・鋼材を抽出、LLMへの継続課金を止めたい |
| 手法 | Geminiで一括ラベリング → GLiNER large v2.5をファインチューニング |
| 結果 | F1 0.83(バリデーション225件)、鋼材Recall 0.911 |
| コスト | ラベル$9 + GPU約$2.50 |
| スタック | TypeScript・MongoDB(スクレイパー)、Python・PyTorch・Modal(クラウドGPU実行環境、トレーニング)、FastAPI(推論サーブ) |
詳細はI had Gemini train its own replacement for $9を参照していただきたい。