AdMob のフロアプライス(floor price)を触り始めて最初に戸惑うのは、調整した結果が良かったのか悪かったのかの判定に最低でも 1 週間はかかる、というところでした。短い期間で動かしすぎると、季節要因や曜日要因と区別がつかなくなります。逆に放置すれば、入札に押されるべきリクエストが安く取られて取りこぼします。個人開発で複数のアプリを長く運用していると、AdMob のメディエーション設計は放っておいても少しずつ複雑になっていきます。私の場合も、6 アプリを並行で回すうちに「週次の CSV を眺めながらの手作業」では追いつかなくなり、フロアプライスの下調べを Gemini 2.5 Pro に任せる構成へと寄せていきました。ここで大事にしているのは、AI に決めさせることではなく、判断の材料を毎週同じ形で整えてもらうことです。
このメモは、AdMob レポート CSV を Gemini 2.5 Pro に投入し、フロアプライス調整候補を構造化出力させるまでの実装を、現場で動かしているものに近い形で残したものです。広告ネットワーク側の eCPM がブラックボックスである以上、AI に「最適解」を出させる類の話ではありません。あくまで人間が判断するための「準備された比較表」を毎週同じフォーマットで自動生成する、という地味な使い方にこだわっています。
なぜ「勘」でフロアを動かすと精度が下がるのか
AdMob のフロアプライスは、各広告ユニット(あるいは Bidding 参加ネットワーク)の入札を「この金額未満ならスキップする」と宣言するための値です。設定すれば即座に観測可能な変化が出るわけではなく、入札の母数が十分に蓄積されてから fill rate と eCPM の両方の動きが見え始めます。私の手元の壁紙アプリ群(DAU 数千〜数万のレンジ)で経験的に見えてきたのは、次のような感覚値です。
- フロアを 30% 引き上げると、Reward 系では fill rate が 5〜10 ポイント程度下がり、eCPM は数十パーセント上がる。ただし広告ユニットや国別構成で大きくぶれる
- フロアを下げ過ぎる(または未設定にする)と、Bidding 参加ネットワークの一部が極端に安値で取りに来て、eCPM の中央値が押し下がる
- 1 アプリの中でも、
Open Ads・Interstitial・Rewarded でフロア感度は別物。Open Ads は需要が薄いので下手に上げると fill が崩れる
これらの感覚をすべて頭に入れたまま、6 アプリ × 各 3〜4 種類の広告ユニット × 主要 5〜10 か国を毎週見直すのは、現実的には無理でした。エクセルに貼って色を塗ってもどこか抜けます。Gemini に渡す前に「何を観測したいのか」だけは人間が決める必要があるものの、観測そのものは AI に任せられます。
6 アプリを並行運用していて見えてきた「週内の眺め方」
私が毎週月曜の朝にやっているのは、AdMob 管理画面から「過去 14 日間」のメトリクスを 2 種類に分けてダウンロードする作業です。1 つ目は広告ユニット粒度、2 つ目は国別粒度。これを 6 アプリ × 2 種類 = 12 ファイルにして ~/admob-weekly/2026-05-20/ のような日付フォルダに置きます。Gemini に投入する前に、CSV の構造を一度自分の目で見て、不自然な列が混ざっていないか確かめます。
過去のフロア調整の履歴は別の floor-history.csv に手で記録しています。列は date, app_id, ad_unit, country, old_floor, new_floor, reason だけのシンプルなものです。Gemini に「先週フロアを変えた箇所だけは、変動要因として参照してね」と伝えるための材料になります。AI に丸投げするのではなく、人間が決めたコンテキストを優先させるためのアンカーです。
~/admob-weekly/
├── 2026-05-20/
│ ├── kabegami_lite_iOS_ad_unit.csv
│ ├── kabegami_lite_iOS_country.csv
│ ├── kabegami_lite_Android_ad_unit.csv
│ ├── kabegami_lite_Android_country.csv
│ ├── ...(残り 4 アプリ × 2 OS × 2 種類)
│ └── floor-history.csv
└── 2026-05-13/
└── ...
Gemini 2.5 Pro に投入する前の最小限の前処理
CSV をそのまま inline_data で渡すよりも、pandas で軽く整えてから JSON Lines に直すほうが、後段のプロンプトが安定しました。Gemini 2.5 Pro はかなり長いコンテキストを扱えますが、列名が日本語と英語で混在していたり、AdMob レポートの先頭行に説明文が入っていたりすると、推論時に「説明文を本文と誤認」する事故が起きやすいです。
# preprocess.py
import json
import re
from pathlib import Path
import pandas as pd
REPORT_DIR = Path("~/admob-weekly/2026-05-20").expanduser()
OUT_PATH = REPORT_DIR / "merged.jsonl"
# AdMob CSV の先頭にある説明行を読み飛ばす(行数は管理画面のエクスポートで変わる)
def load_admob_csv(path: Path) -> pd.DataFrame:
text = path.read_text(encoding="utf-8")
# 列ヘッダ行を検出("Date" で始まる最初の行)
lines = text.splitlines()
header_idx = next(i for i, ln in enumerate(lines) if ln.startswith("Date"))
body = "\n".join(lines[header_idx:])
df = pd.read_csv(pd.io.common.StringIO(body))
# 列名のスペースを _ に統一
df.columns = [re.sub(r"\s+", "_", c).lower() for c in df.columns]
return df
records: list[dict] = []
for csv_path in sorted(REPORT_DIR.glob("*_ad_unit.csv")):
app_id = csv_path.stem.replace("_ad_unit", "")
df = load_admob_csv(csv_path)
# eCPM / impressions / requests / matched_requests / estimated_earnings を抽出
use = df[[
"date", "ad_unit", "country", "impressions",
"match_rate", "ecpm", "estimated_earnings"
]].copy()
use["app_id"] = app_id
use["report_kind"] = "ad_unit"
records.extend(use.to_dict(orient="records"))
# 直近 7 日と前 7 日の集計値だけを残す
df_all = pd.DataFrame(records)
df_all["date"] = pd.to_datetime(df_all["date"])
latest = df_all[df_all["date"] >= df_all["date"].max() - pd.Timedelta(days=6)]
prior = df_all[(df_all["date"] < df_all["date"].max() - pd.Timedelta(days=6)) &
(df_all["date"] >= df_all["date"].max() - pd.Timedelta(days=13))]
def summarize(df: pd.DataFrame, label: str) -> list[dict]:
g = df.groupby(["app_id", "ad_unit", "country"], as_index=False).agg(
impressions=("impressions", "sum"),
match_rate=("match_rate", "mean"),
ecpm=("ecpm", "mean"),
revenue=("estimated_earnings", "sum"),
)
g["window"] = label
return g.to_dict(orient="records")
summary = summarize(latest, "last7") + summarize(prior, "prior7")
with OUT_PATH.open("w", encoding="utf-8") as f:
for row in summary:
f.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
print(f"wrote {len(summary)} rows to {OUT_PATH}")
ポイントは「直近 7 日」と「その前の 7 日」の 2 ウィンドウに集約してから Gemini に渡すところです。日次の生レコードを送ると、AI 側で時系列を逐一足し算する作業に推論コストを使ってしまい、その上で見落としも起きやすかったです。前処理側で集計してしまい、AI には「2 つの集計の比較」と「フロア候補の提案」に集中させたほうが、結果のばらつきが小さくなりました。
Gemini 2.5 Pro への投入プロンプト
google-genai ライブラリ(旧 google-generativeai の後継)で、JSON Lines を inline_data ではなく text として渡しています。私の感覚では、AdMob の集計サイズ(6 アプリ × 数百行程度)であれば text で十分に扱えます。
# analyze.py
from pathlib import Path
from google import genai
from google.genai import types
MODEL = "gemini-2.5-pro"
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
DATA_PATH = Path("~/admob-weekly/2026-05-20/merged.jsonl").expanduser()
HISTORY_PATH = Path("~/admob-weekly/floor-history.csv").expanduser()
system_instruction = """
あなたは個人開発者向けの AdMob 運用補助アシスタントです。
役割:
- 入力された AdMob 週次集計(last7 と prior7)と直近のフロア変更履歴を踏まえ、
「次の 1 週間で試すフロアプライス候補」を広告ユニット単位で提示する
- 推論には eCPM・match rate・impressions・revenue の 4 軸を必ず使う
- 統計的に不十分な小規模ユニット(impressions が両ウィンドウ合計で 5,000 未満)には
「データ不足」とラベルを付け、フロア候補を提示しない
- 推奨値は USD 単位で 2 桁、丸め単位は 0.05 とする
- 「現状維持」も有効な選択肢として明示する
- 変更履歴に直近 7 日以内の調整があるユニットは「観測継続」を優先する
"""
user_text = f"""
## 集計(JSON Lines)
{DATA_PATH.read_text(encoding='utf-8')}
## 直近のフロア変更履歴(CSV)
{HISTORY_PATH.read_text(encoding='utf-8')}
## 出力要件
- 提案件数は最大 12 件まで(影響度が大きい順)
- 同じ ad_unit に対して複数の country 候補がある場合は、影響度の高い 1 つに絞る
- 各候補について「なぜそうするか」を 1〜2 文で記述する
- 全体の所見(季節要因の可能性、観測すべき次の指標)も添える
"""
system_instruction で「データ不足」のラベル付けを明示しているのは、impressions が小さい広告ユニットに無理やり候補を出されると、人間がその提案を信じてしまい、翌週に変動を引き起こすことが多かったからです。AI に「自信のない箇所では候補を出さない」ことを許可しておくほうが、運用は安定します。
Function Calling で候補値を構造化出力させる
最初は Markdown のテーブルで返してもらっていました。読みやすい一方で、AdMob の管理画面に手で転記する際にずれが出やすかったので、Function Calling に切り替えました。Tool 定義側でスキーマを固定すれば、後段の Slack 通知や、AdMob Network API(β)への将来的な連携も楽になります。
# analyze.py(つづき)
candidate_schema = types.Schema(
type=types.Type.OBJECT,
properties={
"candidates": types.Schema(
type=types.Type.ARRAY,
items=types.Schema(
type=types.Type.OBJECT,
properties={
"app_id": types.Schema(type=types.Type.STRING),
"ad_unit": types.Schema(type=types.Type.STRING),
"country": types.Schema(type=types.Type.STRING),
"action": types.Schema(
type=types.Type.STRING,
enum=["raise", "lower", "hold", "observe", "insufficient_data"],
),
"current_floor_usd": types.Schema(type=types.Type.NUMBER),
"suggested_floor_usd": types.Schema(type=types.Type.NUMBER),
"rationale": types.Schema(type=types.Type.STRING),
"expected_impact": types.Schema(
type=types.Type.STRING,
enum=["small", "medium", "large"],
),
},
required=["app_id", "ad_unit", "country", "action", "rationale"],
),
),
"overall_observation": types.Schema(type=types.Type.STRING),
},
required=["candidates", "overall_observation"],
)
submit_floor_candidates = types.FunctionDeclaration(
name="submit_floor_candidates",
description="次週試すフロアプライス候補と全体所見を提出する",
parameters=candidate_schema,
)
resp = client.models.generate_content(
model=MODEL,
contents=user_text,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
tools=[types.Tool(function_declarations=[submit_floor_candidates])],
tool_config=types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="ANY"),
),
temperature=0.2,
),
)
# 関数呼び出しの引数だけを取り出す
call = resp.candidates[0].content.parts[0].function_call
payload = dict(call.args)
print(payload)
tool_config で mode="ANY" を指定しているので、Gemini は必ずこの関数を呼び出します。temperature=0.2 は、「同じ入力に対して同じような候補を返してほしい」という運用要件によるものです。0.4〜0.7 だと、似た傾向のデータに対しても候補が振れて、週次の継続性が崩れました。フロア調整は前週の判断との連続性が重要なので、低温度で固めるほうが現場の納得感が出ます。
Slack 通知と「人間が止められる」ワークフロー
Function Calling の結果を Slack に投げる部分は短いコードですが、運用上はここがいちばん効いています。AI が出した候補を人間が眺め、絵文字で承認・却下するだけで AdMob 管理画面に反映するかどうかを決める、という割り切りです。
# notify.py
import os
import json
import urllib.request
SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"] # 自分用の private channel
def post(payload: dict) -> None:
rows = []
for c in payload["candidates"]:
if c["action"] == "insufficient_data":
continue
rows.append(
f"`{c['app_id']}` / `{c['ad_unit']}` / {c['country']} → "
f"*{c['action'].upper()}* "
f"(now {c.get('current_floor_usd', '—')} → "
f"try {c.get('suggested_floor_usd', '—')}) "
f"_{c['rationale']}_"
)
body = {
"text": "*週次フロア候補(要承認)*",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn",
"text": "*週次フロア候補(要承認)*\n承認は :white_check_mark: 却下は :x:"}},
{"type": "section", "text": {"type": "mrkdwn",
"text": "\n".join(rows) or "(提案なし)"}},
{"type": "context", "elements": [{"type": "mrkdwn",
"text": f"全体所見: {payload['overall_observation']}"}]},
],
}
req = urllib.request.Request(
SLACK_WEBHOOK,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req).read()
「人間が止められる」工程を 1 か所だけ残すと決めてからは、AI の提案精度に過剰な期待をしなくて済むようになりました。極端なフロア値を Gemini が出すこともたまにありますが、それも Slack 上で見て「却下」とリアクションを付ければそこで止まります。完全自動でフロアを書き換える設計にしていたら、入札数が薄い週末にやらかしていたはずです。
観測している現実的な改善幅
数字を出すときは控えめに語るほうが、後で自分が困らないと思っています。6 アプリ平均の話として、Gemini 2.5 Pro を使った週次のフロア提案を 8 週間ほど回した結果、私のところで見えているのは次のような幅です。
- 平均 eCPM は対象広告ユニットで 5〜12% 上振れ。Rewarded で 12% 程度、Open Ads で 5% 程度
- fill rate は 1〜3 ポイント低下したユニットあり。許容範囲だが、低 DAU 国では取りこぼしが目立つ
- ARPDAU は週次の中央値ベースで 4〜7% 改善。週末・連休で別途上下するので参考値
- AI に渡す前処理+プロンプト+Function Calling 周りの実装にかかった時間は実働 8 時間程度。週次運用の人件費は週あたり 30 分以下に縮まった
完全自動化していないので、AI の提案のうちおよそ 6 割をそのまま採用、残り 4 割は却下または別の値に差し替えています。この却下率が下がってきたら、温度を少し上げて新しい提案を引き出すか、入力に新しい列(例: 国別の Bidding 参加ネットワーク数)を足すかを検討します。
私が壁紙アプリ群を運用してきた中で痛感しているのは、AdMob の管理画面を眺める時間そのものが「広告ネットワークの仕様変更」「広告ユニットの新設」「Bidding 参加先の追加」などで毎月のように変化する、ということです。手作業に最適化しすぎると、仕様変更のたびにエクセルのテンプレートを書き直す羽目になります。Gemini に渡す前処理を Python で書いておくと、AdMob の CSV 列名がたまに変わったときも、load_admob_csv の 1 関数を直すだけで済みます。これは AI で運用を楽にしている、というよりは「人間の手作業をスクリプトに置き換える際の取っ掛かりを AI が提供してくれた」と理解するほうが、期待値と現実が一致します。
もう一つ実感しているのは、6 アプリ並行で運用していると、すべてのアプリに同じプロンプトを使ってよいわけではない、ということです。たとえばライブ壁紙系のアプリは Open Ads の比重が高く、写真壁紙系のアプリは Interstitial が中心になります。広告構成が違えば、Gemini に求めたい判断軸も微妙に変わります。私はアプリ群を 3 つのプリセット(ライブ系・写真系・癒し系)に分け、system_instruction の末尾だけを差し替える形で運用しています。プロンプトの大部分を共通化しながら、最後の数行だけプリセットで足すと、メンテナンスもしやすくなりました。
落とし穴 — 私がぶつかった 3 つの注意点
1 つ目は、季節要因の取り扱いです。連休直前・直後・年末年始は eCPM が大きく振れますが、Gemini に「週次比較せよ」と言うと、季節要因によるブレを過剰に拾います。私は floor-history.csv の reason 列に「連休前」「年末」のような短いタグを書き込み、プロンプトで「reason 列にイベントタグがある週は影響度を 1 段下げて評価せよ」と明示するようにしました。これだけで、12 月最終週の暴走が止まりました。
2 つ目は、Bidding 参加ネットワーク(AppLovin MAX に寄せている場合は AppLovin 側のレポートも併読すべきところ)の取り扱いです。AdMob 単体のレポートだけだと、外部 SDK 経由の入札が「AdMob のフロアに弾かれて来ていない」可能性を見落とします。これは AI で解決する問題ではなく、人間が AppLovin / Pangle / Meta Audience Network 側のダッシュボードを横で見ながら判断するほかありません。Gemini の出力にも「AdMob レポート単独の解釈」であることを毎週注釈として残すようにしています。
3 つ目は、temperature を下げすぎたときの「ループ問題」です。0.0 や 0.1 にすると、ほとんど同じ候補ばかり返ってきて、データが動いても提案が動きません。私自身、ここは何度か 0.0 と 0.3 の間を振り直したのですが、0.2〜0.3 で運用するのが手元での落ち着きどころでした。同じ提案が 3 週続いたら temperature を一段上げる、という運用ルールにしています。
一段先のステップとしての AdMob Network API
AdMob には Network API(Reporting API は GA、Settings 系は β)があり、ad unit 単位のフロア値をプログラムで書き換える経路もあります。私はまだ「Slack 承認後に手で AdMob 管理画面に反映」を続けていますが、却下率が安定して 1 割を切ったら、低リスクの広告ユニットだけ Network API 経由の自動反映に移行する計画です。google-api-python-client を使う設計が一般的ですが、書き換えを伴うため、必ず差分プレビューと履歴記録(CloudWatch / BigQuery 等)を挟むのが安全です。
Network API の最新仕様や、Bidding 周りのスキーマ変更については、開発者向けドキュメントを毎週確認しています。ドキュメントは AdMob Network API documentation を起点に追っていくのが分かりやすいです。
プロンプトの育て方 — 8 週間で書き換えた箇所
最初に書いた system_instruction は、いまのものよりずっと短く、「フロアを上げるか下げるかを判断してください」程度のものでした。週次運用を重ねるなかで、誤判定の傾向が見えるたびに 1 行ずつ条件を足し、また誤判定が減ったら削る、という育て方をしています。8 週間で書き換えた主な箇所は次の三点でした。
一点目は「データ不足ユニットでは候補を出さない」という条件を追加したこと。当初は impressions が少ないユニットでも勢いで提案が返ってきて、そのまま採用すると翌週に大きく動くケースが続きました。impressions の閾値(私は 5,000 にしています)を明示してからは、データ不足ユニットには insufficient_data ラベルが付き、人間も「ここは判断しない」と腹を括れます。
二点目は「変更履歴に直近 7 日以内の調整があるユニットは観測継続」という条件。フロアを動かした翌週は、本来は前回の動きの結果が出てくる週なので、ここで重ねて動かすと前回の効果が見えなくなります。最初はこの条件を入れていなかったため、Gemini が毎週同じユニットに対して別々の方向の提案を返してきて、振動的な調整になってしまっていました。
三点目は「USD 単位 2 桁・0.05 刻み」のフォーマット制約。AdMob の管理画面で入力する値の粒度に揃えておかないと、Gemini が「0.123」のような中途半端な値を返してきて、人間が丸めるたびに揺れが入ります。最初から AI 側で丸めてもらうほうが、運用は楽でした。
Bidding と Waterfall — どちらに寄せている運用か
私の 6 アプリのうち、新しめの 3 アプリは AdMob Bidding に全広告ネットワークを寄せています。残りの 3 アプリは過渡期で、Waterfall(手動 eCPM)と Bidding が混在しています。Gemini に投入するデータも、Bidding 中心のアプリではフロアプライスを「Bidding の最低入札価格として」解釈させ、Waterfall 中心のアプリでは「Waterfall 各段の eCPM 設定値として」解釈させる必要があります。プロンプトの中で mediation_kind という列を追加して bidding / waterfall / mixed のラベルを渡し、提案ロジックを切り替えています。
実感として、Bidding 寄せのアプリのほうが Gemini の提案精度は安定します。各広告ネットワークが入札を出してくる構造なので、フロアの上下が即座に競争に反映されます。一方、Waterfall 中心のアプリでは「3 段目に下がってきた eCPM をどこで切るか」のような判断になり、データだけでは決めきれない要素が増えます。AI に判断させるよりも、AI には数字を整理させて、人間が AppLovin / Pangle のダッシュボードと突き合わせて決めるほうが結果が良いです。
コスト面 — 週次運用に Gemini 2.5 Pro はオーバーキルか
「Gemini 2.5 Pro は重いから、Flash で十分では?」という疑問は、最初に自分でも考えました。実際に Flash でも試しましたが、構造化出力の安定性と、AdMob レポートの列の意味理解の深さで Pro のほうが現場の信頼感がありました。とくに「データ不足ユニットには候補を出さない」という指示の遵守率が、Flash では 7〜8 割、Pro では 9 割以上という印象です。
コストはというと、6 アプリ分の集計を JSON Lines で送って、Function Calling の応答を受け取って、全体で入力 30,000 トークン・出力 1,500 トークン程度になります。週 1 回の運用なので月 4〜5 回しか叩きません。月あたりの API 料金は手元のログでは USD 1 未満で済んでおり、削るほどの規模ではないと判断しています。逆に「毎日叩く」運用に切り替えると話が変わってくるので、ここはトレードオフです。
チェックリスト — 月曜の朝に確認する 5 項目
毎週月曜の朝、Slack の提案を眺めるときに私が頭の中で確認している順番をそのまま書き出します。
- データ不足ラベルの数。先週より極端に増えていたら、AdMob レポートのダウンロード設定(日付範囲・粒度)が崩れた可能性
- 全体所見に「季節要因」「連休」のキーワードが含まれているか。含まれていれば、提案の影響度をひとつ下げて読む
- 「raise」と「lower」の比率。極端に raise に寄っていれば、前週に大きな入札増があった可能性。下振れリスクを意識
- 同じ ad_unit が前週も今週も提案に上がっていないか。上がっていれば「振動」が起きている可能性で、温度設定か観測ウィンドウを見直す
- 全体収益の対前週比。提案を反映する前に、収益が下がっている場合は「攻めの調整」を控える
このチェックリスト自体も、運用しながら追加してきたものです。最初は 2 項目しかありませんでしたが、ハマるたびに 1 項目ずつ増えました。
全体を振り返ってに代えて — 次の月曜の朝にやること
このパイプラインを動かす上で、私が大事にしているのは「毎週同じ時間に、同じフォーマットで眺める」という点です。大きな配信規模を持たない個人開発の小さな運用でも、毎週の眺める形が決まっていると判断のばらつきが下がります。Gemini はその「眺める形」を維持してくれる相棒であって、判断そのものはこちらの責任です。
次の月曜の朝、いつもの 14 日分のレポートをダウンロードしたら、preprocess.py を 1 回、analyze.py を 1 回、notify.py を 1 回叩くだけで Slack に提案が並びます。30 分以内に承認・却下を済ませて、その日のうちに AdMob 管理画面で反映する、という運用が、いまの私の落ち着きどころです。同じように個人で複数アプリを抱えている方の運用設計の足場になれば嬉しく思います。
最後にもう一点だけ書き添えるなら、フロアプライス調整は「やる気のある月にはたくさんやって、忙しい月には間引く」では成果が出にくいタスクです。毎週同じ曜日の同じ時間に、同じ手順で眺めるリズムが、結果としていちばん効きます。AI に任せる部分を増やすほどリズムは保ちやすくなりますが、AI の出力を「眺める時間」だけは人間が確保し続ける必要があります。私は月曜の朝、コーヒーを淹れる前にこのパイプラインを 1 回叩いて、Slack の通知を見ながらコーヒーを飲む、という流れが定着しています。仕組みを作るより、続けやすい形に削るほうが時間がかかったというのが、8 週間動かしてみての所感です。
お読みいただきありがとうございました。