statistics

Expected vs Actual CTR: finding the pages that earn fewer clicks than their position deserves

Anyone who spends their days inside Search Console knows that little nagging feeling: a page sits steadily in third position, yet the clicks are few, a CTR that looks like it belongs at the bottom of the page.
The question we usually ask is the wrong one: not “how many clicks does it get?”, but the more uncomfortable one — “how many clicks should it get, sitting where it sits?”. Without a benchmark, a 3% CTR tells us nothing: for position 8 it would be excellent, for position 2 a small disaster.
What we are missing, in order to judge, is an expected CTR: the value to compare the actual one against.

We have already seen, talking about correlation, that position and CTR move together along a steep curve; and that the next step — using one variable to predict another — is the job of linear regression.
Here the two threads tie together: we turn that curve into an expected CTR and measure, page by page, how far each one deviates from it. It is the way to stop reading CTRs as absolute numbers and start reading them for what they really are: deviations from a norm.

What we will cover:


Why a CTR, on its own, means nothing

There are industry tables telling us what the CTR of each position “should” be: the first around 25-30%, the second below half, and so on going down. They are useful as a general horizon, but to judge our pages they lead us astray: CTR depends on the type of query (a heavily clicked brand term or a cold informational search), on the sector, on how crowded the SERP is with ads and rich snippets.
The average CTR of “position 3” on an American e-commerce benchmark has almost nothing to say to our technical blog in Italian.

The way out is to stop comparing ourselves with an external table and build the reference curve on our own data.
We take all the pages, their average positions and their CTRs, and we trace the curve that describes the typical behaviour of CTR as position changes for the way our own site works. That curve becomes the yardstick: the expected CTR of a page is the value the curve assigns it, given its position.
The gap between the actual CTR and that expected value is the information we were after.

A CTR only makes sense next to the position that produced it: it is the deviation from the curve, not the absolute number, that tells us whether a page is working well or leaving clicks on the table.


Modelling the CTR curve: three roads

Building the curve means estimating a function that, given the position, returns the expected CTR. We already know the shape of that curve by eye: it starts high, plummets across the first positions and then flattens towards zero. A straight line does not describe it; we need something that curves.

There is, however, a detail that changes the whole way of reasoning, and it is the kind of deviation we care about.
We do not care that a page gets “two CTR points less” than expected: at the top of the SERP two points are crumbs, at the bottom they are a doubling. We care about the multiplicative deviation — “it earns half of what it should”, “it earns double”.
And a multiplicative deviation is best handled on a logarithmic scale, where a ratio becomes a difference.

The most natural shape for a curve of this kind is the power law, that is the idea that CTR is proportional to position raised to a negative exponent:

\( \text{CTR} = a \cdot position^{b} \\ \)

where \( a \) sets the general level and \( b \) (negative) governs how fast the descent is. The beauty arrives when we take the logarithm of both sides, which turns that curve into a straight line:

\( \log(\text{CTR}) = \log(a) + b \cdot \log(position) \\ \)

In other words: the logarithm of CTR is a linear function of the logarithm of position. And estimating a straight line is exactly what we know how to do with regression. From here, three roads to build the curve.

The first, and the one I recommend as the workhorse, is a linear regression on the logarithmslm(log(ctr) ~ log(position)). It is in base R, it is interpretable (the slope \( b \) is the elasticity of CTR to position: by what percentage CTR drops for each percentage point of extra position), and its residuals are already on a logarithmic scale, hence multiplicative, exactly as we need.
It also extrapolates to rarely observed positions, and it can be weighted by impressions (weights = impression), so that pages with a handful of clicks do not skew the curve as much as those with tens of thousands of views. It is a pragmatic choice rather than the theoretically optimal weight (for a proportion the variance also depends on the CTR itself), but in practice it works very well.

The second is non-linear regression with nls, which estimates \( a \) and \( b \) directly on the natural scale of CTR without going through logarithms. It is an elegant refinement, but it must be primed with sensible starting values (which we fish out precisely from the log-log regression) and on messy data it may fail to converge. I keep it for when I need a clean parameter to put in a report, not as a starting point.

The third is local smoothing with loess, which imposes no shape on the curve and lets the data “draw it”. It is perfect for seeing the trend at a glance, but it wobbles on the tails (few pages in first position) and above all it does not extrapolate: outside the observed range it has nothing to say. It is an exploratory tool, not the model on which to base judgements.

