iOS 側のメディエーション拡張で Liftoff・InMobi・Unity Ads を足したとき、対象になったのは4アプリ分、20を超えるグループでした。管理画面を1つずつ開いて、同じネットワークが同じ形で入っているかを目で確かめていきます。10 グループ目あたりで、自分がどこまで確認したのか分からなくなりました。
翌週になって気づいたのが、あるグループにだけ1ネットワークが入っていなかったことです。収益の数字はほんの少し低いだけで、異常として目に入るような落ち方はしていませんでした。設定の抜けというのは、そういう静かな形で現れます。
この経験から、点検の手順を作り直しました。結論から書くと、抜けの検出は機械の仕事で、Gemini に渡すのは「その状態が意図的かどうか」の判断だけ という分担に落ち着いています。
「おかしいところを教えて」では、抜けは出てきません
最初に試したのは、素直な方法でした。20 グループ分の設定をまとめてテキストにして、Gemini に「不整合があれば指摘してください」と投げます。
返ってきたのは、eCPM の桁が明らかに違うセルや、命名規則から外れたグループ名でした。どれも指摘としては正しいのですが、私が一番見つけたかった「あるグループにだけネットワークが入っていない」は出てきませんでした 。
理由を考えて、腑に落ちました。存在するものの異常は、テキストの中に手がかりが残ります。一方で欠落は、その場所に何も書かれていないという形でしか存在しません。20 行のリストを読んで「19 行目に他の行にはある要素が無い」と気づくには、まず全行の要素を集めて集合を作り、行ごとに差分を取るという操作が要ります。それは言語で読み取る作業ではなく、集合演算です。
言語モデルに苦手なことをさせて精度を嘆くより、集合演算は集合演算として書いてしまう方が早い。そう考えて、渡す前の形を変えることにしました。
まず、設定を1枚の表に正規化します
管理画面のエクスポートでも、スクリーンショットから起こした手書きの JSON でも構いません。大事なのは「グループ × ネットワーク」のマトリクスに変換できる形に揃えることです。
# groups: 管理画面から起こした設定。実運用ではエクスポートを読み込みます
groups = [
{ "group" : "RWD-iOS-Wallpaper-JP" , "app" : "wallpaper" , "sources" : [
{ "network" : "admob_bidding" , "type" : "bidding" },
{ "network" : "applovin" , "type" : "bidding" },
{ "network" : "liftoff" , "type" : "bidding" },
{ "network" : "inmobi" , "type" : "bidding" },
{ "network" : "unity" , "type" : "waterfall" , "ecpm" : 4.2 }]},
{ "group" : "RWD-iOS-Wallpaper-US" , "app" : "wallpaper" , "sources" : [
{ "network" : "admob_bidding" , "type" : "bidding" },
{ "network" : "applovin" , "type" : "bidding" },
{ "network" : "liftoff" , "type" : "bidding" },
{ "network" : "unity" , "type" : "waterfall" , "ecpm" : 4.2 }]},
{ "group" : "RWD-iOS-Ukiyoe-JP" , "app" : "ukiyoe" , "sources" : [
{ "network" : "admob_bidding" , "type" : "bidding" },
{ "network" : "applovin" , "type" : "bidding" },
{ "network" : "liftoff" , "type" : "bidding" },
{ "network" : "inmobi" , "type" : "bidding" },
{ "network" : "unity" , "type" : "waterfall" , "ecpm" : 0.42 }]},
{ "group" : "RWD-iOS-Healing-JP" , "app" : "healing" , "sources" : [
{ "network" : "admob_bidding" , "type" : "bidding" },
{ "network" : "applovin" , "type" : "waterfall" , "ecpm" : 3.9 },
{ "network" : "liftoff" , "type" : "bidding" },
{ "network" : "inmobi" , "type" : "bidding" },
{ "network" : "unity" , "type" : "waterfall" , "ecpm" : 4.2 }]},
]
def build_matrix (groups):
"""グループ × ネットワークの二次元表を作ります。
未設定のセルは None になり、これが後で「欠落」の候補になります。"""
networks = sorted ({s[ "network" ] for g in groups for s in g[ "sources" ]})
matrix = {}
for g in groups:
by_net = {s[ "network" ]: s for s in g[ "sources" ]}
matrix[g[ "group" ]] = {n: by_net.get(n) for n in networks}
return networks, matrix
networks, matrix = build_matrix(groups)
for name, row in matrix.items():
print (name, {n: (row[n][ "type" ] if row[n] else "-" ) for n in networks})
手元で実行すると、次のように並びます。
RWD-iOS-Wallpaper-JP {'admob_bidding': 'bidding', 'applovin': 'bidding', 'inmobi': 'bidding', 'liftoff': 'bidding', 'unity': 'waterfall'}
RWD-iOS-Wallpaper-US {'admob_bidding': 'bidding', 'applovin': 'bidding', 'inmobi': '-', 'liftoff': 'bidding', 'unity': 'waterfall'}
RWD-iOS-Ukiyoe-JP {'admob_bidding': 'bidding', 'applovin': 'bidding', 'inmobi': 'bidding', 'liftoff': 'bidding', 'unity': 'waterfall'}
RWD-iOS-Healing-JP {'admob_bidding': 'bidding', 'applovin': 'waterfall', 'inmobi': 'bidding', 'liftoff': 'bidding', 'unity': 'waterfall'}
- が1つ見えます。この段階で既に、目視では10 グループ目で見失ったものが視界に入っています。表にするというだけの操作ですが、点検の性質がここで変わります。
機械で確定できる3種類のずれを、先に出します
マトリクスができたら、判断の要らない部分を先に取り切ります。私が実運用で拾いたかったのは、次の3つでした。
種類 検出のしかた 典型的な原因
欠落 他のほとんどのグループにあるネットワークが、そのグループにだけ無い 横展開の作業中に1グループ飛ばした
eCPM の外れ値 同じネットワークのウォーターフォール値が、他グループの中央値から大きく離れている 桁の入力ミス、テスト値の消し忘れ
型のずれ 他ではビディングで入っているネットワークが、そのグループだけウォーターフォール 移行作業の途中で止まっている
def find_anomalies (networks, matrix):
"""判断を要さない3種類のずれだけを取り出します。
ここでは「直すべきか」は決めません。事実の抽出に徹します。"""
found = []
total = len (matrix)
# 各ネットワークが何グループに入っているか
coverage = {n: sum ( 1 for g in matrix if matrix[g][n]) for n in networks}
for group, row in matrix.items():
for n in networks:
cell = row[n]
# (1) 欠落: ほぼ全グループにあるのに、ここだけ無い
if cell is None and coverage[n] == total - 1 :
found.append({ "group" : group, "network" : n, "kind" : "missing_source" ,
"detail" : f " { coverage[n] } / { total } のグループに設定あり" })
continue
if cell is None :
continue
# (2) eCPM の外れ値: 同ネットワークの中央値と3倍以上離れている
if cell.get( "type" ) == "waterfall" and "ecpm" in cell:
peers = [matrix[o][n][ "ecpm" ] for o in matrix
if o != group and matrix[o][n]
and matrix[o][n].get( "type" ) == "waterfall"
and "ecpm" in matrix[o][n]]
if peers:
med = sorted (peers)[ len (peers) // 2 ]
if med and (cell[ "ecpm" ] > med * 3 or cell[ "ecpm" ] < med / 3 ):
found.append({ "group" : group, "network" : n, "kind" : "ecpm_outlier" ,
"detail" : f " { cell[ 'ecpm' ] } / 中央値 { med } " })
# (3) 型のずれ: 同じネットワークで bidding と waterfall が混在している
for n in networks:
seen = {matrix[g][n][ "type" ] for g in matrix if matrix[g][n]}
if "bidding" in seen and "waterfall" in seen:
for group in matrix:
cell = matrix[group][n]
if cell and cell[ "type" ] == "waterfall" :
found.append({ "group" : group, "network" : n, "kind" : "type_drift" ,
"detail" : "他グループはビディングで設定" })
return found
for a in find_anomalies(networks, matrix):
print (a)
出力はこうなります。
{'group': 'RWD-iOS-Wallpaper-US', 'network': 'inmobi', 'kind': 'missing_source', 'detail': '3/4 のグループに設定あり'}
{'group': 'RWD-iOS-Ukiyoe-JP', 'network': 'unity', 'kind': 'ecpm_outlier', 'detail': '0.42 / 中央値 4.2'}
{'group': 'RWD-iOS-Healing-JP', 'network': 'applovin', 'kind': 'type_drift', 'detail': '他グループはビディングで設定'}
ここまでに API 呼び出しは1回もありません。20 グループでも 60 グループでも、この部分の実行時間は目に見えるほどにはなりません。
Gemini に渡すのは、判断が要るセルだけです
さて、上の3件は「事実としてずれている」というだけで、直すべきかどうかは別の話です。
米国向けグループに InMobi が入っていないのは、単なる入れ忘れかもしれませんし、その地域では出稿が薄いと判断して意図的に外したのかもしれません。浮世絵アプリの Unity Ads が 0.42 なのは、桁の打ち間違いかもしれませんし、そのアプリだけ実際に単価が低いという運用上の事実かもしれません。
この「意図的か、事故か」は、設定表の中には書かれていません。書かれているのは別の場所 ── 過去の判断メモ、アプリごとの方針、地域ごとの実績 ── です。文脈をまたいで意味を推し量る作業なので、ここは言語モデルに向いています。
そこで、検出した件だけを構造化して渡します。
import json
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_API_KEY" )
REVIEW_SCHEMA = {
"type" : "object" ,
"properties" : {
"verdicts" : {
"type" : "array" ,
"items" : {
"type" : "object" ,
"properties" : {
"group" : { "type" : "string" },
"network" : { "type" : "string" },
# 直す / 意図的 / 情報不足で保留 の3択に固定します
"decision" : { "type" : "string" ,
"enum" : [ "fix" , "intentional" , "needs_info" ]},
"reason" : { "type" : "string" },
# モデルが参照した前提を書かせ、後で人が検算できるようにします
"assumption" : { "type" : "string" },
},
"required" : [ "group" , "network" , "decision" , "reason" , "assumption" ],
},
}
},
"required" : [ "verdicts" ],
}
CONTEXT = """
- 対象は個人開発の iOS アプリ4本(壁紙・浮世絵・ヒーリング)です
- 方針: 主要ネットワークは原則すべてのグループにビディングで入れます
- 例外: 地域別グループでは、実績のないネットワークを外すことがあります
- 浮世絵アプリは他アプリより単価が低い傾向があります
"""
def review (anomalies):
prompt = (
"以下は広告メディエーション設定から機械的に抽出したずれの一覧です。 \n "
"検出そのものは済んでいます。判断してほしいのは、各件が運用方針に照らして \n "
"意図的な設定と考えられるか、修正すべき事故かの一点だけです。 \n "
"設定表に書かれていない事情が必要な場合は needs_info を選んでください。 \n\n "
f "# 運用方針 \n{ CONTEXT }\n "
f "# 検出結果 \n{ json.dumps(anomalies, ensure_ascii = False , indent = 2 ) } "
)
res = client.models.generate_content(
model = "gemini-3.7-flash" ,
contents = prompt,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = REVIEW_SCHEMA ,
),
)
return json.loads(res.text)[ "verdicts" ]
decision を3択に固定しているのが、この設計の要です。自由記述にすると「確認が望ましいでしょう」といった、行動に移せない返事が混ざります。3択なら、そのまま次の処理に流せます。
needs_info を用意しているのも意図があります。判断材料が足りないときに無理やり fix か intentional を選ばせると、もっともらしい理由が付いた推測が返ってきます。逃げ道を作っておくと、モデルは素直にそこへ逃げてくれます。私自身、この選択肢を足す前と後では、reason の欄の信頼度がはっきり変わりました。
判定を、そのまま差分レポートに落とします
戻ってきた判定は、人が読む形に変換します。ここで大事なのは、fix と intentional を同じ画面に並べないことです。
def to_report (verdicts):
buckets = { "fix" : [], "intentional" : [], "needs_info" : []}
for v in verdicts:
buckets.setdefault(v[ "decision" ], []).append(v)
lines = []
if buckets[ "fix" ]:
lines.append( "## 今すぐ直す" )
for v in buckets[ "fix" ]:
lines.append( f "- { v[ 'group' ] } / { v[ 'network' ] } : { v[ 'reason' ] } " )
if buckets[ "needs_info" ]:
lines.append( " \n ## 判断材料が足りない(人が見る)" )
for v in buckets[ "needs_info" ]:
lines.append( f "- { v[ 'group' ] } / { v[ 'network' ] } : { v[ 'reason' ] } " )
if buckets[ "intentional" ]:
lines.append( " \n ## 意図的と判定(前提を確認)" )
for v in buckets[ "intentional" ]:
lines.append( f "- { v[ 'group' ] } / { v[ 'network' ] } : { v[ 'assumption' ] } " )
return " \n " .join(lines)
intentional の欄に reason ではなく assumption を出しているのは、そこが一番危ないからです。「意図的です」という判定は、放置してよいという判断とほぼ同じ効果を持ちます。前提が間違っていた場合、その1件は次の点検でも同じ理由で見送られ続けます。前提を表に出しておけば、目を通したときに違和感が引っかかります。
構造化出力を「抽出」の範囲に留める考え方は、AdMob レポートの判定は Gemini にやらせない でも触れています。今回の記事はその延長で、判定を渡す場合にどこまで枠をはめるか、という話でもあります。スキーマ違反の応答が本番へ流れ込まないようにする実装は、Gemini API のレスポンスを Schema 検証で守る にまとめてあります。
運用に載せてから、効いた調整が3つあります
最初の実装のまま回していたら、たぶん途中で使わなくなっていました。実際に週次で回す中で直した点を3つ挙げます。
検出の閾値を緩め、判定に回す件数を増やす
最初は「全グループにあるのに1つだけ無い」という完全一致の条件で欠落を探していました。実際には、対象アプリが3本のネットワークと4本のネットワークで混在している場面があり、条件が厳しすぎて拾い漏れます。
coverage[n] >= total - 2 くらいまで緩めて、その代わり Gemini に判断を投げる件数が増えることを受け入れる方が、結果として見落としは減りました。落とし穴は逆方向にあります。検出を厳しくすると出力は静かになりますが、静かになったことと問題が無いことは別です。私は、判定コストを少し払ってでも緩い側に倒すことを推奨します。
判定を保存して、前回と突き合わせる
同じ件が2回続けて needs_info になったら、それは私が調べていないという事実の記録です。1回目は流してしまいますが、2回目に同じ行が出ると、さすがに手が動きます。
保存の形式は JSON の追記で十分でした。ここを凝ったデータベースにしようとして半日溶かしかけたのですが、個人開発の規模では読み返す頻度の方が低く、回避して正解でした。
変更した日以外にも点検する
メディエーションの設定は、自分が触っていなくても管理画面側の仕様変更や自動最適化で状態が変わることがあります。週に1度、変更の有無にかかわらず表を作り直しています。
注意点として、本番運用では点検の実行そのものが失敗しても誰も気づきません。エラーで落ちたときに通知が飛ぶところまで作って、ようやく仕組みとして成立しました。個人開発では自分以外に見る人がいないので、定期実行に載せて、かつ落ちたら分かるようにしておかないと、忙しい週にそのまま消えます。
収益の急な落ち込みを検知する仕組みは別に持っていますが、それは既に起きたことを知らせる装置です。AdMob収益の急落を朝8時に知る仕組み と今回の点検は、起きた後と起きる前という関係で、両方あってようやく落ち着きました。
表にしてから渡す、という考え方
この記事の実装そのものより、持ち帰っていただきたいのは分担の引き方の方です。
言語モデルに向いているのは、書かれているものの意味を汲むことです。書かれていないものを見つけるのは、集合を作って差を取る側の仕事です。設定表、権限表、翻訳ファイル、依存関係の一覧 ── 「そろっているはずのもの」を扱うデータは、どれも同じ構造をしています。全部まとめて投げる前に、まず表にしてみると、何を機械で確定できるかが見えてきます。
まずは手元の設定を1つ、build_matrix に通してみてください。- が並ぶ列が1つでも見つかれば、この分担は自分の環境でも効くはずです。
私自身、20 を超えた時点で目視を諦めるまでに1週間かかりました。同じ遠回りをせずに済む方がいれば嬉しいです。お読みいただきありがとうございました。