並行運用していた分類モデルを本番側へ寄せた翌週、アプリの壁紙一覧で上のほうに出てくるカテゴリの顔ぶれが少し変わっていました。
個々の分類が誤っているわけではありません。1枚ずつ目で見れば、どれも納得できる割り当てです。それでも「風景」の棚が少し厚くなり、下のほうにある小さなカテゴリが痩せていました。
切り替えの判断には、手元のゴールデンセットに対する一致率を使っていました。新旧のモデルで同じ画像に同じカテゴリが付く割合です。そこは通っていました。通ったうえで、構成比だけが動いていたわけです。
この記事は、その「通ってしまう」構造を数字で押さえ直し、切替前に気づける形へハーネスを組み替えるまでの記録です。統計まわりの数値は、実運用の分布を模した 30 カテゴリの偏った分布を手元で組み、シミュレーションで求めたものです。運用側の課題のほうは、個人開発で回している壁紙アプリのカテゴリ分類バッチで実際に踏んだものです。配信面は App Store と Google Play の両方で、一覧の並びはどちらも同じ分類結果から組み立てています。片方だけで確かめても意味がない、という前提がこのあとの設計に効いてきます。
一致率 92% が隠していたもの
まず、一致率と構成比が別物であることを数字で確かめました。
30 カテゴリ、構成比は Zipf 的に偏った分布(1位 19.8%、2位 11.1%、3位 7.8%、下位20カテゴリの合計 32.7%)を用意します。そこへ「新モデルは 92% の確率で旧モデルと同じ札を出し、食い違った 8% のうち 7 割は上位3カテゴリのどれかへ流れる」という混同のしかたを与えました。誤りが一様に散らばるのではなく、優勢なカテゴリへ吸われる形です。20万件で回した結果がこちらです。
カテゴリ 旧モデル 新モデル 相対変化
1位 19.78% 21.18% +7.1%
2位 11.06% 11.87% +7.3%
3位 7.80% 8.40% +7.7%
4位 6.13% 5.71% -6.9%
下位20の合計 32.73% 31.72% -3.1%
実効一致率は 91.9%、総変動距離(TVD)は 2.81% でした。
一致率だけを見るゲートは、しきい値を 90% に置いていれば当然のように通します。それでも上位カテゴリは 7% 太り、下位20カテゴリはまとめて 3% 痩せています。一覧の並び順や「新着」の見え方は、この数%で変わります。
一致率は「何件ずれたか」を数えます。構成比のずれは「ずれた分がどこへ行ったか」で決まります。前者が後者を含まないのは考えてみれば当たり前なのですが、ゲートを一本にまとめたときにこの区別が落ちていました。
本番の分布を後から見張る方式は、気づくのが遅すぎます
では、切り替えたあとに本番の出力分布を監視すればよいのか。ここが今回いちばん予想と違ったところです。
30 カテゴリの構成比が基準からずれたかを、カイ二乗適合度検定(自由度29、有意水準5%、臨界値 42.557)で見張る設計を仮定して、上の TVD 2% 弱のドリフトに対する検出力を求めました。
判定に使う件数 検出力
500 9.5%
1,200 19.2%
2,000 39.8%
4,000 79.2%
1日あたり数百枚を処理するバッチだと、4,000 件を貯めるまでに1〜2週間かかります。そのあいだ検出力は 20% 前後で、つまり「異常なし」という表示のほとんどが、異常がないことではなく件数が足りないことを意味していました。
カテゴリを1つに絞った比率検定なら効くだろうと考えて、そちらも測りました。
件数 1位カテゴリ(19.78%→21.18%) 3位カテゴリ(7.80%→8.40%)
1,800 31.4% 16.7%
3,000 47.6% 24.0%
6,000 76.8% 41.8%
15,000 — 76.3%
一括検定よりは効きますが、桁は変わりません。構成比 7.8% のカテゴリを見張るには 15,000 件が要ります。
理由は分散の出どころにあります。本番の分布を観測する方式では、揺れの大半が「そもそもどんな画像が入ってきたか」から来ます。モデルの差はその上に乗った薄い層で、入力の揺らぎに埋もれます。件数を積む以外に埋もれから抜ける手立てがありません。
同じ入力を新旧へ流すと、必要枚数が5分の1になります
入力の揺らぎが邪魔なら、新旧で入力を揃えてしまえばよいわけです。同じ画像を両方のモデルに通し、1枚ごとに「新でこのカテゴリになったか」から「旧でこのカテゴリだったか」を引きます。値は -1、0、+1 のどれかで、一致した 92% の画像はすべて 0 になります。
分散を作るのは食い違った画像だけになり、入力の偏りは差を取る時点で消えます。同じドリフトに対する検出力を測り直しました。
件数 対応あり(同一入力) 対応なし(本番観測)
500 40.5% —
800 58.8% —
1,200 74.7% 約 25%
6,000 — 76.8%
1位カテゴリのずれを 75% の確率で捕まえるのに、本番観測では 6,000 件、対応比較なら 1,200 件でした。おおよそ5分の1です。3位カテゴリ(7.8%)でも、2,500 件で 64.9% まで来ます。
日次 300 枚のバッチなら、対応比較は4日ぶんの画像で判定できます。本番へ寄せてから3週間見張る話が、切り替える前の4日に変わりました。
切替判定ハーネスの実装
実際に使っている形を、依存を減らして書き出します。分類の呼び出し自体は普段のバッチと同じで、違うのは1枚につき2回投げて結果を並べる点だけです。
import os, json, math
from collections import Counter
from google import genai
from google.genai import types
CATEGORIES = [ "landscape" , "night-sky" , "flower" , "animal" , "abstract" ] # 実際は30件
OLD_MODEL = "gemini-3.5-flash"
NEW_MODEL = "gemini-3.7-flash"
client = genai.Client( api_key = os.environ.get( "GEMINI_API_KEY" , "YOUR_API_KEY" ))
SCHEMA = types.Schema(
type = types.Type. OBJECT ,
required = [ "category" ],
properties = { "category" : types.Schema( type = types.Type. STRING , enum = CATEGORIES )},
)
def classify (model: str , image_bytes: bytes ) -> str | None :
"""1枚を1カテゴリへ。失敗は None を返し、その画像は対を作らず捨てる。"""
try :
res = client.models.generate_content(
model = model,
contents = [
types.Part.from_bytes( data = image_bytes, mime_type = "image/jpeg" ),
"この壁紙に最も合うカテゴリを1つだけ選んでください。" ,
],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = SCHEMA ,
),
)
return json.loads(res.text)[ "category" ]
except Exception as exc: # 片方だけ成功した対は使えないので握って捨てる
print ( f "[warn] { model } : { type (exc). __name__ } : { exc } " )
return None
判定側です。対応のある差の平均に対する両側検定を、カテゴリごとに回します。
Z_975 = 1.959964 # 両側 5%
def paired_report (pairs: list[tuple[ str , str ]]) -> list[ dict ]:
"""pairs は (旧ラベル, 新ラベル) の並び。対を作れなかった画像は呼び出し前に除く。"""
n = len (pairs)
if n < 200 :
raise ValueError ( f "対の数が { n } 件では判定に足りません(最低200件)" )
old_share = Counter(o for o, _ in pairs)
rows = []
for cat in CATEGORIES :
d = [( 1 if nw == cat else 0 ) - ( 1 if od == cat else 0 ) for od, nw in pairs]
mean = sum (d) / n
var = sum ((x - mean) ** 2 for x in d) / (n - 1 )
se = math.sqrt(var / n) if var > 0 else 0.0
base = old_share[cat] / n
rows.append({
"category" : cat,
"old_share" : base,
"delta" : mean, # 構成比の変化(絶対値)
"relative" : mean / base if base else 0.0 ,
"significant" : se > 0 and abs (mean) / se > Z_975,
"ci_half_width" : Z_975 * se,
})
return rows
var > 0 の分岐は必要でした。ここが最初の落とし穴です。小さいカテゴリでは、新旧とも一度も選ばれずに差が全部 0 になることがあり、そこで標準誤差が 0 になって割り算が ZeroDivisionError で落ちます。回避策として、例外を出す代わりに significant を False にして通しています。差が観測されていない以上、有意ではないという扱いで正しいはずです。
本番運用に入れてから気づいたのはもう一点、対を作れなかった画像の扱いです。片方のモデルだけが 500 を返した画像を「変化なし」として 0 に数えると、失敗が多い日ほど差が薄まります。対にならなかった画像は判定から外し、除外率が 5% を超えた日は判定そのものを翌日へ送る形で対処しました。
しきい値は相対変化と絶対枚数の二段で置きます
有意かどうかだけで止めると、件数を増やしたときに小さすぎるずれまで拾ってしまいます。実運用では二段にしました。
有意であること (上のハーネスの significant)
相対変化が 5% を超えること (abs(relative) > 0.05)
その変化が1日あたり 30 枚以上に相当すること (abs(delta) * daily_volume >= 30)
3つ目を足したのは、構成比 1.1% のカテゴリで相対 8% が動いても、実際に動く枚数は日に数枚で、一覧の見え方が変わらないからです。相対だけで止めていた頃は、小さなカテゴリの誤差でゲートが赤くなり、そのたびに手で見て「これは無視でよい」と判断していました。判断が毎回同じなら、条件に書けます。
止めるべきかどうかの線引きは、統計ではなくプロダクト側の事情で決まります。ここを検定に丸投げしなかったことが、運用に載せられた理由だと思っています。
日次で 1 万枚を超えるような処理量があるなら、この場合は本番観測でも数日で検出力が立つので、対応比較を組む手間に見合わないこともあります。数百枚から数千枚の規模で、しかも切り替えの判断を自分ひとりで下しているなら、私は切替の前日に対応比較を回しておくことをお勧めします。戻す判断は、寄せたあとより寄せる前のほうがはるかに安く済みます。
運用に落とすときに削ったもの
最初は本番の出力分布も並行して監視していましたが、外しました。検出力が足りないことがわかった以上、緑のランプに意味がないからです。意味のない監視は、あるほうが危険でした。
代わりに残したのは、切替判定に使った 1,200 対のラベルと日時、そして両モデルの ID を1ファイルに保存する処理です。あとで「あの日どちらへ寄せたか」を復元できます。モデルの世代が上がるたびに同じ判定を回すので、過去の判定と同じ画像集合を使えるようにしておくと、比較の土台が揃います。
モデル移行そのものの進め方はGemini API のシャドウトラフィックで新モデル移行を安全に進める にまとめてあり、この記事はその判定部分だけを深掘りした形です。壁紙分類で新旧を並行させたときの運用面の話はGemini 3 Pro と 2.5 Pro を壁紙カテゴリ分類に並行投入した 3週間の実装メモ にあります。
次の一手
いま一致率でモデル入れ替えを判定しているなら、次の切り替えのときに 500 枚だけ両モデルへ流し、カテゴリごとの構成比の差を出してみてください。一致率が 92% でも、上位カテゴリが 7% 動いているかどうかはそこで見えます。
私自身、分類の正しさばかりを見ていて、正しい分類の配られ方が変わることには長く気づけずにいました。同じところで時間を溶かす方が少しでも減れば嬉しいです。お読みいただきありがとうございました。