So: we start from the log-log regression weighted by impressions as the working model, we compare it by eye with a loess to check we are not forcing the wrong shape, and we move to nls only if we need the explicit exponent. Let us see it at work.


An example with Search Console data

We start from an extract like the one anyone can download from Search Console: one row per page, with impressions, clicks and average position. In reality CTR is the ratio of clicks to impressions; here, since these are example data, we go the other way round — we set a plausible CTR and reconstruct the clicks.
I build the table in R with twelve example pages (with, on purpose, a couple of anomalous cases):

gsc <- data.frame(
  page       = c("/technical-seo-guide","/seo-audit-checklist",
                 "/keyword-research-guide","/campaign-roi-calculator",
                 "/google-analytics-tutorial","/statistics-glossary",
                 "/seo-tool-review","/link-building-guide",
                 "/competitor-analysis","/attribution-model",
                 "/ranking-report","/meta-tag-optimization"),
  impression = c(  9800, 5400, 12500, 2100, 7600, 1500,
                   8300, 4200,  6100,  900, 3300, 1800),
  position   = c(  1.3,  2.1,   3.4,  4.0,  4.6,  5.2,
                   2.8,  6.1,   7.0,  8.3,  9.1, 10.2)
)
# observed CTR (usually computed as click / impression)
gsc$ctr   <- c(0.232, 0.150, 0.034, 0.071, 0.066, 0.060,
               0.171, 0.048, 0.041, 0.012, 0.031, 0.028)
gsc$click <- round(gsc$impression * gsc$ctr)

I now estimate the expected-CTR curve with the regression on logarithms, weighting each page by its impressions:

fit <- lm(log(ctr) ~ log(position), data = gsc, weights = impression)
round(coef(fit), 3)
# (Intercept)  log(position)
#      -1.240          -1.088

The slope is −1.088: a value close to −1 describes an almost inversely proportional curve, where doubling the position (going, say, from 3 to 6) cuts the CTR roughly in half.
It is the same steep drop we had glimpsed when measuring correlation, but now written in a formula we can query: given a position number, it returns the typical CTR that position implies on our site.


The other two roads: loess and nls at work

We picked the log-log regression as our working model, but we had promised all three roads. It is worth seeing the other two genuinely at work on the same twelve data points, to understand what they add — and above all where they stumble.

Non-linear regression estimates \( a \) and \( b \) directly on the CTR scale, without going through logarithms. It must be primed with sensible starting values, which we fish out precisely from the log-log we just estimated: the intercept brought back to the natural scale with exp is our \( a \), the slope is our \( b \). I set it running in R:

start <- list(a = exp(coef(fit)[1]), b = coef(fit)[2])  # primed from the log-log
fit_nls <- nls(ctr ~ a * position^b, data = gsc,
               weights = impression, start = start)
round(coef(fit_nls), 3)
#      a       b
#  0.315  -1.073

It converges, and returns an exponent of −1.073, practically the same as the log-log, but with a bonus: now \( a \) and \( b \) are numbers you can read on the true CTR scale. An \( a \) of 0.315 says, in plain terms, that at the top of the SERP the typical CTR is around 31% — exactly the kind of clean parameter to put in a report.
The price we pay is fragility: without those starting values, or on noisier data, nls may fail to converge at all and hand us back only an error.

Local smoothing with loess does the opposite: it imposes no shape, it lets the data draw the curve. I estimate it and ask it for the expected CTR at a few positions, including the first:

fit_lo <- loess(ctr ~ position, data = gsc, span = 0.9)
round(predict(fit_lo, data.frame(position = c(1, 2, 3, 5))), 3)
# [1]    NA 0.173 0.111 0.029

And here is the limit in a single output: at position 1, loess returns NA. The minimum our data observe is 1.3, and outside that range loess refuses to commit — it does not extrapolate.
For a CTR curve this is a serious flaw: the first position, the one we care about most, is often the least populated, and that is exactly where the local method leaves us empty-handed.

Put on the same chart, the three roads tell the whole story at a glance:

