Measure Ad CPM in Python: A Tiny Calculator Before You Scale UGC Spend
Most UGC campaigns do not fail because nobody can generate another talking-head clip. They fail because the next budget increase is approved before anyone can say what a thousand impressions actually cost, or what a click would cost if the same spend produced a known number of clicks.
I work on UGCfy AI, so I see this sequence a lot: a team ships a handful of creator-style variants, the auction looks "fine" in the ads manager, and then someone asks to scale. Fine is not a number. Cost per mille — CPM — is the number for the attention you bought. CPC is the number for the clicks that attention produced. Both are short formulas. Both are easy to get slightly wrong if you treat money as a binary floating-point value.
This post builds a small Python calculator for those two metrics, plus the reverse lookups you need when you only have two of the three fields. The code uses decimal. The tests below were run on this machine (Python 3.13.5). I am not going to invent industry-average CPMs. Where I use a published example, I say so.
What the four common paid-social metrics actually price
People mix these up because ads managers show them in the same table.
| Metric | Formula | What one unit represents |
|---|---|---|
| CPM | (ad spend ÷ impressions) × 1,000 | The cost of 1,000 impressions |
| CPC | ad spend ÷ clicks | The cost of one click |
| CPA | ad spend ÷ conversions | The cost of one conversion |
| ROAS | attributed revenue ÷ ad spend | How much revenue came back per 1.00 of spend |
CPM answers "how expensive was the attention?" CPC answers "how expensive was the click?" They are related through click-through rate, but you do not need CTR to compute either one if you already have spend plus impressions, or spend plus clicks.
A high CPM is not automatically a failed campaign. A retargeting set can pay more per thousand impressions and still convert. The useful habit is to compute the pair — CPM next to CPA or ROAS — before you decide that the auction, the audience, or the creative is the problem.
That pairing also keeps you honest when you scale UGC. More variants can change engagement, and engagement can change what you pay in the auction. If you cannot recompute CPM from the raw spend and impression counts, you will argue about creative quality with no baseline.
The reverse lookups matter as soon as you plan instead of recap. Suppose you are willing to spend $500 and you refuse to pay more than a $12.50 CPM. Then impressions_from_cpm("500", "12.50") should return 40,000. That is the same identity as the FAQ example, read backwards: you are asking how much reach a budget can buy at a rate you already observed. The other inverse is useful when a media plan is written in impressions. spend_from_cpm("12.50", 40_000) returns 500.00, which is what that reach costs if the rate holds.
I treat those inverses as planning tools, not promises. The next thousand impressions may clear the auction at a different rate than the last thousand. What the functions give you is a consistent translation between the three fields so a spreadsheet, a notebook, and a teammate's whiteboard do not drift from each other by a rounding rule.
Exports make this worse if you let them. Ads managers often give you spend, impressions, and a precomputed CPM in the same CSV. I recompute the CPM column from the first two and compare. When they disagree, I trust spend and impressions. Those are counts and currency. The third column is someone else's rounding. Doing this in Python, with tests, is less about cleverness than about not arguing from a display cell.
Why I refuse to do this with raw floats
Python's float is fine for physical simulations. It is a sloppy ledger.
On this machine:
>>> 0.1 + 0.2
0.30000000000000004
>>> (19.99 / 1500) * 1000
13.326666666666666
The second line is a perfectly ordinary "we spent 19.99 to reach 1,500 people" case. The float result trails off. Rounded to cents with Decimal and ROUND_HALF_UP, the same inputs produce 13.33.
That gap will not wreck a single campaign recap. It will create quiet disagreements later: two notebooks, two programming languages, two "why is your dashboard 0.01 off" threads. Money should be decimal from the first function.
The calculator
The module below is the whole useful surface: CPM and CPC in both directions, plus ROAS and CPA because those are the two companion tabs most people open after they have a CPM.
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Union
Number = Union[int, float, str, Decimal]
CENTS = Decimal("0.01")
def _d(value: Number) -> Decimal:
return Decimal(str(value))
def as_money(value: Number) -> Decimal:
return _d(value).quantize(CENTS, rounding=ROUND_HALF_UP)
def cpm(spend: Number, impressions: int) -> Decimal:
"""CPM = (ad spend ÷ impressions) × 1,000."""
if impressions <= 0:
raise ValueError("impressions must be a positive integer")
return as_money(_d(spend) / Decimal(impressions) * Decimal(1000))
def spend_from_cpm(cpm_value: Number, impressions: int) -> Decimal:
if impressions <= 0:
raise ValueError("impressions must be a positive integer")
return as_money(_d(cpm_value) * Decimal(impressions) / Decimal(1000))
def impressions_from_cpm(spend: Number, cpm_value: Number) -> int:
if _d(cpm_value) <= 0:
raise ValueError("cpm must be positive")
raw = _d(spend) / _d(cpm_value) * Decimal(1000)
return int(raw.to_integral_value(rounding=ROUND_HALF_UP))
def cpc(spend: Number, clicks: int) -> Decimal:
"""CPC = ad spend ÷ clicks."""
if clicks <= 0:
raise ValueError("clicks must be a positive integer")
return as_money(_d(spend) / Decimal(clicks))
def spend_from_cpc(cpc_value: Number, clicks: int) -> Decimal:
if clicks <= 0:
raise ValueError("clicks must be a positive integer")
return as_money(_d(cpc_value) * Decimal(clicks))
def clicks_from_cpc(spend: Number, cpc_value: Number) -> int:
if _d(cpc_value) <= 0:
raise ValueError("cpc must be positive")
raw = _d(spend) / _d(cpc_value)
return int(raw.to_integral_value(rounding=ROUND_HALF_UP))
def roas(revenue: Number, spend: Number) -> Decimal:
if _d(spend) <= 0:
raise ValueError("spend must be positive")
return as_money(_d(revenue) / _d(spend))
def cpa(spend: Number, conversions: int) -> Decimal:
if conversions <= 0:
raise ValueError("conversions must be a positive integer")
return as_money(_d(spend) / Decimal(conversions))
A few implementation notes that bit me while writing tests:
- Pass money in as strings (
"500","12.50") orDecimalvalues.Decimal(19.99)would bake the float error back in.Decimal(str(19.99))is the escape hatch if a caller already handed you a float. - Impressions and clicks are counts. I keep them as integers and only round when I invert a rate into a count, using half-up so
40000.0does not become39999through truncation. - Zero impressions or zero clicks raise
ValueError. Silentinfis how a notebook lies to you at 1 a.m.
Tests I actually ran
UGCfy AI's hosted calculator publishes this worked example in its FAQ: if you spent $500 for 40,000 impressions, CPM is $12.50. I used that as the first unit test, then inverted both fields.
import unittest
from decimal import Decimal
from ad_metrics import cpc, cpm, impressions_from_cpm, spend_from_cpm
class CpmTests(unittest.TestCase):
def test_faq_example_five_hundred_over_forty_thousand(self):
self.assertEqual(cpm("500", 40_000), Decimal("12.50"))
def test_solve_any_two_fields(self):
self.assertEqual(spend_from_cpm("12.50", 40_000), Decimal("500.00"))
self.assertEqual(impressions_from_cpm("500", "12.50"), 40_000)
A second test uses a labeled hypothetical on the same $500 spend: if that campaign also produced 250 clicks, CPC is $2.00. That click count is not a published benchmark. It exists so the CPC helpers have a round-trip case.
python3 -m unittest test_ad_metrics.py -v reported five passing tests in under a millisecond:
test_same_spend_with_labeled_clicks ... ok
test_faq_example_five_hundred_over_forty_thousand ... ok
test_rejects_zero_impressions ... ok
test_solve_any_two_fields ... ok
test_roas_and_cpa_formulas ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
The extra ROAS/CPA test checks the FAQ-style identity 4000 / 1000 = 4.00 and a labeled 500 / 20 = 25.00 CPA. Again, those are formula checks, not market averages.
Here is the same arithmetic from an interactive session on this machine:
cpm(500, 40000) = 12.50
spend_from_cpm(12.50, 40000) = 500.00
impressions_from_cpm(500, 12.50) = 40000
cpc(500, 250) = 2.00
If your ads manager export already includes a CPM column, recompute it anyway. I have seen exports where CPM was stored with more than two decimals and then displayed rounded, and the "same" campaign disagreed with itself depending on which column you trusted.
The hosted version of the same arithmetic
If you do not want this module in a repo, UGCfy AI's cpm calculator does the same core identity in the browser: fill any two of spend, impressions, and CPM, and it derives the third. It also has ROAS and CPA tabs. The page says the calculations run locally and are not stored.
That is the right split. The Python module belongs in a pipeline — a nightly sheet, a notebook that reads the ads API, a unit-tested helper you can call from an agent. The hosted page belongs in the five minutes before a stand-up, when someone pastes two numbers from Ads Manager and does not want to open an editor.
I am deliberately not copying the page's platform benchmark ranges into this post. Benchmarks move by geo, season, and audience size, and they age faster than a formula. If you need a published range, read it on that page with its date and sources attached. If you need a number you can defend in a review, compute it from your own spend and impressions.
Where CPM stops and click models start
CPM is accounting. It does not predict whether the next thousand impressions will click.
When teams later ask "can we model the click?", they are in a different problem family: click-through-rate prediction on display logs. A public example on the Hub is reczoo/Criteo_x1, a Criteo display-advertising click dataset used as a CTR benchmark (13 numeric fields, 26 categorical fields, built from about a week of click-through data). That dataset will not compute your campaign CPM, and you should not bolt it onto this calculator. I mention it only to keep the boundary clean: money-in / impressions-out is a decimal identity; predicting a click is a learning problem with its own leakage and calibration issues.
If you are wiring marketing tools into an agent rather than a notebook, I wrote a separate post about that plumbing: Claude for Marketing: Wiring a Remote MCP Server Into an Ad Workflow. The CPM helper here is the boring prerequisite for that kind of workflow. An agent that can generate variants but cannot recompute what those variants cost per thousand impressions will sound confident and still scale the wrong thing.
A practical order of operations
When a UGC test is about to get more budget, I run the numbers in this order:
- Recompute CPM from raw spend and impressions, not from a remembered dashboard tile.
- If the export includes clicks, compute CPC on the same spend. The two numbers together tell you whether attention got expensive, or the click did.
- Only then look at CPA or ROAS. Those need conversion or revenue attribution, which is messier than impression counts and should not be asked to explain a CPM change by themselves.
- If CPM rose while ROAS held, the auction got more expensive and the funnel still paid for itself. If CPM was stable while ROAS fell, you are probably looking at creative fatigue or an offer problem, not a bidding typo.
- Scale the variants that survive that pairing, not the ones that merely "looked native."
None of those steps require a model. They require a function you trust, tests that lock the published identity $500 / 40,000 impressions = $12.50 CPM, and the discipline to run them before the budget slider moves.
The module and tests live beside this draft as ad_metrics.py and test_ad_metrics.py. Run the tests before you copy the functions into a larger repo. If a future you changes the rounding mode, the FAQ example should fail immediately.


