AdMob の管理画面で eCPM が前月より上がっているのに、月末の収益はむしろ下がっている。2014年から個人開発でアプリを出してきて、累計5,000万ダウンロードの大半を占める壁紙アプリ群を運用するなかで、私はこの「数字が良いのに儲かっていない」状態に何度も足を取られてきました。原因のほとんどは eCPM ではなく、もっと地味な fill rate(広告の充填率)にありました。
eCPM は「表示された広告の単価」を語りますが、「そもそも広告が表示されたか」は語ってくれません。メディエーションを複数ネットワークで組んでいると、あるネットワークの fill rate が静かに落ち、リクエストは飛んでいるのに広告が返ってこない時間帯が増えていく。eCPM のグラフだけ見ていると、この穴はまったく見えないのです。
そこで私は、AdMob の指標を Antigravity のエージェントに毎朝読ませて、fill rate の異常をネットワーク別に切り分けさせる仕組みを組みました。ここではその診断パイプラインの設計と実装を、実際にハマった箇所も含めてお伝えします。
fill rate と eCPM は別の物語を語る
まず混同しやすい指標を整理します。AdMob のメディエーションでは、ひとつの広告枠に対して次の流れが走ります。アプリが広告をリクエストし(request)、メディエーションが各ネットワークに順番に問い合わせ、いずれかが広告を返し(match)、実際に画面へ表示される(impression)。
fill rate(match rate)は match ÷ request、つまり「リクエストのうち何割に広告が返ってきたか」です。eCPM は「表示1,000回あたりの収益」で、impression を分母にしています。ここがポイントで、eCPM は表示できなかったリクエストを一切カウントしません。
具体例で考えます。あるネットワークの eCPM が ¥800 と高くても、fill rate が 40% まで落ちていれば、リクエスト1,000回あたりの実収益は単純計算で ¥320 相当にしかなりません。一方、eCPM ¥500 で fill rate 90% のネットワークは ¥450 相当を稼ぎます。eCPM のランキングだけでウォーターフォールの優先順位を決めると、前者を上位に置いてしまい、収益を取りこぼします。私が最初にやらかしたのが、まさにこれでした。
だからこそ、診断エージェントには「eCPM × fill rate を掛け合わせた実効単価」をネットワーク別・時間帯別・国別に見せる必要があります。人間が AdMob のダッシュボードでこれを毎日手で追うのは現実的ではありません。ここがエージェントの出番です。
診断エージェントに何を読ませるか — データソースの設計
最初に決めるのはデータソースです。AdMob の管理画面をスクレイピングする方法もありますが、私は安定性を重視して、AdMob の BigQuery エクスポート(Mediation 用のスキーマ)と、Firebase Analytics の指標を組み合わせています。
判断の軸はシンプルで、「数字の生データは構造化された場所から取り、文章での解釈だけをエージェントに任せる」ことです。エージェントに HTML を読ませて数字を拾わせると、桁の取り違えや欠損が混入します。数値の取得は決定的なコードで行い、エージェントには「この数字をどう読むか」だけを委ねるのが、私の運用での結論です。
具体的には次の3つを束ねます。AdMob の BigQuery テーブルからネットワーク別の request / match / impression / estimated_earnings を日次で取得。Firebase からアプリ別の DAU と広告表示イベント数を取得。そして自分で持っているウォーターフォールの設定(どのネットワークをどの順で並べているか)を YAML で持ち、エージェントの判断材料にします。
エージェントのコア実装:メディエーション指標の取り込み
ここから実装です。まず BigQuery から指標を取り出し、fill rate と実効単価を計算する関数を用意します。これは決定的に動くべき部分なので、エージェントの外側に普通の Python 関数として置きます。
from google.cloud import bigquery
from dataclasses import dataclass
@dataclass
class NetworkMetrics :
network: str
requests: int
matches: int
impressions: int
earnings: float # JPY
@ property
def fill_rate (self) -> float :
return self .matches / self .requests if self .requests else 0.0
@ property
def show_rate (self) -> float :
# match したのに表示されなかった割合の裏返し
return self .impressions / self .matches if self .matches else 0.0
@ property
def ecpm (self) -> float :
return ( self .earnings / self .impressions * 1000 ) if self .impressions else 0.0
@ property
def effective_rpm (self) -> float :
# リクエスト1,000件あたりの実効収益(fill rate を織り込む)
return ( self .earnings / self .requests * 1000 ) if self .requests else 0.0
def fetch_metrics (client: bigquery.Client, dataset: str , day: str ) -> list[NetworkMetrics]:
query = f """
SELECT
ad_source_name AS network,
SUM(ad_request_count) AS requests,
SUM(matched_request_count) AS matches,
SUM(impression_count) AS impressions,
SUM(estimated_earnings_micros) / 1e6 AS earnings
FROM ` { dataset } .mediation_report`
WHERE date = @day
GROUP BY network
HAVING requests > 0
ORDER BY earnings DESC
"""
job = client.query(
query,
job_config = bigquery.QueryJobConfig(
query_parameters = [bigquery.ScalarQueryParameter( "day" , "DATE" , day)]
),
)
return [NetworkMetrics( ** dict (row)) for row in job.result()]
effective_rpm がこの記事の主役です。eCPM ではなく、リクエストを分母にした実効単価を出しておくことで、「表示されなかった分の損失」がそのまま数字に乗ります。
次に、この指標群をエージェントが呼べるツールとして登録します。Antigravity のエージェント定義では、ツールは「決定的な計算を返す関数」と「その結果を解釈する LLM」の役割を明確に分けるのが肝心です。
def diagnose_fill_rate (day: str , waterfall: dict ) -> dict :
client = bigquery.Client()
metrics = fetch_metrics(client, "admob_export_123456" , day)
findings = []
for m in metrics:
configured_rank = waterfall.get(m.network, 999 )
findings.append({
"network" : m.network,
"fill_rate" : round (m.fill_rate, 3 ),
"ecpm_jpy" : round (m.ecpm, 1 ),
"effective_rpm_jpy" : round (m.effective_rpm, 1 ),
"configured_rank" : configured_rank,
})
# 実効単価で並べ直したランクと、設定上のランクの乖離を計算
by_effective = sorted (findings, key =lambda f: - f[ "effective_rpm_jpy" ])
for actual_rank, f in enumerate (by_effective, start = 1 ):
f[ "effective_rank" ] = actual_rank
f[ "rank_gap" ] = f[ "configured_rank" ] - actual_rank
return { "day" : day, "networks" : by_effective}
rank_gap が大きい(設定上は上位なのに実効単価では下位)ネットワークが、収益を取りこぼしている犯人候補です。エージェントにはこの構造化済みの結果を渡し、自然言語で「どのネットワークの fill rate がいつから落ちたか、考えられる原因は何か」を述べさせます。
fill rate 低下を切り分ける判断ロジック
fill rate が落ちる原因は一つではありません。エージェントに丸投げする前に、私が経験から整理した切り分けの軸を、判断材料としてプロンプトに埋め込んでいます。
第一に、特定ネットワークだけ落ちているのか、全体が落ちているのか。全体なら自社側(SDK のバージョン、ネットワーク設定、地域の在庫不足)を疑い、単一ネットワークなら相手側の在庫やフロア価格設定を疑います。第二に、時間帯依存か。深夜帯だけ落ちるなら広告在庫の薄い時間にフロア価格が高すぎる可能性があります。第三に、国別の偏り。特定の国で fill rate が極端に低ければ、その国にそのネットワークの在庫がほとんどないだけ、というケースが多いです。
この3軸を、エージェントが参照できる形でツールの説明文に書いておきます。判断の手順としては次のようになります。
直近7日と前7日で各ネットワークの fill rate を比較し、5ポイント以上の低下を抽出する
低下したネットワークについて、時間帯別・国別の内訳を再クエリする
rank_gap がプラス(過大評価されている)かつ fill rate が低下しているネットワークを優先度の高いアラートとして人に通知する
ここで大事なのは、エージェントに「フロア価格を下げろ」と断定させないことです。フロア価格を下げれば fill rate は上がりますが、eCPM は下がります。そのトレードオフの最終判断は、後述する通り人が握っておくべき領域だと私は考えています。
Before / After:私が実際に書き換えた診断プロンプト
最初に書いたプロンプトは、ありがちな失敗をしていました。
# Before(うまくいかなかった版)
あなたは AdMob の専門家です。以下のメディエーションレポートを分析し、
収益を最大化する改善案を提案してください。
{metrics_json}
このプロンプトだと、エージェントは毎回それらしい一般論(「フロア価格を最適化しましょう」「新しいネットワークを追加しましょう」)を返すだけで、今日この数字に対する具体的な指摘になりませんでした。一般論は私にとって価値がありません。私が欲しいのは「昨日と比べて何が変わったか」です。
書き換えた版では、判断の軸と出力フォーマットを固定しました。
# After(実用になった版)
あなたは個人開発者のメディエーション運用を補佐する診断アシスタントです。
入力は今日と前日比較のネットワーク別指標です。次の手順だけを実行してください。
1. fill rate が前日比で 5pt 以上低下したネットワークだけを列挙する
(低下がなければ「本日は有意な fill rate 低下なし」と1行で返す)
2. 各ネットワークについて rank_gap を見て、過大評価されているか述べる
3. 推測される原因を「在庫」「フロア価格」「地域偏在」のいずれかに分類する
4. 人間が確認すべき1アクションだけを提案する(設定変更は提案しない)
一般的な改善論・教科書的な助言は書かないでください。
{metrics_json}
この「低下がなければ1行で返す」という指示が効きました。Before 版は異常がない日でも長文を返してきて、私はそれを読み流す癖がつき、結果として本当に異常があった日のレポートも読み飛ばしてしまったのです。静かな日は静かに、という設計に変えてから、アラートの信頼性が戻りました。
本番運用でハマったところと回避策
実際に毎朝動かしてみると、いくつか落とし穴がありました。
ひとつ目は、BigQuery エクスポートの遅延です。AdMob の前日データが BigQuery に揃うのは、私の環境では当日の昼前後でした。早朝に走らせると前日分が欠損したまま「fill rate が0%に急落」という誤報を出します。対処として、クエリ対象を「2日前」に固定し、確定したデータだけを見るようにしました。鮮度より正確さを優先する判断です。
ふたつ目は、新規追加ネットワークの初期 fill rate が極端に低く出ることです。導入直後はキャリブレーション期間で fill rate が安定しません。これを「異常低下」と誤検知させないため、運用日数が7日未満のネットワークは比較対象から除外するフラグを waterfall の YAML に持たせました。
みっつ目は、エージェントの出力をそのまま信じてはいけない、という当たり前の教訓です。あるとき「Network A の在庫枯渇」と断定されたので設定を変えようとしたのですが、実際は私自身が前日に SDK を更新した際のアダプタ設定ミスでした。原因が自分側にあるケースをエージェントは構造的に見落とします。だから出力には必ず「直近のリリース・設定変更がなかったか確認」というチェック項目を添えるようにしています。本番運用では、エージェントの結論を疑う一文を仕組みに組み込むことが、結局いちばん効きました。
エージェントに任せる範囲と、人が判断する範囲
ここが運用で最も悩んだところです。技術的には、fill rate が落ちたらフロア価格を自動で下げる、というところまで自動化できます。実際にそうしている事例も見ます。けれど私は、収益に直接効く設定変更はエージェントに握らせない方針にしています。
理由は二つあります。フロア価格と fill rate のトレードオフは、そのアプリのユーザー体験や、私が広告をどれくらい見せたいかという作品としての判断と不可分だからです。壁紙アプリは「静かに使ってほしい」という思想で作っているものもあり、収益最大化が常に正解ではありません。もう一つは、自動で設定を書き換えると、収益が動いたときに「なぜ動いたか」を後から追えなくなるからです。診断と実行を分け、実行のログを人の手元に残しておくことが、長く個人で運用するうえでの安全装置になります。
だから私の線引きは明確です。エージェントが担うのは「観測・比較・原因の分類・通知」まで。「フロア価格をいくらにするか」「このネットワークを切るか」という収益と体験に関わる決定は、私が握る。この境界を最初に引いておくと、エージェントを信頼しつつ依存しすぎない、ちょうどいい距離が保てます。技術への好奇心は1997年にインターネットに触れた頃からずっと私の原動力ですが、その熱量を最終判断まで明け渡さないことが、個人開発を続けるうえで大切だと感じています。
次の一歩
もし同じように複数ネットワークでメディエーションを組んでいるなら、まずは手元のレポートで一つだけ確かめてみてください。eCPM が最上位のネットワークの fill rate を見て、それが80%を下回っているなら、effective_rpm で並べ直すだけで優先順位が変わる可能性があります。診断エージェントを組むのはそのあとで十分です。小さな計算ひとつで、見えていなかった穴が見えてくることがあります。
同じ課題に取り組んでいる個人開発者の方の参考になれば幸いです。