The three estimates of the expected-CTR curve on the twelve example points. In the middle, where pages abound, they almost coincide; on the tails they diverge. loess (purple) stops at position 1.3 — it does not extrapolate — while lm (blue) and nls (orange) continue below the first position. The nls curve sits a touch above the log-log because it estimates the level directly on the CTR scale.

In the middle, where pages abound, the three curves almost coincide: any method works when the data speak clearly. It is on the tails that they part ways — loess wobbles behind the few pages it finds and stops dead at the edge of the data, while lm and nls continue smoothly even where observations are scarce.

So: the weighted log-log stays the workhorse — interpretable, extrapolable, with residuals already on a multiplicative scale. nls refines it when we need a clean exponent to write down; loess is the critical eye that, before we trust the model, tells us whether we are forcing the wrong shape. Three different tools for a single craft: turning a position into an expected CTR.


Residuals: who earns less than they should

Having the curve means being able to compute, for each page, its expected CTR and compare it with the actual one. The comparison, as we said, must be made in terms of a ratio and not a difference: ratio = actual_ctr / expected_ctr. A value around 1 says the page earns as predicted; well below 1 that it is leaving clicks on the table; well above 1 that it captures more than its share.
I compute the expected CTR, the ratio, and flag the cases that truly deviate — but only if they have enough impressions to make their CTR reliable:

gsc$ctr_exp <- exp(predict(fit))           # back from the log scale to the natural one
gsc$ratio   <- gsc$ctr / gsc$ctr_exp

gsc$flag <- ifelse(gsc$ratio < 0.6 & gsc$impression >= 1000, "UNDER",
             ifelse(gsc$ratio > 1.4 & gsc$impression >= 1000, "OVER", "ok"))

gsc[order(gsc$ratio),
    c("page","position","impression","ctr","ctr_exp","ratio","flag")]

n.b. predict gives us the logarithm of the expected CTR, because that is the scale on which we estimated the model: exp brings it back to an actual CTR. Strictly speaking exp returns the median of the expected CTR, not the arithmetic mean (under log-normal errors the true mean is a touch higher), but for the relative ratios we care about the distinction is immaterial.
The output, sorted from the lowest ratio to the highest:

pagepositionimpressionctrctr_expratioflag
/attribution-model8.39000.0120.0290.41ok
/keyword-research-guide3.4125000.0340.0760.44UNDER
/technical-seo-guide1.398000.2320.2181.07ok
/campaign-roi-calculator4.021000.0710.0641.11ok
/seo-audit-checklist2.154000.1500.1291.16ok
/competitor-analysis7.061000.0410.0351.18ok
/ranking-report9.133000.0310.0261.18ok
/link-building-guide6.142000.0480.0401.19ok
/google-analytics-tutorial4.676000.0660.0551.20ok
/meta-tag-optimization10.218000.0280.0231.21ok
/statistics-glossary5.215000.0600.0481.25ok
/seo-tool-review2.883000.1710.0941.81OVER

The case that jumps out is /keyword-research-guide: it sits in third position, where the curve would expect a CTR of 7.6%, and instead it gathers a meagre 3.4% — less than half of what it should, on twelve thousand five hundred impressions that make the figure rock solid.
It is a strong, immediately actionable hypothesis: in all likelihood the title and the meta description are not doing their job, and a rewrite could unlock clicks the position had already earned.

At the opposite end there is /seo-tool-review, which in second-to-third position earns almost double the expected. It is not a problem, it is a lesson: something in that snippet works beautifully — a magnetic title, a rich card, a perfect match with intent — and it is worth understanding what, to try to replicate it elsewhere. Residuals are not only there to find the sick ones: over-performances are the case studies from which to learn what, on our site, makes people click.

The twelve example pages: average SERP position against CTR, with each point’s area proportional to impressions. The blue curve is the expected CTR from the weighted log-log; the vertical segment below each point is its residual, the distance from the curve. Two pages stand out: /keyword-research-guide at 0.44× expected (red, under-performs) and /seo-tool-review at 1.81× (green, over-performs).

Reading the deviations without fooling ourselves

There is a detail in the table that is the heart of the whole matter, and that is easy to miss. /attribution-model has a ratio of 0.41 — even lower than /keyword-research-guide — yet we did not flag it. The reason is in the impressions column: nine hundred.
A CTR computed on so little data is almost pure noise, and next month it will drift back towards its mean regardless of anything we do. Flagging it as a “page to optimise” would send us chasing a ghost. Two pages with the same deviation, two opposite verdicts, and the only thing making the difference is the amount of data behind them.

