<?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>Fri, 17 Jul 2026 08:06:00 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	
	<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" 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" 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" 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" 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" 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" 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>
		<item>
		<title>The peeking problem: why sneaking a look at an A/B test inflates false positives</title>
		<link>https://www.gironi.it/blog/en/peeking-problem-ab-testing/</link>
					<comments>https://www.gironi.it/blog/en/peeking-problem-ab-testing/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Tue, 23 Jun 2026 09:40:54 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3847</guid>

					<description><![CDATA[On 21 January 2015 Optimizely — one of the most widely used A/B testing platforms in the world — switched on a completely new statistical engine for all of its customers, the New Stats Engine. It wasn&#8217;t a technical whim: the old engine, built around a classic fixed-horizon t-test (Fixed Horizon) and developed with statisticians &#8230; <a href="https://www.gironi.it/blog/en/peeking-problem-ab-testing/" class="more-link">Continue reading<span class="screen-reader-text"> "The peeking problem: why sneaking a look at an A/B test inflates false positives"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">On 21 January 2015 Optimizely — one of the most widely used A/B testing platforms in the world — switched on a completely new statistical engine for all of its customers, the <em>New Stats Engine</em>. <br>It wasn&#8217;t a technical whim: the old engine, built around a classic fixed-horizon t-test (<em>Fixed Horizon</em>) and developed with statisticians from Stanford, had a flaw that affected anyone who looked at a test&#8217;s results before the end. And we look at a test&#8217;s results <em>always</em>, before the end.</p>



<p class="wp-block-paragraph">Optimizely had measured the problem themselves, simulating A/A tests — two identical variants, where by construction neither is better than the other, so any declared &#8220;winner&#8221; is a false alarm. <br>According to the figures published by Optimizely, on tests of 5,000 visitors anyone checking the numbers after <em>every</em> visitor saw <strong>57% of A/A tests declare a false winner at least once</strong>; checking every 500 visitors the figure dropped to 26%, every 1,000 to 20%. Chilling numbers for a tool that is supposed to help us decide with rigour. The rewrite — sequential inference plus false discovery rate control, what they call always-valid — was meant precisely to bring the error, as they put it, &#8220;from over 30% to 5%&#8221;.</p>



<p class="wp-block-paragraph">It&#8217;s the same deception we ran into closing the article on <a href="https://www.gironi.it/blog/en/regression-to-the-mean/">regression to the mean</a>: there we selected the worst-performing pages — an extreme instant in the <em>space</em> of the data — and let ourselves be fooled by their rebound. Here we select an extreme instant in <em>time</em>: we stop the moment the test proves us right. The mechanism is a cousin, the risk identical.</p>



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



<h2 class="wp-block-heading">What peeking is</h2>



<p class="wp-block-paragraph">Anyone who runs an <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">A/B test</a> knows it well: the test is running, the data come in day after day, and the temptation to sneak a look at the dashboard is irresistible. <br><em>Peeking</em> isn&#8217;t the mere act of looking: it&#8217;s looking <em>while reserving the right to stop the test</em> the moment the result becomes significant. It&#8217;s that &#8220;great, variant B has crossed the threshold, let&#8217;s wrap up here and declare the winner&#8221; said halfway through data collection.</p>



<p class="wp-block-paragraph">The delicate point is that every look accompanied by the possibility of stopping <strong>is one more statistical test</strong>. <br>A single test with a 5% threshold accepts, by definition, a 5% chance of crying &#8220;winner&#8221; when in fact there&#8217;s no difference at all. But if we repeat that same test twenty times over the course of collection, and all we need is for <em>just one</em> of those twenty times to cross the threshold in order to stop and declare victory, then the chance of stumbling into a false positive is no longer 5%: it <strong>accumulates</strong> with every look.</p>



<p class="wp-block-paragraph">This isn&#8217;t the usual multiplicity of someone comparing ten variants at once. Here the multiplicity is hidden in <em>time</em>: a single variant, looked at many times. It&#8217;s the same logic by which a single coin toss rarely gives a strange result, but if one is allowed to look after every toss and stop at the first favourable moment, sooner or later that moment arrives — and it gets mistaken for a signal.</p>



<h2 class="wp-block-heading">What peeking costs: a simulation</h2>



<p class="wp-block-paragraph">Words convince us up to a point; numbers convince us far more. I simulate in R an A/A test, that is two variants with <strong>exactly the same</strong> true conversion rate (10%): any difference that emerges is noise, and any declared &#8220;victory&#8221; is a false positive by construction. <br>I set the stage by fixing the random number generator&#8217;s seed (so the numbers are reproducible), the function that computes the p-value of the comparison between two proportions, and the function that simulates a single experiment and reports whether at some point it declared a (false) winner:</p>



<pre class="wp-block-code"><code>set.seed(2025)

p_vero  &lt;- 0.10    # same conversion rate for A and B (H0 true)
n_arm   &lt;- 2000    # visitors per variant at the end of the test
n_sim   &lt;- 4000    # number of simulated experiments
alpha   &lt;- 0.05
sguardi &lt;- 20      # how many times we "peek" during collection
look_at &lt;- round(seq(n_arm / sguardi, n_arm, length.out = sguardi))

# p-value of a two-proportion, two-sided z-test
pval_ab &lt;- function(xa, na, xb, nb) {
  pp &lt;- (xa + xb) / (na + nb)
  se &lt;- sqrt(pp * (1 - pp) * (1 / na + 1 / nb))
  2 * pnorm(-abs((xa / na - xb / nb) / se))
}

# one A/A experiment: TRUE if it declares a (false) winner
esperimento &lt;- function(soglia, guarda) {
  a &lt;- cumsum(rbinom(n_arm, 1, p_vero))
  b &lt;- cumsum(rbinom(n_arm, 1, p_vero))
  for (k in guarda) {
    p &lt;- pval_ab(a[k], k, b[k], k)
    if (!is.na(p) &amp;&amp; p &lt; soglia) return(TRUE)
  }
  FALSE
}</code></pre>



<p class="wp-block-paragraph">Let&#8217;s start from the correct behaviour: a single test, at the end, on the 2,000 visitors per variant. I run it 4,000 times and count how many declare a winner:</p>



<pre class="wp-block-code"><code># fixed horizon: a single test, at the end
fisso &lt;- mean(replicate(n_sim, esperimento(alpha, n_arm)))
cat(sprintf("Fixed horizon: %.1f%% false positives\n", 100 * fisso))
# Fixed horizon: 5.0% false positives</code></pre>



<p class="wp-block-paragraph">Out comes <strong>5.0%</strong>: exactly the level we declared with the 5% threshold. The test, used as it should be, keeps its promise. <br>Now I change one thing only: instead of looking once at the end, I look twenty times during collection and stop at the first moment the p-value drops below 0.05. I add the intermediate looks and run again:</p>



<pre class="wp-block-code"><code># peeking: a test at every look, stop at the first significant one
peek &lt;- mean(replicate(n_sim, esperimento(alpha, look_at)))
cat(sprintf("Peeking (%d looks): %.1f%% false positives\n", sguardi, 100 * peek))
# Peeking (20 looks): 24.3% false positives</code></pre>



<p class="wp-block-paragraph">From <strong>5.0%</strong> to <strong>24.3%</strong>. <br><strong>The same data, the same test, the same threshold: the only thing that changed is when we decided to look, and the false positive rate has nearly quintupled.</strong> Almost one A/A test in four, in which the two variants are identical by construction, convinces us we&#8217;ve found a winner. The 24.3% from our simulation and the 30% reported by Optimizely tell the same story with different data: peeking isn&#8217;t a venial sin, it&#8217;s the most effective way to fool ourselves.</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="660" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/peeking-inflazione-en.png" alt="The false positive rate in an A/A test (where by construction no variant is better) as the number of looks grows. Keeping the simulated data fixed and changing only how many times we peek, it climbs from the nominal 5% of a single test at the end to about 25% with twenty looks: the frequency of looks is the fuel of the problem." class="wp-image-4071" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/peeking-inflazione-en.png 1020w, https://www.gironi.it/blog/wp-content/uploads/2026/07/peeking-inflazione-en-300x194.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The false positive rate in an A/A test (where by construction no variant is better) as the number of looks grows. Keeping the simulated data fixed and changing only how many times we peek, it climbs from the nominal 5% of a single test at the end to about 25% with twenty looks: the frequency of looks is the fuel of the problem.</figcaption></figure>

</div></div>



<h2 class="wp-block-heading">Solution 1: the fixed horizon</h2>



<p class="wp-block-paragraph">The simplest cure is also the most annoying one: decide <em>beforehand</em> how much data to collect, and then have the discipline to wait until the end without stopping early, whatever the dashboard says in the meantime. <br>It&#8217;s what the simulation has just shown us: with a single test at the end, the false positive stays nailed to the promised 5%. No magic, just the elimination of opportunistic looks.</p>



<p class="wp-block-paragraph">&#8220;How much data&#8221; isn&#8217;t a number plucked from thin air: it depends on how small a difference we want to be able to detect and on how much certainty we demand. It&#8217;s the sample size calculation, which is done before launching the test with our <a href="https://www.gironi.it/blog/en/ab-test-significance-calculator/">significance calculator</a> and which rests on the concepts of <a href="https://www.gironi.it/blog/en/effect-size-and-power-analysis/">effect size and power analysis</a>. <br>Once that number is fixed, the fixed horizon is the safest road: no statistical correction to apply, no threshold to tweak. The price, though, is paid in patience — resisting the curiosity for days or weeks — and this, in operational reality, is exactly what almost no one manages to do.</p>



<h2 class="wp-block-heading">Solution 2: looking without cheating</h2>



<p class="wp-block-paragraph">And if monitoring on the fly really were necessary — because a test that&#8217;s going terribly needs stopping, because the stakeholders want updates? <br>Then the way is not to look in secret with the usual threshold, but to look <em>openly</em> with a stricter one. The idea is simple: if at every look we raise the bar, making it harder to cry &#8220;winner&#8221; on each occasion, we can arrange for the <strong>overall</strong> error — summed across all the looks — to stay at the 5% we wanted. I calibrate in R the per-look threshold, trying ever more stringent values on the same twenty looks as before:</p>



<pre class="wp-block-code"><code># stricter per-look threshold that brings the overall error back to ~5%
for (sg in c(0.05, 0.02, 0.01, 0.005)) {
  fp &lt;- mean(replicate(n_sim, esperimento(sg, look_at)))
  cat(sprintf("  threshold %.3f -&gt; %.1f%% overall\n", sg, 100 * fp))
}
#   threshold 0.050 -&gt; 25.1% overall
#   threshold 0.020 -&gt; 11.7% overall
#   threshold 0.010 -&gt;  6.6% overall
#   threshold 0.005 -&gt;  3.3% overall</code></pre>



<p class="wp-block-paragraph">As we can see, the usual 0.05 threshold produces a 25.1% overall error (the peeking disaster again), but as we make it stricter the error comes back down: <strong>around 0.01 — a threshold five times more stringent than the standard one — the overall error returns close to the nominal 5%.</strong> It&#8217;s the price to be paid for the right to peek: at every single look much more evidence is required, in exchange for the freedom to look often.</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="660" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/peeking-soglia-en.png" alt="The effect of raising the bar at every look (still twenty looks). With the standard 0.05 threshold the overall error is the peeking's 25.1%; tightening the per-look threshold brings the error down, and around 0.01 it returns close to the nominal 5% (the dashed line). It's the homemade, constant-threshold version of what the Pocock or O'Brien-Fleming boundaries do more elegantly." class="wp-image-4072" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/peeking-soglia-en.png 1020w, https://www.gironi.it/blog/wp-content/uploads/2026/07/peeking-soglia-en-300x194.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The effect of raising the bar at every look (still twenty looks). With the standard 0.05 threshold the overall error is the peeking&#8217;s 25.1%; tightening the per-look threshold brings the error down, and around 0.01 it returns close to the nominal 5% (the dashed line). It&#8217;s the homemade, constant-threshold version of what the Pocock or O&#8217;Brien-Fleming boundaries do more elegantly.</figcaption></figure>

</div></div>



<p class="wp-block-paragraph">What we&#8217;ve just shown is a homemade, constant-threshold version of the idea. The &#8220;textbook&#8221; boundaries — more refined, with thresholds that change over the course of the test, like those of Pocock or O&#8217;Brien-Fleming — are obtained in R with the <code>gsDesign</code> package, and commercial tools like Optimizely use an <em>always-valid</em> variant (the so-called mSPRT) of the same underlying idea. <br>The fine mathematics changes, not the principle: to look often without cheating one must demand, at every look, more evidence than a single test would ask for.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">A word of caution: <strong>a result seen during the test, on its own, proves nothing: what counts is when the decision to look was made.</strong> <br>The same p-value below 0.05 means different things depending on whether it&#8217;s the only fixed-horizon test or the first of the twenty at which one reserved the right to stop. Without declaring in advance how and when the data will be examined, any &#8220;winner&#8221; that emerges on the fly is suspect.</p>



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



<p class="wp-block-paragraph">To feel the mechanism up close, let&#8217;s start from the script and change a single parameter: the number of <code>sguardi</code> (looks). <br>Let&#8217;s go from weekly monitoring (few looks) to daily monitoring (many looks) and re-run the peeking simulation. What to expect: the more frequently one peeks, the higher the false positive rate climbs — the frequency of looks is the fuel of the problem. Then let&#8217;s redo the threshold calibration with that new number of looks and check that, by choosing a strict enough threshold, the overall error comes back under control all the same. It&#8217;s the proof, first-hand, that peeking isn&#8217;t a curse: it&#8217;s just a bill that has to be paid.</p>



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



<p class="wp-block-paragraph">There&#8217;s one last trap in this family, perhaps the most insidious of all, because it doesn&#8217;t hide in our own data but in the data others tell us about. When we read an agency&#8217;s case study — &#8220;we increased conversions by 300% with this tactic&#8221; — we&#8217;re looking at a survivor: the thousand identical attempts that failed are something nobody mentions. It&#8217;s <em>survivorship bias</em>, the reason case studies lie even when they tell the truth, and it&#8217;s the next step on our journey through the pitfalls of marketing data.</p>



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



<h3 class="wp-block-heading">Further Reading</h3>



<p class="wp-block-paragraph">On peeking, early stopping and sequential testing the reference — in English — remains <a href="https://www.amazon.it/dp/1108724264?tag=consulenzeinf-21" rel="nofollow sponsored noopener" target="_blank"><em>Trustworthy Online Controlled Experiments</em></a> by Ron Kohavi, Diane Tang and Ya Xu: written by people who led the experimentation platforms at Microsoft, Google and LinkedIn, it devotes explicit pages to all the ways a running A/B test can fool us, and to how to defend against them. It&#8217;s the book anyone who has to take online experiments seriously pulls out of the drawer.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/peeking-problem-ab-testing/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Regression to the Mean: the SEO Fix That Worked… by Accident</title>
		<link>https://www.gironi.it/blog/en/regression-to-the-mean/</link>
					<comments>https://www.gironi.it/blog/en/regression-to-the-mean/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Mon, 22 Jun 2026 08:51:37 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/?p=3841</guid>

					<description><![CDATA[In the Israeli Air Force, Daniel Kahneman recounts, the flight instructors were sure of one thing: praising a cadet after an excellent manoeuvre made him worse, scolding him after a terrible one made him better. They had seen it happen a thousand times in the field, so it had to be true: with pilots, severity &#8230; <a href="https://www.gironi.it/blog/en/regression-to-the-mean/" class="more-link">Continue reading<span class="screen-reader-text"> "Regression to the Mean: the SEO Fix That Worked… by Accident"</span></a>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the Israeli Air Force, <strong>Daniel Kahneman recounts</strong>, the flight instructors were sure of one thing: praising a cadet after an excellent manoeuvre made him worse, scolding him after a terrible one made him better. <br>They had seen it happen a thousand times in the field, so it had to be true: with pilots, severity works and compliments backfire. <br>Except it wasn&#8217;t true. An exceptional manoeuvre — in either direction — is part skill and part luck; and luck, on the next attempt, doesn&#8217;t repeat. After a brilliant flight you tend to drift back toward your own average (and it looks as if the praise hurt), after a disastrous one you drift back toward the average (and it looks as if the scolding helped). The instructors were crediting themselves with an effect that was just <strong>regression to the mean</strong>.</p>



<p class="wp-block-paragraph">The very same illusion waits for us every time we look at a site&#8217;s data and decide whether one of our changes &#8220;worked&#8221;.</p>



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



<p class="wp-block-paragraph">It&#8217;s worth clearing up the name straight away, because it misleads: regression to the mean <strong>has nothing to do with <a href="https://www.gironi.it/blog/en/correlation-and-regression-analysis-linear-regression/">linear regression</a></strong>, the model that fits a line between two variables. Here &#8220;regression&#8221; means <em>going back</em>, <em>reverting</em>: extreme values sliding back toward their average. They are two different things that happen to share a word.</p>



<h2 class="wp-block-heading">What regression to the mean is</h2>



<p class="wp-block-paragraph">The mechanism is simple, and once you see it you can&#8217;t unsee it. <br>Almost every number we measure is the sum of two parts: a &#8220;true&#8221;, stable value and a dose of <strong>noise</strong> — random fluctuation of the moment. A page&#8217;s average SERP position in a given month depends on its real relevance, but also on chance: the algorithm wobbling, a competitor who pushed hard that month, the query&#8217;s seasonality, a handful of clicks more or fewer.</p>



<p class="wp-block-paragraph">Now, when we single out the <strong>extreme</strong> cases — the worst-performing pages, the worst month — we are almost always picking situations where the noise pushed <em>everything in the same unfavourable direction</em>. <br><strong>At the next measurement that noise won&#8217;t repeat identically, and the value will tend to climb back toward its true mean — without anyone having done anything.</strong> It&#8217;s a purely statistical fact, not an SEO phenomenon: the more extreme a measurement, the more likely the next one is less extreme.</p>



<h2 class="wp-block-heading">The optimization that worked by accident</h2>



<p class="wp-block-paragraph">Let&#8217;s see what this mechanism does in everyday work. Suppose we track the average SERP position of 300 pages over two consecutive months. I simulate the scenario in R, giving each page a stable &#8220;true&#8221; position and adding a random fluctuation each month:</p>



<pre class="wp-block-code"><code>set.seed(48)

# 300 pages, each with its "true" SERP position (stable over time)
pos_vera &lt;- runif(300, 3, 40)

# two consecutive months: same true position, different random noise
mese1 &lt;- pos_vera + rnorm(300, 0, 8)
mese2 &lt;- pos_vera + rnorm(300, 0, 8)

# the 60 worst pages in month 1 (in the SERP "worse" = higher number)
peggiori &lt;- order(mese1, decreasing = TRUE)[1:60]

round(mean(mese1[peggiori]), 1)   # starting average position
# [1] 39.1</code></pre>



<p class="wp-block-paragraph">Our 60 worst pages start from an average position of <strong>39.1</strong>: bottom-of-the-third-page territory. We decide to act — rewrite the titles, update the content, fix internal links — and we check again a month later. <br>Here&#8217;s the result, <strong>with no algorithm change in the meantime and, above all, with no real intervention at all in the simulation</strong>:</p>



<pre class="wp-block-code"><code>round(mean(mese2[peggiori]), 1)   # one month later
# [1] 33</code></pre>



<p class="wp-block-paragraph">From <strong>39.1</strong> to <strong>33</strong>: about <strong>six positions gained</strong>. A jump that would look great in a report, and that anyone would be tempted to credit to the optimization just carried out. <br>Too bad there is <em>no</em> optimization in the code: the pages improved on their own, because they had been chosen precisely for being extreme and the noise that had sunk them in month 1 didn&#8217;t repeat. For scale: the average position of <em>all</em> 300 pages is about 22, and it&#8217;s toward that value that the worst ones are reverting.</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="810" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/regressione-scatter-en.png" alt="The signature scatter of regression to the mean: each point is a page, month-1 position (x) against month-2 (y). The 60 worst (orange, top right) slip below the diagonal — they improve — and the 60 best (green, bottom left) climb back toward the centre. The regression line (slope 0.67) is flatter than the “no change” diagonal: that flattening is regression to the mean." class="wp-image-4065" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/regressione-scatter-en.png 990w, https://www.gironi.it/blog/wp-content/uploads/2026/07/regressione-scatter-en-300x245.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">The signature scatter of regression to the mean: each point is a page, month-1 position (x) against month-2 (y). The 60 worst (orange, top right) slip below the diagonal — they improve — and the 60 best (green, bottom left) climb back toward the centre. The regression line (slope 0.67) is flatter than the “no change” diagonal: that flattening is regression to the mean.</figcaption></figure>

</div></div>



<h2 class="wp-block-heading">How not to be fooled: the control group</h2>



<p class="wp-block-paragraph">If the improvement comes anyway, how can we tell whether our optimization had any <em>real</em> effect? <br>The answer is the same one we&#8217;d use for a drug: we need a <strong>control group</strong>. We split the 60 worst pages into two random halves: one we &#8220;optimize&#8221;, the other we deliberately leave alone. Then we compare how much they improve:</p>



<pre class="wp-block-code"><code># split the 60 worst pages into two random groups
gruppo &lt;- sample(rep(c("optimized", "control"), each = 30))

# average improvement (month1 - month2) in the two groups
round(tapply(mese1[peggiori] - mese2[peggiori], gruppo, mean), 1)
# control optimized
#     6.0       6.1</code></pre>



<p class="wp-block-paragraph">The &#8220;optimized&#8221; group gains <strong>6.1</strong> positions, the control group — left untouched — gains <strong>6.0</strong>. <br>Practically the very same improvement. Our optimization, in the simulation, added nothing: all of the gain was regression to the mean, and the control exposes it by showing it would have happened anyway.</p>



<p class="has-light-gray-background-color has-background wp-block-paragraph">The lesson is this: <strong>an improvement, on its own, proves nothing.</strong> <br>When you act precisely on the pages (or campaigns) that were doing worst, part of their rebound is guaranteed regardless of you. Without a comparison against what you did <em>not</em> touch, you can&#8217;t know how much of the result is your doing and how much is just reversion toward the mean.</p>



<p class="wp-block-paragraph">It&#8217;s the same reasoning, it should be said, behind a properly run <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/">A/B test</a>: you compare the variant against a concurrent control, not against the &#8220;before&#8221;. And it&#8217;s a close cousin of the trap we met discussing <a href="https://www.gironi.it/blog/en/correlation/">correlation and causation</a>: here too we mistake a sequence in time (&#8220;I acted, then it improved&#8221;) for a causal link.</p>



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



<p class="wp-block-paragraph">To lock in the mechanism, try rebuilding the scenario changing a single detail: instead of the 60 <em>worst</em> pages, select the 60 <em>best</em> of month 1 (<code>order(mese1)[1:60]</code>, without <code>decreasing</code>) and watch how they behave in month 2.</p>



<p class="wp-block-paragraph">What to expect: the champions revert toward the mean too, but by <em>getting worse</em> — top positions contain luck that doesn&#8217;t repeat. It&#8217;s the mirror image of the same phenomenon, and it explains why &#8220;that golden month&#8221; or &#8220;that page that was flying&#8221; so often can&#8217;t be reproduced: you hadn&#8217;t lost your magic touch, you were just drifting back toward your average.</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="690" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/regressione-code-en.png" alt="Both tails, on average, revert toward the overall mean (~22). The 60 worst move from 39.1 to 33 (and it looks as if the optimization worked); the 60 best from 4.0 to 9.7 (the “golden month” that doesn't repeat). In both cases nobody touched anything: it's just the noise that, at the next measurement, doesn't repeat identically." class="wp-image-4066" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/regressione-code-en.png 990w, https://www.gironi.it/blog/wp-content/uploads/2026/07/regressione-code-en-300x209.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">Both tails, on average, revert toward the overall mean (~22). The 60 worst move from 39.1 to 33 (and it looks as if the optimization worked); the 60 best from 4.0 to 9.7 (the “golden month” that doesn&#8217;t repeat). In both cases nobody touched anything: it&#8217;s just the noise that, at the next measurement, doesn&#8217;t repeat identically.</figcaption></figure>

</div></div>



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



<p class="wp-block-paragraph">There&#8217;s an even more insidious variant of this trap, because it hides inside the very tools we use <em>to</em> decide with rigour. If we look at a test while it&#8217;s still running and stop as soon as the numbers please us, we are picking an extreme instant exactly as we picked the worst pages — and we&#8217;ll be fooled in the same way. It&#8217;s the <em>peeking problem</em>, and it&#8217;s the next stop on our tour of marketing-data pitfalls.</p>



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



<h3 class="wp-block-heading">Further Reading</h3>



<p class="wp-block-paragraph">The flight-instructor story we opened with comes from <a href="https://www.amazon.it/dp/0141033576?tag=consulenzeinf-21" rel="nofollow sponsored noopener" target="_blank"><em>Thinking, Fast and Slow</em></a> by Daniel Kahneman, Nobel laureate in economics: it&#8217;s the book that made regression to the mean — and dozens of other biases in our reasoning — widely known, told through examples that stick. If you keep one thing from this article, keep this one: it&#8217;s a vaccine against the illusion of having understood why the numbers move.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/regression-to-the-mean/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>A/B Testing: How to Run Statistically Valid Experiments (and the Mistakes to Avoid)</title>
		<link>https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/</link>
					<comments>https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Fri, 19 Jun 2026 07:34:46 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/ab-testing-2/</guid>

					<description><![CDATA[In this article: What an A/B test is Setting up an A/B test correctly Worked example: conversion rate of two landing pages The most common mistakes Frequentist vs Bayesian approach Practical SEO example: meta description A/B test FAQ Try it yourself Over the previous articles we have looked at how hypothesis testing works and how &#8230; <a href="https://www.gironi.it/blog/en/ab-testing-statistically-valid-experiments/" class="more-link">Continue reading<span class="screen-reader-text"> "A/B Testing: How to Run Statistically Valid Experiments (and the Mistakes to Avoid)"</span></a>]]></description>
										<content:encoded><![CDATA[
<div style="background-color: #f8f9fa;padding: 20px;border-radius: 8px;margin-bottom: 30px;border-left: 4px solid #4a90e2">
<h3 style="margin-top: 0">In this article:</h3>
<ul style="margin-bottom: 0">
<li><a href="#what-is-ab-test">What an A/B test is</a></li>
<li><a href="#formulating-test">Setting up an A/B test correctly</a></li>
<li><a href="#landing-example">Worked example: conversion rate of two landing pages</a></li>
<li><a href="#common-mistakes">The most common mistakes</a></li>
<li><a href="#frequentist-vs-bayesian">Frequentist vs Bayesian approach</a></li>
<li><a href="#seo-example">Practical SEO example: meta description A/B test</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href="#try-it-yourself">Try it yourself</a></li>
</ul>
</div>



<p class="wp-block-paragraph">Over the previous articles we have looked at how <a href="/blog/en/hypothesis-testing-a-step-by-step-guide/">hypothesis testing</a> works and how the <a href="/blog/en/the-two-sample-t-test-how-to-test-a-hypothesis-for-dependent-or-independent-samples/">two-sample t-test</a> lets us compare two groups rigorously. We have also built <a href="/blog/en/confidence-intervals-what-they-are-how-to-calculate-them-and-what-they-do-not-mean/">confidence intervals</a>, learned to quantify the uncertainty of our estimates, and seen with the <a href="/blog/en/central-limit-theorem/">Central Limit Theorem</a> why all this works even when the data are not normal.</p>



<p class="wp-block-paragraph">But there is one question that, in the day-to-day reality of anyone doing SEO and marketing, comes up almost daily: <strong>which variant performs better?</strong> Which title tag brings more clicks? Which landing page converts more? Which meta description draws attention? It is not an academic question: it is the question that separates data-driven decisions from opinions disguised as strategies.</p>



<p class="wp-block-paragraph">The good news is that we already have all the tools to answer it. <strong>A/B testing</strong> is nothing more than the direct application of the statistical inference concepts we have built step by step: hypothesis testing, comparison between groups, significance. In this article we put it all together.</p>



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



<h2 class="wp-block-heading" id="what-is-ab-test">What an A/B Test Is</h2>



<p class="wp-block-paragraph">An A/B test is, in essence, a <strong>controlled experiment</strong>: we take two variants of something (a page, a headline, a call-to-action), randomly assign users to one of the two variants, and measure which one produces better results.</p>



<p class="wp-block-paragraph">Variant <strong>A</strong> is the <strong>control</strong> (the current version, the one we are already using). Variant <strong>B</strong> is the <strong>treatment</strong> (the new version we want to test). The logic is the same as a scientific experiment: we change one variable at a time, keep everything else constant, and observe whether the change produces a measurable effect.</p>



<p class="wp-block-paragraph">Three elements make an A/B test reliable. <strong>Randomisation</strong>: users are assigned to A or B at random. This is essential, because if we showed A in the morning and B in the afternoon, any observed difference might depend on the time of day, not on the variant. The <strong>control group</strong>: without A as a reference, we wouldn&#8217;t know whether B&#8217;s results are good or bad. And finally a <strong>success metric</strong> defined in advance: CTR, conversion rate, time on page. The metric must be chosen <em>before</em> collecting the data, not after (we will come back to this point shortly).</p>



<figure class="wp-block-image size-large"><img decoding="async" width="650" height="675" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-flow-en-1.png" class="wp-image-4251" alt="A/B Test Flow" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-flow-en-1.png 650w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-flow-en-1-289x300.png 289w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 984px) 61vw, (max-width: 1362px) 45vw, 600px" /><figcaption class="wp-element-caption">A/B Test Flow</figcaption></figure>



<p class="wp-block-paragraph">The diagram summarises the path we will follow: from the initial question to the final decision, passing through hypotheses, randomisation, data collection and the statistical test. Every step has its pitfalls.</p>



<p class="wp-block-paragraph">But why do we need statistics? Because data are noisy. If variant A has a CTR of 5.0% and variant B of 5.3%, is that difference real or just random fluctuation?<br> The naked eye cannot tell: we need a formal test. And it is precisely the <strong>two-sample test</strong> we have already seen — applied to proportions rather than means.</p>



<h2 class="wp-block-heading" id="formulating-test">Setting Up an A/B Test Correctly</h2>



<p class="wp-block-paragraph">Before collecting data, we have to set up the test rigorously. Let&#8217;s see how.</p>



<p class="wp-block-paragraph"><strong>Choosing the metric.</strong> The metric must be clear, measurable and directly linked to the goal. For a title tag, the natural metric is the <strong>CTR</strong> (Click-Through Rate). For a landing page, the <strong>conversion rate</strong>. For a blog article, perhaps the <strong>average time on page</strong>.<br> Always keep this in mind: a vague metric (&#8220;people like the page more&#8221;) is not a metric.</p>



<p class="wp-block-paragraph"><strong>Defining the hypotheses.</strong> As in every statistical test, we start from a null hypothesis and an alternative hypothesis:</p>



<ul class="wp-block-list"><li>\( H_0 \): the two variants have the same effect (no difference between A and B)</li><li>\( H_1 \): the two variants have a different effect (a difference exists)</li></ul>



<p class="wp-block-paragraph">The test assumes the observations are <strong>independent</strong> and the assignment to variants is <strong>truly random</strong>. In practice, this means: no double-counting sessions from the same user, no time-of-day alternation (A in the morning, B in the afternoon). These conditions seem obvious, but they are violated more often than you might think.</p>



<p class="wp-block-paragraph"><strong>The statistical test.</strong> When we compare two proportions (such as two CTRs or two conversion rates), the appropriate test is the <strong>two-proportion z-test</strong>. The logic is the same as the two-sample t-test, but adapted to binary data (click/no-click, conversion/no-conversion).</p>



<p class="wp-block-paragraph">The test statistic is computed as follows. First, we compute the <strong>pooled proportion</strong>, which is our best estimate of the common proportion under the null hypothesis:</p>



\( \hat{p} = \frac{x_1 + x_2}{n_1 + n_2} \\ \)



<p class="wp-block-paragraph">where \( x_1 \) and \( x_2 \) are the successes (clicks, conversions) in the two groups, and \( n_1 \) and \( n_2 \) the sample sizes.</p>



<p class="wp-block-paragraph">Then we compute the z statistic:</p>



\( z = \frac{\hat{p}_1 &#8211; \hat{p}_2}{\sqrt{\hat{p}(1-\hat{p})\left(\frac{1}{n_1} + \frac{1}{n_2}\right)}} \\ \)



<p class="wp-block-paragraph">The numerator is the observed difference between the two proportions; the denominator is the standard error under the null hypothesis.<br> The ratio tells us how many &#8220;standard-error units&#8221; separate the two proportions: the higher it is, the harder the difference is to attribute to chance.</p>



<h3 class="wp-block-heading">Example: CTR of Two Title Tags</h3>



<p class="wp-block-paragraph">Let&#8217;s take a concrete example. We tested two title tag variants for an important page on the site:</p>



<ul class="wp-block-list"><li><strong>Title A</strong> (control): 1500 impressions, 75 clicks → CTR = 5.0%</li><li><strong>Title B</strong> (treatment): 1500 impressions, 105 clicks → CTR = 7.0%</li></ul>



<p class="wp-block-paragraph">Title B looks better, but is the difference statistically significant? Let&#8217;s compute it step by step.</p>



<p class="wp-block-paragraph"><strong>Step 1</strong>: the pooled proportion:</p>



\( \hat{p} = \frac{75 + 105}{1500 + 1500} = \frac{180}{3000} = 0.06 \\ \)



<p class="wp-block-paragraph"><strong>Step 2</strong>: the standard error:</p>



\( SE = \sqrt{0.06 \times 0.94 \times \left(\frac{1}{1500} + \frac{1}{1500}\right)} = \sqrt{0.0564 \times 0.00133} \approx 0.00867 \\ \)



<p class="wp-block-paragraph"><strong>Step 3</strong>: the z statistic:</p>



\( z = \frac{0.07 &#8211; 0.05}{0.00867} \approx 2.31 \\ \)



<p class="wp-block-paragraph"><strong>Step 4</strong>: the p-value. For a two-tailed test, \( p \approx 0.021 \).</p>



<p class="wp-block-paragraph">So: the p-value is below 0.05. We can reject the null hypothesis and conclude that the difference between the two title tags is statistically significant. Title B has a significantly higher CTR — from a statistical standpoint.</p>



<figure class="wp-block-image size-large"><img decoding="async" width="1020" height="630" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-z-en.png" class="wp-image-4243" alt="Two-proportion z-test: sampling distribution under H₀ with the observed value" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-z-en.png 1020w, https://www.gironi.it/blog/wp-content/uploads/2026/07/ab-test-z-en-300x185.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">Two-proportion z-test: sampling distribution under H₀ with the observed value</figcaption></figure>



<p class="wp-block-paragraph">The figure shows what &#8220;statistically significant&#8221; means with our data. The curve is the distribution of the standardised difference under H₀: if there were no real difference between A and B, the z-value would almost always fall between −1.96 and +1.96 (the red lines). Our z = 2.31 falls beyond the threshold, in the rejection region. The area shaded in orange is the p-value: the probability of observing a difference this large (or larger) by pure chance is only 2.1% — small enough to rule out chance.</p>



<p class="wp-block-paragraph">Let&#8217;s run the same test in R:</p>



<pre class="wp-block-code"><code>n1 &lt;- 1500; x1 &lt;- 75    # Title A
n2 &lt;- 1500; x2 &lt;- 105   # Title B
p1 &lt;- x1 / n1  # 0.05
p2 &lt;- x2 / n2  # 0.07

p_pool &lt;- (x1 + x2) / (n1 + n2)
se &lt;- sqrt(p_pool * (1 - p_pool) * (1/n1 + 1/n2))
z &lt;- (p2 - p1) / se
p_value &lt;- 2 * (1 - pnorm(abs(z)))

cat("z =", round(z, 3), "\n")
cat("p-value =", round(p_value, 4), "\n")</code></pre>



<p class="wp-block-paragraph">Result: z = 2.306, p-value = 0.0211.</p>



<h2 class="wp-block-heading" id="landing-example">Worked Example: Conversion Rate of Two Landing Pages</h2>



<p class="wp-block-paragraph">Let&#8217;s move on to a more elaborate example. An e-commerce store is testing two variants of its landing page:</p>



<ul class="wp-block-list"><li><strong>Page A</strong> (current design): 1000 visitors, 35 conversions → conversion rate = 3.5%</li><li><strong>Page B</strong> (new design): 1000 visitors, 58 conversions → conversion rate = 5.8%</li></ul>



<p class="wp-block-paragraph">The difference looks substantial (2.3 percentage points), but with these numbers is it enough to rule out chance?</p>



<p class="wp-block-paragraph">Let&#8217;s check in R with <code>prop.test()</code>, which runs the two-proportion test:</p>



<pre class="wp-block-code"><code>result &lt;- prop.test(x = c(35, 58), n = c(1000, 1000))
print(result)</code></pre>



<p class="wp-block-paragraph">The function returns the p-value of the test and, very usefully, the <strong>confidence interval of the difference</strong> between the two proportions. In this case the p-value is about 0.019 — below 0.05, so the difference is statistically significant.</p>



<p class="wp-block-paragraph">But it is the confidence interval of the difference that gives us the most valuable information: not only <em>whether</em> B is better than A, but <em>by how much</em>, and with what margin of uncertainty. If the CI of the difference runs from about 0.4 to 4.2 percentage points, we know that B is almost certainly better, and the improvement lies within that range. That is far richer information than a simple &#8220;yes, it&#8217;s significant&#8221;.</p>



<p class="wp-block-paragraph">The p-value tells us whether we can doubt the null hypothesis; the confidence interval tells us how large the observed effect might be. They answer two different questions, and the CI answers the one we ultimately care about most: not just &#8220;is there a difference?&#8221;, but &#8220;how big is it?&#8221;.</p>



<p class="wp-block-paragraph">n.b.: <code>prop.test()</code> applies a <strong>continuity correction</strong> (Yates&#8217;s correction) that makes the test slightly more conservative. For large samples the difference is negligible; for small samples, it is a welcome caution.</p>



<h2 class="wp-block-heading" id="common-mistakes">The Most Common Mistakes</h2>



<p class="wp-block-paragraph">A/B testing is a powerful tool, but a treacherous one. The ease with which a test can be set up hides serious methodological pitfalls. Let&#8217;s look at the most frequent ones.</p>



<h3 class="wp-block-heading">Stopping the Test Too Early</h3>



<p class="wp-block-paragraph">It is the strongest temptation: after a few days, B looks clearly better than A. Why wait any longer?<br> Because those preliminary results are <strong>noise</strong>, not signal.</p>



<p class="wp-block-paragraph">The problem has a technical name: <strong><a href="/blog/en/peeking-problem-ab-testing/">peeking</a></strong>. Every time we look at the interim data and decide whether to stop, we increase the probability of a false positive. It&#8217;s like tossing a coin: if we stop every time we get three heads in a row, we&#8217;ll conclude the coin is rigged. But it isn&#8217;t — we simply haven&#8217;t given it enough tosses.</p>



<p class="wp-block-paragraph"><strong>How to avoid it</strong>: define the required sample size <em>beforehand</em> and wait until you reach that number before drawing conclusions. In the meantime, you can use our <a href="/blog/en/ab-test-sample-size-calculator/">sample size calculator</a> to determine how many users you need before launching the test.</p>



<h3 class="wp-block-heading">Testing Too Many Variants Without Correction</h3>



<p class="wp-block-paragraph">Another frequent mistake: testing three, four, five variants at the same time (A/B/C/D&#8230;) and then comparing them all pairwise. The problem is that of <strong>multiple comparisons</strong>: the more comparisons we make, the more likely we are to find at least one significant result by pure chance.</p>



<p class="wp-block-paragraph">With 5 variants and 10 pairwise comparisons, the probability of finding at least one false positive rises from 5% to almost 40%. This is not a detail: it is an error that invalidates the entire test.</p>



<p class="wp-block-paragraph"><strong>How to avoid it</strong>: if multiple comparisons are needed, apply a <strong>Bonferroni correction</strong> (divide the α threshold by the number of comparisons) or, better still, limit yourself to testing one variant at a time.</p>



<h3 class="wp-block-heading">Ignoring the Power of the Test</h3>



<p class="wp-block-paragraph">We know the risk of a false positive well (type I error, α). But there is a mirror risk that is often ignored: the <strong>false negative</strong> (type II error, β). It happens when B really is better than A, but our test fails to detect it.</p>



<p class="wp-block-paragraph">The most common cause? A <strong>sample that is too small</strong>. If we have only 100 visitors per variant, the test does not have enough &#8220;power&#8221; to detect small but real differences. We will conclude &#8220;no significant difference&#8221; not because the difference doesn&#8217;t exist, but because we didn&#8217;t have enough data to see it.</p>



<p class="wp-block-paragraph"><strong>How to avoid it</strong>: compute the required sample size <em>before</em> launching the test, based on the minimum effect we want to detect. This is the subject of <strong>power analysis</strong>: use the <a href="/blog/en/ab-test-sample-size-calculator/">sample size calculator</a> to check whether your test has enough power.</p>



<h3 class="wp-block-heading">Confusing Statistical Significance with Practical Significance</h3>



<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 low p-value does not automatically mean the result is important. With very large samples, even microscopic differences become statistically significant. If we test two variants on 500,000 visitors, a CTR difference of 0.01% (from 5.00% to 5.01%) might come out significant. Operationally, though, it is irrelevant. The p-value answers the question &#8220;is the difference real?&#8221;, not the question &#8220;is the difference big enough to matter to us?&#8221;. For the latter we need a different measure — the <strong>effect size</strong> — which we cover in a dedicated article.</p>
</div></div>



<h2 class="wp-block-heading" id="frequentist-vs-bayesian">Frequentist vs Bayesian Approach</h2>



<p class="wp-block-paragraph">Everything we have seen so far follows the <strong>frequentist</strong> approach: we compute a test statistic, compare it with a reference distribution, obtain a p-value and make a binary decision (reject or fail to reject \( H_0 \)).</p>



<p class="wp-block-paragraph">It works, and works well. But it has limits that you feel in everyday practice. The p-value does not tell us &#8220;by how much B is better than A&#8221;. It does not tell us &#8220;what the probability is that B is genuinely superior&#8221;. And if we collect new data, we cannot simply update the result: we have to recompute everything from scratch.</p>



<p class="wp-block-paragraph">There is an alternative approach that answers directly the question we care about most: <strong>what is the probability that B is better than A?</strong><br> It is the <strong>Bayesian</strong> approach, to which we have devoted a dedicated article: <a href="/blog/en/bayesian-ab-testing/">Bayesian A/B Testing</a>, where we see how to build a Beta posterior for each variant, compute P(B &gt; A), read the distribution of the difference (by <em>how much</em> B is better, not just <em>whether</em>) and decide when to stop the test using expected loss.</p>



<h2 class="wp-block-heading" id="seo-example">Practical SEO Example: Meta Description A/B Test</h2>



<p class="wp-block-paragraph">Let&#8217;s look at one last scenario, very common in everyday practice. We have two meta description variants for a key page on the site. Alternating the two versions (two weeks each, to minimise seasonal effects) and consulting the Search Console data, we get:</p>



<ul class="wp-block-list"><li><strong>Meta A</strong>: 3200 impressions, 128 clicks → CTR = 4.0%</li><li><strong>Meta B</strong>: 3100 impressions, 155 clicks → CTR = 5.0%</li></ul>



<p class="wp-block-paragraph">Let&#8217;s check in R:</p>



<pre class="wp-block-code"><code>prop.test(c(128, 155), c(3200, 3100))</code></pre>



<p class="wp-block-paragraph">The p-value is about 0.064 — above the 0.05 threshold, so we cannot reject the null hypothesis. The confidence interval of the difference also includes zero, confirming the non-significance. A borderline result, which tells us: with these data we don&#8217;t have enough evidence to conclude that Meta B is genuinely better.</p>



<p class="wp-block-paragraph">Which approach should we use? For a simple test like this, the frequentist approach with <code>prop.test()</code> is more than sufficient: we have large samples, the question is clear. The Bayesian approach becomes more valuable when the samples are small, when we want to update the result as new data arrive, or when we have prior knowledge to incorporate (for example, we know that for that type of page the CTR is typically between 3% and 7%).</p>



<p class="wp-block-paragraph">But the operational decision must not rest on the p-value alone. We have to ask: is the difference (one percentage point more of CTR) big enough to justify the change? With 3000-plus impressions a month, one percentage point more means about 30 additional clicks. Is that significant <em>for our business</em>?<br> This is a question statistics cannot resolve on its own — it is a judgement that falls to us.</p>



<h2 class="wp-block-heading" id="faq">FAQ</h2>



<p class="wp-block-paragraph"><strong>What is the difference between a one-tailed and a two-tailed test?</strong><br> A two-tailed test checks whether B is different from A (better or worse). A one-tailed test checks whether B is <em>better</em> than A (or <em>worse</em>, depending on direction). When in doubt, always use the two-tailed test — it is more conservative.</p>



<p class="wp-block-paragraph"><strong>Can I run A/B tests with more than two variants?</strong><br> Yes, but each additional variant increases the risk of false positives. If you test 5 variants, you need to correct the significance threshold (e.g., Bonferroni). Better to test one variant at a time, unless you use a specific multivariate test design.</p>



<p class="wp-block-paragraph"><strong>Does the p-value tell me how much better B is than A?</strong><br> No. The p-value only tells you whether the observed difference is compatible with the null hypothesis. To know <em>how much</em> better B is, you need the confidence interval of the difference or an effect size measure.</p>



<p class="wp-block-paragraph"><strong>When should I stop an A/B test?</strong><br> When you have reached the planned sample size — not before. Stopping &#8220;because B is winning&#8221; is the most common trap (peeking). If the test runs longer than expected without reaching significance, it might mean the effect is too small to detect with the available data.</p>



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



<p class="wp-block-paragraph">An e-commerce store is testing two call-to-action variants on a product page:</p>



<ul class="wp-block-list"><li><strong>Variant A</strong> (&#8220;Add to cart&#8221;): 450 visits, 23 conversions</li><li><strong>Variant B</strong> (&#8220;Buy it now&#8221;): 430 visits, 31 conversions</li></ul>



<ol class="wp-block-list"><li>Compute the conversion rate of each variant</li><li>Run the test with <code>prop.test(c(23, 31), c(450, 430))</code> and interpret the p-value</li><li>Does the confidence interval of the difference include zero?</li><li>At the 5% significance level, is the difference statistically significant?</li></ol>



<p class="wp-block-paragraph">Hint: if the p-value is above 0.05, we cannot conclude that one variant is better than the other — but this does not mean they are equal. It might simply mean we don&#8217;t have enough data. It is exactly the problem of the power of the test that we discussed.</p>



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



<p class="wp-block-paragraph">A/B testing gives us a rigorous framework for making decisions based on data, not intuition. But as we have seen, a well-run test tells us <em>whether</em> there is a significant difference — it does not tell us how <em>large</em> that effect is, nor how much data we need to detect it with confidence. Those are the questions of <strong>effect size</strong> and <strong>power analysis</strong>, the next tools in our path. For the sample size, the <a href="/blog/en/ab-test-sample-size-calculator/">interactive calculator</a> lets you get the exact number in real time.</p>



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



<h3 class="wp-block-heading">Further Reading</h3>



<p class="wp-block-paragraph">If you want to dig deeper into the methodology of online experiments, <a href="https://www.amazon.it/dp/1108724264?tag=consulenzeinf-21&amp;ascsubtag=ab-testing" rel="nofollow sponsored noopener" target="_blank"><em>Trustworthy Online Controlled Experiments</em></a> by Ron Kohavi, Diane Tang and Ya Xu is the world reference on A/B testing. The authors led the experimentation platforms at Microsoft, Amazon and LinkedIn — and the book covers everything, from test design to the pitfalls we saw in this article, all the way to the organisational aspects that make the difference between a well-run test and a sterile exercise.</p>



<p class="wp-block-paragraph">For those who want to explore the Bayesian approach to A/B testing (which we have just introduced), <a href="https://www.amazon.it/dp/1593279566?tag=consulenzeinf-21&amp;ascsubtag=ab-testing" rel="nofollow sponsored noopener" target="_blank"><em>Bayesian Statistics the Fun Way</em></a> by Will Kurt is an accessible and surprisingly entertaining introduction. It explains priors, posteriors and Bayesian updating with examples that don&#8217;t require a maths degree — and it uses R for the computational part.</p>



<p class="wp-block-paragraph">This article is part of the <a href="/blog/en/bayesian-approach/">«The Bayesian Approach»</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/ab-testing-statistically-valid-experiments/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>PCA (Principal Component Analysis): reduce SEO data complexity without losing insights</title>
		<link>https://www.gironi.it/blog/en/principal-component-analysis-pca/</link>
					<comments>https://www.gironi.it/blog/en/principal-component-analysis-pca/#respond</comments>
		
		<dc:creator><![CDATA[Paolo Gironi]]></dc:creator>
		<pubDate>Fri, 19 Jun 2026 07:29:10 +0000</pubDate>
				<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://www.gironi.it/blog/principal-component-analysis-pca/</guid>

					<description><![CDATA[In this article: What PCA is The mathematical foundations Practical applications PCA in web marketing Implementing PCA in R Verification and interpretation FAQ SEO and web marketing analysis almost always presents us with the same problem: we have too many metrics and we don&#8217;t know which ones really matter. Search volume, CPC, competition, CTR, bounce &#8230; <a href="https://www.gironi.it/blog/en/principal-component-analysis-pca/" class="more-link">Continue reading<span class="screen-reader-text"> "PCA (Principal Component Analysis): reduce SEO data complexity without losing insights"</span></a>]]></description>
										<content:encoded><![CDATA[
<div style="background-color: #f8f9fa;padding: 20px;border-radius: 8px;margin-bottom: 30px;border-left: 4px solid #4a90e2">
<h3 style="margin-top: 0">In this article:</h3>
<ul style="margin-bottom: 0">
<li><a href="#what-is">What PCA is</a></li>
<li><a href="#foundations">The mathematical foundations</a></li>
<li><a href="#applications">Practical applications</a></li>
<li><a href="#concrete-use">PCA in web marketing</a></li>
<li><a href="#implementation">Implementing PCA in R</a></li>
<li><a href="#verification">Verification and interpretation</a></li>
<li><a href="#faq">FAQ</a></li>
</ul>
</div>



<p class="wp-block-paragraph">SEO and web marketing analysis almost always presents us with the same problem: we have too many metrics and we don&#8217;t know which ones really matter. Search volume, CPC, competition, CTR, bounce rate, time on page, conversions by source, average position — the list grows with every new tool we add to our stack.</p>



<p class="wp-block-paragraph">The problem is not a lack of data: it&#8217;s that these metrics are often correlated with each other (more impressions lead to more clicks, which lead to more conversions, which increase costs). When variables move together, most of the information they contain is redundant. <strong>Principal Component Analysis (PCA)</strong> is the tool we have to cut through this redundancy: it reduces a set of many correlated variables to a few uncorrelated <strong>components</strong> that capture most of the original information.</p>



<p class="wp-block-paragraph">In clearer terms: PCA finds the &#8220;directions&#8221; in which our data vary the most, and lets us project everything else onto those few dimensions. It is like looking at a three-dimensional object from its most informative angle instead of from a random viewpoint.</p>



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



<h2 class="wp-block-heading">What PCA Is</h2>



<p class="wp-block-paragraph">To grasp the idea of dimensionality reduction, picture a city with many interconnected streets. PCA works like an urban-planning system that identifies the main traffic arteries. By focusing on these &#8220;main roads&#8221;, we get a clear view of the city&#8217;s structure without having to analyse every single side street.</p>



<p class="wp-block-paragraph">In the context of web marketing and data analysis, PCA is a powerful tool for several reasons. It is <strong>effective for visualising and exploring high-dimensional datasets</strong>, making it easy to <strong>spot trends, patterns or outliers</strong>. It is also commonly used in the data pre-processing stage for machine learning, extracting the most informative features from large datasets while preserving relevant information. Another advantage is its ability to *<em>minimise or eliminate </em>multicollinearity<em> and </em>overfitting***, frequent problems in web marketing datasets with many potentially correlated variables.</p>



<h2 class="wp-block-heading" id="foundations">The Mathematical Foundations</h2>



<p class="wp-block-paragraph">To understand how PCA works we need to familiarise ourselves with a few concepts. Nothing scary — we take them one at a time.</p>



<p class="wp-block-paragraph"><strong>Variance and covariance.</strong> Variance measures how spread out a single variable is around its mean. Covariance measures how two variables move together: positive means they tend to increase or decrease together, negative means they move in opposite directions. PCA looks for directions (components) that maximise variance — because more variance means more information.</p>



<p class="wp-block-paragraph">The <strong>covariance matrix</strong> summarises the covariances between all pairs of variables. It has the variances on the diagonal and the covariances off the diagonal. It is the starting point of PCA.</p>



<p class="wp-block-paragraph"><strong>Eigenvalues and eigenvectors.</strong> This is the mathematical core. The principal components are simply the eigenvectors of the covariance matrix. An eigenvector identifies a direction in the data space; the associated <strong>eigenvalue</strong> tells us how much variance that direction captures.</p>



<p class="wp-block-paragraph">The formula is straightforward:</p>



\( \Sigma \mathbf{v} = \lambda \mathbf{v} \\ \)



<p class="wp-block-paragraph">where \( \Sigma \) is the covariance matrix, \( \mathbf{v} \) is the eigenvector (the direction of the component), and \( \lambda \) is the eigenvalue (the variance along that direction). Finding the principal components means solving this equation for \( \Sigma \).</p>



<figure class="wp-block-image size-large"><img decoding="async" width="900" height="825" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-rotazione-en-1.png" class="wp-image-4265" alt="PCA: the principal axes capture the direction of maximum variance" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-rotazione-en-1.png 900w, https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-rotazione-en-1-300x275.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">PCA: the principal axes capture the direction of maximum variance</figcaption></figure>



<p class="wp-block-paragraph">The figure makes the idea immediate. The grey dots are two correlated variables: as X increases, Y tends to increase too. The red arrow (PC1) is the direction in which the data vary the most — it is the principal axis. The green arrow (PC2) is orthogonal and captures the remaining variance. This is the geometric idea of PCA: finding the axes that &#8220;explain&#8221; most of the variability in the data.</p>



<p class="wp-block-paragraph"><strong>Explained variance.</strong> This is the metric that tells us how valuable each component is. The proportion of variance explained by a component is:</p>



\( \text{variance explained}_k = \frac{\lambda_k}{\sum_{i=1}^{p} \lambda_i} \\ \)



<p class="wp-block-paragraph">where \( \lambda_k \) is the eigenvalue of the \( k \)-th component and \( p \) is the total number of components. The <strong>cumulative explained variance</strong> is the sum of the first \( k \) proportions: it tells us how much original information we keep by retaining only \( k \) components.</p>



<p class="wp-block-paragraph">As a side note: criteria like the <strong>Kaiser rule</strong> (keep only components with eigenvalue &gt; 1) and the <strong>scree plot</strong> (the ordered eigenvalues graph, with the &#8220;elbow&#8221; as the cut-off point) help choose the number of components to retain.</p>



<h2 class="wp-block-heading" id="applications">Practical Applications</h2>



<p class="wp-block-paragraph">PCA is a versatile technique with a wide range of applications. In <strong>image processing</strong>, it is used for compression. In <strong>genomics</strong>, it helps identify the most critical genes. In <strong>finance</strong>, for risk analysis and portfolio optimisation. In <strong>healthcare</strong>, for medical image analysis. In <strong>security</strong>, for biometric systems. In <strong>climatology</strong>, for analysing large environmental datasets.</p>



<p class="wp-block-paragraph">For <strong>data analysis and marketing</strong> specifically, PCA makes it possible to simplify complex datasets, reduce noise, extract the most significant features, and improve the performance of predictive models. Its ability to visualise high-dimensional data in two or three dimensions makes it easier to spot patterns, trends and outliers.</p>



<p class="wp-block-paragraph">An important clarification: <strong>PCA does not eliminate some variables while keeping others</strong>. Instead, it builds new variables — the principal components — obtained as <strong>linear combinations</strong> of the original ones. It is not feature selection: it is feature extraction. The difference is subtle but crucial.</p>



<h2 class="wp-block-heading" id="concrete-use">PCA in Web Marketing</h2>



<p class="wp-block-paragraph">Let&#8217;s see how PCA applies to concrete problems in our daily work.</p>



<p class="wp-block-paragraph"><strong>Keyword analysis.</strong> A keyword dataset has numerous metrics: search volume, competition, CPC, average position on Google and Bing. By applying PCA, we can condense these dimensions into a few components that capture the underlying themes. For example, we might find that one component captures the &#8220;potential value&#8221; of the keyword (high volume + high CPC) and another captures &#8220;competitiveness&#8221; (high competition + low ranking).</p>



<p class="wp-block-paragraph"><strong>Traffic metric analysis.</strong> Sessions, bounce rate, time on page, conversions by source — PCA can reveal latent variables driving performance. One component might capture user engagement and another the effectiveness of different traffic sources.</p>



<p class="wp-block-paragraph"><strong>User segmentation.</strong> By analysing behavioural data with many variables, PCA identifies natural groupings of users, enabling more defined segments.</p>



<p class="wp-block-paragraph"><strong>Campaign performance analysis.</strong> Impressions, clicks, conversions, cost, CTR, CPA — PCA reveals the key factors that determine campaign success.</p>



<h2 class="wp-block-heading" id="implementation">Implementing PCA in R</h2>



<p class="wp-block-paragraph">Let&#8217;s now run PCA on real data, with two examples that mirror the scenarios we just described.</p>



<p class="wp-block-paragraph">First, let&#8217;s set up the keyword positioning data:</p>



<pre class="wp-block-code"><code>set.seed(123)
n_keywords &lt;- 100
keywords &lt;- paste0("keyword_", 1:n_keywords)
search_volume &lt;- round(runif(n_keywords, min = 100, max = 10000))
competition &lt;- runif(n_keywords, min = 0.1, max = 0.9)
cpc &lt;- round(rnorm(n_keywords, mean = 2.5, sd = 1), 2)
ranking_google &lt;- round(rnorm(n_keywords, mean = 15, sd = 10), 0)
ranking_bing &lt;- round(rnorm(n_keywords, mean = 12, sd = 8), 0)

keyword_data &lt;- data.frame(
  Keyword = keywords,
  Search_Volume = search_volume,
  Competition = competition,
  CPC = cpc,
  Ranking_Google = ranking_google,
  Ranking_Bing = ranking_bing
)

head(keyword_data)</code></pre>



<p class="wp-block-paragraph">Result:</p>



<pre class="wp-block-code"><code>    Keyword Search_Volume Competition  CPC Ranking_Google Ranking_Bing
1 keyword_1          2947   0.5799912 1.79             37            6
2 keyword_2          7904   0.3662588 2.76             28            6
3 keyword_3          4149   0.4908904 2.25             12            4
4 keyword_4          8842   0.8635791 2.15             20            4
5 keyword_5          9411   0.4863219 1.55             11            9
6 keyword_6           551   0.8122802 2.45             10           15</code></pre>



<p class="wp-block-paragraph">Now the campaign performance data:</p>



<pre class="wp-block-code"><code>set.seed(456)
n_campaigns &lt;- 50
campaign_ids &lt;- paste0("campaign_", 1:n_campaigns)
impressions &lt;- round(runif(n_campaigns, min = 1000, max = 100000))
clicks &lt;- round(impressions * runif(n_campaigns, min = 0.01, max = 0.1))
conversions &lt;- round(clicks * runif(n_campaigns, min = 0.005, max = 0.05))
cost &lt;- round(clicks * runif(n_campaigns, min = 0.1, max = 2), 2)
ctr &lt;- round((clicks / impressions) * 100, 2)
cpa &lt;- round(cost / conversions, 2)
cpa[is.nan(cpa)] &lt;- 0

campaign_data &lt;- data.frame(
  Campaign_ID = campaign_ids,
  Impressions = impressions,
  Clicks = clicks,
  Conversions = conversions,
  Cost = cost,
  CTR = ctr,
  CPA = cpa
)

head(campaign_data)</code></pre>



<p class="wp-block-paragraph">Result:</p>



<pre class="wp-block-code"><code>  Campaign_ID Impressions Clicks Conversions    Cost  CTR    CPA
1  campaign_1        9866    873          14 1093.32 8.85  78.09
2  campaign_2       21841   1788          20 3360.17 8.19 168.01
3  campaign_3       73563   2866          66 2764.48 3.90  41.89
4  campaign_4       85361   4121          73 1422.12 4.83  19.48
5  campaign_5       79051   3432         133 1623.28 4.34  12.21
6  campaign_6       33864   3064         126 6047.70 9.05  48.00</code></pre>



<p class="wp-block-paragraph">Now run PCA with <code>prcomp()</code>. It is essential to <strong>scale the data</strong> (<code>scale. = TRUE</code>) before applying PCA: otherwise variables with larger scales (thousands of impressions vs fractions of CPC) would dominate the analysis.</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>If one variable is expressed in euros and another in percentages, PCA will be dominated almost entirely by the variable with the larger numerical scale. For this reason, in most cases it is essential to standardise the data before the analysis. It is the most common mistake made by those approaching PCA.</p>
</div></div>



<pre class="wp-block-code"><code>pca_keywords &lt;- prcomp(keyword_data[, 2:6], scale. = TRUE)
summary(pca_keywords)

pca_campaigns &lt;- prcomp(campaign_data[, 2:7], scale. = TRUE)
summary(pca_campaigns)</code></pre>



<p class="wp-block-paragraph">Results:</p>



<pre class="wp-block-code"><code># summary(pca_keywords) — keywords (5 variables, 5 components)
Importance of components:
                          PC1    PC2    PC3    PC4    PC5
Standard deviation     1.1381 1.0298 0.9894 0.9305 0.8941
Proportion of Variance 0.2591 0.2121 0.1958 0.1732 0.1599
Cumulative Proportion  0.2591 0.4712 0.6670 0.8401 1.0000

# summary(pca_campaigns) — campaigns (6 variables, 6 components)
Importance of components:
                          PC1    PC2    PC3     PC4    PC5     PC6
Standard deviation     1.7837 1.2229 0.9303 0.49392 0.4250 0.18138
Proportion of Variance 0.5303 0.2492 0.1442 0.04066 0.0301 0.00548
Cumulative Proportion  0.5303 0.7795 0.9238 0.96442 0.9945 1.00000</code></pre>



<figure class="wp-block-image size-large"><img decoding="async" width="1425" height="675" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-en.png" class="wp-image-4255" alt="Variance explained by PCA: keywords and campaigns" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-en.png 1425w, https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-en-300x142.png 300w, https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-en-1024x485.png 1024w, https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-en-1200x568.png 1200w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">Variance explained by PCA: keywords and campaigns</figcaption></figure>



<p class="wp-block-paragraph">The figure tells the story better than the numbers. For keywords (left) the variance is fairly evenly distributed: the metrics are largely uncorrelated and PCA cannot compress them much without losing information. For campaigns (right), the first two components explain almost 80% of the variance — the metrics are strongly correlated and two dimensions suffice to describe nearly everything.</p>



<figure class="wp-block-image size-large"><img decoding="async" width="975" height="750" src="https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-standalone-en.png" class="wp-image-4261" alt="Scree plot: variance explained by campaign data" srcset="https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-standalone-en.png 975w, https://www.gironi.it/blog/wp-content/uploads/2026/07/pca-scree-standalone-en-300x231.png 300w" sizes="(max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px" /><figcaption class="wp-element-caption">Scree plot: variance explained by campaign data</figcaption></figure>



<p class="wp-block-paragraph">The enlarged plot on the campaign data confirms: PC1+PC2 exceed 80% cumulative variance. The dashed line at 80% is a common threshold — below it lie components that contribute little.</p>



<p class="wp-block-paragraph">The <strong>loadings</strong> (<code>pca_keywords\( rotation</code>) show the correlation between original variables and components, helping interpret the meaning of each component. The <strong>scores</strong> (<code>pca_keywords \)x</code>) represent the projection of the original data onto the new space.</p>



<p class="wp-block-paragraph">For further visualisation, you can use the scree plot (<code>plot(pca_keywords)</code>) and the biplot (<code>biplot(pca_keywords)</code>), which displays both scores and loadings in the plane of the first two components.</p>



<h2 class="wp-block-heading" id="verification">Verification and Interpretation</h2>



<p class="wp-block-paragraph">Interpreting principal components requires domain knowledge. If in the keyword data PCA the first component has high positive loadings for search volume and CPC, it might represent &#8220;high-potential keywords&#8221;. If the second component is dominated by ranking, it might represent &#8220;actual visibility&#8221;. Interpretation is always context-dependent.</p>



<p class="wp-block-paragraph">Keep in mind that <strong>principal components do not have a &#8220;natural&#8221; meaning</strong>. They are mathematical constructs that we must interpret by looking at the loadings of the original variables. There is no predefined label for PC1: we have to build it, based on the data and the context.</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>It is important to keep PCA&#8217;s limitations in mind. It assumes <strong>linear relationships</strong> between variables, and it is <strong>sensitive to data scale</strong> (which is why we always scale before applying it). For non-linear relationships, techniques like t-SNE and UMAP may be more appropriate.</p>
</div></div>



<h2 class="wp-block-heading" id="faq">FAQ</h2>



<p class="wp-block-paragraph"><strong>When does it make sense to use PCA?</strong><br> When we have many correlated variables and want to reduce them to a few interpretable dimensions. It is ideal for exploration, visualisation, and pre-processing for machine learning. It does not make sense when variables are already few and independent.</p>



<p class="wp-block-paragraph"><strong>How many components should I keep?</strong><br> It depends on the cumulative explained variance. A rule of thumb: stop when the scree plot curve flattens (the &#8220;elbow&#8221;), or when cumulative variance reaches 70-80%. The Kaiser rule (eigenvalue &gt; 1) is another criterion, but should be used flexibly.</p>



<p class="wp-block-paragraph"><strong>Why do I need to scale the data before PCA?</strong><br> Because PCA maximises variance. If one variable is measured in thousands (impressions) and another in hundredths (CTR), the first would artificially dominate the analysis. Scaling (mean 0, standard deviation 1) puts all variables on an equal footing.</p>



<p class="wp-block-paragraph"><strong>Does PCA work with non-linear data?</strong><br> No, standard PCA assumes linear relationships. For non-linear structures there are variants like Kernel PCA, or non-linear techniques such as t-SNE and UMAP.</p>



<h2 class="wp-block-heading" id="conclusion">Conclusion</h2>



<p class="wp-block-paragraph">Principal Component Analysis gives us an elegant way to untangle the complexity of our data. It reduces dimensionality, reveals hidden patterns, improves predictive models, and makes visualisable what would otherwise be a jumble of numbers.</p>



<p class="wp-block-paragraph">Its power, however, also lies in its limitations: it works well when relationships are linear, requires scaling, and the interpretation of the components is our responsibility, not the algorithm&#8217;s. Used with awareness, it is one of the most versatile tools in the web marketing data analysis toolkit.</p>



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



<h3 class="wp-block-heading">Further Reading</h3>



<p class="wp-block-paragraph">Principal Component Analysis is covered with exemplary clarity in <a href="https://www.amazon.it/dp/1461471370?tag=consulenzeinf-21&amp;ascsubtag=una-introduzione-allanalisi-delle-componenti-principali-pca" rel="nofollow sponsored noopener" target="_blank"><em>An Introduction to Statistical Learning</em></a> by James, Witten, Hastie and Tibshirani, alongside other unsupervised learning techniques.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.gironi.it/blog/en/principal-component-analysis-pca/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
