App Store と Google Play で配信している壁紙アプリでは、届いた画像を30ほどのカテゴリへ振り分ける処理を Gemini API に任せています。日々の新規分はたいした量ではありません。問題になるのはカテゴリ定義を見直したときで、約38,000枚を全件かけ直すことになります。1回あたり約¥3,400、所要およそ5時間。年に3〜4回は発生します。
この全件再分類をローカルに寄せられないか、と考えたのが始まりでした。
Gemma 4 のマルチモーダル変種は Apache 2.0 で商用利用が自由ですし、NVIDIA の Nemotron 3 Nano Omni は視覚・音声・言語を1モデルに統合して30B総パラメータ(アクティブ3B)で動きます。公開されているベンチマークの数字を眺めていると、素直に「これはもう十分では」と思えてきます。私も一度そう思いました。
思ったうえで、自分の画像100枚で測りました。結論はきれいに反転しました。
反転したのは一致率が低かったからではありません。一致率は悪くなかったのに、外し方が悪かった からです。その差がどこで生まれ、どう測れば事前に見えるのかを、実際に使ったコードとあわせて書いていきます。
3つのモデルを、混同しないところから
最初に立ち位置だけ揃えておきます。ここを曖昧にしたまま比較すると、後の数字が読めなくなります。
Gemini API(Gemini 3.5 系) : クラウドのフロンティアモデル。2026年7月時点で一般提供されている最新は gemini-3.5-flash で、gemini-flash-latest が指す実体もこちらに切り替わっています。日本語マルチモーダル理解の安定性では、現時点でも明確に頭ひとつ抜けています。従量課金で、画像を含む呼び出しはテキストのみより高くつきます。
Gemma 4(26B A4B / 31B Dense など) : ローカル・オンプレ向けの中規模オープンモデル。Apache 2.0 で商用利用自由。マルチモーダル変種は画像理解と音声理解に対応し、手元のワークステーションで動く規模感です。
Nemotron 3 Nano Omni : NVIDIA のオムニモーダル統合モデル。視覚・音声・言語を単一アーキテクチャで扱う設計で、エッジAIエージェント向けに最適化されています。アクティブパラメータが3Bと小さく、DGX Spark クラスの機器で動作します。
注意したいのは、Nemotron の「オムニ」はモダリティを跨ぐ処理で効く設計 だという点です。静止画1枚から単一ラベルを引くだけの私の用途では、その強みが発揮される場面がそもそもありません。あとで実際にそういう数字が出ます。
総合スコアが「自分の用途」を代表しない仕組み
日本語マルチモーダルの評価では Heron-Bench と JMMMU がよく参照されます。どちらも良いベンチですが、採否の材料として総合スコアを使うのは筋が悪いです。
Heron-Bench は画像説明・OCR・図表理解といったサブタスクの集合で、公開されるのはその加重平均です。JMMMU は日本語版 MMMU にあたり、大学入試レベルの多分野問題を画像とテキストで解かせます。学術的な理解力を測るには適した設計です。
ここで自分の用途を並べてみます。私がやらせたいのは「画像1枚 → 30カテゴリのうち1つ」だけ。OCR は要りません。図表理解も、多段推論も、縦書きの認識も要りません。
つまり総合スコアを構成する要素の大半が、私の用途と無関係です。総合で2ポイント差があっても、その差が私の使うサブタスクの外側で生まれているなら、判断材料としては何も言っていないのと同じことになります。
逆もあります。総合では負けているモデルが、自分のサブタスクだけ見れば勝っている、ということが普通に起こります。
ベンチマークは「どのモデルが世界的に賢いか」を測る道具です。「このモデルを自分の本番に入れてよいか」を測る道具ではありません。後者を測れるのは、自分のデータだけです。
評価セットは「代表的な100枚」ではなく「層化した100枚」
では自分のデータで測ろう、となったとき、最初にやりがちな失敗があります。全件からランダムに100枚抜くことです。
私の壁紙は分布がかなり偏っていて、上位3カテゴリ(ミニマル・夜景・自然)だけで全体の約58%を占めます。ランダムに100枚抜くと、この3カテゴリで58枚が埋まり、残り27カテゴリを42枚で分け合うことになります。少数カテゴリは1〜2枚。そこが壊滅していても、総合の一致率はほとんど動きません。
見たいのは平均ではなく、壊れている場所 です。ですから層化して抜きます。
各カテゴリから最低3枚。30カテゴリで90枚。残り10枚は「境界事例プール」として別枠で確保します。境界事例は、過去にユーザーから分類の指摘が来た画像や、自分でもどちらか迷った画像です。ここが評価セットの本体だと私は考えています。
# build_eval_set.py — 層化サンプリングで評価セットを作る
import json
import random
from collections import defaultdict
from pathlib import Path
random.seed( 42 ) # 再現性のため必ず固定する
CATALOG = Path( "data/wallpapers.jsonl" ) # {"id":..., "path":..., "label":...}
BOUNDARY = Path( "data/boundary_ids.txt" ) # 過去に指摘・迷いのあった画像ID
OUT = Path( "data/eval_set.jsonl" )
PER_CATEGORY = 3
BOUNDARY_QUOTA = 10
def load_catalog (path: Path) -> list[ dict ]:
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def stratified_sample (rows: list[ dict ], per_category: int ) -> list[ dict ]:
buckets: dict[ str , list[ dict ]] = defaultdict( list )
for row in rows:
buckets[row[ "label" ]].append(row)
picked: list[ dict ] = []
for label, items in sorted (buckets.items()):
if len (items) < per_category:
# 母数が足りないカテゴリこそ壊れやすい。黙って減らさず気づけるようにする
print ( f "[warn] { label } : only { len (items) } images (< { per_category } )" )
picked.extend(random.sample(items, min (per_category, len (items))))
return picked
def main () -> None :
rows = load_catalog( CATALOG )
boundary_ids = set ( BOUNDARY .read_text().split())
base = [r for r in rows if r[ "id" ] not in boundary_ids]
sampled = stratified_sample(base, PER_CATEGORY )
boundary_rows = [r for r in rows if r[ "id" ] in boundary_ids]
sampled += random.sample(boundary_rows, min ( BOUNDARY_QUOTA , len (boundary_rows)))
with OUT .open( "w" ) as f:
for row in sampled:
f.write(json.dumps(row, ensure_ascii = False ) + " \n " )
print ( f "eval set: { len (sampled) } images / { len ( set (r[ 'label' ] for r in sampled)) } categories" )
if __name__ == "__main__" :
main()
random.seed(42) を固定しているのは、後でモデルを差し替えたときに同じ100枚で比べる ためです。評価セットが動くと、モデルの差なのかサンプルの差なのか永久に分からなくなります。ここを固定しないまま数日測って、途中で気づいて全部やり直したことがあります。
母数が3枚に満たないカテゴリで警告を出しているのも意図があります。そういうカテゴリは、そもそも学習側にも実運用側にもデータが足りていないので、モデルを替えても解決しません。評価の前に分かるだけで、判断の順序が変わります。
一致率は「どこで外したか」を隠す
100枚が用意できたら測ります。ローカルモデルとクラウドの両方に同じ100枚を通し、一致率と混同行列を出します。
# evaluate.py — 予測を集めて一致率と混同行列を出す
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Callable
EVAL_SET = Path( "data/eval_set.jsonl" )
def evaluate (predict: Callable[[ str ], str ], name: str ) -> dict :
rows = [json.loads(line) for line in EVAL_SET .read_text().splitlines()]
confusion: dict[tuple[ str , str ], int ] = Counter()
per_label_total: dict[ str , int ] = Counter()
hits = 0
for row in rows:
truth = row[ "label" ]
pred = predict(row[ "path" ])
confusion[(truth, pred)] += 1
per_label_total[truth] += 1
if pred == truth:
hits += 1
accuracy = hits / len (rows)
# 誤りだけを件数順に並べる。ここが読みどころ
errors = sorted (
((t, p, n) for (t, p), n in confusion.items() if t != p),
key =lambda x: x[ 2 ],
reverse = True ,
)
print ( f " \n === { name } ===" )
print ( f "accuracy: { accuracy :.2% } ( { hits } / { len (rows) } )" )
print ( "top confusions:" )
for truth, pred, n in errors[: 5 ]:
share = n / per_label_total[truth]
print ( f " { truth } -> { pred } : { n } ( { share :.0% } of { truth } )" )
return { "name" : name, "accuracy" : accuracy, "confusion" : dict (confusion)}
私の100枚での実測はこうなりました。
モデル 一致率 最大の誤りペア
gemini-3.5-flash(クラウド) 94% ミニマル → 抽象(2件)
Gemma 4 マルチモーダル(Q4_K_M・ローカル) 82% 夜景 → 都市(4件)
Nemotron 3 Nano Omni(ローカル) 79% 動物 → 自然(5件)
82% という数字だけ見ると、「まあ許容範囲では」と思えます。私も最初にこの行を見たときは、そう思いました。
けれど混同行列を開くと景色が変わります。Gemma 4 の誤り18件のうち、7件が「夜景 ⇄ 都市」の相互混同でした。これは正直、どちらでもいい誤りです。夜の街の写真が夜景一覧に出ても都市一覧に出ても、アプリを触っている人は困りません。
問題は残りでした。「動物」を「人物」に入れた誤りが2件あったのです。件数としては小さい。けれど、これは目に見える事故です。
誤りのコストは一様ではない
一致率は、すべての誤りを1件として数えます。実際のプロダクトで、すべての誤りが等価だったことは私には一度もありません。
そこでコスト行列を置きます。「真のラベル t を p と誤ったとき、どれだけ痛いか」を数字で書き下すだけの、素朴な表です。
誤りの種類 コスト そう置いた理由
隣接カテゴリ間(夜景 ⇄ 都市 / ミニマル ⇄ 抽象) 0.2 どちらの一覧に出ても違和感がない。実質的な実害がほぼない
同系統だが粒度違い(動物 → 自然) 0.5 探している人は見つけにくくなるが、不快ではない
系統をまたぐ(動物 → 人物 / 自然 → 乗り物) 1.0 一覧の統一感が崩れる。指摘が来るのはほぼこの型
正解 0.0 —
この重みを掛けて割り直します。
# cost_weighted_error.py — 誤りのコストで重み付けした誤り率
import json
from pathlib import Path
# {(true, pred): cost}。書いていない組み合わせは既定値を使う
COST_MATRIX_PATH = Path( "config/cost_matrix.json" )
DEFAULT_COST = 1.0
def load_cost_matrix () -> dict[tuple[ str , str ], float ]:
raw = json.loads( COST_MATRIX_PATH .read_text())
return {(item[ "true" ], item[ "pred" ]): item[ "cost" ] for item in raw}
def cost_weighted_error (confusion: dict , costs: dict ) -> float :
"""confusion: {(true, pred): count} を受け取り、加重誤り率を返す"""
total = sum (confusion.values())
if total == 0 :
return 0.0
penalty = 0.0
for (truth, pred), n in confusion.items():
if truth == pred:
continue
penalty += costs.get((truth, pred), DEFAULT_COST ) * n
return penalty / total
def report (result: dict ) -> None :
costs = load_cost_matrix()
# evaluate.py の返り値は JSON 化のためキーが文字列になっている前提で戻す
confusion = { tuple (k.split( " \t " )): v for k, v in result[ "confusion" ].items()}
cwe = cost_weighted_error(confusion, costs)
print ( f " { result[ 'name' ] } : accuracy= { result[ 'accuracy' ] :.2% } / cost-weighted error= { cwe :.3f } " )
同じ100枚での結果です。
モデル 一致率 コスト重み付き誤り
gemini-3.5-flash(クラウド) 94% 0.12
Gemma 4 マルチモーダル(Q4_K_M) 82% 0.31
Nemotron 3 Nano Omni 79% 0.44
一致率では 82% と 79% で3ポイント差でしたが、コスト重み付きでは 0.31 と 0.44 で、体感としては別物の開きになります。Nemotron の誤りは系統をまたぐものに寄っていました。静止画1枚から単一ラベルを引くという、モダリティを跨がない用途に、オムニモーダル統合の強みが出る余地がなかったのだと私は解釈しています。
この表を作った時点で、Nemotron はこの用途では候補から外れました。モデルが悪いのではなく、置きどころが違っただけです。
採否は「一致率何%以上」ではなく「予算内か」で決める
ここまで来て、ようやく採否の話ができます。
「一致率90%以上なら採用」といった基準をよく見かけますが、その90%はどこから来た数字なのでしょうか。私は自分で書いておきながら根拠を説明できず、決め直したことがあります。
引くべきは品質予算です。手順としてはこうなります。
現在のクラウド運用で、コスト1.0の誤り(系統をまたぐ誤り)が実際に何件出ているかを数える。私の場合は再分類1回・38,000枚あたり、ユーザーからの指摘に至ったものが月あたり2〜3件でした
どこまで増えたら許容できないかを、自分の言葉で決める。私は「月5件を超えたら、アプリの印象を損なう」と置きました
評価セットのコスト重み付き誤りが、その件数にどう対応するかを換算する
3つめが要点です。100枚での 0.12 が月2〜3件に対応しているなら、0.31 はおよそ2.6倍。単純に引き延ばせば月5〜8件です。置いた許容ラインを超えます。
全面置換は不採用 。これが私の結論でした。一致率82%は、この換算を通すと予算外だったということです。
この順序を、私はこの手の判断のたびに踏むようにしています。先に予算を決めてから測る。逆にすると、出た数字に合わせて基準のほうが動いてしまいます。
ただ、ここで終わらせるのはもったいない話でもありました。混同行列をもう一度見ると、上位3カテゴリに対する Gemma 4 の適合率は97%あり、そこだけは十分に信頼できました。ローカルの出力がその3カテゴリのいずれかで、かつ確信度が高いときだけ採用し、それ以外はクラウドへ回す。その形なら再分類1回あたり約¥3,400が¥1,430前後まで下がり、所要も5時間から3.5時間ほどになります。
つまり答えは「採用」でも「不採用」でもなく、部分採用 でした。100枚を測っていなければ、この線は引けませんでした。
なお、この振り分けをどう実装し、しきい値をどう保つかは、それ自体が別の設計課題です。ルーティングの実装とコスト設計は「Gemma 4 ローカル推論と Gemini API の使い分けで月額¥32,000を¥9,000台にした — ハイブリッドルーターの本番設計 」に、確信度そのものの取り出し方は「Gemini API の logprobs で分類タスクの信頼度を測る — 動くコードと判断軸 」に、置いたしきい値が古くなっていく問題は「Flash と Pro のルーティングしきい値を、シャドウ再評価で較正し続ける 」に分けて書いています。この記事の担当範囲は、その手前にある「そもそも入れてよいのか」の判定までです。
量子化を上げ直した日に、結論は静かに腐る
採否を決めて数週間後、Gemma 4 の量子化を Q4_K_M から Q5_K_M に上げました。品質が上がる方向の変更ですから、確認は形式的なもののつもりでした。
同じ100枚を通した結果です。
量子化 一致率 コスト重み付き誤り
Q4_K_M 82% 0.31
Q5_K_M 84% 0.38
一致率は2ポイント改善しています。コスト重み付き誤りは悪化しています。
内訳を見ると理由が分かりました。多数派カテゴリでの正解が増えた一方で、少数カテゴリでの誤りが「隣接カテゴリへの取り違え」から「系統をまたぐ取り違え」へ移っていたのです。件数は減ったのに、痛い方に寄った。
一致率だけを見ていたら、この変更は改善として通っていました。しかも通ったあと、指摘が増えても原因が量子化だと結びつけるのは相当難しかったはずです。
ですから、この確認は毎回自動で回します。
# regression_check.py — ベースラインとの差でCIを落とす
import json
import sys
from pathlib import Path
BASELINE = Path( "data/baseline.json" ) # 採用時に確定させた基準値
CURRENT = Path( "data/current.json" ) # 今回の評価結果
# 一致率が上がっていても、加重誤りがこの幅を超えて悪化したら通さない
CWE_TOLERANCE = 0.03
def main () -> int :
base = json.loads( BASELINE .read_text())
now = json.loads( CURRENT .read_text())
delta_acc = now[ "accuracy" ] - base[ "accuracy" ]
delta_cwe = now[ "cost_weighted_error" ] - base[ "cost_weighted_error" ]
print ( f "accuracy: { base[ 'accuracy' ] :.2% } -> { now[ 'accuracy' ] :.2% } ( { delta_acc :+.2% } )" )
print ( f "cost-weighted error: { base[ 'cost_weighted_error' ] :.3f } -> "
f " { now[ 'cost_weighted_error' ] :.3f } ( { delta_cwe :+.3f } )" )
if delta_cwe > CWE_TOLERANCE :
print ( f " \n FAIL: 加重誤りが許容幅 { CWE_TOLERANCE } を超えて悪化しています。"
f "一致率が改善していても採用しません。" )
return 1
print ( " \n PASS" )
return 0
if __name__ == "__main__" :
sys.exit(main())
25行ほどのスクリプトです。ローカルモデルを使うということは、Google が黙って面倒を見てくれていた部分を自分で持つということでした。その負担のうち、少なくとも「気づけない」部分だけは、この程度の仕掛けで消せます。
エッジ機器で先に音を上げるのは GPU ではない
評価の話に、ハードウェアの話が絡んできます。本番運用に入ってから分かった注意点が3つあり、いずれも評価のやり方そのものに跳ね返ってきました。
第一に、ストレージI/Oが先に律速します。マルチモーダルモデルは画像をロードするたびに大きな I/O が走るので、GPU に余裕があるのに SSD の読み出し待ちでスループットが止まります。4K の壁紙を連続で流すと、これがはっきり出ます。
第二に、温度です。エッジ機器は冷却能力が限られていて、連続推論を続けると thermal throttling でスループットが3割ほど落ちます。私の環境では、連続30分の後半で p95 レイテンシが1.8秒から3.1秒へ伸びました。
ここが評価設計に効いてきます。冷えた状態の10枚で測った速度は、38,000枚を流す本番の速度ではありません 。回避策は単純で、測る場所をずらすだけです。所要5時間・3.5時間という見積もりも、後半の落ち込みを含めて測り直したあとの数字です。冷えた状態の実測をそのまま38,000倍していたら、見積もりは2割ほど楽観に振れていました。
第三に、モデル更新の運用フローです。クラウドAPIなら Google が更新してくれますが、ローカルモデルは量子化バージョンの追従も、回帰の検知も自分の仕事になります。前の節で書いたとおりです。
評価は、冷えた10枚ではなく、連続30分の後半で測る。これは覚えておくと安く済む種類の教訓でした。
次の一歩
もしローカルのマルチモーダルを検討されているなら、比較表やベンチのスコアを集める前に、100枚を作るところから始めてみてください。
層化して各カテゴリ3枚。過去に指摘の来た画像を別枠で10枚。シードを固定して保存する。ここまでで、たいてい半日もかかりません。
コスト行列は後回しで構いません。むしろ最初の1周では省くことをお勧めします。混同行列を眺めて、「この誤りは痛い / これは別にいい」と自分の言葉で仕分けるだけでも、総合スコアを見ていたときには見えなかった線が引けます。
私自身、この100枚を作るまで、82%という数字を採用の根拠にしかけていました。数字が悪かったのではなく、その数字が自分の何を代表しているのかを確かめていなかっただけでした。個人開発では判断を自分ひとりで引き受けるぶん、こういう小さな装置が効いてきます。お読みいただきありがとうございました。