A word of caution: the expected CTR is a conditional typical value — the median the curve associates with a position — not a law of nature. A page can “under-perform” for reasons that have nothing to do with the title: a brand query inflating competitors’ CTR, a featured snippet or a block of ads eating the clicks before the first organic result, a purely informational intent already satisfied by reading the snippet. And a CTR built on few impressions measures almost nothing: it will regress towards its mean on its own, as we saw talking about regression to the mean. A negative residual is a hypothesis to verify — “maybe the title earns little here” — not a verdict to execute.

It is also worth remembering that we estimated the curve on our own data, and that data influences it: a handful of very anomalous pages can tilt it just enough to shift the judgements on the others.
We can already see it in our table: ten pages out of twelve have a ratio above 1, but it is not that the site “over-performs” almost everywhere. It is that the single large negative deviation, /keyword-research-guide, weighs a great deal (twelve thousand five hundred impressions) and pulls the curve downwards, raising the ratio of all the others as a side effect. The “centre” of the cloud, in short, is not exactly 1, and it is better to read the ratios in relative terms — who sits well below and who well above the bulk of the group — rather than against the hard threshold of one.
This is why the impression weighting is precious, and why it pays to recompute the curve whenever the picture changes, instead of treating it as a constant carved in stone.


Try it yourself

The best way to internalise the mechanism is to get your hands on it. Building on the code above, there are three interesting directions to explore:

  1. Aggregate by query instead of by page: the same page can appear on dozens of searches with different positions and CTRs, and often it is there — on the single query — that the missed opportunity hides. The curve and the residuals are built in the exact same way.
  2. Drop the impression weighting — lm(log(ctr) ~ log(position)) without weights — and compare the exponent and the verdicts with the weighted version: how much do the conclusions shift once the pages with more data no longer have a louder voice? It is the way to feel how much the weighting matters, instead of taking it on faith.
  3. Change the impression threshold below which you do not trust the CTR: going from 1000 to 3000, which pages leave the radar? The right number does not exist in the abstract, it depends on how much traffic your site moves.

A hint: the structure never changes — you estimate the curve, you predict the expected value, you look at the ratio. It is by playing with the threshold and the level of aggregation that you really understand how much of what we call an “under-performing page” is signal and how much is, simply, noise.


Spotting a page that earns less than expected for its position is a close cousin of another problem every analyst knows: spotting a day that earns less than expected over time, a drop or a spike in traffic that does not square with the usual trend.
It is the same reasoning on residuals — observed value against expected value — moved from the space of positions to the axis of time, where the expected value is provided by the historical trend of the series. From there springs anomaly detection: telling signal from noise when the numbers move over time, and the next step of our path.


Further reading

If you want to go deeper into regression, logarithmic transformations and the reading of residuals — the very backbone of the model we built here — and then push beyond the power law towards local methods like loess, An Introduction to Statistical Learning by James, Witten, Hastie and Tibshirani is the book I recommend: it covers both the “why” of the logarithms and the “how” of interpreting coefficients, with hands-on labs in R, always starting from applied problems.

Paolo Gironi

Recent Posts

Keyword Clustering: grouping thousands of queries with K-means and hierarchical clustering

It happens with every reasonably serious project: you export the keyword list from Search Console…

1 month ago

Naive Bayes: classifying search intent with Bayes’ theorem

In the article on the multi-armed bandit we used Bayes to decide between variants: shifting…

1 month ago

Multi-armed bandit: optimising the variants while the test is still running

In the article on Bayesian A/B testing we compared two variants at a fixed sample…

1 month ago

Bayesian A/B Testing: not just “whether” B beats A, but “by how much”

In the article on classic A/B testing we saw how to compare two variants with…

1 month ago

Bayesian Conversion Rate Estimation: how much can we trust limited data

In the article on the foundations of Bayesian statistics, we saw how Bayesian updating works…

1 month ago

The peeking problem: why sneaking a look at an A/B test inflates false positives

On 21 January 2015 Optimizely — one of the most widely used A/B testing platforms…

1 month ago