壁紙アプリのカテゴリを30個から34個に増やしたとき、最初に頭をよぎったのは新カテゴリの命名でも画面設計でもなく、「すでに分類済みの8,142枚をどうするか」でした。
新しいカテゴリは、既存のどれかから資産を奪います。「夜景」を足せば、それまで「風景」や「都市」に入っていた画像の一部は、本来そちらへ移るべきものです。かといって8,142枚を Gemini にもう一度全部投げるのは、費用の面でも待ち時間の面でも気が進みません。
個人開発でこの種の判断を迫られるたびに感じるのは、「正しさ」と「かけられるコスト」の折り合いをどこで付けるかが設計そのものだということです。今回はその折り合いを、埋め込みと確信度マージンという2つの信号で決めた記録を残しておきます。
全件再分類が高くつく理由を、まず数字で押さえる
判断の前に、全件やった場合のコストを見積もりました。Flash 系のモデルに 768px 相当の画像1枚とラベル定義を渡し、上位2ラベルを構造化出力で返させる構成です。
項目 全件再分類
対象枚数 8,142 枚
1枚あたり入力トークン(画像+プロンプト) 約 1,120
1枚あたり出力トークン 約 45
推定費用 約 3,900 円
実行時間(並列度8) 4 時間 12 分
ラベルが実際に変わった枚数 1,046 枚(12.8%)
最後の行が肝心なところです。8,142枚を投げて、変わったのは1,046枚。裏を返せば87%は投げる前から結論が決まっていた計算になります。この87%を投げずに済ませられれば、費用も時間もそのまま削れます。
なお上の表の「変わった枚数」は、後述する差分再分類の妥当性を測るために、一度だけ全件を回して答え合わせをした結果です。日々の運用では、この全件実行こそが避けたい対象になります。
再分類すべき資産は「新カテゴリに奪われうるもの」だけ
新カテゴリ「夜景」が奪うのは、意味的に近いラベルを持つ資産だけです。「猫」や「幾何学模様」に入っている画像が夜景に移ることは、まず起きません。
そこで再分類の対象を、次の2つの和集合として定義しました。
影響ラベル集合 — 新カテゴリと意味的に近い既存ラベルを持つ資産
境界資産集合 — 分類時の確信度マージンが小さく、もともと判定が揺れていた資産
1つ目は「新カテゴリのせいで答えが変わりうる」資産、2つ目は「新カテゴリがなくても答えが揺れていた」資産です。前者だけでは、たとえば風景とも夜景ともつかない曖昧な1枚を、影響ラベルの網から漏らしてしまいます。
分類台帳に taxonomy_version と確信度マージンを持たせる
この設計が成立する前提として、過去の分類結果に「何のカテゴリ体系で、どれくらいの確からしさで付けたか」が残っていなければなりません。私自身、最初のバージョンではラベル文字列だけを保存していて、後からこの判断ができずに一度全件やり直しています。
現在の台帳は次の形です。
CREATE TABLE asset_labels (
asset_id TEXT PRIMARY KEY ,
label TEXT NOT NULL , -- 採用ラベル
runner_up TEXT , -- 2位ラベル
confidence REAL NOT NULL , -- 1位の確信度 0.0-1.0
runner_up_conf REAL , -- 2位の確信度
margin REAL NOT NULL , -- confidence - runner_up_conf
taxonomy_version INTEGER NOT NULL , -- 分類時のカテゴリ体系のバージョン
classified_with TEXT NOT NULL , -- 使用モデル名(固定バージョン)
classified_at TEXT NOT NULL
);
CREATE INDEX idx_margin ON asset_labels(margin);
CREATE INDEX idx_label_version ON asset_labels(label, taxonomy_version);
runner_up と margin を持たせておくと、後から「どの資産が境界にいたか」をクエリ1本で取り出せます。分類のたびに上位2ラベルを返させるコストは、出力トークンにして1枚あたり20トークン程度の増分でした。保険としては十分に安い部類です。
分類そのものは構造化出力で受け取ります。
from pydantic import BaseModel, Field
from google import genai
class LabelGuess ( BaseModel ):
label: str
confidence: float = Field( ge = 0.0 , le = 1.0 )
class Classification ( BaseModel ):
top: LabelGuess
runner_up: LabelGuess
client = genai.Client( api_key = "YOUR_API_KEY" )
MODEL = "gemini-3.5-flash" # エイリアスではなく固定バージョンを台帳に残す
def classify (image_bytes: bytes , categories: list[ str ]) -> Classification:
resp = client.models.generate_content(
model = MODEL ,
contents = [
{ "inline_data" : { "mime_type" : "image/jpeg" , "data" : image_bytes}},
"以下のカテゴリから最も適切なものと、次点を1つずつ選び、"
"それぞれの確信度を0.0-1.0で返してください。 \n "
+ " \n " .join( f "- { c } " for c in categories),
],
config = {
"response_mime_type" : "application/json" ,
"response_schema" : Classification,
"temperature" : 0.0 ,
},
)
return Classification.model_validate_json(resp.text)
temperature を 0 にしても出力は完全には固定されません。同じ画像を10回投げたところ、1位ラベルは10回とも一致しましたが、確信度は 0.82〜0.87 の幅で揺れました。マージンの閾値を決めるときは、この揺れ幅より大きな値を取る必要があります。
影響ラベルの特定 — 埋め込みで新旧カテゴリの近傍を測る
新カテゴリ「夜景」が、既存30ラベルのうちどれと近いか。これは画像を1枚も投げずに、カテゴリ名と説明文の埋め込みだけで測れます。
import numpy as np
from google import genai
client = genai.Client( api_key = "YOUR_API_KEY" )
EMBED_MODEL = "gemini-embedding-2"
def embed (texts: list[ str ]) -> np.ndarray:
resp = client.models.embed_content(
model = EMBED_MODEL ,
contents = texts,
config = { "task_type" : "SEMANTIC_SIMILARITY" , "output_dimensionality" : 768 },
)
vecs = np.array([e.values for e in resp.embeddings], dtype = np.float32)
# 768次元に縮めた場合は再正規化が必要(縮約後はノルムが1でない)
return vecs / np.linalg.norm(vecs, axis = 1 , keepdims = True )
def impacted_labels (old_labels: dict[ str , str ],
new_labels: dict[ str , str ],
threshold: float = 0.62 ) -> set[ str ]:
"""old_labels / new_labels は {ラベル名: 一文の定義} """
old_names = list (old_labels)
new_names = list (new_labels)
old_vec = embed([ f " { k } : { v } " for k, v in old_labels.items()])
new_vec = embed([ f " { k } : { v } " for k, v in new_labels.items()])
sim = new_vec @ old_vec.T # (new, old) のコサイン類似度
impacted: set[ str ] = set ()
for i, _ in enumerate (new_names):
for j, old in enumerate (old_names):
if sim[i, j] >= threshold:
impacted.add(old)
return impacted
task_type に SEMANTIC_SIMILARITY を指定する点と、output_dimensionality を縮めたあとに再正規化する点は、どちらも外すと結果が静かに劣化します。特に後者は例外も警告も出ないまま類似度が全体的に小さく出るため、閾値を下げてつじつまを合わせたくなる罠になります。私は一度これで閾値を 0.45 まで下げてしまい、無関係なラベルまで影響ラベルに拾っていました。
実際に4つの新カテゴリで測った結果が次の表です。
新カテゴリ 影響ラベル(類似度 ≥ 0.62) 該当資産数
夜景 風景 / 都市 / 光 612
ミニマル 幾何学模様 / 単色 / 抽象 388
水面 海 / 風景 241
季節・秋 自然 / 植物 / 風景 509
重複を除いた影響資産は 1,003 枚。全体の 12.3% です。
選定ルールの実装 — Before と After
以前のバックフィルは、素直な全件ループでした。
# Before: 全件を無条件に再分類する
def backfill_all (conn, categories, taxonomy_version):
rows = conn.execute( "SELECT asset_id FROM asset_labels" ).fetchall()
for (asset_id,) in rows: # 8,142 回
result = classify(load_image(asset_id), categories)
upsert(conn, asset_id, result, taxonomy_version)
現在は、影響ラベルとマージンで対象を絞ってから回します。
# After: 影響ラベル ∪ 低マージン資産だけを再分類する
MARGIN_THRESHOLD = 0.15 # 確信度の揺れ幅(実測 ±0.03)の5倍を取った
def select_targets (conn, impacted: set[ str ], current_version: int ) -> list[ str ]:
placeholders = "," .join( "?" for _ in impacted)
by_label = conn.execute(
f "SELECT asset_id FROM asset_labels "
f "WHERE label IN ( { placeholders } ) AND taxonomy_version < ?" ,
( * impacted, current_version),
).fetchall()
by_margin = conn.execute(
"SELECT asset_id FROM asset_labels "
"WHERE margin < ? AND taxonomy_version < ?" ,
( MARGIN_THRESHOLD , current_version),
).fetchall()
return sorted ({r[ 0 ] for r in by_label} | {r[ 0 ] for r in by_margin})
def backfill_selective (conn, categories, taxonomy_version, impacted):
targets = select_targets(conn, impacted, taxonomy_version)
print ( f "targets: { len (targets) } / { count_all(conn) } " )
for asset_id in targets: # 1,180 回
result = classify(load_image(asset_id), categories)
upsert(conn, asset_id, result, taxonomy_version)
# 再分類しなかった資産は、体系バージョンだけ進めて「判定済み」にする
conn.execute(
"UPDATE asset_labels SET taxonomy_version = ? "
"WHERE taxonomy_version < ? AND asset_id NOT IN "
"(SELECT value FROM json_each(?))" ,
(taxonomy_version, taxonomy_version, json.dumps(targets)),
)
conn.commit()
最後の UPDATE を忘れると、次のカテゴリ追加のときに taxonomy_version < ? の条件へ古い資産が延々と引っかかり続けます。再分類を「しなかった」ことも判断の結果なので、台帳にはその判断を書き戻します。この一行を入れておくと、次回以降の無駄な再送を回避できます。
もう一点、select_targets は taxonomy_version < current_version を必ず条件に入れています。バックフィルの途中で処理が落ちて再実行したとき、すでに終わった分を投げ直さないための冪等性の担保です。8,000枚規模になると、途中で API 側の一時エラーに当たる前提で組んでおいたほうが落ち着いて運用できます。本番運用では、この冪等性がないと再実行のたびに費用が二重にかかります。
差分再分類を回した結果と、外した1件
影響ラベル 1,003 枚と、マージン 0.15 未満の低マージン資産 341 枚。重複を除いた再分類対象は 1,180 枚になりました。
項目 全件再分類 差分再分類 差
投げた枚数 8,142 1,180 -85.5%
推定費用 約 3,900 円 約 570 円 -85.4%
実行時間(並列度8) 4 時間 12 分 38 分 -84.9%
ラベルが変わった枚数 1,046 1,045 -1
全件実行と突き合わせたところ、差分再分類が拾い損ねた資産は1枚でした。その1枚は「猫」ラベルの写真で、背景に大きく夜景が写り込んでおり、全件実行では「夜景」に移っていました。確信度は 0.79、マージンは 0.31。影響ラベルにも入らず、低マージンにも引っかからない位置にいた1枚です。
この取りこぼしを潰すには閾値を緩めるしかなく、マージン閾値を 0.35 に上げると対象は 2,890 枚まで膨らみます。1枚のために 1,710 枚を追加で投げる取引を、私は選びませんでした。壁紙アプリのカテゴリ分類において、8,142枚中1枚の誤配置は、ユーザーが検索で辿り着けなくなる種類の障害ではありません。
確信度の揺れ幅を実測してから、その5倍程度をマージン閾値に取ることを推奨します。値そのものより、揺れ幅を測ってから決めたという経路が後で効いてきます。
ここは判断が割れるところです。医療画像や与信のように誤りのコストが非対称な領域であれば、逆に全件実行を選ぶのが筋だと私は考えます。差分再分類は「誤りが安い領域」でのみ成立する最適化で、その前提を記事の読者にもコードのコメントにも残しておく価値があります。
タクソノミー変更をリリース手順に組み込む
一度きりのスクリプトで終わらせず、カテゴリ追加のたびに同じ道筋を通れるように手順化しました。App Store への提出前に必ず通す3ステップです。
カテゴリ定義ファイルを更新し、taxonomy_version を1つ進める。 定義は「ラベル名: 一文の説明」の形で1ファイルに集約し、これを埋め込みの入力にもプロンプトにも使います。定義が2箇所にあると、影響ラベルの計算とモデルへの指示がずれます。
impacted_labels() をドライランで実行し、影響ラベルの一覧を目視する。 埋め込みの類似度は直感と食い違うことがあり、ここで「季節・秋」が「食べ物」を拾ったときは定義文を書き直しました。閾値をいじるより定義文を直すほうが、結果が安定します。
差分再分類を実行し、変更後のラベル分布を前バージョンと比較する。 ある既存カテゴリの資産が想定より大きく減っていれば、新カテゴリの定義が広すぎるサインです。「ミニマル」を追加した際、「幾何学模様」が 41% 減っており、定義を「装飾を排した単色基調の構図」へ絞り直しました。
このうち3つ目が、実は最も学びの多い工程でした。差分再分類は費用を削るための工夫として始めましたが、対象を絞る過程で「新カテゴリが既存のどこを侵食するか」を事前に言語化することになり、結果としてカテゴリ設計そのものの精度が上がりました。コストの制約が設計を鍛えることもあるのだと、静かに納得しています。
次にカテゴリを増やすときは、台帳の margin 分布をヒストグラムで眺めるところから始めるつもりです。マージンが小さい資産が特定のラベルに偏っていれば、それはカテゴリを追加する前に、既存のカテゴリを分けるべきという合図なのかもしれません。
カテゴリ体系はコードよりも静かに古くなります。その古さを台帳に記録しておくだけで、次の変更がずいぶん軽くなりました。