月初にダッシュボードを開いて、いちばん上の数字が前月より低いことに気づいた朝がありました。ブレンド eCPM が四分の一ほど下がっていました。
私はすぐ国別のレポートを開き、単価の低い順に並べ替えました。止める国を探していたのです。候補は数分で二つ見つかりました。
ところが同じ画面の下のほうで、見込みの収益額は前月より増えていました。
平均が下がっているのに、受け取る金額は増えています。どちらかが間違っているはずだと思って、二日かけてレポートを分解し直しました。間違っていたのは、私の読み方のほうでした。
ブレンド eCPM は単価ではなく、加重平均です
AdMob の管理画面に出るブレンド eCPM は、収益を表示回数で割って 1,000 を掛けた値です。式にすると、各国の eCPM を表示シェアで重み付けした平均になります。
つまりこの数字は、二つの別々の理由で動きます。
- 各国の単価そのものが動いたこと(レート要因)
- 各国の表示シェアが動いたこと(ミックス要因)
厄介なのは、2 番だけで平均が大きく動くことです。単価の低い国の表示が増えれば、どの国の単価も一切変わっていなくてもブレンド eCPM は下がります。そして表示が増えたぶん、収益は増えます。
私が並べ替えていたのは 1 番のつもりで、実際に起きていたのは 2 番だと気づいたのは、二日目の午後でした。個人開発でアプリを何本か並行して回していると、この取り違えは季節ごとに戻ってきます。オーガニックの流入が伸びる国は、たいてい単価の低い地域だからです。
単価は比べるための数字であって、止めるかどうかを決める数字ではありません。 この線引きを持たないまま国別レポートを開くと、収益を削る側の判断へまっすぐ歩いていきます。
差分を「構成」と「単価」に、誤差なく割ります
要因分解にはいくつも流儀がありますが、月次運用で使うなら「二つの効果を足すと合計差分にぴたり一致する」ことが条件だと私は考えています。残差が出る分解は、翌月にはもう信用されなくなるためです。
条件を満たす一番簡単な形が、対称平均を使う分解です。国 i の eCPM を r、表示シェアを w、前期を 0、当期を 1 とすると、こう書けます。
| 要因 | 式 | 読み方 |
| レート要因 | Σ (r1 − r0) × (w0 + w1) / 2 | 構成が動かなかったとしたら、単価の変化だけで平均はどれだけ動いたか |
| ミックス要因 | Σ (w1 − w0) × (r0 + r1) / 2 | 単価が動かなかったとしたら、構成の変化だけで平均はどれだけ動いたか |
この二つを足すと、ブレンド eCPM の差分と厳密に一致します。交差項をどちらかに押し付けるのではなく、半分ずつ両側に配る形になっているためです。
片方の期にしか存在しない国の扱いだけ、決めておく必要があります。新しく配信が始まった国には前期の単価がありません。ここは「単価は変わっていない」と置いて、寄与を丸ごとミックス側へ寄せるのが実務に合います。新規の国が平均を動かしたのは、単価が変わったからではなく、そこに表示が生まれたからです。
レポートを分解できる形に整えます
AdMob の国別レポートをそのまま食わせる前に、三つだけ整えます。どれも、一度は間違えた場所です。
- 表示が 0 の行を落とす — eCPM が定義できません。落とさずに読み込むと、ゼロ除算か、無限大が平均に混ざります
- 通貨をそろえる — 表示通貨を切り替えて出力した月が混ざると、レート要因に為替が化けて入ります
- 確定前の推定収益をまたがない — 直近数日の値は後から確定値に置き換わります。締めをまたいだ比較は、実際には起きていない「単価の変化」を作ります
3 番目がいちばん見えにくいところで効いてくるのかもしれません。私は月初に前月分を取り、確定してから同じ CSV をもう一度取り直して差分を見るようにしました。置き換わり幅がわかると、翌月からは待つべき日数の見当がつきます。
必要な列は三つだけです。
country,impressions,revenue
US,1230000,4551.00
JP,880000,2860.00
分解スクリプト
前期と当期の CSV を渡すと、合計差分と、国ごとの寄与を出します。標準ライブラリだけで動きます。
#!/usr/bin/env python3
"""ブレンド eCPM の増減を「構成(mix)」と「単価(rate)」に分解する。
python3 ecpm_split.py prev.csv curr.csv
CSV の列: country,impressions,revenue (同一通貨・確定値)
合計差分と誤差なく一致する対称平均(Bennet 型)の分解を使う。
"""
import csv
import sys
from collections import defaultdict
def load(path):
rows = defaultdict(lambda: [0.0, 0.0]) # country -> [impressions, revenue]
with open(path, newline="", encoding="utf-8") as f:
for r in csv.DictReader(f):
imp = float(r["impressions"])
rev = float(r["revenue"])
if imp <= 0:
continue # 表示 0 の行は eCPM が定義できないので落とす
rows[r["country"]][0] += imp
rows[r["country"]][1] += rev
total_imp = sum(v[0] for v in rows.values())
if total_imp == 0:
raise SystemExit(f"{path}: impressions がすべて 0 です")
out = {}
for c, (imp, rev) in rows.items():
out[c] = {
"imp": imp,
"rev": rev,
"w": imp / total_imp, # 表示シェア
"r": rev / imp * 1000.0, # その国の eCPM
}
return out, total_imp, sum(v[1] for v in rows.values())
def decompose(prev, curr):
countries = sorted(set(prev) | set(curr))
rate_total = mix_total = 0.0
per_country = []
for c in countries:
p, q = prev.get(c), curr.get(c)
# 片方にしか無い国は「単価は変わっていない」と置き、寄与を丸ごと mix に寄せる
w0, w1 = (p["w"] if p else 0.0), (q["w"] if q else 0.0)
if p and q:
r0, r1 = p["r"], q["r"]
elif q:
r0 = r1 = q["r"]
else:
r0 = r1 = p["r"]
rate = (r1 - r0) * (w0 + w1) / 2.0
mix = (w1 - w0) * (r0 + r1) / 2.0
rate_total += rate
mix_total += mix
per_country.append((c, w0, w1, r0, r1, rate, mix))
return per_country, rate_total, mix_total
def main():
if len(sys.argv) != 3:
raise SystemExit("usage: ecpm_split.py prev.csv curr.csv")
prev, imp0, rev0 = load(sys.argv[1])
curr, imp1, rev1 = load(sys.argv[2])
b0 = rev0 / imp0 * 1000.0
b1 = rev1 / imp1 * 1000.0
per, rate_total, mix_total = decompose(prev, curr)
print(f"blended eCPM {b0:8.3f} -> {b1:8.3f} diff {b1 - b0:+8.3f}")
print(f" rate effect (単価) {rate_total:+8.3f}")
print(f" mix effect (構成) {mix_total:+8.3f}")
print(f" reconciliation err {(rate_total + mix_total) - (b1 - b0):+.12f}")
print(f"revenue {rev0:10.2f} -> {rev1:10.2f} diff {rev1 - rev0:+10.2f}"
f" ({(rev1 / rev0 - 1) * 100:+.1f}%)")
print()
print(f"{'country':<8}{'share0':>8}{'share1':>8}{'eCPM0':>9}{'eCPM1':>9}"
f"{'rate':>9}{'mix':>9}")
for c, w0, w1, r0, r1, rate, mix in sorted(per, key=lambda x: -abs(x[5] + x[6])):
print(f"{c:<8}{w0 * 100:7.1f}%{w1 * 100:7.1f}%{r0:9.3f}{r1:9.3f}"
f"{rate:+9.3f}{mix:+9.3f}")
if __name__ == "__main__":
main()
reconciliation err を必ず出しているのは、自分を疑うためです。ここが 0 でない日は、分解ではなく入力を直します。
実際に流したときの出力
以下は私の月次レポートを、国を 6 つに畳んで桁を丸めた縮小版です。実額そのままではありませんが、比率と向きは手元の傾向をそのまま残しています。
blended eCPM 1.560 -> 1.148 diff -0.412
rate effect (単価) -0.003
mix effect (構成) -0.409
reconciliation err +0.000000000000
revenue 9203.00 -> 9828.50 diff +625.50 (+6.8%)
country share0 share1 eCPM0 eCPM1 rate mix
US 20.3% 14.4% 3.800 3.700 -0.017 -0.224
JP 15.3% 10.3% 3.200 3.250 +0.006 -0.160
DE 5.1% 3.5% 3.100 3.140 +0.002 -0.049
ID 0.0% 8.2% 0.190 0.190 +0.000 +0.016
IN 35.6% 39.7% 0.210 0.220 +0.004 +0.009
BR 23.7% 23.9% 0.280 0.290 +0.002 +0.001
読み方は三行で済みます。
- 平均は 0.412 下がりましたが、そのうち単価の寄与は 0.003 です
- 残りの 0.409 は全部、表示シェアの移動です
- 同じ期間に収益は 6.8% 増えています
US と JP の単価はほとんど動いていません。動いたのは、この二国の表示シェアが 20.3% から 14.4% へ、15.3% から 10.3% へ縮んだことです。縮んだのは配信を絞ったからではなく、IN と BR、そして新しく入ってきた ID の表示が増えて、全体の分母が膨らんだためです。
止めるべき国を探していた私が見ていたのは、この分母の変化でした。
低単価の国を止める前に、必ず確かめること
単価の低い国を切ると、ブレンド eCPM は確実に上がります。上がったことをもって施策が成功したと読むと、ここで判断を誤ります。
上の当期データから IN と ID を落とすと、どうなるかを計算しました。
| 指標 | そのまま | IN と ID を止めた場合 | 変化 |
| ブレンド eCPM | 1.148 | 2.006 | +74.7% |
| 収益 | 9,828.50 | 8,947.50 | −9.0% |
| 表示回数 | 8,560,000 | 4,460,000 | −47.9% |
ダッシュボードのいちばん上の数字は 75% 改善して見えます。実際には収益が 9% 減っています。
そこで私は、国別レポートを開いたら必ず二つの列で並べ替えるようにしました。単価の昇順と、収益寄与の降順です。この二つの順位が大きく食い違う国が、判断を間違えやすい国になります。
| 国 | eCPM | 収益シェア | 表示シェア |
| US | 3.700 | 46.3% | 14.4% |
| JP | 3.250 | 29.1% | 10.3% |
| DE | 3.140 | 9.6% | 3.5% |
| IN | 0.220 | 7.6% | 39.7% |
| BR | 0.290 | 6.0% | 23.9% |
| ID | 0.190 | 1.4% | 8.2% |
IN は単価では下から二番目ですが、収益シェアでは四番目で、DE のすぐ下につけています。単価の順位だけを見て切る候補にしていた国が、実際には DE と同じ規模のお金を運んでいたわけです。
もちろん、低単価の国に手を入れる余地がないという話ではありません。表示回数あたりの体験コストは国によらず同じですから、フォーマットの配分や表示頻度を地域ごとに変えるのは筋の通った打ち手です。私が変えたのは、その打ち手の入口を「単価が低いから止める」ではなく「収益寄与のわりに表示を使いすぎているから配分を見直す」に置き換えたことでした。判断の材料は、eCPM ではなく表示あたりの収益と体験の釣り合いのほうにあります。
配信ネットワークの側で単価を動かす話は、Rork アプリで AdMob Bidding を本番投入した実装メモのほうに書いてあります。ここで扱っているのは、その手前で数字をどう読むかという話です。
同じ罠は、時間帯とフォーマットでも踏みます
この分解は国に限った話ではありません。集計の軸を差し替えれば、そのまま使えます。
- 時間帯 — 深夜帯の表示が伸びた月は、平均が下がります。単価の日内変動と、利用時間の分布が同時に動くためです
- 広告フォーマット — バナーの表示回数はインタースティシャルより一桁多いので、バナー面を一つ増やしただけで平均は下がります。この場合の平均低下は、ほぼ設計どおりの結果です
- OS — 端末構成の変化は、たいてい国構成の変化と重なって現れます。二重に数えないよう、軸は一度に一つだけ動かします
スクリプトの country 列を hour や format に読み替えるだけで動きますので、私は列名を汎用にした版と国専用の版の二本を持たずに、CSV を作る側で列名を country に寄せています。分解する側を増やすと、後で式を直すときに二箇所を直すことになります。
週次で回すための最小構成
月次だけだと気づくのが遅すぎます。私は週次で回していますが、通知は出していません。
1. 取得を固定する
前週と当週の国別 CSV を、同じ期間長・同じ通貨・同じ確定状態で取ります。ここが揃っていない比較は、あとから何を見ても意味を取り違えます。
2. しきい値ではなく比率で見る
平均が動いたときに見るのは、絶対値ではなくレート要因の占める割合です。私は「レート要因が差分の 3 割を超えたときだけ手を止める」という線を引いています。それ未満は構成の話なので、収益の側だけを確認して先へ進みます。
3. 記録を残す
分解の結果を、月ごとに 1 行ずつ追記していきます。半年ぶんたまると、自分のアプリでどの季節にどの地域が伸びるのかが見えてきます。翌年の同じ月に同じ驚き方をしなくて済むのが、いちばん効きました。
収益の全体設計から見直したい場合は、Rork で作ったアプリを AdMob で月10万円にするまでのほうに、面の置き方から順に書いてあります。
明日、最初にやること
前月と当月の国別 CSV を落として、このスクリプトを一度だけ流してみてください。reconciliation err が 0 で、レート要因とミックス要因の比が出れば、それだけで「止める国を探すべき月かどうか」は決まります。
私はこの一手を挟むようになってから、月初に国を切る判断をしなくなりました。切る必要がある月は、レート要因のほうがはっきり大きく出ます。数字の下がり方には理由の種類があるのだと、遅れて気づいた次第です。
最後までお読みいただき、ありがとうございました。