きっかけは、6本並行で運用している壁紙アプリのうち、収益が伸び悩んでいた1本のメディエーション設定を見直そうとして、AdMob の管理画面を開いたときでした。ネットワークごとの平均eCPMは並んでいます。AdMob が高い、AppLovin がそこそこ、Pangle は低め。では、その数字を信じて Pangle のフロアを下げるべきでしょうか。
手が止まりました。管理画面が見せているのは「そのネットワークが、すべての地域・すべてのプレースメントを混ぜた平均」です。私のアプリは日本のユーザーが6割、残りが北米と東南アジアに散らばっています。インタースティシャルとアプリ起動時広告では単価がまるで違います。混ぜた平均で「Pangle は低い」と判断して切り捨てると、実は東南アジアのバナーで Pangle だけが埋めてくれていた在庫を、まるごと空にしてしまうかもしれません。
2014年から個人開発を続けて累計5,000万ダウンロードを超えるなかで、広告収益については長いあいだ「管理画面の数字を眺めて勘で調整する」をくり返してきました。この記事は、その勘を捨てて、1インプレッションごとの収益を自分の計測基盤に取り込み、ネットワーク×プレースメント×地域で「本当のeCPM」を出せるようにした実装の記録です。
なぜ管理画面の平均eCPMでは判断を誤るのか
eCPMは「1,000インプレッションあたりの収益」です。管理画面はこれをネットワーク単位、日単位で集計して見せてくれます。便利なのですが、メディエーションの優先度やフロア(最低落札価格)を決めるには粒度が粗すぎます。
問題は3つあります。第一に、地域差が平均に埋もれます。同じバナー1枠でも、北米のインプレッション単価は日本の数倍、東南アジアの数分の一になることが普通です。第二に、プレースメント差が埋もれます。起動時広告と記事下バナーでは、ユーザーの注意の量が違うので単価も違います。第三に、メディエーションの「勝者」しか管理画面の収益には現れないため、「このネットワークを切ったら、その在庫を誰が拾うのか」が平均値からは読めません。
つまり、フロアや優先度という「プレースメント×地域×ネットワーク」の3次元で効く操作を、管理画面が見せる1次元(ネットワーク平均)で決めようとしているのが、判断を誤る根本でした。必要なのは、自分のアプリの中で、1インプレッションが実際にいくら稼いだかを記録することです。
Impression-Level Ad Revenue は何を返すのか
AdMob には Impression-Level Ad Revenue(ILRD、インプレッションレベル広告収益)という仕組みがあり、広告が表示されるたびに、その1インプレッションの推定収益を端末側のコールバックで受け取れます。react-native-google-mobile-ads では、各広告オブジェクトの paidEventListener がこれを担当します。
コールバックが返すのは主に次の値です。value(収益額。ただしマイクロ単位=実額の100万倍の整数)、currencyCode("USD" や "JPY" などの通貨コード)、precision(推定精度。ESTIMATED / PUBLISHER_PROVIDED / PRECISE など)、そしてメディエーションのアダプター情報(どのネットワークが落札したか)です。
ここで最初の落とし穴があります。value はマイクロ単位なので、value / 1_000_000 で初めて通貨単位の金額になります。そして currencyCode はアカウント設定や入札ネットワークによって USD で返ることも JPY で返ることもあります。この2点を取り違えると、収益が桁違いにずれます。私は実際にやりました(後述します)。
paidEventListener で1インプレッションごとに収益を取る
まずは収益イベントを取りこぼさずに受け取る部分です。広告をロードするたびに paidEventListener を登録します。インタースティシャルの例で示します。
import {
InterstitialAd,
AdEventType,
TestIds,
} from 'react-native-google-mobile-ads' ;
import { logAdRevenue } from './adRevenue' ;
const adUnitId = __DEV__ ? TestIds. INTERSTITIAL : 'ca-app-pub-XXXXXXXX/YYYYYYYY' ;
export function createInterstitial ( placement : string ) {
const ad = InterstitialAd. createForAdRequest (adUnitId);
// 1インプレッションごとの収益を受け取る
const unsubscribePaid = ad. addAdEventListener (
AdEventType. PAID ,
( payload ) => {
// payload: { value, currency, precision } とメディエーション情報
logAdRevenue ({
placement, // 'interstitial_home' など自分で決めた枠の名前
format: 'interstitial' ,
valueMicros: payload.value, // マイクロ単位の整数
currency: payload.currency, // 'USD' / 'JPY' ...
precision: payload.precision, // 推定精度
});
},
);
const unsubscribeClosed = ad. addAdEventListener (AdEventType. CLOSED , () => {
unsubscribePaid ();
unsubscribeClosed ();
});
ad. load ();
return ad;
}
ポイントは、placement を「自分のアプリの中で意味のある枠名」として渡していることです。AdMob の広告ユニットIDは機械的すぎて、後から集計するときに「これはどの画面の広告だっけ」が分かりません。interstitial_home、appopen_cold_start、banner_article_footer のように、人間が読める枠名を必ず添えておくと、後の分析が一気に楽になります。
通貨とマイクロ単位を取り違えた失敗と、その直し方
ここが私が最初にしくじったところです。最初の実装で、私は収益をこう記録していました。
// Before(壊れていた実装)
function logAdRevenue ( e ) {
analytics (). logEvent ( 'ad_revenue' , {
placement: e.placement,
value: e.valueMicros, // ← マイクロのまま記録してしまった
currency: e.currency, // ← 通貨を混在させたまま合計してしまった
});
}
これで集計すると、収益の合計がダッシュボードの実額の100万倍になりました。マイクロ単位を割り戻していなかったからです。さらに厄介だったのは、USD のインプレッションと JPY のインプレッションが同じ value 列に混ざって合計されていたことです。USD建ての0.01と JPY建ての1.5が単純に足され、数字としては意味を成していませんでした。
直したのが次の実装です。マイクロ単位を割り戻し、すべてを基準通貨(私の場合は JPY)に正規化してから記録します。
// After(正規化してから記録する)
import { getRate } from './fxRates' ; // 日次で更新する為替レート表
function logAdRevenue ( e : {
placement : string ;
format : string ;
valueMicros : number ;
currency : string ;
precision : string ;
}) {
const amount = e.valueMicros / 1_000_000 ; // マイクロ → 通貨単位
const amountJpy = amount * getRate (e.currency); // 基準通貨(JPY)へ正規化
analytics (). logEvent ( 'ad_revenue' , {
placement: e.placement,
format: e.format,
value_jpy: Number (amountJpy. toFixed ( 4 )), // 正規化済みの金額のみを集計列に
src_currency: e.currency, // 元通貨は監査用に別フィールドで保持
precision: e.precision,
});
}
為替レートは厳密である必要はありません。私は日次の概算レートを Remote Config に置いて、起動時に取得しています。広告収益の分析で見たいのは「どの枠が相対的に稼ぐか」であって、会計上の正確な円換算ではないからです。大事なのは、集計に使う列(value_jpy)の通貨を1つに統一しておくことです。元通貨は別フィールドに残し、おかしな数字が出たときに遡れるようにしておきます。
重複・サンプリング・PII — 計測イベントの設計
イベントを流す前に、3つだけ決めておくと後で困りません。
まず重複です。paidEventListener は基本的に1インプレッション1回ですが、リスナーの登録が広告の再ロードで二重になると、同じインプレッションを2回記録してしまいます。私は広告オブジェクトの生成時にだけリスナーを張り、CLOSED で確実に解除する形にして、登録と解除を1対1に保っています。先ほどのコードで unsubscribePaid() を CLOSED の中で呼んでいるのはこのためです。
次にサンプリングです。収益イベントは量が多く、解析サービスによっては従量課金です。ただ、ad_revenue を間引くと当然ながら収益分析が狂います。私は ad_revenue だけはサンプリングせず全量送り、代わりに画面遷移などの細かいイベントの方を間引くことで全体の送信量を抑えています。お金に直結するイベントは間引かない、という線引きです。
最後に PII です。収益イベントに個人を特定できる情報(メールやデバイス固有ID)を載せる必要はありません。地域はストア国や IP由来の粗い国コードで十分で、ユーザー単位の特定は不要です。プライバシーマニフェストや ATT の観点でも、収益分析に必要なのは「集計可能な粒度」だけだと割り切っておくと、審査でもユーザー説明でも安全側に倒せます。
ネットワーク×プレースメント×地域で本当のeCPMを出す
正規化済みのイベントが貯まれば、本当のeCPMは単純な割り算で出せます。インプレッション数を別途カウントしておき(paidEventListener の発火回数がそのままインプレッション数になります)、収益合計をインプレッション数で割って1,000倍するだけです。
-- 枠×国ごとの「本当のeCPM」(基準通貨JPY)
SELECT
placement,
country,
COUNT ( * ) AS impressions,
SUM (value_jpy) AS revenue_jpy,
SUM (value_jpy) / COUNT ( * ) * 1000 AS ecpm_jpy
FROM ad_revenue_events
WHERE event_date BETWEEN '2026-05-01' AND '2026-05-31'
GROUP BY placement, country
HAVING impressions >= 500 -- 少なすぎる組合せはノイズなので除外
ORDER BY ecpm_jpy DESC ;
HAVING impressions >= 500 を入れているのは、インプレッションが数十件しかない組合せの eCPM はブレが大きすぎて判断に使えないからです。私は最低500、できれば1,000インプレッション貯まるまではその数字を信じないことにしています。
この集計を初めて出したとき、冒頭の「Pangle は低い」という管理画面の印象が、ある地域では完全に逆転していました。日本のインタースティシャルでは確かに Pangle のeCPMは低かったのですが、東南アジアのバナーでは Pangle が他ネットワークの2倍近い数字を出していました。混ぜた平均では絶対に見えなかった事実です。
その数字を入札フロアと優先度に反映する
本当のeCPMが枠×地域で出れば、操作は具体的になります。私が今まわしている運用ルールはこうです。
第一に、eCPMが基準を大きく下回る「枠×地域×ネットワーク」の組合せには、その地域向けのフロアを設定して、安値での落札を拒否します。ただし在庫を空にしないよう、フロアは段階的に上げ、フィルレート(充填率)が急落しないかを毎回確認します。第二に、明確に高いeCPMを出すネットワークは、その地域のメディエーション優先度を上げます。第三に、500インプレッションに満たない組合せは触りません。データが足りない場所をいじるのは勘に戻るのと同じだからです。
宮大工だった祖父たちは「手を動かすことが一つの信心だ」とよく言っていました。私はこの数字を出すまで、広告の調整を「手を動かさずに勘で決める」例外にしていたのだと思います。1インプレッションごとに測るようにしてから、ようやく他の実装と同じ手触りで広告も扱えるようになりました。
Claude in Chrome で管理画面操作を半自動化した運用メモ
最後に、いま進行中の取り組みを1つ。本当のeCPMが出ても、それを AdMob の管理画面のフロアやメディエーショングループに反映する作業は、ブラウザ上のクリックの連続で、6アプリ分やると地味に時間を食います。私はこの「数字は出たが、画面に反映するのが面倒」という部分を、Claude in Chrome にブラウザUIを操作させる形で半自動化しています。
具体的には、上の SQL で出した「フロアを上げるべき枠×地域」のリストをそのまま渡し、AdMob の該当メディエーショングループを開いてフロア値を入力していく操作を任せます。最終的な値の確定は必ず自分の目で確認しますが、画面を辿ってフィールドを開くまでの単純作業がなくなるだけで、6アプリの見直しが現実的な時間に収まるようになりました。個人開発でこの規模の最適化を続けられるかは、こういう単純作業をどれだけ削れるかにかかっていると感じています。
次の一歩
もし今、メディエーションの調整を管理画面の平均eCPMだけで決めているなら、まず paidEventListener を1つのプレースメントに付けて、value をマイクロ単位として割り戻し、基準通貨に正規化したイベントを解析基盤に流すところから始めてみてください。最初の1枠で集計が回り始めると、「平均では見えなかった地域差」が必ず出てきます。そこからフロアと優先度を動かすのは、その後の話です。
同じように複数アプリの広告収益と向き合っている方の参考になれば嬉しいです。お読みいただきありがとうございました。