●PRICING — Standard Rork's tiers are now clear: Junior at $25 a month for 100 credits, Middle at $50 for 250, and Senior at $100 for 500●CREDITS — Billing is per message. One prompt to the AI costs one credit, whether you are asking it to build an entire screen or just to change a button color●PRACTICE — Which means credits drain fastest when you iterate in small nudges. Bundle the requirements, then handle the fine details in your own editor. That split moves the real cost more than anything●STACK — Standard Rork generates React Native through Expo and reaches both iOS and Android. Rork Max is a separate product that writes native Swift instead●MAX — Max starts at $200 a month. It compiles on a cloud Mac fleet, streams a live simulator to your browser, and publishes to the App Store in two clicks, with no Xcode required●FOUNDATION — In the model layer beneath it, Gemini 3.8 Flash went generally available on September 2 and reached GitHub Copilot on September 3. Its introductory pricing expires December 31●PRICING — Standard Rork's tiers are now clear: Junior at $25 a month for 100 credits, Middle at $50 for 250, and Senior at $100 for 500●CREDITS — Billing is per message. One prompt to the AI costs one credit, whether you are asking it to build an entire screen or just to change a button color●PRACTICE — Which means credits drain fastest when you iterate in small nudges. Bundle the requirements, then handle the fine details in your own editor. That split moves the real cost more than anything●STACK — Standard Rork generates React Native through Expo and reaches both iOS and Android. Rork Max is a separate product that writes native Swift instead●MAX — Max starts at $200 a month. It compiles on a cloud Mac fleet, streams a live simulator to your browser, and publishes to the App Store in two clicks, with no Xcode required●FOUNDATION — In the model layer beneath it, Gemini 3.8 Flash went generally available on September 2 and reached GitHub Copilot on September 3. Its introductory pricing expires December 31
Your Blended eCPM Dropped and Revenue Went Up: Splitting the Change Into Country Mix and Rate
Blended eCPM is a weighted average, not a price. Here is how I split a month-over-month change into a country-mix effect and a rate effect that reconcile exactly to the total, plus what to check before you cut a low-eCPM country.
One morning I opened the dashboard and the number at the top was lower than the month before. Blended eCPM had fallen by about a quarter.
I went straight to the country report and sorted by unit price, ascending. I was looking for countries to cut. Two candidates showed up within a couple of minutes.
Then I noticed that the estimated payout further down the same screen was higher than the previous month.
The average was down and the money was up. One of the two had to be wrong, so I spent two days pulling the report apart. What was wrong turned out to be the way I was reading it.
Blended eCPM is a weighted average, not a price
The blended eCPM in the AdMob console is revenue divided by impressions, times 1,000. Written out, it is each country's eCPM weighted by that country's share of impressions.
Which means it moves for two entirely separate reasons.
Unit prices themselves changed (rate effect)
Impression shares shifted between countries (mix effect)
The awkward part is that the second one alone can move the average a long way. If impressions grow in a low-price country, blended eCPM falls even when not a single country's price has changed. And because impressions grew, revenue rises.
I was sorting as if I were looking at the first case. What had actually happened was the second. Running a few apps in parallel as an indie developer, I hit this mix-up roughly once a season, because the countries where organic installs grow fastest tend to be the low-price ones.
A unit price is a number for comparing, not a number for deciding what to switch off. Open a country report without that line drawn and you walk straight toward the decision that shrinks revenue.
Splitting the change with no residual
There are several ways to do factor decomposition. For something I run every month, my requirement is that the two effects add up to the total change exactly. A decomposition that leaves a residual stops being trusted by the second month.
The simplest form that satisfies this uses symmetric averages. With r as a country's eCPM, w as its impression share, 0 for the prior period and 1 for the current one:
Effect
Formula
How to read it
Rate
Σ (r1 − r0) × (w0 + w1) / 2
How far the average would have moved on price changes alone, had the mix held still
Mix
Σ (w1 − w0) × (r0 + r1) / 2
How far the average would have moved on share changes alone, had prices held still
Add the two and you get the change in blended eCPM exactly. The interaction term is split evenly between the two sides rather than being shoved into one of them.
The one case you have to decide on is a country that exists in only one of the periods. A newly served country has no prior price. Treating its price as unchanged and pushing its entire contribution into the mix side is what matches reality: the average moved because impressions appeared there, not because a price changed.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to tell whether a move in blended eCPM came from your country mix or from actual unit prices, using nothing but the country report you already have
✦Before you cut traffic from a low-eCPM country, you will be able to check in a few minutes whether that country is merely dragging the average down or genuinely carrying revenue
✦You will have a 60-line script that decomposes a country CSV into mix and rate contributions and reconciles exactly to the total change, ready to drop into your monthly routine
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Three things to clean up before feeding an AdMob country report to anything. I got each of them wrong at least once.
Drop rows with zero impressions. eCPM is undefined there. Leave them in and you get a division by zero, or an infinity quietly mixed into the average
Keep the currency consistent. If one month was exported under a different display currency, exchange-rate movement shows up disguised as a rate effect
Do not compare across the estimate boundary. Recent days get replaced by finalized numbers later. A comparison that straddles the close invents price changes that never happened
The third one is the quietest. I now pull the prior month at the start of the month, then pull the same CSV again after it finalizes and diff the two. Once you know how far the numbers move, you know how many days to wait next time.
Give it a prior-period and a current-period CSV and it prints the total change plus each country's contribution. Standard library only.
#!/usr/bin/env python3"""Split a blended eCPM change into mix and rate effects. python3 ecpm_split.py prev.csv curr.csvCSV columns: country,impressions,revenue (one currency, finalized figures)Uses a symmetric-average (Bennet-style) decomposition that reconciles exactly."""import csvimport sysfrom collections import defaultdictdef 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 # eCPM is undefined with zero impressions 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}: every row has zero impressions") out = {} for c, (imp, rev) in rows.items(): out[c] = { "imp": imp, "rev": rev, "w": imp / total_imp, # impression share "r": rev / imp * 1000.0, # this country's 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) # Present in only one period: hold the price fixed, put it all in 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_totaldef 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()
The reconciliation err line is there so I can doubt myself. On any day it is not zero, I fix the input rather than the decomposition.
What it printed
Below is my monthly report folded down to six countries with the magnitudes rounded. These are not the raw figures, but the ratios and the direction match what I actually saw.
The average fell by 0.412, and 0.003 of that came from prices
The other 0.409 is entirely impression share moving around
Revenue over the same span rose 6.8%
US and JP prices barely moved. What moved was their share of impressions, from 20.3% to 14.4% and from 15.3% to 10.3%. They shrank not because I throttled anything, but because IN, BR, and a newly served ID added impressions and the denominator grew.
The denominator was what I had been staring at while hunting for countries to cut.
What to check before cutting a low-eCPM country
Cut a low-price country and blended eCPM goes up, guaranteed. Read that rise as proof the change worked and you have made the wrong call.
Here is what dropping IN and ID from the current period does.
Metric
As is
IN and ID cut
Change
Blended eCPM
1.148
2.006
+74.7%
Revenue
9,828.50
8,947.50
−9.0%
Impressions
8,560,000
4,460,000
−47.9%
The number at the top of the dashboard looks 75% better. Revenue is 9% lower.
So I started sorting every country report twice: by unit price ascending, and by revenue contribution descending. The countries where those two rankings disagree the most are the ones I am most likely to misjudge.
Country
eCPM
Revenue share
Impression share
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 sits second from the bottom on price and fourth on revenue share, just under DE. The country I had shortlisted for cutting was carrying roughly as much money as Germany.
None of which means low-price regions are untouchable. The experience cost of an impression is the same everywhere, so varying format allocation and display frequency by region is a perfectly sound lever. What I changed was the doorway into that lever: not "the price is low, so stop serving," but "this region spends more impressions than its revenue contribution justifies, so rebalance." The material for that judgment is revenue per impression against experience, not eCPM.
None of this is specific to countries. Swap the aggregation axis and the decomposition still holds.
Hour of day. A month where late-night impressions grow shows a lower average, because intraday price variation and usage distribution move together
Ad format. Banners produce an order of magnitude more impressions than interstitials, so adding a single banner placement pulls the average down. That drop is essentially the design working as intended
OS. Device-mix shifts usually arrive tangled up with country-mix shifts. Move one axis at a time so you do not count the same thing twice
Reading the country column as hour or format is all it takes, so rather than maintaining a generic version and a country version, I normalize the column name to country on the CSV-producing side. Two copies of the decomposition means fixing the formula in two places later.
A weekly loop worth keeping small
Monthly alone finds out too late. I run this weekly, and I deliberately do not send myself alerts about it.
1. Freeze how you pull
Take the prior week and the current week with the same span length, the same currency, and the same finalization state. A comparison where those differ will mislead you no matter how carefully you read the output.
2. Watch a ratio, not a threshold
When the average moves, the thing I look at is the share of the change attributable to the rate effect, not its absolute size. My line is that the rate effect has to exceed 30% of the total change before I stop and investigate. Below that, it is a mix story, so I check revenue and move on.
3. Keep the log
Append one row per period. After half a year you can see which regions grow in which season for your own apps. Not being surprised by the same thing twelve months later is what paid off most.
Pull last month's and this month's country CSV and run the script once. If reconciliation err is zero and you have a rate-to-mix ratio, that alone settles whether this is a month for cutting countries at all.
Since adding that one step, I have stopped making cut decisions at the start of the month. In the months where cutting is warranted, the rate effect comes out clearly larger. There are different kinds of reasons for a number going down, and I was late in learning to tell them apart.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.