<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>paologironi blog</title>
	<atom:link href="https://www.gironi.it/blog/en/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.gironi.it/blog</link>
	<description>Scattered notes on (retro) computing, data analysis, statistics, SEO, and things that change</description>
	<lastBuildDate>Thu, 15 Oct 2026 14:15:04 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	
	<item>
		<title>Survivorship Bias: Why Success Case Studies Fool Us</title>
		<link>https://www.gironi.it/blog/en/survivorship-bias-case-studies/</link>
					<comments>https://www.gironi.it/blog/en/survivorship-bias-case-studies/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Thu, 15 Oct 2026 14:13:59 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=4555</guid>

					<description><![CDATA[We have all read it, perhaps on LinkedIn, complete with an upward-sloping chart: &#8220;We rewrote the titles of 300 pages and in three months traffic grew by 38%&#8221;. The case study is honest, the numbers are real, and the temptation to copy the recipe is strong. But there is a question the post does not &#8230; <a href="https://www.gironi.it/blog/en/survivorship-bias-case-studies/" class="more-link">Continue reading<span class="screen-reader-text"> "Survivorship Bias: Why Success Case Studies Fool Us"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">We have all read it, perhaps on LinkedIn, complete with an upward-sloping chart: &#8220;We rewrote the titles of 300 pages and in three months traffic grew by 38%&#8221;. <br>The case study is honest, the numbers are real, and the temptation to copy the recipe is strong. But there is a question the post does not ask, and it is worth asking on its behalf: <strong>how many other companies did the same thing, and how did it go for them?</strong> <br>Whoever rewrote their titles and lost 10% does not write a case study. Nor does whoever saw no change at all. Their silence is not a detail: it is half of the data.</p>



<p class="wp-block-paragraph">In the <a href="https://www.gironi.it/blog/en/p-value-misconceptions/">article on the p-value</a> we saw that even &#8220;significant&#8221; results can mislead us. Here the trap is even simpler, and for that very reason more insidious: we only look at those who made it. It is called <strong>survivorship bias</strong>.</p>



<span id="more-4555"></span>



<p class="wp-block-paragraph"><strong>In short:</strong> <strong>survivorship bias</strong> is the error we make when we draw conclusions by looking only at the cases that passed some selection, ignoring those that were discarded along the way. If the selection depends on the very outcome we are measuring (the case study gets published only if things went well, an article stays online only if it brings traffic) the average computed on the survivors is biased, sometimes heavily. We fight it by always asking <strong>who is missing from the dataset, and why</strong>, by defining the group to study <em>before</em> knowing the outcome, and by keeping the data on the cases that failed.</p>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#what-is-it">What is survivorship bias?</a></li><li><a href="#case-study">An example: 1,000 sites and 55 case studies</a></li><li><a href="#graveyard">Those who do not come back do not tell</a></li><li><a href="#pruning">A second example: content pruning</a></li><li><a href="#where">Where survivorship bias hides in SEO</a></li><li><a href="#defence">How to protect ourselves?</a></li><li><a href="#faq">Frequently asked questions</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="what-is-it">What is survivorship bias?</h2>



<p class="wp-block-paragraph">The most famous story comes from the Second World War. In New York, at Columbia University, there was the <em>Statistical Research Group</em>, a team of mathematicians working for the American armed forces; one of them was <strong>Abraham Wald</strong>. <br>The problem, in the version usually told, was this: the planes returning from missions were riddled with bullet holes, but not uniformly. The fuselage and the wings had many, the engines very few. Where should armour be added, knowing it could not go everywhere without making the plane too heavy?</p>



<p class="wp-block-paragraph">The instinctive answer is &#8220;where the holes are&#8221;. Wald proposed the exact opposite: <strong>the armour goes where the holes are missing</strong>. <br>The planes being examined were the ones that <em>came back</em>. If the engines of the survivors had few holes, it was not because engines were rarely hit: it was because planes hit in the engines did not come back. The missing holes were on the planes that had been shot down, which nobody could count.</p>



<p class="wp-block-paragraph">(The anecdotal version simplifies a great deal: Wald&#8217;s work was a real mathematical method for estimating aircraft vulnerability from the damage on the survivors. But the heart of the reasoning is exactly that.)</p>



<p class="wp-block-paragraph">So: <strong>survivorship bias is the error of drawing conclusions only from the cases that passed a selection, as if they represented everyone.</strong> <br>It is not a calculation error: the averages on the survivors are computed perfectly well. It is an error about <em>who</em> enters the calculation. And it becomes serious when the selection depends on the outcome we care about: planes come back <em>if</em> they were not hit in a vital spot, the case study gets published <em>if</em> the strategy worked.</p>



<h2 class="wp-block-heading" id="case-study">An example: 1,000 sites and 55 case studies</h2>



<p class="wp-block-paragraph">Let us build the example by simulation, so that we know the truth. A thousand sites make the same change, for instance they rewrite the titles of their main pages, and <strong>the change does nothing at all</strong>: the traffic change over the following three months is pure noise, the normal fluctuation of a site from one quarter to the next (we simulate it with a standard deviation of about 15%). <br>Then we do what happens in reality: only those who saw at least +25% write a case study.</p>



<p class="wp-block-paragraph">I generate the data in R:</p>



<pre class="wp-block-code"><code>set.seed(42)
n_sites &lt;- 1000
change_q1 &lt;- exp(rnorm(n_sites, 0, 0.15)) - 1   # traffic change over three months
publish   &lt;- change_q1 &gt;= 0.25                  # those who tell their success</code></pre>



<p class="wp-block-paragraph">(The noise is generated on the log scale and then turned into a percentage: this way a site can lose at most 100% but can also gain more than 100%, as real traffic does.)</p>



<p class="wp-block-paragraph">Let us first compute the truth, that is the average over all thousand sites, and the share of those that improved:</p>



<pre class="wp-block-code"><code>round(100 * mean(change_q1), 1)       # average change, in %
# [1] 0.7
round(100 * mean(change_q1 &gt; 0), 1)   # % of sites that improved
# [1] 48.5</code></pre>



<p class="wp-block-paragraph">As expected from a useless change, the average is practically zero (+0.7%) and <strong>more than half of the sites got worse</strong>. Now let us look at the world as we see it, that is through the published case studies:</p>



<pre class="wp-block-code"><code>sum(publish)                              # how many case studies
# [1] 55
round(100 * mean(change_q1[publish]), 1)  # average change reported
# [1] 34.9
round(100 * range(change_q1[publish]), 1)
# [1] 25.1 68.9</code></pre>



<p class="wp-block-paragraph">Out come 55 case studies, with an average growth of <strong>+34.9%</strong> and peaks up to +68.9%. 100% of the case studies report a success, while among all the sites fewer than half succeeded. <br>The reader who collects them all and averages them, scrupulously, concludes that rewriting titles is worth +35% traffic. <strong>Every single number is true, and the conclusion is completely false.</strong></p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/survivorship-case-study-en.png" alt="The traffic change of the 1,000 sites that made the same useless change. The distribution is centred on zero; the 55 sites above +25% (highlighted) are the only ones that publish a case study, and the average we read is theirs."/><figcaption class="wp-element-caption">The traffic change of the 1,000 sites that made the same useless change. The distribution is centred on zero; the 55 sites above +25% (highlighted) are the only ones that publish a case study, and the average we read is theirs.</figcaption></figure>



<p class="wp-block-paragraph">There is a simple way to expose the trick: ask the 55 winners how the following quarter went. If the recipe worked, they should keep growing. I simulate another quarter of noise:</p>



<pre class="wp-block-code"><code>change_q2 &lt;- exp(rnorm(n_sites, 0, 0.15)) - 1   # the following quarter
round(100 * mean(change_q2[publish]), 1)        # the 55 winners, three months later
# [1] 2.2
sum(change_q2[publish] &gt;= 0.25)                 # how many do it again
# [1] 4</code></pre>



<p class="wp-block-paragraph">The winners go back to everybody&#8217;s average (+2.2%), and only 4 out of 55 repeat their success, more or less as many as chance alone would predict. It is the <a href="https://www.gironi.it/blog/en/regression-to-the-mean/">regression to the mean</a> we have already met: whoever was selected for an extreme result tends, at the next measurement, to fall back into line. <strong>Survivorship bias and regression to the mean are two faces of the same problem: we chose the cases by looking at the outcome.</strong></p>



<h2 class="wp-block-heading" id="graveyard">Those who do not come back do not tell</h2>



<p class="wp-block-paragraph">The idea is ancient. Cicero tells that the philosopher Diagoras of Melos, on Samothrace, was shown the votive tablets painted by those who had survived a shipwreck after praying to the gods: was this not proof that prayers work? Diagoras replied by asking where the portraits were of those who had prayed and drowned. <br>Nassim Taleb calls this body of invisible data the <em>silent graveyard</em>: the cases that did not make it do not write, do not paint tablets, do not come back to base. And precisely because they are silent, we forget they exist.</p>



<p class="wp-block-paragraph">The delicate point is that the silent graveyard is invisible <em>by construction</em>. It is not data missing through carelessness, which a little care can recover: it is the very mechanism that produces the data that excludes it. No case study will ever tell us how many others tried the same thing.</p>



<h2 class="wp-block-heading" id="pruning">A second example: content pruning</h2>



<p class="wp-block-paragraph">Survivorship bias does not need LinkedIn: it can arise inside our own data. Take a blog that publishes 100 articles a year from 2020 to 2025. Every year it runs a content audit, what is known as <em>content pruning</em>: articles at least one year old that bring fewer than 30 visits a month are removed (or set to noindex) with probability 1/2, because someone judges them salvageable and someone else does not. <br>In the simulation, and this is the point, <strong>an article&#8217;s traffic does not depend in any way on the year it was written</strong>.</p>



<p class="wp-block-paragraph">I build the blog in R:</p>



<pre class="wp-block-code"><code>set.seed(2025)
years &lt;- 2020:2025
posts &lt;- data.frame(year = rep(years, each = 100))
posts$visits &lt;- round(rlnorm(nrow(posts), log(40), 1.1))   # visits per month
posts$online &lt;- TRUE
for (audit in 2021:2025) {
  candidates &lt;- posts$online &amp; posts$year &lt; audit &amp; posts$visits &lt; 30
  posts$online[candidates &amp; runif(nrow(posts)) &lt; 0.5] &lt;- FALSE
}</code></pre>



<p class="wp-block-paragraph">Traffic follows a log-normal distribution, as it usually does: many articles with few visits, a few articles with a great many. That is exactly why we use the median as a summary and not the mean, which the few big hits would drag upwards.</p>



<p class="wp-block-paragraph">A year after the last audit, someone wonders whether to invest in <em>evergreen</em> content. They export the online articles from the CMS and compute the median visits by year of publication:</p>



<pre class="wp-block-code"><code>online &lt;- posts[posts$online, ]
tapply(online$visits, online$year, median)
#  2020 2021 2022 2023 2024 2025
#  78.0 78.5 73.0 54.0 54.0 41.5</code></pre>



<p class="wp-block-paragraph">The result looks clear-cut: the 2020 articles bring almost twice the visits of the 2025 ones. &#8220;Content ages well, traffic accumulates over time.&#8221; Now let us look at the same table over <strong>all</strong> the articles ever published, including those removed:</p>



<pre class="wp-block-code"><code>tapply(posts$visits, posts$year, median)
#  2020 2021 2022 2023 2024 2025
#  38.0 38.0 43.0 38.0 40.5 41.5</code></pre>



<p class="wp-block-paragraph">The growth disappears: the year of publication makes no difference, as we had established. The &#8220;age&#8221; effect was produced entirely by the audits. The 2020 articles went through five rounds of pruning and 62% of them are left, the strongest; the 2025 articles went through none and are all still there, weak ones included. <strong>By comparing groups that went through different selections, we measured the selection, not the content.</strong></p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/survivorship-potatura-en.png" alt="Median monthly visits by year of publication. Over all articles (dashed line) traffic does not depend on the year; over the articles still online (solid line) it seems to grow with age, because old weak articles were removed and new ones were not. At the bottom, the share of articles that survived the audits."/><figcaption class="wp-element-caption">Median monthly visits by year of publication. Over all articles (dashed line) traffic does not depend on the year; over the articles still online (solid line) it seems to grow with age, because old weak articles were removed and new ones were not. At the bottom, the share of articles that survived the audits.</figcaption></figure>



<p class="wp-block-paragraph">There is a subtle irony here: precisely those who work well, and clean up regularly, build themselves an archive that lies. And the lie is convenient, because it confirms that old content is valuable and that pruning worked.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1.5rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-flow wp-block-group-is-layout-flow">
<p>A warning: <strong>before looking at a result, let us ask who left the dataset, and why.</strong> <br>If leaving depends on the outcome we are measuring (traffic, conversions, the success of a strategy), any average computed on those who remained is biased, and no statistical test, however refined, will notice: the test works on the data we give it, not on the data that is missing.</p>
</div></div>



<h2 class="wp-block-heading" id="where">Where survivorship bias hides in SEO</h2>



<p class="wp-block-paragraph">Once we learn to recognise it, we find it everywhere. A few examples from everyday practice:</p>



<ul class="wp-block-list"><li><strong>Ranking factor studies.</strong> Every so often a study measures the pages in first position: so many words on average, so many images, so many backlinks. But the study only looks at the top ten positions: how many pages with the same features sit on page four? Without them we cannot tell whether those features distinguish the winners or are simply common to everyone. (It is also a matter of <a href="https://www.gironi.it/blog/en/correlation/">correlation mistaken for causation</a>, but the first flaw is in the sample.)</li><li><strong>&#8220;What the sites that survived the core update have in common&#8221;.</strong> Without looking at the sites with the same features that collapsed instead, the list of the survivors&#8217; virtues proves nothing.</li><li><strong>Search Console.</strong> It shows the pages and queries that obtained impressions: pages Google never shows do not appear, and neither do queries anonymised for privacy. The interface tables, moreover, stop at the first thousand rows. Analysing &#8220;the site&#8217;s pages&#8221; starting from there means analysing the pages that already work.</li><li><strong>A/B testing best practices.</strong> Collections of &#8220;winning tests&#8221; tell us about the variants that won, not about the ten identical ones that lost elsewhere. And the winners of an <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">A/B test</a> stopped at the right moment, as we saw when discussing <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/">peeking</a>, are survivors selected twice.</li><li><strong>Portfolios and testimonials.</strong> An agency shows the clients that grew, not the ones it lost; a course shows the students who found a job. It is not dishonesty: it is how a shop window works. But the shop window is not the warehouse.</li><li><strong>Tool datasets.</strong> Expired domains, closed sites and deleted pages silently drop out of backlink and visibility indexes. A historical trend computed on the sites active today is a trend of the survivors.</li></ul>



<h2 class="wp-block-heading" id="defence">How to protect ourselves?</h2>



<p class="wp-block-paragraph">There is no formula that corrects survivorship bias after the fact: if the discarded cases are lost, no calculation brings them back. There are, however, habits that prevent it.</p>



<p class="wp-block-paragraph"><strong>Define the group before knowing the outcome.</strong> This is the lesson of the second example. If we want to know how the 2020 articles perform, the right group is <em>all the articles published in 2020</em>, not those still online. Epidemiologists call this a cohort study: the whole group is followed from the start, the dead included. In the simulation, it is exactly the difference between the dashed line and the solid one.</p>



<p class="wp-block-paragraph"><strong>Ask for the denominator.</strong> In front of a success, the question is always &#8220;out of how many attempts?&#8221;. Fifty-five enthusiastic case studies out of a thousand attempts tell a very different story from fifty-five out of sixty. If the denominator is unknown, the honest conclusion is that we do not know.</p>



<p class="wp-block-paragraph"><strong>Keep the dead.</strong> In our work the silent graveyard can, in part, be preserved: old sitemaps, the list of removed or redirected URLs with their dates, full Search Console exports downloaded regularly, the results of <em>all</em> A/B tests, not only the winning ones. It costs little, and it turns an archive that lies into one we can question.</p>



<p class="wp-block-paragraph"><strong>Compare with those who did nothing.</strong> The best way to know whether a change works is to compare the modified pages with similar pages left as they were, chosen before looking at the results: this is the idea behind <a href="https://www.gironi.it/blog/en/seo-split-testing/">SEO split testing</a>. The control group is, in a sense, the graveyard we build on purpose.</p>



<p class="wp-block-paragraph"><strong>Distrust large effects selected for success.</strong> When a result reaches us because it was exceptional, let us expect it to be less exceptional next time. It is not pessimism: it is what we saw happen to the 55 winners.</p>



<h2 class="wp-block-heading" id="faq">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is the most famous example of survivorship bias?</h3>



<p class="wp-block-paragraph">The Second World War planes analysed by the mathematician Abraham Wald. The planes that came back had few hits on the engines, and the temptation was to armour the most damaged parts. Wald understood that hits on the engines were missing because the planes hit there did not come back: the armour belonged exactly where, on the survivors, there were no holes.</p>



<h3 class="wp-block-heading">Are survivorship bias and selection bias the same thing?</h3>



<p class="wp-block-paragraph">Survivorship bias is a special case of selection bias. Selection bias occurs whenever the sample analysed does not represent the population of interest; in survivorship bias the sample is made of those who passed a selection, and the selection usually depends on the outcome itself, such as success or staying around over time.</p>



<h3 class="wp-block-heading">Why are success case studies unreliable?</h3>



<p class="wp-block-paragraph">Not because they are false, but because they are selected: they get published when the strategy worked and stay in the drawer when it did not. A collection of case studies therefore shows only the lucky tail of the results, and the average of the successes reported can be very far from the real effect, which may even be zero.</p>



<h3 class="wp-block-heading">How is survivorship bias different from regression to the mean?</h3>



<p class="wp-block-paragraph">They are closely related. Survivorship bias is about who we see: we only observe the cases that passed a selection based on the outcome. Regression to the mean is about what happens next: cases selected for an extreme result tend to come back towards the average at the following measurement. Selecting case studies by their success produces both at once.</p>



<h3 class="wp-block-heading">Can survivorship bias be corrected after the data has been collected?</h3>



<p class="wp-block-paragraph">Only if we recover information on the excluded cases, or if we know precisely the mechanism that excluded them and can model it. If the excluded cases have simply vanished, no statistical technique brings them back: that is why it pays to prevent it, by defining the group to study before knowing the outcome and by keeping the data on the cases that failed.</p>



<script type="application/ld+json">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What is the most famous example of survivorship bias?", "acceptedAnswer": {"@type": "Answer", "text": "The Second World War planes analysed by the mathematician Abraham Wald. The planes that came back had few hits on the engines, and the temptation was to armour the most damaged parts. Wald understood that hits on the engines were missing because the planes hit there did not come back: the armour belonged exactly where, on the survivors, there were no holes."}}, {"@type": "Question", "name": "Are survivorship bias and selection bias the same thing?", "acceptedAnswer": {"@type": "Answer", "text": "Survivorship bias is a special case of selection bias. Selection bias occurs whenever the sample analysed does not represent the population of interest; in survivorship bias the sample is made of those who passed a selection, and the selection usually depends on the outcome itself, such as success or staying around over time."}}, {"@type": "Question", "name": "Why are success case studies unreliable?", "acceptedAnswer": {"@type": "Answer", "text": "Not because they are false, but because they are selected: they get published when the strategy worked and stay in the drawer when it did not. A collection of case studies therefore shows only the lucky tail of the results, and the average of the successes reported can be very far from the real effect, which may even be zero."}}, {"@type": "Question", "name": "How is survivorship bias different from regression to the mean?", "acceptedAnswer": {"@type": "Answer", "text": "They are closely related. Survivorship bias is about who we see: we only observe the cases that passed a selection based on the outcome. Regression to the mean is about what happens next: cases selected for an extreme result tend to come back towards the average at the following measurement. Selecting case studies by their success produces both at once."}}, {"@type": "Question", "name": "Can survivorship bias be corrected after the data has been collected?", "acceptedAnswer": {"@type": "Answer", "text": "Only if we recover information on the excluded cases, or if we know precisely the mechanism that excluded them and can model it. If the excluded cases have simply vanished, no statistical technique brings them back: that is why it pays to prevent it, by defining the group to study before knowing the outcome and by keeping the data on the cases that failed."}}]}</script>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">The code in this article lends itself to a few experiments.</p>



<ul class="wp-block-list"><li>Lower the publication threshold from +25% to +10% (<code>publish &lt;- change_q1 &gt;= 0.10</code>): how many case studies come out now, and what is their average? The distortion shrinks, but does it disappear?</li><li>Give the change a real but small effect, +3%, by adding <code>log(1.03)</code> to the mean of <code>rnorm()</code>. How many times over do the published case studies exaggerate the real effect?</li><li>In the pruning simulation, raise the removal probability from 1/2 to 1 (<code>runif(nrow(posts)) &lt; 1</code>): how does the table of online articles change? And if the threshold moves from 30 to 60 visits?</li></ul>



<p class="wp-block-paragraph">The last experiment is the most instructive: the more severe the pruning, the more the archive &#8220;proves&#8221; that old content is better, without anything having changed in the content itself.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">We have seen that looking only at those who survived a selection distorts the averages, even when every data point is true. There is a related trap that awaits us whenever a piece of news seems much more likely than it really is: forgetting how rare, to begin with, the event we are looking for is. A test that is 99% accurate, if what it looks for is rare enough, can be wrong most of the times it raises the alarm. It is the <em>base rate fallacy</em>, and it will be the next stop on our journey through the traps hidden in data.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">On the role of chance in success and on the survivors we mistake for winners, I recommend <a href="https://www.amazon.it/dp/0141031484?tag=consulenzeinf-21&amp;ascsubtag=survivorship-bias-en" rel="nofollow sponsored noopener" target="_blank"><em>Fooled by Randomness</em></a> by Nassim Nicholas Taleb: a brilliant and combative book, born in the world of finance but perfect for anyone who has to judge strategies, case studies and consultants by their results.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/survivorship-bias-case-studies/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>The p-value and Its Misconceptions: What p &lt; 0.05 Really Means</title>
		<link>https://www.gironi.it/blog/en/p-value-misconceptions/</link>
					<comments>https://www.gironi.it/blog/en/p-value-misconceptions/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Fri, 09 Oct 2026 08:32:25 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=4522</guid>

					<description><![CDATA[&#8220;The new title has a p-value of 0.03: there is a 97% probability that it works.&#8221; It is a sentence found in reports, in case studies, sometimes even in textbooks. And it is wrong. Not slightly, not pedantically: it is wrong in a way that can lead us to take half our decisions on the &#8230; <a href="https://www.gironi.it/blog/en/p-value-misconceptions/" class="more-link">Continue reading<span class="screen-reader-text"> "The p-value and Its Misconceptions: What p < 0.05 Really Means"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">&#8220;The new title has a p-value of 0.03: there is a 97% probability that it works.&#8221; It is a sentence found in reports, in case studies, sometimes even in textbooks. And it is wrong. Not slightly, not pedantically: it is wrong in a way that can lead us to take half our decisions on the basis of results that do not exist.</p>



<p class="wp-block-paragraph">On this blog the p-value appears in dozens of articles, from <a href="https://www.gironi.it/blog/en/hypothesis-testing-a-step-by-step-guide/">hypothesis testing</a> to <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">A/B testing</a>, from regression to <a href="https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/">multiple comparisons</a>. It is the most used number in applied statistics, and probably the most misunderstood. It is worth stopping once to look at what it says, and above all at what it does <strong>not</strong> say.</p>



<span id="more-4522"></span>



<p class="wp-block-paragraph"><strong>In short:</strong> the <strong>p-value</strong> is the probability of observing data at least as extreme as those obtained, <strong>assuming the null hypothesis is true</strong> (that is, that the effect does not exist). It is not the probability that the null hypothesis is true, nor the probability that the result is due to chance. A small p-value does not say the effect is large, and a p-value above 0.05 does not say there is no effect. The 0.05 threshold is a convention, not a natural boundary. To decide, we also need the size of the effect, the confidence interval and an estimate of how many of the ideas we test actually work.</p>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#definition">What is the p-value, in one sentence</a></li><li><a href="#probability">Misconception 1: &#8220;there is a 97% probability that it works&#8221;</a></li><li><a href="#effect">Misconception 2: &#8220;a small p means a large effect&#8221;</a></li><li><a href="#absence">Misconception 3: &#8220;p above 0.05, so there is no effect&#8221;</a></li><li><a href="#threshold">Misconception 4: &#8220;0.05 is the line between true and false&#8221;</a></li><li><a href="#what-to-report">What should we report, then?</a></li><li><a href="#faq">Frequently asked questions</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="definition">What is the p-value, in one sentence</h2>



<p class="wp-block-paragraph">Let&#8217;s start from an A/B test on two ad headlines: 4,000 impressions each, 200 clicks for headline A (CTR 5.0%) and 240 for headline B (CTR 6.0%). The test on the two proportions returns p = 0.0498.</p>



<p class="wp-block-paragraph">To read that number we need a small exercise of imagination. <strong>Suppose the two headlines are identical</strong>, that the CTR difference does not exist and everything depends on the chance of clicks. That is the null hypothesis. In that imaginary world, how likely would we be to see a difference at least as large as the one observed, 40 clicks out of 4,000? The answer is the p-value: about 5%.</p>



<p class="wp-block-paragraph">As a formula, with H₀ the null hypothesis:</p>



<p class="wp-block-paragraph">\(p = P(\text{data at least this extreme} \mid H_0 \text{ true})\)</p>



<p class="wp-block-paragraph">The vertical bar reads &#8220;assuming that&#8221;: the whole calculation happens <em>inside</em> the null hypothesis. <strong>The p-value measures how surprising the data would be if the effect did not exist.</strong> It says nothing, directly, about how likely it is that the effect exists. And it is precisely here that almost all the misconceptions are born.</p>



<p class="wp-block-paragraph">One consequence that few people know, but which helps to understand everything else: when the null hypothesis is true, <strong>the p-value is a random number between 0 and 1, with a uniform distribution</strong>. A p of 0.03 is exactly as rare as a p of 0.73. We check it by simulating 10,000 A/B tests in which B changes nothing, and another 10,000 in which B takes the CTR from 5% to 6%:</p>



<pre class="wp-block-code"><code>one_test &lt;- function(ctr_b, n = 4000, ctr_a = 0.05) {
  clicks_a &lt;- rbinom(1, n, ctr_a)
  clicks_b &lt;- rbinom(1, n, ctr_b)
  prop.test(c(clicks_a, clicks_b), c(n, n), correct = FALSE)$p.value
}

set.seed(1)
p_null &lt;- replicate(10000, one_test(0.05))     # B changes nothing
p_effect &lt;- replicate(10000, one_test(0.06))   # B really works (+20%)

mean(p_null &lt; 0.05)
# [1] 0.0481
quantile(p_null, c(0.25, 0.50, 0.75))
#       25%       50%       75%
# 0.2483948 0.4970851 0.7524771
mean(p_effect &lt; 0.05)
# [1] 0.501</code></pre>



<p class="wp-block-paragraph">With no effect, 4.8% of tests fall below 0.05 (the promised 5%, give or take chance) and the quartiles fall at 0.25, 0.50 and 0.75: the distribution is flat. With a real effect, the p-values pile up near zero, but <strong>only half fall below 0.05</strong>: with 4,000 impressions per variant, the test&#8217;s <a href="https://www.gironi.it/blog/en/effect-size-and-power-analysis/">power</a> is 50%.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/p-value-distribuzione-en.png" alt="The distribution of the p-values of 10,000 A/B tests. If B changes nothing (left) the p-values are uniform between 0 and 1, and about 5% fall below 0.05 by pure chance. If B really works (right) they pile up near zero, but with 50% power half stay above the threshold."/><figcaption class="wp-element-caption">The distribution of the p-values of 10,000 A/B tests. If B changes nothing (left) the p-values are uniform between 0 and 1, and about 5% fall below 0.05 by pure chance. If B really works (right) they pile up near zero, but with 50% power half stay above the threshold.</figcaption></figure>



<h2 class="wp-block-heading" id="probability">Misconception 1: &#8220;there is a 97% probability that it works&#8221;</h2>



<p class="wp-block-paragraph">It is the most widespread misconception, and the most costly. A p of 0.03 does not mean there is a 3% probability that the null hypothesis is true, nor 97% that the effect exists. The p-value is the probability of the data assuming H₀, not the probability of H₀ given the data. It sounds like wordplay, but the two probabilities can be miles apart.</p>



<p class="wp-block-paragraph">An example from outside statistics. The probability that a person is American, given that they are the President of the United States, is very high. The probability that they are the President, given that they are American, is about one in three hundred and thirty million. Inverting the &#8220;given that&#8221; changes everything.</p>



<p class="wp-block-paragraph">To get from the p-value to the question we really care about (does this idea work?) one ingredient is missing: <strong>how many of the ideas we put to the test work in the first place</strong>. Let&#8217;s take a realistic example. We test 1,000 ideas (headlines, meta descriptions, calls to action) and only one in ten really works; the other nine are neutral. I simulate it in R with the same tests as before:</p>



<pre class="wp-block-code"><code>set.seed(5)
works &lt;- seq_len(1000) &lt;= 100        # the good ideas are 100 out of 1,000
p_ideas &lt;- sapply(works, function(w) one_test(ifelse(w, 0.06, 0.05)))

table(significant = p_ideas &lt; 0.05, works)

#            works
# significant FALSE TRUE
#       FALSE   853   50
#       TRUE     47   50</code></pre>



<p class="wp-block-paragraph">Out of 1,000 tests, 97 come out &#8220;significant&#8221;. Fifty are ideas that really work; <strong>47 are neutral ideas that passed the threshold by chance</strong>. Among our &#8220;winners&#8221;, almost one in two is a false alarm, and yet each of them had a p-value below 0.05.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/p-value-mille-idee-en.png" alt="A thousand ideas tested, one in ten really works. Each small square is a test. The winners are the blue squares (true discoveries) and the orange ones (false alarms): they are almost the same number. The good ideas lost because the test did not see them (light blue) are as many as those found."/><figcaption class="wp-element-caption">A thousand ideas tested, one in ten really works. Each small square is a test. The winners are the blue squares (true discoveries) and the orange ones (false alarms): they are almost the same number. The good ideas lost because the test did not see them (light blue) are as many as those found.</figcaption></figure>



<p class="wp-block-paragraph">The sum can also be done without simulation. If π is the share of ideas that work, α the threshold and the power the probability of spotting a good idea, the share of false alarms among the significant results is:</p>



<p class="wp-block-paragraph">\(\text{share of false alarms} = \frac{\alpha \, (1 &#8211; \pi)}{\alpha \, (1 &#8211; \pi) + \text{power} \cdot \pi}\)</p>



<p class="wp-block-paragraph">The numerator is the neutral ideas that pass by chance (5% of the 900); the denominator adds the good ideas the test recognises (50% of the 100). With our numbers: 45 / (45 + 50) = <strong>47%</strong>. The table shows how the share changes as the two unknowns vary:</p>



<figure class="wp-block-table"><table><thead><tr><th>Ideas that work (π)</th><th>Power 50%</th><th>Power 80%</th></tr></thead><tbody><tr><td>10%</td><td>47.4%</td><td>36.0%</td></tr><tr><td>25%</td><td>23.1%</td><td>15.8%</td></tr><tr><td>50%</td><td>9.1%</td><td>5.9%</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong>The same 5% threshold produces 6% of false alarms or almost half, depending on how good the ideas we test are and how much data we have.</strong> It is the same mechanism as the <em>base rate fallacy</em> in medical tests: an accurate test for a rare disease still produces an avalanche of false positives.</p>



<h2 class="wp-block-heading" id="effect">Misconception 2: &#8220;a small p means a large effect&#8221;</h2>



<p class="wp-block-paragraph">The p-value mixes two things: how large the effect is and how much data we have. With abundant data, even a minuscule effect produces a minuscule p-value.</p>



<p class="wp-block-paragraph">Search Console offers the opportunity every day. Two sections of the site, 10 million impressions each, CTR of 5.00% against 5.05%: a difference of five hundredths of a point, 1% in relative terms. I compute it in R:</p>



<pre class="wp-block-code"><code>n &lt;- 1e7
prop.test(c(n * 0.05, n * 0.0505), c(n, n), correct = FALSE)$p.value
# [1] 3.091392e-07</code></pre>



<p class="wp-block-paragraph">A p-value of 0.0000003: &#8220;highly significant&#8221;. But the effect is worth five clicks every ten thousand impressions, and almost certainly does not justify any decision. <strong>Significant, in statistics, means &#8220;unlikely to be due to chance&#8221;, not &#8220;important&#8221;.</strong> For importance we need to look at the size of the effect, the subject of the article on <a href="https://www.gironi.it/blog/en/effect-size-and-power-analysis/">effect size and power analysis</a>.</p>



<h2 class="wp-block-heading" id="absence">Misconception 3: &#8220;p above 0.05, so there is no effect&#8221;</h2>



<p class="wp-block-paragraph">It is the mirror-image misconception, and just as dangerous. Let&#8217;s go back to the figure with the two distributions: when B really works, with 4,000 impressions per variant, half the tests stay above 0.05. A &#8220;non-significant&#8221; p-value means that <strong>the data are not enough to rule out chance</strong>, not that the effect has been ruled out.</p>



<p class="wp-block-paragraph">An example. Headline A: 200 clicks out of 4,000. Headline B: 222 clicks out of 4,000, an 11% higher CTR. I check it in R, looking at the confidence interval too:</p>



<pre class="wp-block-code"><code>t &lt;- prop.test(c(222, 200), c(4000, 4000), correct = FALSE)
t$p.value
# [1] 0.271176
t$conf.int     # CTR difference, B minus A
# [1] -0.004295887  0.015295887</code></pre>



<p class="wp-block-paragraph">The p-value is 0.27, well above the threshold. But the <a href="https://www.gironi.it/blog/en/confidence-intervals-what-they-are-how-to-calculate-them-and-what-they-do-not-mean/">confidence interval</a> of the difference runs from −0.43 to +1.53 CTR points: in relative terms, from an 8.6% drop to a <strong>30.6% increase</strong>. The data are compatible with a slightly worse headline and with a clearly better one. Concluding &#8220;headline B is useless&#8221; would be like saying a match not yet played ended in a draw.</p>



<p class="wp-block-paragraph"><strong>Absence of evidence is not evidence of absence.</strong> The test is telling us that more data are needed, not that the idea should be thrown away.</p>



<h2 class="wp-block-heading" id="threshold">Misconception 4: &#8220;0.05 is the line between true and false&#8221;</h2>



<p class="wp-block-paragraph">The 0.05 threshold does not come from nature: it comes from Ronald Fisher, who in the 1920s proposed it as a convenient reference, one in twenty, to flag results that deserved a closer look. Over time it became a bureaucratic boundary, with a curious effect: a result with p = 0.049 gets published, communicated, applied; one with p = 0.051 ends up in the drawer.</p>



<p class="wp-block-paragraph">How different, in the data, are those two results? In our A/B test with 200 clicks for headline A, if headline B gets 239 the p-value is 0.0555; if it gets 240 it is 0.0498. <strong>One click in eight thousand separates &#8220;not significant&#8221; from &#8220;significant&#8221;.</strong> No reasonable person would change their mind over one click, yet the threshold asks us to.</p>



<p class="wp-block-paragraph">In 2016 the American Statistical Association published an official statement on the p-value, the first time in its 177-year history that it had taken a position on a single aspect of statistical practice. It contains six principles. Three say exactly what we have seen: the p-value does not measure the probability that the hypothesis is true; it does not measure the size of an effect; conclusions should not be based only on whether a threshold is passed.</p>



<p class="wp-block-paragraph">There is then a fourth way of going wrong, the subtlest, because it concerns not the reading of the p-value but the way we arrive at it. The p-value holds <strong>for a test decided before looking at the data</strong>. If we try many metrics, many segments, many stopping points, and keep the result that passes the threshold, the 5% no longer holds. It is what we saw when discussing <a href="https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/">multiple comparisons</a> and <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/">peeking</a>, and it even has an unflattering name: <em>p-hacking</em>.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1.5rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-flow wp-block-group-is-layout-flow">
<p>A warning: <strong>a p-value below 0.05 is not a promise, it is a clue.</strong> <br>It says the data would be surprising if the effect did not exist. How convincing that clue is depends on things the p-value does not contain: how many of the ideas we test really work, how much power the test had, how many other tests we ran before finding it.</p>
</div></div>



<h2 class="wp-block-heading" id="what-to-report">What should we report, then?</h2>



<p class="wp-block-paragraph">The p-value should not be thrown away: it answers its own question, the one about chance, honestly. We just need to stop making it answer questions that are not its business. In day-to-day practice, a well-reported result has three parts.</p>



<p class="wp-block-paragraph"><strong>The size of the effect</strong>, in the unit that matters for the business: CTR points, clicks per month, euros. Not &#8220;significant&#8221;, but &#8220;+0.8 CTR points, about 1,200 more clicks a month&#8221;.</p>



<p class="wp-block-paragraph"><strong>The confidence interval</strong>, which says how uncertain we are about that size: &#8220;between +0.1 and +1.5 points&#8221;. It contains everything that is in the p-value (if it excludes zero, the test is significant) and a great deal more.</p>



<p class="wp-block-paragraph"><strong>The context of the test</strong>: was it the only planned test, or one of twenty? Was the power sufficient? How promising were the ideas to begin with?</p>



<p class="wp-block-paragraph">Anyone who wants to answer directly the question &#8220;what is the probability that B is better than A?&#8221; has to change approach: it is the territory of the <a href="https://www.gironi.it/blog/en/bayesian-ab-testing/">Bayesian A/B test</a>, which does answer that question, provided we declare our starting assumptions.</p>



<h2 class="wp-block-heading" id="faq">Frequently asked questions</h2>



<h3 class="wp-block-heading">What does a p-value of 0.05 mean?</h3>



<p class="wp-block-paragraph">It means that, if the effect did not exist, we would observe data at least this extreme in about 5 cases out of 100. It does not mean there is a 95% probability that the effect exists, nor a 5% probability that the result is due to chance.</p>



<h3 class="wp-block-heading">Is the p-value the probability that the null hypothesis is true?</h3>



<p class="wp-block-paragraph">No. It is the probability of the data assuming the null hypothesis is true, not the probability of the null hypothesis given the data. To get the latter we also need to know how plausible the hypothesis was before the test: with unpromising ideas, even among results with p below 0.05 false alarms can be almost half.</p>



<h3 class="wp-block-heading">Does a very small p-value indicate an important effect?</h3>



<p class="wp-block-paragraph">No. The p-value depends both on the size of the effect and on the amount of data: with millions of impressions, even an irrelevant CTR difference produces a minuscule p-value. Importance is judged from the size of the effect and its confidence interval.</p>



<h3 class="wp-block-heading">If the p-value is greater than 0.05, does the effect not exist?</h3>



<p class="wp-block-paragraph">Not necessarily. It means the data are not enough to rule out chance. If the test had little power, a real effect can easily produce a high p-value: the confidence interval shows which effects are still compatible with the data.</p>



<h3 class="wp-block-heading">Why 0.05, of all numbers?</h3>



<p class="wp-block-paragraph">It is a convention proposed by Ronald Fisher in the 1920s as a convenient reference, a probability of one in twenty. It has no natural foundation: a result with p = 0.049 and one with p = 0.051 carry practically the same evidence.</p>



<script type="application/ld+json">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What does a p-value of 0.05 mean?", "acceptedAnswer": {"@type": "Answer", "text": "It means that, if the effect did not exist, we would observe data at least this extreme in about 5 cases out of 100. It does not mean there is a 95% probability that the effect exists, nor a 5% probability that the result is due to chance."}}, {"@type": "Question", "name": "Is the p-value the probability that the null hypothesis is true?", "acceptedAnswer": {"@type": "Answer", "text": "No. It is the probability of the data assuming the null hypothesis is true, not the probability of the null hypothesis given the data. To get the latter we also need to know how plausible the hypothesis was before the test: with unpromising ideas, even among results with p below 0.05 false alarms can be almost half."}}, {"@type": "Question", "name": "Does a very small p-value indicate an important effect?", "acceptedAnswer": {"@type": "Answer", "text": "No. The p-value depends both on the size of the effect and on the amount of data: with millions of impressions, even an irrelevant CTR difference produces a minuscule p-value. Importance is judged from the size of the effect and its confidence interval."}}, {"@type": "Question", "name": "If the p-value is greater than 0.05, does the effect not exist?", "acceptedAnswer": {"@type": "Answer", "text": "Not necessarily. It means the data are not enough to rule out chance. If the test had little power, a real effect can easily produce a high p-value: the confidence interval shows which effects are still compatible with the data."}}, {"@type": "Question", "name": "Why 0.05, of all numbers?", "acceptedAnswer": {"@type": "Answer", "text": "It is a convention proposed by Ronald Fisher in the 1920s as a convenient reference, a probability of one in twenty. It has no natural foundation: a result with p = 0.049 and one with p = 0.051 carry practically the same evidence."}}]}</script>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">The article&#8217;s code lends itself to a few revealing experiments.</p>



<ul class="wp-block-list"><li>Raise the share of good ideas from 100 to 500 out of 1,000 (<code>works &lt;- seq_len(1000) &lt;= 500</code>) and recompute the table: how many of the &#8220;winners&#8221; are false alarms now? The result should approach the table&#8217;s 9%.</li><li>Increase the impressions per variant from 4,000 to 16,000 in the <code>one_test()</code> function: what happens to the power, and to the share of false alarms among the significant ideas?</li><li>Repeat the <code>p_null</code> simulation and count the tests with p between 0.04 and 0.05, and those between 0.94 and 0.95. Why are they about the same number?</li></ul>



<p class="wp-block-paragraph">The last exercise is the shortest, and perhaps the most instructive: when there is no effect, an &#8220;almost significant&#8221; p-value is no closer to the truth than a very high one.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">We have seen that, among significant results, the share of false alarms depends on how good the ideas we test are. But there is an even more insidious way of being fooled by successes: looking only at the ones that made it. The case studies we read tell of the strategies that worked, never of the hundred identical ones that failed. It is <a href="https://www.gironi.it/blog/en/survivorship-bias-case-studies/"><em>survivorship bias</em></a>, and it is the next step of our journey through the traps of data.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">On the p-value, its uses and its abuses, I recommend <a href="https://www.amazon.it/dp/0241258766?tag=consulenzeinf-21&amp;ascsubtag=p-value-en" rel="nofollow sponsored noopener" target="_blank"><em>The Art of Statistics</em></a> by David Spiegelhalter: it explains what a significance test really says, why so many &#8220;significant&#8221; results do not replicate and how to reason about uncertainty without being dazzled by a threshold.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/p-value-misconceptions/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Multiple Comparisons: Why Out of 200 Queries Some Always «Improve» by Chance</title>
		<link>https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/</link>
					<comments>https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Wed, 07 Oct 2026 08:06:02 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=4507</guid>

					<description><![CDATA[In 2009 a group of neuroscientists put a salmon in a functional MRI scanner, showed it a series of photographs of people in social situations and &#8220;asked&#8221; it what emotions they were feeling. The analysis found a small area of the brain with statistically significant activity. The catch is that the salmon was dead. The &#8230; <a href="https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/" class="more-link">Continue reading<span class="screen-reader-text"> "Multiple Comparisons: Why Out of 200 Queries Some Always «Improve» by Chance"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In 2009 a group of neuroscientists put a salmon in a functional MRI scanner, showed it a series of photographs of people in social situations and &#8220;asked&#8221; it what emotions they were feeling. The analysis found a small area of the brain with statistically significant activity. The catch is that the salmon was dead. <br>The study was a very serious joke (it won an Ig Nobel in 2012), and its target was not MRI: it was a widespread methodological error. <strong>With enough tests, something &#8220;significant&#8221; turns up anyway.</strong> The scanner had compared tens of thousands of small brain volumes, one by one, each at the 5% threshold.</p>



<p class="wp-block-paragraph">In our line of work the dead salmon has a familiar name: Search Console. We compare this month&#8217;s CTR with last month&#8217;s across 200 queries, find a dozen &#8220;significant&#8221; changes and write in the report which queries improved. But how many of those twelve really changed? This is the problem of <strong>multiple comparisons</strong>, and we have already seen its outline when talking about <a href="https://www.gironi.it/blog/en/seo-split-testing/">SEO split testing</a> and <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/">peeking</a>.</p>



<span id="more-4507"></span>



<p class="wp-block-paragraph"><strong>In short:</strong> when many statistical tests are run together, the 5% threshold no longer holds for the whole set: with 10 independent tests the probability of at least one false positive rises to 40%, with 100 to 99%. <strong>Multiple comparisons</strong> are corrected in two ways. The <strong>Bonferroni correction</strong> (or its improved version, the <strong>Holm</strong> method) controls the probability of making even a single false positive, by dividing the threshold by the number of tests: it is cautious, but loses many real effects. The <strong>Benjamini-Hochberg</strong> method instead controls the <strong>share of false positives among the discoveries</strong> (the <em>false discovery rate</em>): it is less strict and better suited to sifting through many pages or queries. In R we use <code>p.adjust()</code>.</p>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#why">Why do more tests mean more false alarms?</a></li><li><a href="#example">An example: 200 queries in Search Console</a></li><li><a href="#bonferroni">The Bonferroni correction (and Holm&#8217;s)</a></li><li><a href="#fwer-fdr">Family-wise error or share of false discoveries?</a></li><li><a href="#benjamini-hochberg">The Benjamini-Hochberg method</a></li><li><a href="#which">Which correction should we choose?</a></li><li><a href="#where">Where multiple comparisons hide in SEO</a></li><li><a href="#faq">Frequently asked questions</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="why">Why do more tests mean more false alarms?</h2>



<p class="wp-block-paragraph">Let&#8217;s recall what the 5% threshold promises in a <a href="https://www.gironi.it/blog/en/hypothesis-testing-a-step-by-step-guide/">hypothesis test</a>: if there is no effect, the test will give us a false alarm 5 times in 100. It is a promise that holds <strong>for one test</strong>. The trouble begins when we run many of them, and <em>any one</em> turning out significant is enough for us to raise our hand.</p>



<p class="wp-block-paragraph">A quick example. Rolling a die once, the probability of a 6 is one in six; rolling it ten times, the probability of <em>at least one</em> 6 rises to 84%. Tests work the same way. If one test alone has a 95% probability of not giving a false alarm, two independent tests have 95% of 95%, and so on. The probability of <strong>at least one false positive</strong> across m independent tests, which statisticians call the <em>family-wise error rate</em> (FWER), is therefore:</p>



<p class="wp-block-paragraph">\(FWER = 1 &#8211; (1 &#8211; \alpha)^m\)</p>



<p class="wp-block-paragraph">where α is the threshold of the single test (0.05) and m the number of tests. With 10 tests it is 1 − 0.95¹⁰ = <strong>40%</strong>; with 14 it passes 50%; with 100 it reaches 99.4%.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/confronti-multipli-fwer-en.png" alt="The probability of at least one false positive as the number of tests grows, each at the 5% threshold. With just 14 tests a false alarm is more likely than not; with 100 it is practically certain."/><figcaption class="wp-element-caption">The probability of at least one false positive as the number of tests grows, each at the 5% threshold. With just 14 tests a false alarm is more likely than not; with 100 it is practically certain.</figcaption></figure>



<p class="wp-block-paragraph">There is another way to see it, even more direct: across m tests with no real effect, <strong>the expected number of false positives is m × α</strong>. Across 200 queries that did not change at all, we should expect about ten &#8220;significant&#8221; changes (200 × 0.05 = 10). They are not an anomaly: they are exactly what the 5% threshold promises.</p>



<h2 class="wp-block-heading" id="example">An example: 200 queries in Search Console</h2>



<p class="wp-block-paragraph">We build the example by simulation, so we know the truth. Take 200 queries, each with a few thousand impressions a month and a CTR between 1% and 6%. For 180 of them <strong>nothing changes</strong> from one month to the next; for the first 20 the CTR really rises, by 50%. For each query we compare the two months with a test on proportions, as we would in a report.</p>



<p class="wp-block-paragraph">I generate the data in R:</p>



<pre class="wp-block-code"><code>set.seed(7)
n_queries &lt;- 200
impressions &lt;- round(300 + rlnorm(n_queries, log(5000), 0.8))  # monthly impressions
ctr &lt;- runif(n_queries, 0.01, 0.06)                             # CTR between 1% and 6%
real &lt;- seq_len(n_queries) &lt;= 20                                # the 20 that really change
clicks_before &lt;- rbinom(n_queries, impressions, ctr)
clicks_after  &lt;- rbinom(n_queries, impressions, ifelse(real, ctr * 1.5, ctr))

# one test per query: last month's CTR against this month's
p &lt;- mapply(function(a, b, n) prop.test(c(a, b), c(n, n))$p.value,
            clicks_before, clicks_after, impressions)</code></pre>



<p class="wp-block-paragraph">Now let&#8217;s do what is usually done: flag as &#8220;changed&#8221; the queries with a p-value below 0.05, and compare with the truth.</p>



<pre class="wp-block-code"><code>table(discovery = p &lt; 0.05, real)

#          real
# discovery FALSE TRUE
#     FALSE   171    2
#     TRUE      9   18</code></pre>



<p class="wp-block-paragraph">The test flagged <strong>27 queries</strong>. Eighteen really changed, but <strong>nine did not</strong>: they are false alarms, exactly 5% of the 180 unchanged queries. In plainer terms: <strong>a third of our report&#8217;s &#8220;discoveries&#8221; is noise</strong>, and nothing in the numbers tells us which third. If we devoted an analysis, an intervention or a line of commentary to the client to each of those nine queries, we would be working on a ghost.</p>



<h2 class="wp-block-heading" id="bonferroni">The Bonferroni correction (and Holm&#8217;s)</h2>



<p class="wp-block-paragraph">The oldest solution, and the most intuitive, is to make every single test stricter. If we want the probability of <em>at least one</em> false positive over the whole family of tests to stay at 5%, it is enough to divide the threshold by the number of tests. This is the <strong>Bonferroni correction</strong>:</p>



<p class="wp-block-paragraph">\(\alpha_{Bonferroni} = \frac{\alpha}{m} = \frac{0.05}{200} = 0.00025\)</p>



<p class="wp-block-paragraph">A query has &#8220;changed&#8221; only if its p-value falls below 0.00025. In R there is no need to do the division by hand: <code>p.adjust()</code> returns <strong>adjusted</strong> p-values, to be compared with the usual threshold. I check it in R:</p>



<pre class="wp-block-code"><code>table(discovery = p.adjust(p, method = "bonferroni") &lt; 0.05, real)

#          real
# discovery FALSE TRUE
#     FALSE   180    6
#     TRUE      0   14</code></pre>



<p class="wp-block-paragraph">No false alarms: the 14 queries flagged are all real. The price, though, shows in the top right-hand corner. <strong>Six queries that really changed have vanished from the radar</strong>, because a 50% rise on a few hundred clicks is not enough to fall below such a low threshold. Bonferroni buys the certainty of not being wrong with blindness to part of the real effects.</p>



<p class="wp-block-paragraph">There is a refinement that costs nothing, the <strong>Holm method</strong>. Sort the p-values from smallest to largest; compare the first with α/m, the second with α/(m − 1), the third with α/(m − 2), and stop at the first one that does not pass its threshold. It guarantees the same control as Bonferroni and is <strong>always at least as powerful</strong>, often more so. In our example it gives the same result (14 discoveries, none false); with <code>p.adjust(p, method = "holm")</code> there is no reason to prefer Bonferroni over it.</p>



<h2 class="wp-block-heading" id="fwer-fdr">Family-wise error or share of false discoveries?</h2>



<p class="wp-block-paragraph">Before going further, it is worth pausing on a question that sounds philosophical and is in fact practical: <em>what</em> do we want to control?</p>



<p class="wp-block-paragraph">Bonferroni and Holm control the probability of making <strong>even one</strong> false positive. That is the right requirement when every error is costly: we decide to redo the template of every product page on the strength of a test, and a false positive means weeks of wasted work.</p>



<p class="wp-block-paragraph">But in many SEO analyses the situation is different. We sift through 200 queries or 500 pages to understand <strong>which deserve a closer look</strong>. Here a false positive is not a catastrophe: it is one more glance at a page that did not need it. What we do not want is for the discoveries to be <em>mostly</em> false alarms. The quantity to control then becomes the <strong>share of false positives among the discoveries</strong>, which statisticians call the <em>false discovery rate</em> (FDR). An FDR of 5% means: of the queries we flag, on average 5% will be noise.</p>



<p class="wp-block-paragraph"><strong>They are two different promises, and the second is far less demanding than the first</strong>: that is why, with the same data, it lets through more real effects.</p>



<h2 class="wp-block-heading" id="benjamini-hochberg">The Benjamini-Hochberg method</h2>



<p class="wp-block-paragraph">The method that controls the FDR, proposed by Yoav Benjamini and Yosef Hochberg in 1995, is surprisingly simple. In words: we sort the p-values from smallest to largest and, instead of a fixed threshold, we use a threshold that <strong>grows with the position in the ranking</strong>. The p-value in first place must be below 1/m × 0.05, the one in second place below 2/m × 0.05, and so on:</p>



<p class="wp-block-paragraph">\(p_{(i)} \le \frac{i}{m} \cdot q\)</p>



<p class="wp-block-paragraph">where p₍ᵢ₎ is the p-value in position i, m the number of tests and q the FDR we accept (here 0.05). We look for the <strong>lowest position in the ranking</strong> (the largest p-value) that satisfies the inequality: that query and all those before it are the discoveries.</p>



<p class="wp-block-paragraph">Let&#8217;s look at the p-values of our example from position 14 to 20, each with its threshold:</p>



<figure class="wp-block-table"><table><thead><tr><th>Rank</th><th>p-value</th><th>BH threshold (rank/200 × 0.05)</th><th>CTR really changed?</th></tr></thead><tbody><tr><td>14</td><td>0.000018</td><td>0.00350</td><td>yes</td></tr><tr><td>15</td><td>0.00070</td><td>0.00375</td><td>yes</td></tr><tr><td>16</td><td>0.00124</td><td>0.00400</td><td>yes</td></tr><tr><td><strong>17</strong></td><td><strong>0.00356</strong></td><td><strong>0.00425</strong></td><td><strong>no</strong></td></tr><tr><td>18</td><td>0.00531</td><td>0.00450</td><td>yes</td></tr><tr><td>19</td><td>0.00683</td><td>0.00475</td><td>no</td></tr><tr><td>20</td><td>0.00720</td><td>0.00500</td><td>yes</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The last position below its threshold is 17: the first 17 queries are the discoveries. Bonferroni, with its fixed threshold of 0.00025, would have stopped at 14. I check it in R:</p>



<pre class="wp-block-code"><code>table(discovery = p.adjust(p, method = "BH") &lt; 0.05, real)

#          real
# discovery FALSE TRUE
#     FALSE   179    4
#     TRUE      1   16</code></pre>



<p class="wp-block-paragraph">Seventeen discoveries, <strong>sixteen real and one false</strong>: one false alarm in seventeen, 5.9%, in line with the promised 5%. Compared with Bonferroni we have recovered two queries that really changed, at the price of a single error. And compared with the uncorrected report we have gone from nine false alarms in 27 to one in 17.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/confronti-multipli-pvalue-en.png" alt="The 40 smallest p-values of the 200 queries, in order, coloured by the truth. The flat 5% threshold also catches many queries that did not change. The Bonferroni threshold is so low that it leaves several real ones out. The Benjamini-Hochberg line rises with the rank and cuts after the seventeenth."/><figcaption class="wp-element-caption">The 40 smallest p-values of the 200 queries, in order, coloured by the truth. The flat 5% threshold also catches many queries that did not change. The Bonferroni threshold is so low that it leaves several real ones out. The Benjamini-Hochberg line rises with the rank and cuts after the seventeenth.</figcaption></figure>



<p class="wp-block-paragraph">The chart shows the logic well. At the top of the ranking are the large effects, with minuscule p-values: any method finds them. The problem is the middle zone, where real but modest effects mix with noise. Bonferroni discards it wholesale; the flat threshold accepts it wholesale; Benjamini-Hochberg crosses it with a threshold that widens just enough.</p>



<p class="wp-block-paragraph">(n.b. the method guarantees an <em>average</em> FDR of 5%: in a single report the actual share can be higher or lower. Repeating the simulation 300 times, Benjamini-Hochberg produces on average 0.6 false discoveries per report, a share of 3.7%: below the promised 5%.)</p>



<h2 class="wp-block-heading" id="which">Which correction should we choose?</h2>



<p class="wp-block-paragraph">There is no correction that is right in absolute terms: there is the one that fits the question. In short:</p>



<figure class="wp-block-table"><table><thead><tr><th>Situation</th><th>Correction</th><th>Why</th></tr></thead><tbody><tr><td>A single test, decided before looking at the data</td><td>none</td><td>there is no multiplicity</td></tr><tr><td>A few costly decisions (a template, a migration, the winning variant of an A/B/n test)</td><td>Holm</td><td>even one error is expensive</td></tr><tr><td>Many queries, pages or segments from which to choose what to investigate</td><td>Benjamini-Hochberg</td><td>what matters is the share of false discoveries, not the single error</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The most common case in day-to-day practice, however, is another one: <strong>we had not decided anything beforehand</strong>. We opened Search Console, filtered, segmented, compared, and stopped at the numbers that looked interesting. In this scenario no correction is possible, because we do not even know how many tests we really ran.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1.5rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-flow wp-block-group-is-layout-flow">
<p>A warning: <strong>a multiple comparisons correction is valid only if it counts all the tests we ran, not just the ones we show.</strong> <br>If we looked at 30 segments and report 5, the correction must be made on 30. If we tried three different ways of computing the metric and keep the one that &#8220;works&#8221;, there are three times as many tests. The simplest way not to get lost is to decide beforehand, and put in writing, which comparisons we will make.</p>
</div></div>



<h2 class="wp-block-heading" id="where">Where multiple comparisons hide in SEO</h2>



<p class="wp-block-paragraph">The month-on-month query report is the most obvious case, but multiplicity comes in many disguises.</p>



<p class="wp-block-paragraph"><strong>Segments.</strong> &#8220;CTR has not changed, but on mobile in Lombardy it has&#8221;: slice the data enough ways by device, country, category and page type, and some significant slice always turns up. It is the same trap as <a href="https://www.gironi.it/blog/en/simpsons-paradox-in-seo-when-aggregate-data-can-lie/">Simpson&#8217;s paradox</a>, seen from the opposite side.</p>



<p class="wp-block-paragraph"><strong>Metrics.</strong> In an <a href="https://www.gironi.it/blog/en/seo-split-testing/">SEO split test</a>, looking at clicks, impressions, CTR, position and conversions means running five tests. That is why the primary metric is chosen in advance.</p>



<p class="wp-block-paragraph"><strong>Variants.</strong> An <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">A/B test</a> with five variants against the control is five comparisons; if we also compare the variants with each other they become fifteen. For comparisons between all pairs of means, <a href="https://www.gironi.it/blog/en/analysis-of-variance-anova-explained-simply/">ANOVA</a> has a dedicated tool of its own, Tukey&#8217;s test.</p>



<p class="wp-block-paragraph"><strong>Time.</strong> Looking at a test every day while reserving the right to stop it is a multiplicity hidden in the calendar: it is the <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/">peeking problem</a>.</p>



<p class="wp-block-paragraph"><strong>Correlations.</strong> A <a href="https://www.gironi.it/blog/en/correlation/">correlation</a> matrix among 20 metrics contains 190 pairs: about ten will turn out &#8220;significant&#8221; even if the metrics were all independent.</p>



<p class="wp-block-paragraph">(n.b. in reality things are even worse than in our simulation. A query&#8217;s CTR does not fluctuate only because of the binomial chance of clicks: it changes with position, with SERP features, with the device mix. <code>prop.test()</code> ignores these sources of variability and underestimates the uncertainty, so on real data false alarms exceed 5%. Multiple comparisons corrections do not fix a wrong model: they assume it is right.)</p>



<h2 class="wp-block-heading" id="faq">Frequently asked questions</h2>



<h3 class="wp-block-heading">What are multiple comparisons in statistics?</h3>



<p class="wp-block-paragraph">They are situations in which many statistical tests are run on the same data or the same question: comparing many queries, many segments, many metrics or many variants. Each test has its own probability of a false positive, and together the probabilities add up: with enough tests, some &#8220;significant&#8221; result arrives by pure chance.</p>



<h3 class="wp-block-heading">How is the Bonferroni correction applied?</h3>



<p class="wp-block-paragraph">Divide the significance threshold by the number of tests: with 20 tests and a 5% threshold, each test needs a p-value below 0.05 / 20 = 0.0025. Equivalently, multiply each p-value by the number of tests and compare it with 0.05. In R, <code>p.adjust(p, method = "bonferroni")</code> does it.</p>



<h3 class="wp-block-heading">What is the difference between FWER and FDR?</h3>



<p class="wp-block-paragraph">The FWER (family-wise error rate) is the probability of making at least one false positive across the set of tests; the FDR (false discovery rate) is the average share of false positives among the results declared significant. Controlling the FWER is stricter and suited to a few costly decisions; controlling the FDR suits the exploration of many hypotheses.</p>



<h3 class="wp-block-heading">When should we use Benjamini-Hochberg instead of Bonferroni?</h3>



<p class="wp-block-paragraph">When the tests are many and the aim is to select what to investigate, accepting that a small share of the discoveries is false: analyses of hundreds of queries or pages, screening of segments, anomaly searches. In these cases Bonferroni is so strict that it loses a good part of the real effects.</p>



<h3 class="wp-block-heading">Is the correction needed even with few tests?</h3>



<p class="wp-block-paragraph">With two or three tests the overall risk grows only a little (from 5% to about 10-14%), but it grows. If the comparisons were decided in advance and are few, the Holm method costs very little in power and keeps us safe. If instead a single primary comparison was decided in advance, no correction is needed.</p>



<script type="application/ld+json">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What are multiple comparisons in statistics?", "acceptedAnswer": {"@type": "Answer", "text": "They are situations in which many statistical tests are run on the same data or the same question: comparing many queries, many segments, many metrics or many variants. Each test has its own probability of a false positive, and together the probabilities add up: with enough tests, some \"significant\" result arrives by pure chance."}}, {"@type": "Question", "name": "How is the Bonferroni correction applied?", "acceptedAnswer": {"@type": "Answer", "text": "Divide the significance threshold by the number of tests: with 20 tests and a 5% threshold, each test needs a p-value below 0.05 / 20 = 0.0025. Equivalently, multiply each p-value by the number of tests and compare it with 0.05. In R, p.adjust(p, method = \"bonferroni\") does it."}}, {"@type": "Question", "name": "What is the difference between FWER and FDR?", "acceptedAnswer": {"@type": "Answer", "text": "The FWER (family-wise error rate) is the probability of making at least one false positive across the set of tests; the FDR (false discovery rate) is the average share of false positives among the results declared significant. Controlling the FWER is stricter and suited to a few costly decisions; controlling the FDR suits the exploration of many hypotheses."}}, {"@type": "Question", "name": "When should we use Benjamini-Hochberg instead of Bonferroni?", "acceptedAnswer": {"@type": "Answer", "text": "When the tests are many and the aim is to select what to investigate, accepting that a small share of the discoveries is false: analyses of hundreds of queries or pages, screening of segments, anomaly searches. In these cases Bonferroni is so strict that it loses a good part of the real effects."}}, {"@type": "Question", "name": "Is the correction needed even with few tests?", "acceptedAnswer": {"@type": "Answer", "text": "With two or three tests the overall risk grows only a little (from 5% to about 10-14%), but it grows. If the comparisons were decided in advance and are few, the Holm method costs very little in power and keeps us safe. If instead a single primary comparison was decided in advance, no correction is needed."}}]}</script>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">The example code is complete, and lends itself to a few experiments.</p>



<ul class="wp-block-list"><li>Set the number of queries that really change to 0 (<code>real &lt;- rep(FALSE, n_queries)</code>) and count the discoveries with and without correction. Without correction there should be about ten; with Bonferroni and with Benjamini-Hochberg almost always zero. This is the dead salmon case.</li><li>Bring the CTR increase down from 1.5 to 1.2: a smaller effect, more realistic for a rewritten title. How many of the 20 real queries does each method find?</li><li>Repeat the simulation with 2,000 queries instead of 200, still with 20 real ones. What happens to the Bonferroni threshold, and how many real queries survive?</li></ul>



<p class="wp-block-paragraph">The last exercise, in particular, shows why large sites need the FDR: the more tests we run, the blinder Bonferroni makes us.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">We have talked about p-values throughout the article: correcting them, ranking them, comparing them with different thresholds. But what exactly does a p-value of 0.03 <em>say</em>? That there is a 97% probability the effect is real? That the null hypothesis has a 3% probability of being true? Neither, and it is perhaps the most misunderstood number in all of applied statistics. It will be the subject of the next step: <strong><a href="https://www.gironi.it/blog/en/p-value-misconceptions/">the p-value and its misconceptions</a></strong>.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">To understand why so many «significant» results do not stand up to the facts, and how much the number of questions we have put to the data matters, I recommend <a href="https://www.amazon.it/dp/0241258766?tag=consulenzeinf-21&amp;ascsubtag=multiple-comparisons" rel="nofollow sponsored noopener" target="_blank"><em>The Art of Statistics</em></a> by David Spiegelhalter: a book that explains uncertainty without needless formulas and without shortcuts. For multiple comparisons in online experiments, from metrics to variants, the reference remains <a href="https://www.amazon.it/dp/1108724264?tag=consulenzeinf-21&amp;ascsubtag=multiple-comparisons" rel="nofollow sponsored noopener" target="_blank"><em>Trustworthy Online Controlled Experiments</em></a> by Ron Kohavi, Diane Tang and Ya Xu.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SEO Split Testing: How to Tell Whether a Change Really Worked</title>
		<link>https://www.gironi.it/blog/en/seo-split-testing/</link>
					<comments>https://www.gironi.it/blog/en/seo-split-testing/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Tue, 06 Oct 2026 08:24:39 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=4489</guid>

					<description><![CDATA[Over the past few weeks I have reworked some of the most-read pages of this blog from top to bottom: new openings, rewritten titles, FAQ sections. In a month I will open Search Console, and the temptation will be the usual one: compare the clicks before with the clicks after. If they have gone up, &#8230; <a href="https://www.gironi.it/blog/en/seo-split-testing/" class="more-link">Continue reading<span class="screen-reader-text"> "SEO Split Testing: How to Tell Whether a Change Really Worked"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Over the past few weeks I have reworked some of the most-read pages of this blog from top to bottom: new openings, rewritten titles, FAQ sections. In a month I will open Search Console, and the temptation will be the usual one: compare the clicks before with the clicks after. <br>If they have gone up, will it be thanks to me? And if they have gone down, will I have got everything wrong? <strong>A before/after comparison cannot answer either question</strong>: in the meantime the season changes, competitors move, the algorithm shifts. One core update in the wrong week and the best work in the world looks like a disaster.</p>



<p class="wp-block-paragraph">In marketing, the classic answer to this problem is the <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">A/B test</a>: two versions, users split at random, results compared. With SEO, though, there is an insurmountable obstacle, because Google sees only one version of the page. We cannot split the users: we can split <strong>the pages</strong>. That is the idea behind <em>SEO split testing</em>, and the statistical tool that makes it reliable has a name that sounds like a tongue-twister: <strong>difference-in-differences</strong>.</p>



<span id="more-4489"></span>



<p class="wp-block-paragraph"><strong>In short:</strong> an <strong>SEO split test</strong> measures the effect of a change (titles, templates, structured data) by randomly dividing a set of similar pages into <strong>test pages</strong>, which receive the change, and <strong>control pages</strong>, which are left untouched. A before/after comparison on its own is misleading, because seasonality and core updates hit every page; <strong>difference-in-differences</strong> (DiD) subtracts the change in the control pages from the change in the test pages, and what remains is the effect of the edit. A t-test on the page-by-page changes tells us whether that effect can be distinguished from chance. The pages must be numerous and homogeneous: detecting a +8% effect typically takes a few hundred of them.</p>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#why-not-ab">Why the classic A/B test does not work for SEO</a></li><li><a href="#before-after">Why before/after comparisons mislead</a></li><li><a href="#setup">How do we set up an SEO split test?</a></li><li><a href="#example">An example: 200 product pages and a core update</a></li><li><a href="#did">What is difference-in-differences?</a></li><li><a href="#parallel-trends">How do we check that the control is a good control?</a></li><li><a href="#how-many-pages">How many pages do we need, and for how long?</a></li><li><a href="#pitfalls">The pitfalls of split testing</a></li><li><a href="#counterfactual">What if we cannot randomise?</a></li><li><a href="#faq">Frequently asked questions</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="why-not-ab">Why the classic A/B test does not work for SEO</h2>



<p class="wp-block-paragraph">In an A/B test on a landing page&#8217;s conversion rate, each visitor sees version A or version B, assigned at random. Since randomisation makes the two groups of users alike in everything else, a difference in results can be attributed to the version. Chance acts as the guarantor.</p>



<p class="wp-block-paragraph">With rankings the mechanism jams straight away. <strong>In SEO, the user who counts is only one: Googlebot.</strong> If we show it two versions of the same page depending on the visit, at best it cannot tell which one to index, at worst it is called <em>cloaking</em>. And the outcome we want to measure (rankings, impressions, organic clicks) depends on how Google evaluates <em>the</em> page, not on how an individual visitor experiences it.</p>



<p class="wp-block-paragraph">The way out is to move the randomisation one level up. Instead of users, we draw pages: one group gets the change, the other gets nothing. It works when the site has many pages <strong>built on the same template</strong>, answering the same kind of search: the product pages of an e-commerce site, the listings of a classifieds site, category pages, the city pages of a portal. A site with ten pages that are all different, it should be said at once, cannot run a split test: it does not have enough units to randomise.</p>



<h2 class="wp-block-heading" id="before-after">Why before/after comparisons mislead</h2>



<p class="wp-block-paragraph">Without a control group, the only possible comparison is with the past. The trouble is that the past differs from the present in a thousand ways that have nothing to do with our change:</p>



<ul class="wp-block-list"><li><strong>seasonality</strong>: in December people search for gifts, in August far less for everything else;</li><li><strong>algorithm updates</strong>, which move the traffic of entire sectors within a few days;</li><li><strong>competition</strong>, which in the meantime publishes, improves, disappears;</li><li><a href="https://www.gironi.it/blog/en/regression-to-the-mean/">regression to the mean</a>: if we chose to work precisely on the pages that had done worst, part of the improvement would have come anyway.</li></ul>



<p class="wp-block-paragraph">Each of these factors can be larger than the effect we are looking for. A better title might be worth 5-10% more clicks; a core update can take away 20% in a week. <strong>Comparing only before with after, we measure the sum of everything that changed, and our edit is just one piece of it, often the smallest.</strong></p>



<p class="wp-block-paragraph">The control group solves the problem at the root: control pages live through the same season, the same core update, the same competition as the test pages. The only thing that sets them apart is the change.</p>



<h2 class="wp-block-heading" id="setup">How do we set up an SEO split test?</h2>



<p class="wp-block-paragraph">The rules are few, but all of them must be respected.</p>



<p class="wp-block-paragraph"><strong>Homogeneous pages.</strong> Test and control must be pages of the same type, with the same template and the same search intent. Mixing product pages and blog posts means comparing apples with oranges.</p>



<p class="wp-block-paragraph"><strong>Random assignment.</strong> We do not choose which pages to change: we draw them. If we picked &#8220;the most promising ones&#8221;, we would introduce exactly the initial difference the test is meant to avoid. A useful refinement is <strong>paired randomisation</strong>: sort the pages by traffic, form pairs of neighbouring pages and, within each pair, toss a coin to decide which one goes into the test. The two groups are then born balanced on traffic too, which is the bulkiest factor.</p>



<p class="wp-block-paragraph"><strong>A long enough &#8220;before&#8221; period.</strong> We need it to know how the pages behave when nobody touches them, and to check that test and control move together (we will come back to this). Six to eight weeks is a good starting point.</p>



<p class="wp-block-paragraph"><strong>Decide in advance when to look.</strong> Test duration and metric are fixed beforehand. Looking at the data every morning and stopping at the first favourable signal is the <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/">peeking problem</a>, and it inflates false positives exactly as it does in an A/B test.</p>



<h2 class="wp-block-heading" id="example">An example: 200 product pages and a core update</h2>



<p class="wp-block-paragraph">As always, an example helps to clarify things. We build it by simulation, so we know the truth and can see which methods recover it.</p>



<p class="wp-block-paragraph">Let&#8217;s imagine an e-commerce site with 200 product pages on the same template. We decide to rewrite the titles of half of them, drawn in pairs. In our simulated world the change works: <strong>it brings 8% more clicks</strong>. But a week after it goes live, a core update takes 15% of clicks away from every page on the site, test and control alike. And every page, as always happens, also fluctuates on its own, going up or down a few positions.</p>



<p class="wp-block-paragraph">I build the pages in R, with paired randomisation:</p>



<pre class="wp-block-code"><code>set.seed(2020)
n_pages &lt;- 200
base &lt;- rlnorm(n_pages, meanlog = log(30), sdlog = 1)   # average clicks per day

# paired randomisation: sort by traffic and draw within each pair
ord &lt;- order(base)
group &lt;- character(n_pages)
for (k in seq(1, n_pages, by = 2)) {
  group[ord[k:(k + 1)]] &lt;- sample(c("test", "control"))
}
pages &lt;- data.frame(page = 1:n_pages, base = base, group = group,
                    drift = rlnorm(n_pages, -0.25^2 / 2, 0.25))

tapply(pages$base, pages$group, median)
#  control     test
#    32.36    31.96</code></pre>



<p class="wp-block-paragraph">Paired randomisation has done its job: the &#8220;typical&#8221; page makes about 32 clicks a day in both groups.</p>



<p class="wp-block-paragraph">Then I generate 14 weeks of daily clicks: 8 before the change and 6 after. On day 57 the new titles arrive (on the test pages only), on day 64 the core update (on all of them); at weekends traffic drops by 30%.</p>



<pre class="wp-block-code"><code>simulate_clicks &lt;- function(pages, effect = 1.08, change = 57, core_update = 64) {
  d &lt;- expand.grid(page = pages$page, day = 1:98)
  d$group &lt;- pages$group[d$page]
  lambda &lt;- pages$base[d$page] *
    ifelse(d$day %% 7 %in% c(6, 0), 0.7, 1) *                   # weekend
    ifelse(d$day &gt;= core_update, 0.85, 1) *                     # core update
    ifelse(d$day &gt;= change, pages$drift[d$page], 1) *           # drift
    ifelse(d$group == "test" &amp; d$day &gt;= change, effect, 1) *    # the change
    rlnorm(nrow(d), 0, 0.15)                                    # noise
  d$clicks &lt;- rpois(nrow(d), lambda)
  d$period &lt;- ifelse(d$day &gt;= change, "after", "before")
  d
}
clicks &lt;- simulate_clicks(pages)</code></pre>



<p class="wp-block-paragraph">(n.b. the <code>drift</code> is each page&#8217;s own fluctuation after the change: new competitors, links gained or lost, small shifts in position. It averages 1, but an individual page can gain or lose 30-40%.)</p>



<p class="wp-block-paragraph">Now let&#8217;s put ourselves in the shoes of someone who made the change and has no control group. For each page I compute the average daily clicks before and after, and their change:</p>



<pre class="wp-block-code"><code>changes &lt;- function(d) {
  m &lt;- aggregate(clicks ~ page + group + period, data = d, FUN = mean)
  w &lt;- reshape(m, idvar = c("page", "group"), timevar = "period",
               direction = "wide")
  w$delta &lt;- log(w$clicks.after / w$clicks.before)
  w
}
w &lt;- changes(clicks)

# average change of the test pages, as a percentage
exp(mean(w$delta[w$group == "test"])) - 1
# [1] -0.101</code></pre>



<p class="wp-block-paragraph"><strong>The pages with the new titles lost, on average, 10% of their clicks.</strong> Without a control, the conclusion would be immediate: the change did damage, let&#8217;s roll it back. And it would be wrong, because we know (we decided it ourselves) that the change works.</p>



<p class="wp-block-paragraph">(n.b. we work on the <strong>logarithm</strong> of the after/before ratio for two reasons. Pages have wildly different volumes, from one click a day to a few hundred, and an effect such as &#8220;+8%&#8221; is proportional, not a fixed number of clicks: on the log scale, percentage changes become differences, which can be added and averaged. And the mean of the logarithms, brought back with <code>exp()</code>, is the geometric mean: it does not get dragged around by a page going from 2 to 9 clicks.)</p>



<h2 class="wp-block-heading" id="did">What is difference-in-differences?</h2>



<p class="wp-block-paragraph">Now let&#8217;s look at the control pages as well:</p>



<pre class="wp-block-code"><code>tapply(w$delta, w$group, function(x) exp(mean(x)) - 1)
# control    test
#  -0.166  -0.101</code></pre>



<p class="wp-block-paragraph">The control pages, which nobody touched, lost 16.6%. That is the core update, plus the fluctuations of the period: <strong>it is what would have happened to the test pages too, had we not changed them</strong>. Economists call it the <em>counterfactual</em>, the story that was never staged.</p>



<p class="wp-block-paragraph">The whole idea of difference-in-differences is right here. First we compute the change in each group (the first difference), then the difference between the two changes (the second difference). In words: <strong>the effect of the change is how much better, or worse, the test pages did compared with the control pages.</strong> As a formula, writing Δ for the average change on the log scale:</p>



<p class="wp-block-paragraph">\(DiD = \bar{\Delta}_{test} &#8211; \bar{\Delta}_{control} = (\log T_{after} &#8211; \log T_{before}) &#8211; (\log C_{after} &#8211; \log C_{before})\)</p>



<p class="wp-block-paragraph">where T and C are the clicks of the test and control pages. Everything that hit both groups in the same way (seasonality, the core update, weekends) appears in both brackets and cancels out in the subtraction. Only what distinguishes the groups is left: the change.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/split-test-seo-serie-en.png" alt="The two groups day by day, each page relative to its own &quot;before&quot; average (7-day moving average). Before the change the two lines walk together; the core update pulls both down, but afterwards the test pages stay steadily above the control pages. The effect is not the drop below 100: it is the gap between the two lines."/><figcaption class="wp-element-caption">The two groups day by day, each page relative to its own &#8220;before&#8221; average (7-day moving average). Before the change the two lines walk together; the core update pulls both down, but afterwards the test pages stay steadily above the control pages. The effect is not the drop below 100: it is the gap between the two lines.</figcaption></figure>



<p class="wp-block-paragraph">The chart tells the story better than any table. In the first week after the change the test pages really do rise, and anyone looking only at those days would cry victory; then the core update arrives and everything plunges. But the control plunges further. The gap between the two lines, which holds until the end, is our effect.</p>



<p class="wp-block-paragraph">It remains to be seen whether that gap is large enough not to be a fluke. The pages are few and noisy: perhaps the draw put into the test group, through sheer bad luck for the control, a few pages that would have risen anyway. That is a question for a statistical test, and since we have one change per page and two groups to compare, the tool is the familiar <a href="https://www.gironi.it/blog/en/the-two-sample-t-test-how-to-test-a-hypothesis-for-dependent-or-independent-samples/">two-sample t-test</a>. I compute it in R:</p>



<pre class="wp-block-code"><code>t.test(delta ~ group, data = w)

# t = -2.1086, df = 197.34, p-value = 0.03624
# 95 percent confidence interval:
#  -0.144123939 -0.004821942
# mean in group control    mean in group test
#            -0.1809326            -0.1064596</code></pre>



<p class="wp-block-paragraph">The difference between the two means is 0.0745 on the log scale, that is exp(0.0745) − 1 = <strong>+7.7% clicks</strong>: very close to the true 8%. The p-value of 0.036 tells us that a difference this large, had the change been useless, would come up by chance fewer than 4 times in 100.</p>



<p class="wp-block-paragraph">The <a href="https://www.gironi.it/blog/en/confidence-intervals-what-they-are-how-to-calculate-them-and-what-they-do-not-mean/">confidence interval</a>, however, is the most honest piece of information. R reports it for the difference control − test; flipped the way round we care about and turned into a percentage, it runs from <strong>+0.5% to +15.5%</strong>. In plainer terms: the change almost certainly worked, but the data are compatible both with a negligible effect and with an effect twice as large as the estimate.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/split-test-seo-pagine-en.png" alt="Each dot is a page: its change in clicks after the edit. The two clouds overlap almost entirely, and dozens of test pages do worse than control pages. The vertical bars are the geometric means of the two groups: the effect of the change lives only in the gap between the two bars."/><figcaption class="wp-element-caption">Each dot is a page: its change in clicks after the edit. The two clouds overlap almost entirely, and dozens of test pages do worse than control pages. The vertical bars are the geometric means of the two groups: the effect of the change lives only in the gap between the two bars.</figcaption></figure>



<p class="wp-block-paragraph">This second figure explains why nobody should judge a change by looking at one page, or even ten. <strong>The fluctuations of a single page are much larger than the effect we are looking for</strong>: dozens of pages with the new titles lost more than untouched control pages. Only the average over many pages, compared with the average of the control, brings the signal to the surface.</p>



<p class="wp-block-paragraph">(n.b. the same result comes out of a regression, <code>lm(delta ~ group, data = w)</code>: the <code>grouptest</code> coefficient is 0.0745, with the same p-value. It is the form to use when we want to add other variables, such as page type, and the reason why textbooks almost always present DiD as a <a href="https://www.gironi.it/blog/en/correlation-and-regression-analysis-linear-regression/">regression</a> model.)</p>



<h2 class="wp-block-heading" id="parallel-trends">How do we check that the control is a good control?</h2>



<p class="wp-block-paragraph">The whole argument rests on one assumption: that, without the change, test and control would have moved in the same way. It is called the <strong>parallel trends assumption</strong>, and it is the point where a split test stands or falls.</p>



<p class="wp-block-paragraph">We cannot check it in the &#8220;after&#8221; period, because there we do not see the counterfactual. But we can check it in the &#8220;before&#8221; period, when nobody had touched anything. Two tools, both extremely useful.</p>



<p class="wp-block-paragraph">The first is the chart we have already seen: <strong>before the change the lines must overlap</strong>. If in the &#8220;before&#8221; period the test pages were already growing faster than the control, the draw went wrong, or the pages are not really homogeneous, and any later difference is suspect.</p>



<p class="wp-block-paragraph">The second is a <strong>placebo test</strong>: we pretend the change happened halfway through the &#8220;before&#8221; period and repeat the analysis. Since nothing happened at that moment, the DiD must come out as zero. I check it in R, moving the fake change to day 29:</p>



<pre class="wp-block-code"><code>pre &lt;- subset(clicks, day &lt; 57)
pre$period &lt;- ifelse(pre$day &gt;= 29, "after", "before")
t.test(delta ~ group, data = changes(pre))

# t = 0.071079, df = 197.57, p-value = 0.9434</code></pre>



<p class="wp-block-paragraph">A difference of practically zero (−0.09%), p-value 0.94: the control behaved like the test when nothing told them apart. That is exactly what we want to see. A &#8220;significant&#8221; placebo test, on the contrary, would be an alarm bell not to be ignored.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1.5rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-flow wp-block-group-is-layout-flow">
<p>A warning: <strong>difference-in-differences removes everything that hits test and control in the same way, but nothing that hits them differently.</strong> <br>If the core update penalises precisely the kind of title we introduced, or if in the meantime a competitor attacks only some of our categories, DiD will attribute that effect to the change as well. Randomisation makes these coincidences unlikely, not impossible: that is why checking parallel trends and running a placebo test are not optional.</p>
</div></div>



<h2 class="wp-block-heading" id="how-many-pages">How many pages do we need, and for how long?</h2>



<p class="wp-block-paragraph">In our example it went well: p-value 0.036, effect recovered. But <em>how</em> well did it go? We find out with <a href="https://www.gironi.it/blog/en/effect-size-and-power-analysis/">power analysis</a>: the probability that the test detects an effect that is really there.</p>



<p class="wp-block-paragraph">To compute it we need three ingredients: the effect we want to detect (+8%, that is log(1.08) = 0.077 on the log scale), the number of pages and the variability of the page-by-page changes within each group. We estimate the latter from the test data: it is about 0.25. Computing it in R:</p>



<pre class="wp-block-code"><code>s &lt;- sqrt(mean(tapply(w$delta, w$group, var)))   # within-group standard deviation
s
# [1] 0.2497

power.t.test(n = 100, delta = log(1.08), sd = s)$power
# [1] 0.583

power.t.test(power = 0.8, delta = log(1.08), sd = s)$n
# [1] 166.3</code></pre>



<p class="wp-block-paragraph">With 100 pages per group, <strong>power is 58%: little better than a coin toss.</strong> If we repeated the very same experiment many times, in four cases out of ten we would conclude that the change is useless, while knowing that it works. To reach 80%, the usual threshold, we need about 166 pages per group, more than 330 in total.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/split-test-seo-potenza-en.png" alt="Test power as the number of pages grows, for three possible effects. A +15% is detected with about a hundred pages; a +8% needs more than 300 to reach 80%; a +5% more than 800. Our example, with 200 pages, sits halfway up the 8% curve."/><figcaption class="wp-element-caption">Test power as the number of pages grows, for three possible effects. A +15% is detected with about a hundred pages; a +8% needs more than 300 to reach 80%; a +5% more than 800. Our example, with 200 pages, sits halfway up the 8% curve.</figcaption></figure>



<p class="wp-block-paragraph">The figure gives the measure of the problem. <strong>The typical effects of SEO changes are small, and small effects require many pages.</strong> A +15% shows up even on a hundred product pages; a +5%, which on a large e-commerce site is still worth a lot of money, requires more than 800.</p>



<p class="wp-block-paragraph">Before starting, then, it pays to do the sum backwards: how many pages do we have? With that number, what minimum effect can we hope to see? The variability is estimated from the same pages, comparing two past periods as long as the test. If the minimum detectable effect is larger than what we can reasonably expect, the test is underpowered. In that case we either widen it to more pages, or accept that it will only say &#8220;I don&#8217;t know&#8221;.</p>



<p class="wp-block-paragraph">As for time, two considerations. Google must first crawl and reprocess the changed pages, and on large sites that can take more than a week: the first days after the change are often best left out of the comparison. Then, a longer &#8220;after&#8221; period reduces day-to-day noise, but not page-to-page noise, which is the bulk of it. <strong>Lengthening the test helps less than widening it</strong>: four to six weeks is usually a good compromise.</p>



<h2 class="wp-block-heading" id="pitfalls">The pitfalls of split testing</h2>



<p class="wp-block-paragraph">The method is sound, but in day-to-day practice there is no shortage of ways to be fooled.</p>



<p class="wp-block-paragraph"><strong>Contamination.</strong> Test and control pages do not live in isolation: they link to each other, share menus and category pages. If the change alters internal links, for instance by adding a related-products block, the effect spills over onto the control too, and DiD underestimates it.</p>



<p class="wp-block-paragraph"><strong>Pages with very few clicks.</strong> A page going from 2 clicks a day to 0 has an infinite change on the log scale. It is better to exclude from the start the pages below a minimum traffic threshold, or to work on impressions, which are far more numerous.</p>



<p class="wp-block-paragraph"><strong>The metric chosen after the fact.</strong> Clicks, impressions, CTR, average position, conversions: if we look at all of them, one will turn out &#8220;significant&#8221; by chance. The primary metric is decided beforehand, and the others remain background information. This is the <a href="https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/">multiple comparisons</a> problem, and it deserves a discussion of its own.</p>



<p class="wp-block-paragraph"><strong>Groups that are too different.</strong> If the site has pages with very different volumes, it pays to randomise in pairs or in blocks (by category, by traffic band), as we did. A simple draw over 40 pages can produce, through sheer bad luck, a test group holding all the seasonal pages.</p>



<h2 class="wp-block-heading" id="counterfactual">What if we cannot randomise?</h2>



<p class="wp-block-paragraph">So far we have taken for granted that we can choose which pages to change. But some of the most important, and riskiest, decisions cannot be split in two: a domain or platform <strong>migration</strong>, a new template for the whole site, the switch to HTTPS. In these cases there is no internal control group, because all the pages change together. And the before/after comparison is as unreliable as ever.</p>



<p class="wp-block-paragraph">The idea of difference-in-differences, however, survives. If we cannot observe the counterfactual inside the site, we can <strong>rebuild it from outside</strong>: from series that live through the same seasons and the same algorithm updates, but that the migration does not touch. A sister site from the same group, a section of the site left out of the migration, the Google Trends index for the main topics. In the &#8220;before&#8221; period we estimate how our traffic moves together with these series; in the &#8220;after&#8221; period we use the same relationship to predict <strong>how much traffic we would have had without the migration</strong>. The difference between observed and predicted is the effect.</p>



<p class="wp-block-paragraph">Let&#8217;s take an example. I simulate 180 days of clicks for a site that migrates on day 121, losing 12% of its traffic; ten days later a core update takes 25% away from the whole sector. Alongside there are two control series: a sister site that did not migrate, and Google Trends.</p>



<pre class="wp-block-code"><code>set.seed(3)
days &lt;- 1:180; migration &lt;- 121                           # 4 months before, 2 after
sector &lt;- exp(cumsum(rnorm(180, 0, 0.02))) *              # sector trend
          ifelse(days %% 7 %in% c(6, 0), 0.75, 1) *        # weekend
          (1 - 0.25 * (days &gt; 130))                        # sector-wide core update
noise &lt;- function(sd) exp(as.numeric(arima.sim(list(ar = 0.6), 180, sd = sd)))
site   &lt;- 800 * sector * noise(0.05) * ifelse(days &gt;= migration, 0.88, 1)
sister &lt;- 300 * sector * noise(0.03)                       # sister site, not migrated
trends &lt;- 60  * sector * noise(0.05)                       # Google Trends for the topic
before &lt;- days &lt; migration

mean(site[!before]) / mean(site[before]) - 1
# [1] -0.273</code></pre>



<p class="wp-block-paragraph">The before/after says <strong>−27%</strong>: more than twice the true effect, because it charges the core update to the migration too. So I build the counterfactual with a <a href="https://www.gironi.it/blog/en/multiple-regression-analysis-explained-simply/">multiple regression</a> on the &#8220;before&#8221; period, on the log scale, and project it onto the &#8220;after&#8221; period:</p>



<pre class="wp-block-code"><code>m &lt;- lm(log(site) ~ log(sister) + log(trends), subset = before)
summary(m)$r.squared
# [1] 0.792

predicted &lt;- predict(m, newdata = data.frame(sister, trends))
exp(mean(log(site[!before]) - predicted[!before])) - 1
# [1] -0.125

sum(exp(predicted[!before])) - sum(site[!before])   # clicks lost in 60 days
# [1] 4532.7</code></pre>



<p class="wp-block-paragraph">The two control series explain 79% of the site&#8217;s daily fluctuations before the migration. The comparison with the counterfactual estimates an effect of <strong>−12.5%</strong>, a step away from the true −12%: about 4,500 clicks lost in two months, out of 36,400 expected.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.gironi.it/blog/wp-content/uploads/2026/10/split-test-seo-controfattuale-en.png" alt="The site's daily clicks (7-day moving average) and the counterfactual predicted from the control series. Before the migration the two lines chase each other; afterwards the core update knocks both down, but observed clicks stay below the prediction. The shaded area is the clicks lost to the migration, not to the algorithm."/><figcaption class="wp-element-caption">The site&#8217;s daily clicks (7-day moving average) and the counterfactual predicted from the control series. Before the migration the two lines chase each other; afterwards the core update knocks both down, but observed clicks stay below the prediction. The shaded area is the clicks lost to the migration, not to the algorithm.</figcaption></figure>



<p class="wp-block-paragraph">How far can we trust that −12.5%? The days of a time series are not independent of each other (a good day tends to be followed by another good day), and an interval computed as if they were would be too optimistic. Here too the <strong>placebo</strong> comes to our aid, this time in time: we pretend the migration happened at various moments of the &#8220;before&#8221; period, estimate the model on the preceding days and measure the fake &#8220;effect&#8221; over the next 30 days.</p>



<pre class="wp-block-code"><code>placebo &lt;- sapply(seq(40, 90, by = 5), function(k) {
  mk &lt;- lm(log(site) ~ log(sister) + log(trends), subset = days &lt; k)
  pk &lt;- predict(mk, newdata = data.frame(sister, trends))
  exp(mean(log(site[k:(k + 29)]) - pk[k:(k + 29)])) - 1
})
range(placebo)
# [1] -0.018  0.028</code></pre>



<p class="wp-block-paragraph">When nothing happened, the method &#8220;finds&#8221; effects between −1.8% and +2.8%: that is its noise threshold. The migration&#8217;s −12.5% lies well outside that band, and we can regard it as real.</p>



<p class="wp-block-paragraph">The method we have just built by hand is the heart of <strong>CausalImpact</strong>, the R package developed at Google for exactly this problem. Instead of a plain regression it uses a Bayesian time-series model, which accounts for trend, seasonality and the dependence between days, and returns the effect directly with its credible interval. The logic is the same: a counterfactual built from series the intervention did not touch.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1.5rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-flow wp-block-group-is-layout-flow">
<p>A warning: <strong>the counterfactual is only as credible as the control series.</strong> <br>They must move with the site before the intervention (checked with the R² and with the placebo), and above all they must not be touched by the intervention itself. If the migration also changes the sister site, or if we use Google Trends as a control for a campaign that also boosted searches, the counterfactual moves together with the site and the effect vanishes from the books.</p>
</div></div>



<h2 class="wp-block-heading" id="faq">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is an SEO split test?</h3>



<p class="wp-block-paragraph">It is an experiment that measures the effect of a change on organic traffic by randomly dividing a set of similar pages into two groups: the test pages receive the change, the control pages stay as they are. Comparing how the two groups change separates the effect of the edit from seasonality, algorithm updates and competition.</p>



<h3 class="wp-block-heading">What is the difference between an SEO split test and an A/B test?</h3>



<p class="wp-block-paragraph">An A/B test splits users: each one sees one of two versions of the same page. An SEO split test splits pages, because Google must see a single version of each. That is why it needs a site with many pages of the same type.</p>



<h3 class="wp-block-heading">How long should an SEO split test run?</h3>



<p class="wp-block-paragraph">Usually six to eight weeks of data before the change and four to six weeks after, leaving out the first days while Google is still crawling the changed pages. Lengthening the period helps less than increasing the number of pages.</p>



<h3 class="wp-block-heading">Can we run a split test with only a few pages?</h3>



<p class="wp-block-paragraph">With a few dozen pages only large effects, 15-20% or more, can be detected. For typical effects of 5-10% a few hundred pages are usually needed: it is worth computing the test&#8217;s power before starting.</p>



<h3 class="wp-block-heading">How do we measure the impact of an SEO migration?</h3>



<p class="wp-block-paragraph">By comparing post-migration traffic with a counterfactual: a prediction of how much traffic there would have been without the migration, built from series the migration does not touch (a sister site, a section that was not migrated, Google Trends). The R package CausalImpact automates this approach.</p>



<h3 class="wp-block-heading">What can we test with an SEO split test?</h3>



<p class="wp-block-paragraph">Any change that can be applied to a group of pages on the same template: titles and meta descriptions, heading structure, structured data, content blocks, internal links, page speed. Changes that touch the whole site at once, such as a migration, are not suitable: there is no internal control group, and a counterfactual built from outside is needed.</p>



<script type="application/ld+json">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What is an SEO split test?", "acceptedAnswer": {"@type": "Answer", "text": "It is an experiment that measures the effect of a change on organic traffic by randomly dividing a set of similar pages into two groups: the test pages receive the change, the control pages stay as they are. Comparing how the two groups change separates the effect of the edit from seasonality, algorithm updates and competition."}}, {"@type": "Question", "name": "What is the difference between an SEO split test and an A/B test?", "acceptedAnswer": {"@type": "Answer", "text": "An A/B test splits users: each one sees one of two versions of the same page. An SEO split test splits pages, because Google must see a single version of each. That is why it needs a site with many pages of the same type."}}, {"@type": "Question", "name": "How long should an SEO split test run?", "acceptedAnswer": {"@type": "Answer", "text": "Usually six to eight weeks of data before the change and four to six weeks after, leaving out the first days while Google is still crawling the changed pages. Lengthening the period helps less than increasing the number of pages."}}, {"@type": "Question", "name": "Can we run a split test with only a few pages?", "acceptedAnswer": {"@type": "Answer", "text": "With a few dozen pages only large effects, 15-20% or more, can be detected. For typical effects of 5-10% a few hundred pages are usually needed: it is worth computing the test's power before starting."}}, {"@type": "Question", "name": "How do we measure the impact of an SEO migration?", "acceptedAnswer": {"@type": "Answer", "text": "By comparing post-migration traffic with a counterfactual: a prediction of how much traffic there would have been without the migration, built from series the migration does not touch (a sister site, a section that was not migrated, Google Trends). The R package CausalImpact automates this approach."}}, {"@type": "Question", "name": "What can we test with an SEO split test?", "acceptedAnswer": {"@type": "Answer", "text": "Any change that can be applied to a group of pages on the same template: titles and meta descriptions, heading structure, structured data, content blocks, internal links, page speed. Changes that touch the whole site at once, such as a migration, are not suitable: there is no internal control group, and a counterfactual built from outside is needed."}}]}</script>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">The example code is complete: just copy it into R and change one parameter at a time.</p>



<ul class="wp-block-list"><li>Set <code>effect</code> to 1 (the change does nothing) and repeat the analysis with a few different <code>set.seed()</code> values. How often does the t-test wrongly declare a significant effect? (It should happen about 5 times in 100.)</li><li>Set <code>effect</code> back to 1.08 and double the pages, <code>n_pages &lt;- 400</code>: how much does the confidence interval narrow?</li><li>&#8220;Break&#8221; the randomisation, putting the 100 pages with the most traffic into the test group instead of drawing them, and give the big pages a more favourable drift. What does the placebo test say?</li></ul>



<p class="wp-block-paragraph">For the last exercise, a hint: it is the quickest way to understand why randomisation is not a bureaucratic detail.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">There is a question we have brushed against without tackling it. In a split test we usually look at one primary metric, but the temptation to look at ten is very strong: clicks, impressions, CTR, position, and then every category of pages on its own. Every extra look is one more chance to find a &#8220;significant&#8221; effect that does not exist. How quickly these false alarms pile up, and how to correct for them, is the subject of <strong><a href="https://www.gironi.it/blog/en/multiple-comparisons-bonferroni-benjamini-hochberg/">multiple comparisons</a></strong>: the next step of our journey through the traps of data.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">On online experimentation, the reference is <a href="https://www.amazon.it/dp/1108724264?tag=consulenzeinf-21&amp;ascsubtag=seo-split-testing" rel="nofollow sponsored noopener" target="_blank"><em>Trustworthy Online Controlled Experiments</em></a> by Ron Kohavi, Diane Tang and Ya Xu: from choosing metrics to the pitfalls of control groups, it is written by the people who ran experimentation at Microsoft, Google and LinkedIn.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/seo-split-testing/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Keyword Clustering: grouping thousands of queries with K-means and hierarchical clustering</title>
		<link>https://www.gironi.it/blog/en/keyword-clustering/</link>
					<comments>https://www.gironi.it/blog/en/keyword-clustering/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Tue, 30 Jun 2026 06:42:57 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3947</guid>

					<description><![CDATA[It happens with every reasonably serious project: you export the keyword list from Search Console or a tool, and you find yourself facing thousands of rows. Three thousand, ten thousand queries. Reading them one by one is unthinkable, and grouping them by hand &#8220;by feel&#8221; is slow, subjective and impossible to reproduce.Yet we need that &#8230; <a href="https://www.gironi.it/blog/en/keyword-clustering/" class="more-link">Continue reading<span class="screen-reader-text"> "Keyword Clustering: grouping thousands of queries with K-means and hierarchical clustering"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">It happens with every reasonably serious project: you export the keyword list from Search Console or a tool, and you find yourself facing thousands of rows. Three thousand, ten thousand queries. Reading them one by one is unthinkable, and grouping them by hand &#8220;by feel&#8221; is slow, subjective and impossible to reproduce.<br>Yet we need that grouping: we want to understand which big families of searches exist in our market, in order to decide where to create content, which pages to build, what to bet on.<br>The question is: can we let the data reveal the groups, instead of imposing them ourselves? Turning that mountain of queries into a few homogeneous sets is the job of <em>keyword clustering</em>.</p>



<p class="wp-block-paragraph">We have already tackled a close problem, classifying the <a href="https://www.gironi.it/blog/en/naive-bayes-search-intent/">intent of a query with Naive Bayes</a> — but there we had an ingredient we lack today: a set of <em>already labelled</em> examples to learn from. Here nobody has handed us the labels. This is the territory of <em>clustering</em>, one of the most used tools of <a href="https://www.gironi.it/blog/en/understanding-the-basics-of-machine-learning-a-beginners-guide/">machine learning</a>, and in this article we build it in R with its two classic algorithms: <em>K-means</em> and hierarchical clustering.</p>



<span id="more-3947"></span>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#without-labels">Grouping without labels: the idea of clustering</a></li><li><a href="#k-means">K-means: centroids and the problem of choosing k</a></li><li><a href="#reading-clusters">Reading the clusters: who are these groups?</a></li><li><a href="#hierarchical">Hierarchical clustering: the dendrogram</a></li><li><a href="#which-method">Which method, and the traps</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="without-labels">Grouping without labels: the idea of clustering</h2>



<p class="wp-block-paragraph">The difference with Naive Bayes is one of principle, not of detail. There we did <em>supervised learning</em>: we had queries already marked as informational, navigational or transactional, and we taught the algorithm to recognise new ones. Here we do <em>unsupervised learning</em>: nobody told us which and how many groups exist. <strong>Clustering does not verify a label we already know: it looks for a structure we did not know was there.</strong></p>



<p class="wp-block-paragraph">Grouping requires two things. The first is describing each keyword with numbers: in our example we will use search volume, cost per click (<em>cpc</em>), average position and word count.<br>The second is a notion of <em>distance</em>: two keywords are &#8220;close&#8221; if their numbers are alike. The most common distance is the Euclidean one, the same we would use on a map, only computed in a four-dimensional space (one per metric).</p>



<p class="wp-block-paragraph">There is, however, a trap to defuse straight away. Volume is measured in tens of thousands, cpc in cents of a euro: left as they are, the distances would be dominated by volume, and cpc would count for almost nothing.<br><strong>Before computing any distance we must put all the variables on the same scale</strong>, standardising them — in R with the <code>scale()</code> function, which subtracts the mean from each column and divides by the standard deviation. Only then does one euro of difference in cpc and ten thousand searches of difference in volume &#8220;weigh&#8221; the same.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="k-means">K-means: centroids and the problem of choosing k</h2>



<p class="wp-block-paragraph">The idea of K-means is almost naive in its simplicity. We decide into how many groups (k) we want to split the data; the algorithm places k representative points, the <em>centroids</em>, and then repeats two steps until things settle: it assigns each keyword to the nearest centroid, then moves each centroid to the centre of the keywords assigned to it. At each pass the groups grow a little more cohesive, until they stop moving.</p>



<p class="wp-block-paragraph">What the algorithm tries to minimise, in words, is the internal spread of the groups: the sum of the (squared) distances of each point from the centroid of its own cluster. In a formula:</p>



\( \text{WCSS} = \sum_{k=1}^{K} \sum_{x \in C_k} \| x-\mu_k \|^2 \\ \)



<p class="wp-block-paragraph">where \( C_k \) is the k-th cluster, \( \mu_k \) its centroid and the double sum runs over all points of all groups. The lower the WCSS (<em>within-cluster sum of squares</em>), the more compact the groups.</p>



<p class="wp-block-paragraph">The sore point remains: we have to decide k ourselves, before starting. A help comes from the <em>elbow method</em>: we try several values of k and watch how the WCSS falls. At first adding a cluster helps a lot, then the improvements become marginal; the &#8220;elbow&#8221; of the curve — the point where the descent flattens — suggests a reasonable k. I build the keyword table and compute the WCSS from 1 to 6 groups in R:</p>



<pre class="wp-block-code"><code>kw &lt;- data.frame(
  keyword = c("running shoes","running shoes men","nike pegasus","nike pegasus 40",
              "best trail running shoes 2026","how to choose running shoes",
              "running shoes deal","buy trail shoes online",
              "trail vs road running shoes","running shoes overpronation",
              "asics gel nimbus","saucony endorphin","trail running shoes review",
              "discount running shoes","running shoe store london"),
  volume   = c(40000,18000,12000,8000,2400,1900,3200,880,1300,2100,9000,4000,1500,2600,720),
  cpc      = c(0.45,0.55,0.30,0.35,0.40,0.10,0.95,1.10,0.08,0.30,0.28,0.33,0.15,0.90,0.85),
  position = c(3.1,4.2,2.0,5.5,8.1,11.2,6.0,9.4,14.0,7.3,2.5,6.8,12.1,5.9,4.7),
  n_words  = c(2,3,2,3,5,5,3,4,6,3,3,2,3,3,4)
)

# standardise the four metrics (different scales -&gt; same weight)
X &lt;- scale(kw[, c("volume","cpc","position","n_words")])

# elbow method: WCSS for k from 1 to 6
set.seed(1)
wss &lt;- sapply(1:6, function(k) kmeans(X, centers = k, nstart = 10)$tot.withinss)
round(wss, 1)
# [1] 56.0 34.4 20.7 12.4  8.7  6.9</code></pre>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" width="975" height="600" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-elbow-en.png" alt="The elbow method: the within-cluster spread (WCSS) drops sharply up to three groups and then flattens. The elbow of the curve suggests k = 3." class="wp-image-4028" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-elbow-en.png 975w, https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-elbow-en-300x185.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The elbow method: the within-cluster spread (WCSS) drops sharply up to three groups and then flattens. The elbow of the curve suggests k = 3.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">The drop is steep up to three groups (56 → 34 → 21) and then slows down markedly (21 → 12 → 9 → 7). The elbow is never a sharp line — it is a reading, not a theorem — but here it reasonably points to <strong>k = 3</strong>. So I run K-means with three centroids:</p>



<pre class="wp-block-code"><code>set.seed(1)
km &lt;- kmeans(X, centers = 3, nstart = 25)
kw$cluster &lt;- km$cluster
table(km$cluster)
# 1 2 3
# 4 7 4</code></pre>



<p class="wp-block-paragraph">n.b. the argument <code>nstart = 25</code> restarts the algorithm 25 times from different initial centroids, keeping the best solution: K-means can in fact get stuck in a local minimum depending on where it starts, and restarting several times is the standard defence. The <code>set.seed(1)</code> only serves to make the example reproducible (including the cluster numbering, which is itself arbitrary).</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="reading-clusters">Reading the clusters: who are these groups?</h2>



<p class="wp-block-paragraph">Having three groups is useless until we understand <em>what</em> they represent. The most direct way is to look at the average metrics of each cluster.<br>I compute them on the original scale (not the standardised one, which is unreadable):</p>



<pre class="wp-block-code"><code>aggregate(kw[, c("volume","cpc","position","n_words")],
          by = list(cluster = kw$cluster), FUN = mean)
#   cluster volume  cpc position n_words
# 1       1   1775 0.18    11.35    4.75
# 2       2  13300 0.37     4.49    2.57
# 3       3   1850 0.95     6.50    3.50</code></pre>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="975" height="600" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-profilo-en.png" alt="The fingerprint of the three clusters across the four standardised metrics: the heads spike on volume, the commercial group on cpc, the informational one on position and word count. It is the table of means, read at a glance." class="wp-image-4029" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-profilo-en.png 975w, https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-profilo-en-300x185.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The fingerprint of the three clusters across the four standardised metrics: the heads spike on volume, the commercial group on cpc, the informational one on position and word count. It is the table of means, read at a glance.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">Now the groups speak.<br><strong>Cluster 2</strong> collects the very high-volume queries (13,300 searches on average), short (two-three words), well positioned and with modest cpc: they are the generic and brand <em>heads</em> — &#8220;running shoes&#8221;, &#8220;nike pegasus&#8221;, &#8220;asics gel nimbus&#8221;. <strong>Cluster 1</strong> has low volumes, long queries (almost five words), low positions and minimal cpc: it is the <em>informational</em> long-tail — &#8220;how to choose running shoes&#8221;, &#8220;trail vs road running shoes&#8221;, &#8220;trail running shoes review&#8221;. <strong>Cluster 3</strong> stands out for a very high cpc (€0.95) and contains the clearly <em>commercial</em> queries — &#8220;running shoes deal&#8221;, &#8220;buy trail shoes online&#8221;, &#8220;discount running shoes&#8221;, &#8220;running shoe store london&#8221;.</p>



<p class="wp-block-paragraph">It is worth pausing a moment on what just happened. <strong>Without giving the algorithm any label, the three groups that emerge closely echo a split by intent — informational research, generic and brand heads, commercial queries — close to the one that with Naive Bayes we had instead to teach it through examples.</strong><br>The alignment, mind you, is not magic: it emerges because the metrics we chose (cpc, length, position) <em>indirectly track</em> intent, not because clustering knows it — of the queries&#8217; meaning, here, it has not seen a single word. The reading is still immediately actionable: the informational cluster calls for articles and guides, the commercial one for product and offer pages, the high-volume one for robust pillar pages.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="hierarchical">Hierarchical clustering: the dendrogram</h2>



<p class="wp-block-paragraph">K-means forced us to choose k in advance. <em>Hierarchical</em> clustering flips the approach: it decides nothing a priori, and builds instead a complete tree of groupings. It starts with each keyword as a group of its own, then progressively merges the two closest, then the two closest among those remaining, and so on up to a single big group. The result is a <em>dendrogram</em>: a tree showing at what &#8220;height&#8221; (that is, at what distance) each merge happens.</p>



<p class="wp-block-paragraph">I build it in R by first computing the distance matrix, then the tree:</p>



<pre class="wp-block-code"><code>d  &lt;- dist(X)                       # euclidean distances between standardised keywords
hc &lt;- hclust(d, method = "ward.D2") # hierarchical tree (Ward's criterion)
plot(hc, labels = kw$keyword)       # the dendrogram</code></pre>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1050" height="780" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-dendrogramma-en.png" alt="The dendrogram of hierarchical clustering (Ward's criterion): the tree of merges among the keywords. Cutting it at k = 3 recovers the three families — heads, commercial, informational — with boundaries slightly different from K-means." class="wp-image-4030" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-dendrogramma-en.png 1050w, https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-dendrogramma-en-300x223.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/clustering-dendrogramma-en-1024x761.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The dendrogram of hierarchical clustering (Ward&#8217;s criterion): the tree of merges among the keywords. Cutting it at k = 3 recovers the three families — heads, commercial, informational — with boundaries slightly different from K-means.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">The beauty of the dendrogram is that we make the decision on <em>how many</em> groups to keep <em>after</em> seeing it, simply by &#8220;cutting&#8221; it at a chosen height: a low cut leaves many small groups, a high cut a few large ones. I cut at three groups and compare with K-means:</p>



<pre class="wp-block-code"><code>kw$cluster_hc &lt;- cutree(hc, k = 3)
table(kw$cluster_hc)
# 1 2 3
# 8 3 4</code></pre>



<p class="wp-block-paragraph">The partition is not identical to the K-means one (here the groups have 8, 3 and 4 keywords), and that is normal: the two methods optimise different criteria and on little data the differences show.<br>But the substance of the groupings — the high-cpc transactional block, the high-volume heads, the informational tail — remains recognisable in both. When two different methods converge on the same story, we can trust that story a little more.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="which-method">Which method, and the traps</h2>



<p class="wp-block-paragraph">The choice between the two, in practice, follows a few simple rules. <strong>K-means</strong> is fast and efficient even on tens of thousands of keywords, but it wants to be told k and tends to build &#8220;spherical&#8221; groups of similar size. The <strong>hierarchical</strong> one does not ask for k in advance and gives the dendrogram — precious for <em>seeing</em> how the groups nest inside one another — but becomes heavy when the keywords are too many. The most common practice: explore with hierarchical on a sample to get a sense of the number of groups, then apply K-means to the whole set with the k thus identified.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">A word of caution, the most important of all: <strong>clustering always finds groups, even when there are none.</strong> Even pure noise comes back dutifully split into k tidy clusters. The number of groups, the metrics chosen to describe the keywords, the standardisation: they are all <em>our</em> decisions, and each one changes the result. A grouping is not a truth discovered in the data, it is a working hypothesis that only makes sense if it survives the test of business common sense. If we cannot explain a cluster in words, it probably does not really exist.</p>



<p class="wp-block-paragraph">There is then a question of dimensions. Here we used four metrics, but in real operations the variables describing a keyword can be many more, and with many dimensions distances lose meaning (everything tends to look equally far). It is exactly the problem that <a href="https://www.gironi.it/blog/en/principal-component-analysis-pca/">principal component analysis</a> knows how to ease, compressing many metrics into a few components before passing the baton to clustering.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">The best way to understand clustering is to watch it change its answer as the choices change. Building on the code above:</p>



<ol class="wp-block-list"><li>Skip the standardisation: run K-means directly on <code>kw[, c("volume","cpc","position","n_words")]</code> without <code>scale()</code>. Do the groups all collapse onto volume? It is the practical demonstration of why we standardise.</li><li>Change k: try four or five groups and re-read the averages. Does the informational cluster split into sensible sub-themes or are you just cutting noise?</li><li>Change the merging criterion of the hierarchical method: <code>method = "complete"</code> or <code>"average"</code> instead of <code>"ward.D2"</code>. Does the dendrogram change shape? And the groups cut at k=3?</li></ol>



<p class="wp-block-paragraph">A hint: always keep an eye on the per-cluster averages with <code>aggregate()</code>. It is there, and not in the code, that you decide whether a grouping is useful or just an elegant partition of nothing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">We grouped the keywords by how they <em>behave</em> — volume, cost, position.<br>But what matters most to an SEO is left out: their <em>meaning</em>. Two queries can have different metrics and mean the same thing, or similar metrics and opposite intents. Grouping by sense, and not only by numbers, means turning the very text of the queries into something measurable: it is the job of <em>text mining</em>, where words become vectors and similarity is computed on language. And that is where we will pick up next.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">If you want to go deeper into clustering — K-means, hierarchical methods, the choice of the number of groups and the pitfalls of interpretation — <em><a href="https://www.amazon.it/dp/1461471370?tag=consulenzeinf-21&#038;ascsubtag=keyword-clustering" rel="nofollow sponsored noopener" target="_blank">An Introduction to Statistical Learning</a></em> by James, Witten, Hastie and Tibshirani devotes a lucid chapter to unsupervised learning, with R labs that retrace exactly the steps we saw here. It is the reference I recommend to anyone who wants to move from the toy example to clustering on real data.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/keyword-clustering/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Expected vs Actual CTR: finding the pages that earn fewer clicks than their position deserves</title>
		<link>https://www.gironi.it/blog/en/expected-vs-actual-ctr/</link>
					<comments>https://www.gironi.it/blog/en/expected-vs-actual-ctr/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Mon, 29 Jun 2026 08:22:50 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3942</guid>

					<description><![CDATA[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 &#8220;how many clicks does it get?&#8221;, but the &#8230; <a href="https://www.gironi.it/blog/en/expected-vs-actual-ctr/" class="more-link">Continue reading<span class="screen-reader-text"> "Expected vs Actual CTR: finding the pages that earn fewer clicks than their position deserves"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">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.<br>The question we usually ask is the wrong one: not &#8220;how many clicks does it get?&#8221;, but the more uncomfortable one — &#8220;how many clicks <em>should</em> it get, sitting where it sits?&#8221;. Without a benchmark, a 3% CTR tells us nothing: for position 8 it would be excellent, for position 2 a small disaster.<br>What we are missing, in order to judge, is an expected CTR: the value to compare the actual one against.</p>



<p class="wp-block-paragraph">We have already seen, talking about <a href="https://www.gironi.it/blog/en/correlation/">correlation</a>, 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 <a href="https://www.gironi.it/blog/en/correlation-and-regression-analysis-linear-regression/">linear regression</a>.<br>Here the two threads tie together: we turn that curve into an <strong>expected CTR</strong> 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.</p>



<span id="more-3942"></span>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#why-baseline">Why a CTR, on its own, means nothing</a></li><li><a href="#modelling-the-curve">Modelling the CTR curve: three roads</a></li><li><a href="#the-example">An example with Search Console data</a></li><li><a href="#loess-nls">The other two roads: loess and nls at work</a></li><li><a href="#residuals">Residuals: who earns less than they should</a></li><li><a href="#reading-deviations">Reading the deviations without fooling ourselves</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="why-baseline">Why a CTR, on its own, means nothing</h2>



<p class="wp-block-paragraph">There are industry tables telling us what the CTR of each position &#8220;should&#8221; 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 <em>our</em> 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 <em>rich snippets</em>.<br>The average CTR of &#8220;position 3&#8221; on an American e-commerce benchmark has almost nothing to say to our technical blog in Italian.</p>



<p class="wp-block-paragraph">The way out is to stop comparing ourselves with an external table and build the reference curve <em>on our own data</em>.<br>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 <em>for the way our own site works</em>. That curve becomes the yardstick: the expected CTR of a page is the value the curve assigns it, given its position.<br>The gap between the actual CTR and that expected value is the information we were after.</p>



<p class="wp-block-paragraph"><strong>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.</strong></p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="modelling-the-curve">Modelling the CTR curve: three roads</h2>



<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph">There is, however, a detail that changes the whole way of reasoning, and it is the kind of deviation we care about.<br>We do not care that a page gets &#8220;two CTR points less&#8221; than expected: at the top of the SERP two points are crumbs, at the bottom they are a doubling. We care about the <em>multiplicative</em> deviation — &#8220;it earns half of what it should&#8221;, &#8220;it earns double&#8221;.<br>And a multiplicative deviation is best handled on a logarithmic scale, where a ratio becomes a difference.</p>



<p class="wp-block-paragraph">The most natural shape for a curve of this kind is the <strong>power law</strong>, that is the idea that CTR is proportional to position raised to a negative exponent:</p>



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



<p class="wp-block-paragraph">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:</p>



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



<p class="wp-block-paragraph">In other words: the logarithm of CTR is a <em>linear</em> 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.</p>



<p class="wp-block-paragraph">The first, and the one I recommend as the workhorse, is a <strong>linear regression on the logarithms</strong> — <code>lm(log(ctr) ~ log(position))</code>. 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 <em>multiplicative</em>, exactly as we need.<br>It also extrapolates to rarely observed positions, and it can be <strong>weighted by impressions</strong> (<code>weights = impression</code>), 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.</p>



<p class="wp-block-paragraph">The second is <strong>non-linear regression</strong> with <code>nls</code>, 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.</p>



<p class="wp-block-paragraph">The third is <strong>local smoothing</strong> with <code>loess</code>, which imposes no shape on the curve and lets the data &#8220;draw it&#8221;. It is perfect for <em>seeing</em> 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.</p>



<p class="wp-block-paragraph">So: we start from the log-log regression weighted by impressions as the working model, we compare it by eye with a <code>loess</code> to check we are not forcing the wrong shape, and we move to <code>nls</code> only if we need the explicit exponent. Let us see it at work.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="the-example">An example with Search Console data</h2>



<p class="wp-block-paragraph">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.<br>I build the table in R with twelve example pages (with, on purpose, a couple of anomalous cases):</p>



<pre class="wp-block-code"><code>gsc &lt;- 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   &lt;- 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 &lt;- round(gsc$impression * gsc$ctr)</code></pre>



<p class="wp-block-paragraph">I now estimate the expected-CTR curve with the regression on logarithms, weighting each page by its impressions:</p>



<pre class="wp-block-code"><code>fit &lt;- lm(log(ctr) ~ log(position), data = gsc, weights = impression)
round(coef(fit), 3)
# (Intercept)  log(position)
#      -1.240          -1.088</code></pre>



<p class="wp-block-paragraph">The slope is <strong>−1.088</strong>: 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.<br>It is the same steep drop we had glimpsed when measuring correlation, but now written in a formula we can <em>query</em>: given a position number, it returns the typical CTR that position implies on our site.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="loess-nls">The other two roads: loess and nls at work</h2>



<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph"><strong>Non-linear regression</strong> 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 <code>exp</code> is our \( a \), the slope is our \( b \). I set it running in R:</p>



<pre class="wp-block-code"><code>start &lt;- list(a = exp(coef(fit)[1]), b = coef(fit)[2])  # primed from the log-log
fit_nls &lt;- nls(ctr ~ a * position^b, data = gsc,
               weights = impression, start = start)
round(coef(fit_nls), 3)
#      a       b
#  0.315  -1.073</code></pre>



<p class="wp-block-paragraph">It converges, and returns an exponent of <strong>−1.073</strong>, 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.<br>The price we pay is fragility: without those starting values, or on noisier data, <code>nls</code> may fail to converge at all and hand us back only an error.</p>



<p class="wp-block-paragraph"><strong>Local smoothing</strong> with <code>loess</code> 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:</p>



<pre class="wp-block-code"><code>fit_lo &lt;- 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</code></pre>



<p class="wp-block-paragraph">And here is the limit in a single output: at position 1, <code>loess</code> returns <strong><code>NA</code></strong>. The minimum our data observe is 1.3, and outside that range <code>loess</code> refuses to commit — it <em>does not extrapolate</em>.<br>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.</p>



<p class="wp-block-paragraph">Put on the same chart, the three roads tell the whole story at a glance:</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1020" height="690" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-tre-metodi-en.png" alt="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." class="wp-image-4077" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-tre-metodi-en.png 1020w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-tre-metodi-en-300x203.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">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.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">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 — <code>loess</code> wobbles behind the few pages it finds and stops dead at the edge of the data, while <code>lm</code> and <code>nls</code> continue smoothly even where observations are scarce.</p>



<p class="wp-block-paragraph"><strong>So</strong>: the weighted log-log stays the workhorse — interpretable, extrapolable, with residuals already on a multiplicative scale. <code>nls</code> refines it when we need a clean exponent to write down; <code>loess</code> 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.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="residuals">Residuals: who earns less than they should</h2>



<p class="wp-block-paragraph">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: <code>ratio = actual_ctr / expected_ctr</code>. 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.<br>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:</p>



<pre class="wp-block-code"><code>gsc$ctr_exp &lt;- exp(predict(fit))           # back from the log scale to the natural one
gsc$ratio   &lt;- gsc$ctr / gsc$ctr_exp

gsc$flag &lt;- ifelse(gsc$ratio &lt; 0.6 &amp; gsc$impression &gt;= 1000, "UNDER",
             ifelse(gsc$ratio &gt; 1.4 &amp; gsc$impression &gt;= 1000, "OVER", "ok"))

gsc[order(gsc$ratio),
    c("page","position","impression","ctr","ctr_exp","ratio","flag")]</code></pre>



<p class="wp-block-paragraph">n.b. <code>predict</code> gives us the logarithm of the expected CTR, because that is the scale on which we estimated the model: <code>exp</code> brings it back to an actual CTR. Strictly speaking <code>exp</code> returns the <em>median</em> 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.<br>The output, sorted from the lowest ratio to the highest:</p>



<figure class="wp-block-table"><table><thead><tr><th>page</th><th>position</th><th>impression</th><th>ctr</th><th>ctr_exp</th><th>ratio</th><th>flag</th></tr></thead><tbody><tr><td>/attribution-model</td><td>8.3</td><td>900</td><td>0.012</td><td>0.029</td><td>0.41</td><td>ok</td></tr><tr><td>/keyword-research-guide</td><td>3.4</td><td>12500</td><td>0.034</td><td>0.076</td><td>0.44</td><td><strong>UNDER</strong></td></tr><tr><td>/technical-seo-guide</td><td>1.3</td><td>9800</td><td>0.232</td><td>0.218</td><td>1.07</td><td>ok</td></tr><tr><td>/campaign-roi-calculator</td><td>4.0</td><td>2100</td><td>0.071</td><td>0.064</td><td>1.11</td><td>ok</td></tr><tr><td>/seo-audit-checklist</td><td>2.1</td><td>5400</td><td>0.150</td><td>0.129</td><td>1.16</td><td>ok</td></tr><tr><td>/competitor-analysis</td><td>7.0</td><td>6100</td><td>0.041</td><td>0.035</td><td>1.18</td><td>ok</td></tr><tr><td>/ranking-report</td><td>9.1</td><td>3300</td><td>0.031</td><td>0.026</td><td>1.18</td><td>ok</td></tr><tr><td>/link-building-guide</td><td>6.1</td><td>4200</td><td>0.048</td><td>0.040</td><td>1.19</td><td>ok</td></tr><tr><td>/google-analytics-tutorial</td><td>4.6</td><td>7600</td><td>0.066</td><td>0.055</td><td>1.20</td><td>ok</td></tr><tr><td>/meta-tag-optimization</td><td>10.2</td><td>1800</td><td>0.028</td><td>0.023</td><td>1.21</td><td>ok</td></tr><tr><td>/statistics-glossary</td><td>5.2</td><td>1500</td><td>0.060</td><td>0.048</td><td>1.25</td><td>ok</td></tr><tr><td>/seo-tool-review</td><td>2.8</td><td>8300</td><td>0.171</td><td>0.094</td><td>1.81</td><td><strong>OVER</strong></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The case that jumps out is <strong>/keyword-research-guide</strong>: 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.<br>It is a strong, immediately actionable hypothesis: in all likelihood the <em>title</em> and the <em>meta description</em> are not doing their job, and a rewrite could unlock clicks the position had already earned.</p>



<p class="wp-block-paragraph">At the opposite end there is <strong>/seo-tool-review</strong>, which in second-to-third position earns almost double the expected. It is not a problem, it is a lesson: something in that <em>snippet</em> works beautifully — a magnetic title, a <em>rich card</em>, a perfect match with intent — and it is worth understanding what, to try to replicate it elsewhere. <strong>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.</strong></p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1110" height="690" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-curva-attesa-en.png" alt="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)." class="wp-image-4078" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-curva-attesa-en.png 1110w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-curva-attesa-en-300x186.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ctr-curva-attesa-en-1024x637.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The twelve example pages: average SERP position against CTR, with each point&#8217;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).</figcaption></figure>

</div></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="reading-deviations">Reading the deviations without fooling ourselves</h2>



<p class="wp-block-paragraph">There is a detail in the table that is the heart of the whole matter, and that is easy to miss. <strong>/attribution-model</strong> 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.<br>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 &#8220;page to optimise&#8221; 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.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">A word of caution: the expected CTR is a <strong>conditional typical value</strong> — the median the curve associates with a position — not a law of nature. A page can &#8220;under-perform&#8221; for reasons that have nothing to do with the <em>title</em>: a brand query inflating competitors&#8217; CTR, a <em>featured snippet</em> or a block of ads eating the clicks before the first organic result, a purely informational intent already satisfied by reading the <em>snippet</em>. And a CTR built on few impressions measures almost nothing: it will regress towards its mean on its own, as we saw talking about <a href="https://www.gironi.it/blog/en/regression-to-the-mean/">regression to the mean</a>. <strong>A negative residual is a hypothesis to verify — &#8220;maybe the title earns little here&#8221; — not a verdict to execute.</strong></p>



<p class="wp-block-paragraph">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.<br>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 &#8220;over-performs&#8221; almost everywhere. It is that the single large negative deviation, <code>/keyword-research-guide</code>, 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 &#8220;centre&#8221; 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.<br>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.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">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:</p>



<ol class="wp-block-list"><li>Aggregate by <strong>query</strong> 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.</li><li>Drop the impression weighting — <code>lm(log(ctr) ~ log(position))</code> without <code>weights</code> — 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 <em>feel</em> how much the weighting matters, instead of taking it on faith.</li><li>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.</li></ol>



<p class="wp-block-paragraph">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 &#8220;under-performing page&#8221; is signal and how much is, simply, noise.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">Spotting a page that earns less than expected <em>for its position</em> is a close cousin of another problem every analyst knows: spotting a day that earns less than expected <em>over time</em>, a drop or a spike in traffic that does not square with the usual trend.<br>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 <a href="https://www.gironi.it/blog/en/time-series-analysis-and-forecasting-in-r/">trend of the series</a>. From there springs <a href="https://www.gironi.it/blog/en/anomaly-detection-how-to-identify-outliers-in-your-data/">anomaly detection</a>: telling signal from noise when the numbers move over time, and the next step of our path.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">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 <code>loess</code>, <em><a href="https://www.amazon.it/dp/1461471370?tag=consulenzeinf-21&#038;ascsubtag=expected-vs-actual-ctr" rel="nofollow sponsored noopener" target="_blank">An Introduction to Statistical Learning</a></em> by James, Witten, Hastie and Tibshirani is the book I recommend: it covers both the &#8220;why&#8221; of the logarithms and the &#8220;how&#8221; of interpreting coefficients, with hands-on labs in R, always starting from applied problems.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/expected-vs-actual-ctr/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Naive Bayes: classifying search intent with Bayes&#8217; theorem</title>
		<link>https://www.gironi.it/blog/en/naive-bayes-search-intent/</link>
					<comments>https://www.gironi.it/blog/en/naive-bayes-search-intent/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Sun, 28 Jun 2026 08:27:35 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3902</guid>

					<description><![CDATA[In the article on the multi-armed bandit we used Bayes to decide between variants: shifting traffic toward the one converting best while the test is still running. Now we take a step sideways, while staying within the same line of reasoning: instead of choosing between options, we want to classify, that is to attach to &#8230; <a href="https://www.gironi.it/blog/en/naive-bayes-search-intent/" class="more-link">Continue reading<span class="screen-reader-text"> "Naive Bayes: classifying search intent with Bayes&#8217; theorem"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the article on the <a href="https://www.gironi.it/blog/en/thompson-sampling-multi-armed-bandit/">multi-armed bandit</a> we used Bayes to <em>decide between variants</em>: shifting traffic toward the one converting best while the test is still running. Now we take a step sideways, while staying within the same line of reasoning: instead of choosing between options, we want to <em>classify</em>, that is to attach to each new observation the most probable label given its features.<br> The concrete case is one that anyone doing SEO knows well: <strong>the intent behind a search query</strong>. Someone searching &#8220;how to bake a cake&#8221; wants to learn something; someone searching &#8220;buy shoes online&#8221; is ready to pull out a credit card. They are two different worlds, and they call for different content: a guide, a tutorial, a glossary for the first; a product page, a price list, a clearly visible <em>call to action</em> for the second. Getting the intent wrong means answering the right question in the wrong way.</p>



<p class="wp-block-paragraph">The trouble is that queries are many and always new, and classifying them by hand does not scale. We need a method that learns from a handful of labelled examples and then copes on its own with queries it has never seen. The algorithm that does this with almost disarming elegance is <em>Naive Bayes</em>, and — as the name hints — it starts once again from the <a href="https://www.gironi.it/blog/en/bayesian-statistics-how-to-learn-from-data-one-step-at-a-time/">Bayes&#8217; theorem</a> that has accompanied us throughout this path.</p>



<span id="more-3902"></span>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#from-theorem">From the theorem to the classifier</a></li><li><a href="#training">Training on labelled queries</a></li><li><a href="#classifying">Classifying new queries</a></li><li><a href="#limits">The limits of &#8220;naive&#8221;</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="from-theorem">From the theorem to the classifier</h2>



<p class="wp-block-paragraph">Let us pick up the thread. Bayes&#8217; theorem tells us how to update the probability of a hypothesis in the light of the observed data. Here the hypothesis is &#8220;this query belongs to the <em>informational</em> class&#8221; (or <em>transactional</em>), and the data are the words that make up the query. We want, in other words, the probability of a class <em>given</em> the text: in symbols, <em>P(class | words)</em>.</p>



<p class="wp-block-paragraph">Computing it directly would be a nightmare, because the possible combinations of words are boundless. Bayes lets us flip the problem around: instead of asking how probable the class is given the words, we ask how probable those words are given the class — a question the training data can answer. The rule, first in words and then in a formula, is that the probability of a class given a query is proportional to the prior probability of the class times the probability of observing those words if the class were that one:</p>



\( P(\text{class} \mid \text{words}) \propto P(\text{class}) \cdot \prod_i P(\text{word}_i \mid \text{class}) \\ \)



<p class="wp-block-paragraph">Let us unpack the symbols. <em>P(class)</em> is the <strong>prior</strong>: how frequent that class is to begin with, before looking at the text (if half of the example queries are informational, the prior is 0.5). <em>P(word | class)</em> is how often that word appears in the queries of that class. The symbol ∏ is simply the product: we multiply the contributions of all the words in the query. The sign ∝ (&#8220;proportional to&#8221;) reminds us that we are dropping a constant denominator, identical across classes: since in the end we only care <em>which</em> class wins, we can ignore it with no harm.</p>



<p class="wp-block-paragraph">And here it is, the spot where &#8220;naive&#8221; hides. <strong>Multiplying the probabilities of the individual words as if they were independent of one another amounts to assuming that, given the class, the presence of one word says nothing about the presence of the others.</strong> It is a plainly false assumption about real language — &#8220;credit&#8221; and &#8220;card&#8221; certainly do not appear at random independently of each other — and it is precisely this naivety that gives the algorithm its name. The surprising thing, as we shall see, is that despite starting from so crude an assumption the method works beautifully in practice.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="training">Training on labelled queries</h2>



<p class="wp-block-paragraph">Training a Naive Bayes means one thing only: counting. For each class we count how many times each word appears in the example queries, and from those counts we derive the <em>P(word | class)</em>. No optimisation, no iterations: we scan the data once and fill a table of frequencies.</p>



<p class="wp-block-paragraph">Let us start from a small set of hand-labelled queries, five informational and five transactional. I train the classifier in R like this:</p>



<pre class="wp-block-code"><code>train &lt;- list(
  info  = c("how to bake a cake", "what is statistics", "seo guide for beginners",
            "free r tutorial", "how bayesian works"),
  trans = c("buy shoes online", "iphone price deal", "best cheap hosting",
            "purchase seo course", "gym membership discount")
)
tok &lt;- function(s) unlist(strsplit(tolower(s), "\\s+"))
vocab &lt;- unique(unlist(lapply(unlist(train), tok)))
counts &lt;- lapply(train, function(docs) {
  w &lt;- table(factor(unlist(lapply(docs, tok)), levels = vocab)); w + 1
})
tots &lt;- sapply(counts, sum); V &lt;- length(vocab)
prior &lt;- sapply(train, length) / length(unlist(train))   # 0.5 / 0.5</code></pre>



<p class="wp-block-paragraph">Let us see what happens line by line. The <code>tok</code> function splits each query into lowercase words (a spartan tokenisation, but enough). <code>vocab</code> is the vocabulary, the list of all distinct words seen in training. For each class, <code>table(factor(...))</code> counts the occurrences of each vocabulary word; <code>tots</code> is the total of the counts per class, and <code>prior</code> here is 0.5 and 0.5 because the two classes have the same number of examples.</p>



<p class="wp-block-paragraph">There is a detail in that <code>w + 1</code> worth pausing on, because it is the trick that holds the whole edifice up. <strong>If a word never appears in the queries of a class, its count is zero, and with it the entire product of probabilities would collapse to zero: a single unknown word would be enough to drive the class probability to zero, wiping out the contribution of all the others.</strong> It is the classic case where &#8220;multiplying by zero&#8221; ruins the party. The fix is called <strong>Laplace smoothing</strong>: we add 1 to the count of every word, in every class, before computing the proportions. No word any longer has exactly zero probability, only a very small one.</p>



<p class="wp-block-paragraph">The price of smoothing is that the per-class totals gain the number of vocabulary words (the units added one per word): that is why <code>tots</code> is not the raw token counts of the two classes but the smoothed totals. With these numbers, for example, the word &#8220;buy&#8221; (present once among the transactional queries, never among the informational ones) ends up clearly more probable under <em>trans</em> than under <em>info</em> — and it is exactly this imbalance that pushes a query toward the right intent. A neutral word like &#8220;seo&#8221;, appearing once on each side, stays roughly balanced between the two classes and does not move the needle.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="990" height="630" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-impronta-en.png" alt="The lexical fingerprint of the two classes: P(word | class) for a few vocabulary terms, after Laplace smoothing. "buy" and "discount" weigh more than twice toward transactional, "how" toward informational, "seo" stays almost balanced: it is this word-by-word imbalance that pushes a query toward the right intent." class="wp-image-4035" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-impronta-en.png 990w, https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-impronta-en-300x191.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The lexical fingerprint of the two classes: P(word | class) for a few vocabulary terms, after Laplace smoothing. &#8220;buy&#8221; and &#8220;discount&#8221; weigh more than twice toward transactional, &#8220;how&#8221; toward informational, &#8220;seo&#8221; stays almost balanced: it is this word-by-word imbalance that pushes a query toward the right intent.</figcaption></figure>

</div></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="classifying">Classifying new queries</h2>



<p class="wp-block-paragraph">With the frequency table ready, classifying a new query is genuinely child&#8217;s play: we tokenise it, sum the logarithms of the probabilities of each word (summing logarithms instead of multiplying probabilities avoids the product of many tiny numbers going into numerical <em>underflow</em>, but the result is the same), add the logarithm of the prior, and pick the class with the highest score. I compute it in R:</p>



<pre class="wp-block-code"><code>classify &lt;- function(query) {
  w &lt;- tok(query)
  logp &lt;- log(prior)
  for (cl in names(train)) {
    p &lt;- counts[[cl]][w]; p[is.na(p)] &lt;- 1            # out-of-vocab word: neutral weight
    logp[cl] &lt;- logp[cl] + sum(log(as.numeric(p) / tots[cl]))
  }
  names(which.max(logp))
}
cat("'buy seo course discount' -&gt;", classify("buy seo course discount"), "\n")
cat("'how to learn statistics'   -&gt;", classify("how to learn statistics"), "\n")</code></pre>



<p class="wp-block-paragraph">The output is clear-cut:</p>



<pre class="wp-block-code"><code>'buy seo course discount' -&gt; trans
'how to learn statistics'   -&gt; info</code></pre>



<p class="wp-block-paragraph">As we can see, the classifier assigns the first query to the transactional intent and the second to the informational one, exactly as a human would have. It is worth noting how it gets there. In the first query &#8220;buy&#8221; and &#8220;discount&#8221; pull decisively toward <em>trans</em>, &#8220;seo&#8221; stays neutral, and not even &#8220;course&#8221; — which in training appeared in the transactional &#8220;purchase seo course&#8221; — rows against it: the verdict is solid. In the second, &#8220;statistics&#8221; is a markedly informational word, and &#8220;learn&#8221;, though out of vocabulary, does no harm thanks to that <code>p[is.na(p)] &lt;- 1</code> which assigns never-seen words a neutral weight, identical for both classes: having nothing to say, it simply does not vote.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1110" height="585" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-tiro-alla-fune-en.png" alt="The words' tug of war: for each query, the log-likelihood ratio of each word. To the right (orange) the word pushes toward transactional, to the left (blue) toward informational, near zero the neutral or out-of-vocabulary words (like "learn", which does not vote). The sum of the contributions decides the verdict." class="wp-image-4036" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-tiro-alla-fune-en.png 1110w, https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-tiro-alla-fune-en-300x158.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/naive-bayes-tiro-alla-fune-en-1024x540.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The words&#8217; tug of war: for each query, the log-likelihood ratio of each word. To the right (orange) the word pushes toward transactional, to the left (blue) toward informational, near zero the neutral or out-of-vocabulary words (like &#8220;learn&#8221;, which does not vote). The sum of the contributions decides the verdict.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">A handful of example queries per side is very little, and yet the mechanism is already all here. In a real case it is enough to replace the handfuls of labelled queries with a few hundred or thousand queries pulled from Search Console and annotated for intent, and the same code — unchanged in structure — becomes an intent classifier you can actually use.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="limits">The limits of &#8220;naive&#8221;</h2>



<p class="wp-block-paragraph">Before diving in, though, it is worth knowing where the method shows its seams, because its simplicity has a flip side.</p>



<p class="wp-block-paragraph">The most obvious limit is precisely the independence assumption it takes its name from. By treating each word as detached from the others, Naive Bayes ignores order and context entirely: to it &#8220;cheap running shoes&#8221; and &#8220;the economics of running shoes&#8221; are the same <em>bag of words</em>. In intent classification this matters little, but in subtler tasks it can mislead. Then there is the question of <strong>out-of-vocabulary words</strong>: a query made only of terms never seen in training would be decided by the prior alone, that is by a pure coin toss — the sign that the example dataset is too thin and needs widening.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">A note of caution that holds for any classifier, and all the more for one trained on little data: the model learns <em>exactly</em> what we show it, biases included. If the transactional example queries all contain the word &#8220;buy&#8221;, the classifier will associate purchase intent with that term and will struggle on an equally transactional but lexically different &#8220;add to cart&#8221;. The quality and representativeness of the labelled data matter more than the sophistication of the algorithm: a Naive Bayes fed with varied, balanced examples beats a sophisticated model trained badly.</p>



<p class="wp-block-paragraph">That said, Naive Bayes remains a genuinely valuable tool to have in the box: it is extremely fast to train, needs little data to get going, is interpretable (we can always go and look at which words drove the decision) and in text classification it holds its own against far more complex models. It is often the <em>baseline</em> to beat before bringing in heavier artillery — and this is where the door opens onto machine learning proper, where classification is done with trees, regressions and networks that drop the independence assumption in exchange for more power (and less transparency).</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">The code above is a perfect playground for building intuition. Search queries are not only informational or transactional: at least a third family is missing, the <strong>navigational</strong> one (someone searching &#8220;facebook login&#8221; or &#8220;gironi blog&#8221; just wants to reach a specific site). Here are a few changes to try:</p>



<ol class="wp-block-list"><li>Add a <code>nav</code> class to <code>train</code> with four or five navigational queries (&#8220;facebook login&#8221;, &#8220;youtube&#8221;, &#8220;amazon sign in&#8221;, &#8220;gmail inbox&#8221;), then retrain: the <code>prior</code> will no longer be 0.5 but roughly a third per class. How do the classifications of the earlier queries change?</li><li>Feed the classifier an ambiguous query like &#8220;iphone review&#8221; (informational? transactional?) and see which way it leans. Did the verdict make sense, given the words in training?</li><li>Remove the smoothing (replace <code>w + 1</code> with <code>w</code>) and try classifying a query with a rare word: what happens to the score when a count is zero? It is the quickest way to see with your own eyes why Laplace is needed.</li></ol>



<p class="wp-block-paragraph">The nice part is that the structure of the code never changes: adding a class just means lengthening the <code>train</code> list, and everything else — vocabulary, counts, prior, classification rule — adapts on its own.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">With this we close the Bayesian thread we have unspooled article after article: from <a href="https://www.gironi.it/blog/en/bayesian-conversion-rate-estimation/">estimating a conversion rate</a> to comparing variants, from the adaptive allocation of traffic to this last leap, from deciding to classifying. The same theorem, reworn each time in a different guise, has proved a surprisingly sturdy common thread. From here the road forks toward machine learning in the full sense — decision trees, logistic regression, neural networks — where the methods give up the most comfortable assumptions in exchange for power, and where Bayes stays in the background as the grammar by which, in the end, we always learn from data.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">If you want to move from Naive Bayes to machine learning proper while keeping your feet on the ground (and R at hand), <a href="https://www.amazon.it/dp/1461471370?tag=consulenzeinf-21&#038;ascsubtag=naive-bayes-search-intent" rel="nofollow sponsored noopener" target="_blank"><em>An Introduction to Statistical Learning</em></a> by James, Witten, Hastie and Tibshirani is the book I recommend. It is the most accessible doorway into applied machine learning: it explains classification, trees and regression with the right rigour but without intimidating mathematics, and every chapter has R labs you can redo step by step. The second edition devotes space to Naive Bayes itself, so the jump from this article to the rest of the path is a natural one.</p>



<p class="wp-block-paragraph">This article is part of the <a href="https://www.gironi.it/blog/en/bayesian-approach/">&#8220;The Bayesian Approach&#8221;</a> path, a guided route through the articles on Bayesian statistics and inference for SEO.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/naive-bayes-search-intent/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Multi-armed bandit: optimising the variants while the test is still running</title>
		<link>https://www.gironi.it/blog/en/thompson-sampling-multi-armed-bandit/</link>
					<comments>https://www.gironi.it/blog/en/thompson-sampling-multi-armed-bandit/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Sat, 27 Jun 2026 17:05:23 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3889</guid>

					<description><![CDATA[In the article on Bayesian A/B testing we compared two variants at a fixed sample size: we collect the data for the whole planned duration, compute the probability that B beats A, and decide. It is a solid method, but it carries a cost that usually goes unmentioned. That cost is the traffic that, for &#8230; <a href="https://www.gironi.it/blog/en/thompson-sampling-multi-armed-bandit/" class="more-link">Continue reading<span class="screen-reader-text"> "Multi-armed bandit: optimising the variants while the test is still running"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the article on <a href="https://www.gironi.it/blog/en/bayesian-ab-testing/">Bayesian A/B testing</a> we compared two variants at a fixed sample size: we collect the data for the whole planned duration, compute the probability that B beats A, and decide. It is a solid method, but it carries a cost that usually goes unmentioned.<br> That cost is the traffic that, for the entire duration of the test, we keep sending to the worse variant. If halfway through the experiment B is already winning hands down, every visitor assigned to A is a conversion we are probably throwing away. <strong>The fixed-sample test makes us pay for the information we gather: to learn which variant is better, we must keep showing the one we suspect to be the worse.</strong></p>



<p class="wp-block-paragraph">There is a way to cut this bill, and it is called a <em>multi-armed bandit</em>. The idea is to shift traffic adaptively toward the variant that is winning <em>while the test is still running</em>, instead of waiting for the final verdict. In this article we build one with one of the most elegant and practical algorithms, <em>Thompson sampling</em>, which is the natural continuation of the <a href="https://www.gironi.it/blog/en/bayesian-statistics-how-to-learn-from-data-one-step-at-a-time/">Bayesian</a> reasoning we have followed so far.</p>



<span id="more-3889"></span>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#exploration-exploitation">The exploration versus exploitation dilemma</a></li><li><a href="#thompson-sampling">Thompson sampling: letting the posterior decide</a></li><li><a href="#regret-avoided">How much we really gain: the regret avoided</a></li><li><a href="#when-it-makes-sense">When a bandit makes sense, and when it does not</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="exploration-exploitation">Exploration versus exploitation</h2>



<p class="wp-block-paragraph">The name comes from slot machines: a <em>one-armed bandit</em> is the casino machine, and let us imagine we have several of them in front of us, each with an unknown win probability different from the others. We have a limited number of tokens. At each play we must choose which arm to pull. What is the strategy that maximises the total winnings?</p>



<p class="wp-block-paragraph">The translation for anyone doing SEO or marketing is immediate: the arms are the variants (three versions of a <em>title tag</em>, of a <em>call to action</em>, of a landing page), the tokens are the visitors, the winnings are the conversions. Each visitor must be assigned to a variant, and we want to maximise total conversions across the whole experiment.</p>



<p class="wp-block-paragraph">Here arises the underlying tension, the one that makes the problem interesting. On one hand we would like to <strong>exploit</strong> the variant that so far seems the best, to bank as many conversions as possible. On the other we must keep <strong>exploring</strong> the others too, because &#8220;so far it seems the best&#8221; rests on little data and might be a wrong impression.<br> It is a delicate balance: too much exploration and we waste traffic on mediocre variants; too much exploitation and we risk crowning the wrong winner on the basis of an early stroke of luck. <strong>The exploration-exploitation dilemma is the heart of every multi-armed bandit problem: each choice is at once an opportunity for gain and an opportunity for learning, and the two pull in opposite directions.</strong></p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="thompson-sampling">Thompson sampling: letting the posterior decide</h2>



<p class="wp-block-paragraph">The classic A/B test solves the dilemma in the crudest possible way: it explores and nothing more, in equal parts, until the end. Half the traffic to A, half to B, no adaptation. Thompson sampling solves it in a far cleverer, and almost surprisingly simple, way.</p>



<p class="wp-block-paragraph">Let us pick up the Bayesian thread. For each variant we keep a posterior on its conversion rate: as we saw when estimating the conversion rate, starting from a <a href="https://www.gironi.it/blog/en/the-beta-distribution-explained-simply/">Beta</a> prior and observing binary outcomes (conversion yes/no), the posterior of each arm is again a Beta distribution, updated with every visitor. At the start, when we know nothing, each arm begins from a non-informative Beta(1, 1) prior.</p>



<p class="wp-block-paragraph">Thompson&#8217;s rule, in words before formulas, is this: instead of asking &#8220;which is the variant with the highest mean so far?&#8221;, at each visitor we <strong>sample a rate at random from the posterior of each arm, and play the arm that produced the highest sample</strong>. It is a way of choosing &#8220;in proportion to the probability of being the best&#8221;: a variant we are very uncertain about can still win the draw now and then (and that is how it keeps being explored), but as the data accumulate its samples concentrate and, if it really is worse, it almost stops being chosen on its own.</p>



<p class="wp-block-paragraph">The elegance lies precisely here: there is no exploration parameter to tune by hand. The uncertainty of the posterior <em>is</em> the engine of exploration. The more uncertain an arm, the more its samples are spread out, the more often it happens to win the draw and get tried; the more certain it becomes, the more its samples tighten around the true value and the arm gets played (or avoided) decisively.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1080" height="555" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-beta-prima-dopo-en.png" alt="The three Beta posteriors, one per variant. After 300 visitors (left) they are wide and overlapping: the uncertainty keeps exploration alive. At the end of the experiment (right) the best variant is narrow and well separated, while the other two, barely explored, stay wide." class="wp-image-4020" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-beta-prima-dopo-en.png 1080w, https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-beta-prima-dopo-en-300x154.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-beta-prima-dopo-en-1024x526.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The three Beta posteriors, one per variant. After 300 visitors (left) they are wide and overlapping: the uncertainty keeps exploration alive. At the end of the experiment (right) the best variant is narrow and well separated, while the other two, barely explored, stay wide.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">I simulate in R the whole process on three variants with true rates of 5%, 7% and 9% (which the algorithm of course does not know), over 5000 visitors:</p>



<pre class="wp-block-code"><code>set.seed(7)
true_rates &lt;- c(0.05, 0.07, 0.09)   # 3 variants, the third is the best
K &lt;- length(true_rates); N &lt;- 5000
alpha &lt;- rep(1, K); beta_ &lt;- rep(1, K)   # Beta(1,1) prior per arm
pulls &lt;- rep(0, K); rewards &lt;- rep(0, K)
for (t in 1:N) {
  theta &lt;- rbeta(K, alpha, beta_)        # sample a rate per arm
  arm &lt;- which.max(theta)                 # play the best sampled arm
  r &lt;- rbinom(1, 1, true_rates[arm])      # outcome (conv yes/no)
  alpha[arm] &lt;- alpha[arm] + r; beta_[arm] &lt;- beta_[arm] + (1 - r)
  pulls[arm] &lt;- pulls[arm] + 1; rewards[arm] &lt;- rewards[arm] + r
}</code></pre>



<p class="wp-block-paragraph">The loop is all there is. At each iteration we sample a <code>theta</code> per arm (<code>rbeta</code>), choose the maximum (<code>which.max</code>), simulate the outcome (<code>rbinom</code>) and update the parameters of the arm played: a conversion increases its <code>alpha</code> by one, a non-conversion its <code>beta_</code>. No other logic, no threshold to calibrate.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="regret-avoided">How much we gain: the regret avoided</h2>



<p class="wp-block-paragraph">Let us see where the simulation took us. The first thing to look at is how the 5000 visitors were distributed across the three arms:</p>



<pre class="wp-block-code"><code>cat("pulls per arm:", pulls, "\n")
cat("bandit total conv:", sum(rewards), "\n")</code></pre>



<p class="wp-block-paragraph">Output: pulls per arm = 359, 278, 4363; bandit total conv = 424.</p>



<p class="wp-block-paragraph"><strong>Of the 5000 visitors, a full 4363 ended up on the best variant, and only a little over 600 in total on the two worse ones.</strong> The algorithm, without our having told it anything about the true rates, worked out on its own which arm to reward and steered the vast majority of the traffic there. It is exactly the exploitation we wanted, reached through just enough early exploration to tell the arms apart.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="975" height="600" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-allocazione-tempo-en.png" alt="The share of traffic assigned to each variant as the test proceeds: with no intervention, the bandit steers the vast majority of visitors (4363 out of 5000) toward the best variant, leaving the other two at just over 300 each." class="wp-image-4021" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-allocazione-tempo-en.png 975w, https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-allocazione-tempo-en-300x185.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The share of traffic assigned to each variant as the test proceeds: with no intervention, the bandit steers the vast majority of visitors (4363 out of 5000) toward the best variant, leaving the other two at just over 300 each.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">Now the comparison that reveals the size of the advantage. A classic A/B test would have split the 5000 visitors equally across the three variants, roughly 1667 each, for the whole duration. The expected conversions in that scenario are simply the mean of the three rates times the number of visitors:</p>



<pre class="wp-block-code"><code>exp_ab &lt;- sum(true_rates) / K * N
cat("expected conv equal-split A/B:", round(exp_ab), "\n")
cat("regret avoided (approx):", round(sum(rewards) - exp_ab), "conv\n")</code></pre>



<p class="wp-block-paragraph">Output: expected conv equal-split A/B = 350; regret avoided (approx) = 74 conv.</p>



<p class="wp-block-paragraph">An equal-split A/B would have taken home about 350 conversions; the bandit collected 424. The difference, about <strong>74 more conversions on the very same traffic</strong>, is what is technically called the <em>regret</em> avoided: the regret, that is the conversions lost by playing the wrong arms, that the adaptive allocation saved us. In plainer terms, for the same number of visitors the bandit converted over 20% more, simply by not insisting on the weak variants.<br> Note that we did not have to sacrifice anything to get it: at the end of the experiment we still know the winner (indeed we know it with great confidence, given the 4363 data points gathered on it), and in the meantime we converted more. This is the structural advantage of the bandit over the fixed-sample test.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="975" height="600" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-cumulate-vs-ab-en.png" alt="Cumulative conversions of the bandit versus an equal-split A/B: on the same traffic the two curves diverge, and the final gap — 424 against 350, i.e. the 74 extra conversions — is the regret avoided." class="wp-image-4022" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-cumulate-vs-ab-en.png 975w, https://www.gironi.it/blog/wp-content/uploads/2026/07/bandit-cumulate-vs-ab-en-300x185.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">Cumulative conversions of the bandit versus an equal-split A/B: on the same traffic the two curves diverge, and the final gap — 424 against 350, i.e. the 74 extra conversions — is the regret avoided.</figcaption></figure>

</div></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="when-it-makes-sense">When it makes sense (and when it does not)</h2>



<p class="wp-block-paragraph">That said, a bandit is not the right answer to every question, and it is important to understand where it shines and where instead a traditional A/B test remains preferable.</p>



<p class="wp-block-paragraph">The bandit is at its best when traffic is <strong>continuous and long-running</strong> (an always-on page, a campaign running for months) and when the variants to compare are <strong>many</strong>: there the adaptive allocation pays off, because there is time and volume to shift the traffic and plenty of weak variants to drop quickly. It is ideal for ongoing optimisations where the goal is to maximise conversions along the whole journey, not to take a clean statistical snapshot at a given moment.</p>



<p class="wp-block-paragraph">When instead the goal is precisely that snapshot — a precise, unbiased estimate of <em>how much</em> a variant is better, perhaps to defend in front of a client or to use for a strategic decision — the fixed-sample test remains more suitable: precisely because it explores in equal parts, it gathers balanced data on all variants and produces sharper estimates of the effect. The bandit, by concentrating early on the winner, gathers little data on the losers and therefore estimates <em>by how much</em> they are worse less well.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">A note of caution: the bandit assumes that the conversion rates stay stable over time. But the real world is often non-stationary — seasonality, shifts in audience, a promotion that kicks in. If the best variant changes <em>after</em> the algorithm has already concentrated on another, a naive Thompson sampling like the one shown here struggles to notice, because it has stopped exploring the alternatives. In changing contexts we need variants that &#8220;forget&#8221; old data (for example by gradually discounting past observations), otherwise we risk staying anchored to a winner that no longer is one.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">Simulation is a perfect playground for building intuition. Taking the code above, try changing the starting numbers and observe how the <code>pulls</code> redistribute:</p>



<ol class="wp-block-list"><li>Bring the true rates closer, for example <code>true_rates &lt;- c(0.07, 0.08, 0.09)</code>: with more similar variants, how much more traffic is needed before the best arm breaks away? Does the concentration of pulls stay this sharp?</li><li>Cut the traffic drastically (<code>N &lt;- 500</code>): with few visitors does the bandit still have time to spot the winner, or does exploration eat up the whole budget?</li><li>Add variants: move to four or five arms. With more alternatives to discard, does the advantage in <code>regret avoided</code> over the equal-split A/B grow or shrink?</li></ol>



<p class="wp-block-paragraph">Hint: the structure of the loop never changes, you just modify <code>true_rates</code> and <code>N</code>. It is precisely by watching how the <code>pulls</code> vector tips (or fails to tip) as a function of the distance between the rates that you truly grasp what Thompson sampling does under the hood.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">So far we have used Bayes to <em>decide between variants</em>: estimate a rate, compare two, allocate traffic adaptively. But the same machinery — a prior, some data, a posterior — also serves a different and very common task: <em>classifying</em>, that is assigning to each new observation the most probable label given its features. It is the leap from deciding to classifying, and the algorithm that does it with disarming elegance, once again starting from Bayes&#8217; theorem, is <em>Naive Bayes</em>: the subject of the next article.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">If you want to explore bandits applied to website optimisation, <a href="https://www.amazon.it/dp/1449341330?tag=consulenzeinf-21&#038;ascsubtag=thompson-sampling-multi-armed-bandit" rel="nofollow sponsored noopener" target="_blank"><em>Bandit Algorithms for Website Optimization</em></a> by John Myles White is the book I recommend. It is a slim, avowedly practical volume that compares A/B tests and bandit algorithms (epsilon-greedy, softmax, UCB) precisely from the standpoint of someone optimising pages and conversions, with the code at hand. It is the ideal starting point for anyone wanting to move from the simulation we have seen to a bandit that actually runs on their own site.</p>



<p class="wp-block-paragraph">This article is part of the <a href="https://www.gironi.it/blog/en/bayesian-approach/">&#8220;The Bayesian Approach&#8221;</a> path, a guided route through the articles on Bayesian statistics and inference for SEO.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/thompson-sampling-multi-armed-bandit/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Bayesian A/B Testing: not just &#8220;whether&#8221; B beats A, but &#8220;by how much&#8221;</title>
		<link>https://www.gironi.it/blog/en/bayesian-ab-testing/</link>
					<comments>https://www.gironi.it/blog/en/bayesian-ab-testing/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Fri, 26 Jun 2026 07:59:22 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3881</guid>

					<description><![CDATA[In the article on classic A/B testing we saw how to compare two variants with the two-proportion test: we compute a statistic, get a p-value, and decide whether to reject the null hypothesis. It works, and it is the daily bread of anyone running online experiments. But there is a subtle gap between what the &#8230; <a href="https://www.gironi.it/blog/en/bayesian-ab-testing/" class="more-link">Continue reading<span class="screen-reader-text"> "Bayesian A/B Testing: not just &#8220;whether&#8221; B beats A, but &#8220;by how much&#8221;"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the article on <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">classic A/B testing</a> we saw how to compare two variants with the two-proportion test: we compute a statistic, get a p-value, and decide whether to reject the null hypothesis. It works, and it is the daily bread of anyone running online experiments. But there is a subtle gap between what the p-value tells us and what we actually want to know.<br> The p-value answers a convoluted question: &#8220;if A and B were identical, how unlikely would it be to observe a difference as large as this one?&#8221;. The question we care about in practice is a different, far more direct one: <strong>what is the probability that B is better than A?</strong> And, right after: by how much, and how much can we trust that &#8220;how much&#8221;?</p>



<p class="wp-block-paragraph">The <a href="https://www.gironi.it/blog/en/bayesian-statistics-how-to-learn-from-data-one-step-at-a-time/">Bayesian</a> approach answers both questions natively. In this article we apply it to the comparison of two variants, picking up the thread we left hanging when we estimated <a href="https://www.gironi.it/blog/en/bayesian-conversion-rate-estimation/">the conversion rate of a single variant</a>.</p>



<p class="wp-block-paragraph">It is the question behind every conversion test with an SEO angle: two versions of the same landing page competing for the same organic traffic, or two title-and-meta wordings judged by their <a href="https://www.gironi.it/blog/en/expected-vs-actual-ctr/">actual versus expected CTR</a> in the SERP. Wherever there are <em>successes over trials</em> — clicks over impressions, sign-ups over visits, conversions over sessions — the reasoning that follows is the same.</p>



<span id="more-3881"></span>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#two-posteriors">Two posteriors instead of one: a distribution per variant</a></li><li><a href="#probability-b-wins">What is the probability that B really wins</a></li><li><a href="#distribution-of-difference">By how much it is better: the distribution of the difference</a></li><li><a href="#when-to-stop">When to stop: expected loss and the peeking problem</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="two-posteriors">Two posteriors instead of one</h2>



<p class="wp-block-paragraph">When we estimated the conversion rate of a single variant, we saw that — starting from a <a href="https://www.gironi.it/blog/en/the-beta-distribution-explained-simply/">Beta</a> prior and observing binomial data — the posterior is again a Beta distribution. The updating rule was simple arithmetic: if the prior is Beta(α, β) and we observe \( k \) conversions out of \( n \) sessions, the posterior is:</p>



\( Beta(\alpha + k,\ \beta + (n &#8211; k)) \\ \)



<p class="wp-block-paragraph">Put in words: we add the observed conversions to the first parameter and the non-conversions to the second, nothing more.</p>



<p class="wp-block-paragraph">In an A/B test we do not have a single proportion, we have two: one for the control (A) and one for the treatment (B). The mechanism, however, is identical: we build <strong>one posterior per variant</strong>, independently, applying the same rule twice.</p>



<p class="wp-block-paragraph">Here is a quick example. We tested two versions of a landing page that receives organic traffic, assigning visitors at random:</p>



<ul class="wp-block-list"><li><strong>Variant A</strong> (control): 90 conversions out of 1000 sessions → raw rate 9.0%</li><li><strong>Variant B</strong> (treatment): 120 conversions out of 1000 sessions → raw rate 12.0%</li></ul>



<p class="wp-block-paragraph">We start from a non-informative Beta(1, 1) prior for both — &#8220;we know nothing, before the data every rate is equally plausible&#8221;. Applying the rule, the posterior of A is Beta(91, 911) and that of B is Beta(121, 881).</p>



<p class="wp-block-paragraph">I build the two posteriors in R, sampling them by simulation:</p>



<pre class="wp-block-code"><code>set.seed(42)
# Variant A: 90 conv / 1000 ; Variant B: 120 conv / 1000
cA &lt;- 90; nA &lt;- 1000; cB &lt;- 120; nB &lt;- 1000

# Posteriors with uniform Beta(1,1) prior
postA &lt;- rbeta(1e5, 1 + cA, 1 + nA - cA)   # Beta(91, 911)
postB &lt;- rbeta(1e5, 1 + cB, 1 + nB - cB)   # Beta(121, 881)</code></pre>



<p class="wp-block-paragraph">Now we hold two distributions, not two numbers. And this is precisely the point: instead of comparing 9.0% against 12.0% as if they were fixed values, we compare the whole uncertainty surrounding them. The operational questions become operations on these distributions.</p>



<p class="wp-block-paragraph">It helps to keep the picture in mind: two ridges of probability side by side along the conversion-rate axis. The further B&#8217;s ridge sits to the right of A&#8217;s, and the less the two overlap, the more likely it becomes that B is genuinely the better variant.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1050" height="630" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-due-posterior-en-1.png" alt="The two Beta posteriors side by side: not two numbers (9% and 12%) but two distributions, still overlapping a little — and it is in that overlap that the residual doubt lives." class="wp-image-4217" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-due-posterior-en-1.png 1050w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-due-posterior-en-1-300x180.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-due-posterior-en-1-1024x614.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The two Beta posteriors side by side: not two numbers (9% and 12%) but two distributions, still overlapping a little — and it is in that overlap that the residual doubt lives.</figcaption></figure>

</div></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="probability-b-wins">What is the probability that B wins?</h2>



<p class="wp-block-paragraph">The first question — the one the p-value never answers directly — is the probability that B is genuinely better than A.<br> With the posteriors in hand, the calculation is almost trivial: we compare B&#8217;s samples with A&#8217;s, pair by pair, and count in what fraction of cases B exceeds A. That fraction <em>is</em> the probability we are after.</p>



<p class="wp-block-paragraph">I compute in R the probability that B beats A:</p>



<pre class="wp-block-code"><code>cat("P(B&gt;A) =", round(mean(postB &gt; postA), 3), "\n")</code></pre>



<p class="wp-block-paragraph">Output: P(B&gt;A) = 0.985.</p>



<p class="wp-block-paragraph"><strong>There is a 98.5% probability that variant B converts better than variant A.</strong><br> Notice the change of register compared to the frequentist version. We are not saying &#8220;the observed difference is unlikely under the null hypothesis&#8221;: we are saying, directly, that given the evidence collected it is almost certain that B is the better variant. This is exactly the statement we would want to base a decision on — and the Bayesian approach hands it over without circumlocutions.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="distribution-of-difference">By how much it is better: the distribution of the difference</h2>



<p class="wp-block-paragraph">Knowing that B wins with 98.5% probability is not enough to decide. There is almost surely an improvement, but if it were two tenths of a percentage point, perhaps it would not be worth shipping the new page. The next question is therefore: <em>by how much</em> is it better?</p>



<p class="wp-block-paragraph">So far we have kept A and B separate, one posterior each. But the object we actually care about is neither of them on its own: it is the distance between them. The answer therefore lives in the <strong>distribution of the difference</strong> between the two posteriors. We subtract, sample by sample, A&#8217;s rate from B&#8217;s: we obtain a new distribution, that of the uplift. From it we read both the typical value (the mean) and a credible interval that quantifies its uncertainty.</p>



<p class="wp-block-paragraph">I compute in R the difference and its 95% interval:</p>



<pre class="wp-block-code"><code>diff &lt;- postB - postA
cat("mean uplift (pct points) =", round(mean(diff)*100, 2), "\n")
cat("95% CI of difference =", round(quantile(diff, c(.025,.975))*100, 2), "\n")</code></pre>



<p class="wp-block-paragraph">Output: mean uplift = 3.00 pct points, 95% CI = [0.31, 5.68].</p>



<p class="wp-block-paragraph"><strong>The expected gain is about 3 percentage points of conversion, with a 95% credible interval running from 0.31 to 5.68 points.</strong><br> Here too the meaning is direct, not an abstract property of the procedure: there is a 95% probability that the true improvement of B over A lies between 0.3 and 5.7 percentage points. The interval does not touch zero, which confirms — consistently with the earlier 98.5% — that B is almost certainly superior. But the valuable figure is the width: the improvement could be modest (half a point) or robust (over five points), and this spread is information the operational decision must keep in mind.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1080" height="645" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-differenza-en-1.png" alt="The distribution of the B − A uplift in percentage points: the area to the right of zero is the probability that B wins (98.5%), the blue bar the 95% credible interval, from 0.31 to 5.68 points. A single chart answers both questions — “whether” and “by how much”." class="wp-image-4218" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-differenza-en-1.png 1080w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-differenza-en-1-300x179.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-differenza-en-1-1024x612.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The distribution of the B − A uplift in percentage points: the area to the right of zero is the probability that B wins (98.5%), the blue bar the 95% credible interval, from 0.31 to 5.68 points. A single chart answers both questions — “whether” and “by how much”.</figcaption></figure>

</div></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="when-to-stop">When to stop: expected loss and the peeking problem</h2>



<p class="wp-block-paragraph">In the article on classic A/B testing we devoted space to one of the most insidious errors: <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/">peeking</a>, that is, glancing at the interim data and stopping as soon as the difference looks significant. In the frequentist framework this inflates the false-positive rate, because each glance is effectively a new test on the same null hypothesis, and repeated tests multiply the chances of being wrong.</p>



<p class="wp-block-paragraph">The Bayesian approach changes the nature of the problem. Here we are not repeating a test on a null hypothesis: we are updating a belief. Today&#8217;s posterior becomes tomorrow&#8217;s prior, and looking at the data as it arrives does not &#8220;consume&#8221; an error budget in the same way. This does not mean we can stop on a whim: we still need a <strong>stopping rule</strong> declared in advance. And the natural Bayesian rule is not &#8220;stop when P(B&gt;A) is high&#8221;, but is based on <strong>expected loss</strong>.</p>



<p class="wp-block-paragraph">The idea is this: if we choose B but A were in fact the better variant, we are wrong, and the size of the error is how much A beats B in those cases. The expected loss of choosing B is the average of this &#8220;regret&#8221; over all the residual uncertainty. In plain words: by how much, on average, we would regret having chosen B if we were wrong.</p>



<p class="wp-block-paragraph">I compute in R the expected loss of choosing B:</p>



<pre class="wp-block-code"><code># Expected loss of choosing B: average loss if A were actually better
loss_B &lt;- mean(pmax(postA - postB, 0))
cat("expected loss choosing B =", round(loss_B*100, 3), "pct points\n")</code></pre>



<p class="wp-block-paragraph">Output: expected loss choosing B = 0.007 pct points.</p>



<p class="wp-block-paragraph">The expected loss of choosing B is a mere 0.007 percentage points: negligible. In plainer terms, even in the unlucky scenario where we were wrong, the average damage would be tiny. We then set a tolerance threshold <em>before</em> starting — for example &#8220;I stop when the expected loss drops below 0.01 points&#8221; — and let the test run until we reach it.</p>



<p class="wp-block-paragraph">And here is where we see why the expected loss, and not &#8220;P(B&gt;A) is high&#8221;, is the right criterion. If we recompute at every step of the test, as traffic comes in, the two quantities do not trip at the same moment: the probability that B wins crosses 95% well before the expected loss becomes truly negligible.</p>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-eed7543b wp-block-group-is-layout-constrained">

<figure class="wp-block-image size-large"><img decoding="async" width="1140" height="600" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-expected-loss-en-1.png" alt="As data accumulate, P(B&gt;A) crosses 95% already around 570 visitors per variant, but the expected loss drops below the 0.01-point threshold only near 855: stopping as soon as the probability “looks high” is precisely the peeking trap." class="wp-image-4219" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-expected-loss-en-1.png 1140w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-expected-loss-en-1-300x158.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-expected-loss-en-1-1024x539.png 1024w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">As data accumulate, P(B&gt;A) crosses 95% already around 570 visitors per variant, but the expected loss drops below the 0.01-point threshold only near 855: stopping as soon as the probability “looks high” is precisely the peeking trap.</figcaption></figure>

</div></div>



<div class="wp-block-group has-background" style="background-color:#f5f7f9;margin-top:2.5rem;margin-bottom:2.5rem;padding-top:1.5rem;padding-right:1.5rem;padding-bottom:1.5rem;padding-left:1.5rem"><div class="wp-block-group__inner-container is-layout-flow wp-block-group-is-layout-flow">
<p>A note of caution: the freedom to look at the data as it comes in is not a licence to stop whenever the result pleases us. The stopping rule — the expected-loss threshold, or a minimum level of P(B&gt;A) — must be fixed before collecting the data, exactly as in the frequentist setting we fix the sample size. Rigour does not lie in the method we use, but in deciding the criterion before seeing the numbers.</p>
</div></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">A lead generation website tests two variants of its contact form. On variant A we observe 45 conversions out of 600 sessions; on B, 52 conversions out of 600 sessions.</p>



<ol class="wp-block-list"><li>Build the two posteriors with a non-informative Beta(1, 1) prior: <code>postA &lt;- rbeta(1e5, 1 + 45, 1 + 555)</code> and the analogue for B.</li><li>Compute <strong>P(B&gt;A)</strong>: is B better with a probability high enough to convince you?</li><li>Compute the <strong>mean uplift and 95% interval</strong> of the difference: does the interval touch zero?</li><li>Compute the <strong>expected loss</strong> of choosing B. With these numbers (closer to each other than in the case above), how does it change compared to the article&#8217;s example?</li></ol>



<p class="wp-block-paragraph">Hint: the structure of the code is identical to the one we used. Only the starting counts change — and the result, far less clear-cut, is precisely why the interval and the expected loss matter more than a plain &#8220;B won&#8221;.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">So far we have compared two variants at a fixed sample size: we collect the data, compute, decide. And yet we have just watched that probability recompute itself at every step, as traffic comes in — and if one variant is turning out clearly better, why keep sending half the visitors to the worse one? We can do better: use that real-time probability to allocate traffic adaptively, shifting it toward the winning variant while the test is still running. It is the leap from the test to the <em>bandit</em>, the subject of the next article.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">If you want to explore Bayesian A/B testing with a practical, code-oriented angle, <a href="https://www.amazon.it/dp/0133902838?tag=consulenzeinf-21&#038;ascsubtag=bayesian-ab-testing" rel="nofollow sponsored noopener" target="_blank"><em>Bayesian Methods for Hackers</em></a> by Cameron Davidson-Pilon is the book I recommend. It tackles Bayesian reasoning starting from programming rather than formal mathematics, and devotes an explicit chapter to the Bayesian comparison of variants — probability that B wins, distribution of the difference, expected loss. It is written for those who learn better by reading code than proofs.</p>



<p class="wp-block-paragraph">This article is part of the <a href="https://www.gironi.it/blog/en/bayesian-approach/">&#8220;The Bayesian Approach&#8221;</a> path, a guided route through the articles on Bayesian statistics and inference for SEO.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/bayesian-ab-testing/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Bayesian Conversion Rate Estimation: how much can we trust limited data</title>
		<link>https://www.gironi.it/blog/en/bayesian-conversion-rate-estimation/</link>
					<comments>https://www.gironi.it/blog/en/bayesian-conversion-rate-estimation/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Thu, 25 Jun 2026 08:50:19 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3872</guid>

					<description><![CDATA[In the article on the foundations of Bayesian statistics, we saw how Bayesian updating works through simulation: generate samples from the prior, simulate data, filter. An intuitive method, but one that runs into a practical limit as soon as data becomes even slightly numerous. In this article we move to the elegant analytical solution that &#8230; <a href="https://www.gironi.it/blog/en/bayesian-conversion-rate-estimation/" class="more-link">Continue reading<span class="screen-reader-text"> "Bayesian Conversion Rate Estimation: how much can we trust limited data"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the article on the <a href="https://www.gironi.it/blog/en/bayesian-statistics-how-to-learn-from-data-one-step-at-a-time/">foundations of Bayesian statistics</a>, we saw how Bayesian updating works through simulation: generate samples from the prior, simulate data, filter. An intuitive method, but one that runs into a practical limit as soon as data becomes even slightly numerous.<br> In this article we move to the elegant analytical solution that the Bayesian approach provides for one of the most common problems in marketing analysis: estimating a conversion rate with limited data.</p>



<p class="wp-block-paragraph">The problem always starts the same way. A small e-commerce store has collected 23 conversions out of 412 sessions. The raw rate is 23/412 ≈ 5.6%. A seemingly precise number. But how much do we trust it? We could be looking at the true 3% or the true 9% — with that sample, we simply do not know. The point estimate &#8220;5.6%&#8221; says nothing about its own uncertainty.</p>



<span id="more-3872"></span>



<p class="wp-block-paragraph"><strong>What we will cover</strong>:</p>



<ul class="wp-block-list"><li><a href="#beta-binomial-model">The Beta-Binomial model: why Beta is the natural distribution for a conversion rate</a></li><li><a href="#non-informative-prior">Non-informative prior: letting the data speak</a></li><li><a href="#informative-prior">Informative prior: using historical data without cheating</a></li><li><a href="#posterior-tomorrow-prior">Today&#8217;s posterior is tomorrow&#8217;s prior</a></li><li><a href="#try-it-yourself">Try it yourself</a></li><li><a href="#further-reading">Further reading</a></li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="beta-binomial-model">From single click to rate: the Beta-Binomial model</h2>



<p class="wp-block-paragraph">Each session is a binary event: the user converts or does not.<br> With \( n \) sessions and \( k \) conversions, the generative mechanism is binomial. The parameter we want to estimate — the true conversion rate \( \theta \) — is a proportion: a value between 0 and 1.</p>



<p class="wp-block-paragraph">When the prior on \( \theta \) is a <a href="https://www.gironi.it/blog/en/the-beta-distribution-explained-simply/">Beta distribution</a> and the data are binomial, something very convenient happens: <strong>the posterior is also a Beta distribution</strong>. This is called a <em>conjugate</em> prior, and it means Bayesian updating reduces to a simple arithmetic operation on the parameters.</p>



<p class="wp-block-paragraph">The updating rule is: if the prior is Beta(α, β), after observing \( k \) conversions out of \( n \) sessions the posterior is:</p>



\( Beta(\alpha + k,\ \beta + (n &#8211; k)) \\ \)



<p class="wp-block-paragraph">In plain words: we add the observed conversions to α and the observed failures to β. The prior Beta(α, β) encodes in α the &#8220;conversions already seen&#8221; (or an equivalent belief) and in β the &#8220;non-conversions&#8221;. Each new observation updates both counters.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="non-informative-prior">Non-informative prior: letting the data speak</h2>



<p class="wp-block-paragraph">The most neutral prior possible for a proportion is the uniform distribution on [0, 1], which corresponds to Beta(1, 1): all values of the rate are considered equally plausible before seeing any data.</p>



<p class="wp-block-paragraph">Our case: 23 conversions out of 412 sessions (389 non-conversions).</p>



<p class="wp-block-paragraph">We calculate the posterior in R:</p>



<pre class="wp-block-code"><code># Observed data
conv &lt;- 23; sess &lt;- 412; nonconv &lt;- sess - conv

# NON-INFORMATIVE prior: uniform Beta(1,1)
a0 &lt;- 1; b0 &lt;- 1
a1 &lt;- a0 + conv; b1 &lt;- b0 + nonconv      # posterior Beta(24, 390)

cat("Non-informative: mean =", round(a1/(a1+b1), 4), "\n")
cat("  95% CI =", round(qbeta(c(.025,.975), a1, b1), 4), "\n")</code></pre>



<p class="wp-block-paragraph">Output: mean = 0.058, 95% CI = [0.0376, 0.0824].</p>



<p class="wp-block-paragraph"><strong>The posterior Beta(24, 390) has mean 5.8% and a 95% credible interval between 3.8% and 8.2%.</strong><br> The Bayesian credible interval is not an abstract statistical exercise: it means directly that there is a 95% probability that the true conversion rate lies between 3.8% and 8.2%. Not a frequency over infinite repetitions — a direct probability statement on the parameter.</p>



<figure class="wp-block-image size-large"><img decoding="async" width="975" height="600" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/conversion-rate-posterior.png" alt="Non-informative prior Beta(1,1) and posterior Beta(24,390) after 23 conversions in 412 sessions: the data concentrate the estimate around 5.8%." class="wp-image-3950" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/conversion-rate-posterior.png 975w, https://www.gironi.it/blog/wp-content/uploads/2026/07/conversion-rate-posterior-300x185.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">Non-informative prior Beta(1,1) and posterior Beta(24,390) after 23 conversions in 412 sessions: the data concentrate the estimate around 5.8%.</figcaption></figure>



<p class="wp-block-paragraph">With 412 sessions, the uncertainty is still appreciable: almost 5 percentage points of width. The point estimate 5.6% was misleading in its precision.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">A note of caution: the Bayesian credible interval and the frequentist confidence interval have similar numbers but profoundly different meanings. The frequentist 95% is a property of the procedure (&#8220;repeating the experiment 100 times, 95 intervals would contain the true parameter&#8221;); the Bayesian 95% is a direct statement about the parameter in the specific case we are analysing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="informative-prior">Informative prior: using historical data without cheating</h2>



<p class="wp-block-paragraph">The non-informative prior is the honest starting point when we know nothing. But often we do know something: years of campaigns, sector history, category benchmarks.<br> Our e-commerce has four seasons of history with an average conversion rate around 4%. How do we translate this knowledge into a prior?</p>



<p class="wp-block-paragraph">The Beta(8, 192) distribution has mean exactly 8/(8+192) ≈ 4% and — because α+β = 200 — a concentration equivalent to &#8220;trusting&#8221; our data as much as 200 fictitious historical sessions. It is not an arbitrary number: it is a declared and verifiable choice.</p>



<p class="wp-block-paragraph">We calculate the informative posterior in R:</p>



<pre class="wp-block-code"><code># INFORMATIVE prior from history: mean ~4% -&gt; Beta(8, 192)
a0i &lt;- 8; b0i &lt;- 192
a1i &lt;- a0i + conv; b1i &lt;- b0i + nonconv  # posterior Beta(31, 581)

cat("Informative: mean =", round(a1i/(a1i+b1i), 4), "\n")
cat("  95% CI =", round(qbeta(c(.025,.975), a1i, b1i), 4), "\n")</code></pre>



<p class="wp-block-paragraph">Output: mean = 0.0507, 95% CI = [0.0347, 0.0694].</p>



<p class="wp-block-paragraph"><strong>The informative posterior Beta(31, 581) gives mean 5.1% and 95% credible interval between 3.5% and 6.9%.</strong><br> Two things to notice. First: the mean drops slightly from 5.8% to 5.1% — the prior &#8220;pulls&#8221; the estimate toward the historical 4%. Second: the interval narrows (from 4.4 to 3.4 percentage points) — the historical data acts as additional information, so uncertainty decreases.</p>



<p class="wp-block-paragraph">With limited data, the informative prior helps: it adds information where data alone is insufficient. With many data points — thousands of sessions — the prior gets <em>overwhelmed</em> by the data and the difference between informative and non-informative priors becomes negligible. This is a fundamental feature of Bayesian inference: <strong>the prior matters when data is scarce; data always wins in the end</strong>.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="posterior-tomorrow-prior">Today&#8217;s posterior is tomorrow&#8217;s prior</h2>



<p class="wp-block-paragraph">The most practical elegance of the Bayesian approach is sequential updating. After one month, new data arrives: 15 conversions from 300 additional sessions. We do not need to start over — the posterior we just calculated <em>becomes</em> the new prior.</p>



<p class="wp-block-paragraph">We update in R:</p>



<pre class="wp-block-code"><code># New data: 15 conversions from 300 additional sessions
conv2 &lt;- 15; sess2 &lt;- 300
a2 &lt;- a1i + conv2; b2 &lt;- b1i + (sess2 - conv2)   # Beta(46, 866)

cat("After update: mean =", round(a2/(a2+b2), 4), "\n")
cat("  95% CI =", round(qbeta(c(.025,.975), a2, b2), 4), "\n")</code></pre>



<p class="wp-block-paragraph">Output: mean = 0.0504, 95% CI = [0.0372, 0.0655].</p>



<p class="wp-block-paragraph">Three stages compared:</p>



<figure class="wp-block-table"><table><thead><tr><th>Stage</th><th>Accumulated data</th><th>Mean</th><th>95% CI</th></tr></thead><tbody><tr><td>Pure prior (before any data)</td><td>—</td><td>4.0%</td><td>[1.8%, 7.1%]</td></tr><tr><td>After first month</td><td>23/412</td><td>5.1%</td><td>[3.5%, 6.9%]</td></tr><tr><td>After second month</td><td>38/712</td><td>5.0%</td><td>[3.7%, 6.6%]</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong>The credible interval has narrowed</strong> at each stage: 5.3 percentage points for the pure prior, 3.4 after the first month, 2.8 after the update. The mean remained stable: the new data confirms the previous estimate instead of shifting it, and uncertainty decreases as expected.<br> This is the real operational advantage: there is no need to wait for a &#8220;large enough&#8221; sample to accumulate before making any estimate. We start from an uncertain estimate and refine it progressively, with each new data point.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading" id="try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">A lead generation website has a historical conversion rate around 2%. After an optimisation campaign, 8 conversions are observed from 150 sessions.</p>



<ol class="wp-block-list"><li>Build an <strong>informative prior</strong> reflecting the historical 2%: try Beta(4, 196) — it has mean exactly 2%.</li><li>Calculate the <strong>posterior</strong> after 8 conversions from 150 sessions.</li><li>Calculate the <strong>95% credible interval</strong>.</li><li>Now try a <strong>non-informative</strong> Beta(1, 1) prior: does the posterior change much? Why?</li></ol>



<p class="wp-block-paragraph">Hint: the formula is always the same — <code>qbeta(c(.025, .975), a0 + conv, b0 + nonconv)</code>. The only thing that changes is the starting point.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">What we have built so far is the estimation of a rate for a single variant. The next step is comparing two variants — a control page and a modified page — and calculating the Bayesian probability that one beats the other. That is exactly what we will do in the next article: Bayesian A/B testing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading" id="further-reading">Further reading</h3>



<p class="wp-block-paragraph">If you want to understand how Bayesian reasoning enters practical decisions — from market forecasting to uncertainty estimation in real data — <a href="https://www.amazon.it/dp/0141975652?tag=consulenzeinf-21&#038;ascsubtag=bayesian-conversion-rate-estimation" rel="nofollow sponsored noopener" target="_blank"><em>The Signal and the Noise</em></a> by Nate Silver is the book I recommend. Silver devotes explicit chapters to Bayesian updating, showing it in concrete contexts (weather forecasting, politics, sports) that make the idea of &#8220;updating beliefs with new data&#8221; immediately intuitive. Rigorous but written like a story, it is a rare kind of book that leaves you thinking differently about uncertainty.</p>



<p class="wp-block-paragraph">This article is part of the <a href="https://www.gironi.it/blog/en/bayesian-approach/">&#8220;The Bayesian Approach&#8221;</a> path, a guided route through the articles on Bayesian statistics and inference for SEO.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/bayesian-conversion-rate-estimation/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
