<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Guillem Mateu</title>
    <description>The latest articles on DEV Community by Guillem Mateu (@canalenguillem).</description>
    <link>https://dev.to/canalenguillem</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4090737%2F5e083ad4-3c58-48c3-97ff-21c19e0e47c7.png</url>
      <title>DEV Community: Guillem Mateu</title>
      <link>https://dev.to/canalenguillem</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/canalenguillem"/>
    <language>en</language>
    <item>
      <title>I divided by possession and made my football data worse</title>
      <dc:creator>Guillem Mateu</dc:creator>
      <pubDate>Sun, 23 Aug 2026 11:31:17 +0000</pubDate>
      <link>https://dev.to/canalenguillem/normalising-made-my-data-worse-the-intercept-nobody-divides-out-2c2i</link>
      <guid>https://dev.to/canalenguillem/normalising-made-my-data-worse-the-intercept-nobody-divides-out-2c2i</guid>
      <description>&lt;p&gt;I spent an evening adjusting football statistics for how much of the ball each&lt;br&gt;
team had. The correction was obvious, took ten minutes, and made the result&lt;br&gt;
measurably worse.&lt;/p&gt;

&lt;p&gt;The mistake is not specific to football. It happens any time you normalise a&lt;br&gt;
count by an exposure variable — requests per user, errors per deploy, sales per&lt;br&gt;
visit — and it is invisible unless you measure the thing you were trying to fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I have a vector space of 1,419 footballers built from 5.3 million events across&lt;br&gt;
four leagues. Each player is 17 dimensions: passes per 90 minutes, progressive&lt;br&gt;
carries per 90, share of aerial duels won, and so on, each converted to a&lt;br&gt;
percentile against every other player. Cosine similarity between two vectors is&lt;br&gt;
"these two play alike".&lt;/p&gt;

&lt;p&gt;Position is never given to the model. That the eight players nearest a centre&lt;br&gt;
back turn out to be eight centre backs is the evidence that the space captures&lt;br&gt;
playing style at all.&lt;/p&gt;

&lt;p&gt;It worked, with one obvious flaw. Gerard Piqué's nearest neighbour was Jérémy&lt;br&gt;
Mathieu — his own centre-back partner at Barcelona. Sergio Busquets sat next to&lt;br&gt;
two other holding midfielders from Spanish possession sides.&lt;/p&gt;

&lt;p&gt;The reason is not subtle. Barcelona had 67% of the ball that season. Carpi had&lt;br&gt;
38%. A Barcelona player touches it &lt;strong&gt;1.74 times as often&lt;/strong&gt; as a Carpi player, so&lt;br&gt;
every per-90 count is partly a measurement of his employer rather than of him.&lt;/p&gt;
&lt;h2&gt;
  
  
  The obvious fix
&lt;/h2&gt;

&lt;p&gt;If a player sees 1.74× as much of the ball, divide his counts by his team's&lt;br&gt;
possession share. Restore everyone to a level playing field. One line of numpy.&lt;/p&gt;

&lt;p&gt;To check whether it worked I needed a metric for the defect itself. I used the&lt;br&gt;
correlation between a player's team possession and the mean team possession of&lt;br&gt;
his eight nearest neighbours. If the space is contaminated by possession, the&lt;br&gt;
players nearest you will come from teams that hold the ball like yours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no correction               0.695
divide by possession        0.572
chance baseline             0.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better. But nowhere near fixed, for a correction that should have removed the&lt;br&gt;
effect almost entirely. Something was wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;The relationship between a metric and possession is &lt;strong&gt;affine&lt;/strong&gt;, not&lt;br&gt;
proportional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;metric ≈ a + b · possession
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an intercept. A player makes some passes regardless of how much his&lt;br&gt;
team dominates — he receives it, he plays it, the game happens.&lt;/p&gt;

&lt;p&gt;Divide that by possession and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(a + b · possession) / possession  =  a/possession + b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first term is a hyperbola. It &lt;strong&gt;decreases&lt;/strong&gt; steeply as possession rises.&lt;br&gt;
So dividing does not remove the possession signal — it removes one and injects&lt;br&gt;
a new one of the opposite sign. Overcorrection, dressed up as a fix.&lt;/p&gt;

&lt;p&gt;Division is only correct when the relationship passes through the origin. It&lt;br&gt;
almost never does.&lt;/p&gt;

&lt;p&gt;The right operation is to subtract the fitted line and keep the residual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;polyfit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;possession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;adjusted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;polyval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;possession&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removes exactly the linear component and nothing else. No intercept&lt;br&gt;
smuggled into a denominator.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no correction               0.695
divide by possession        0.572
residualise                 0.450   ← adopted
chance baseline             0.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Role coherence, meanwhile, was unaffected — 76.3% to 76.8% — so the correction&lt;br&gt;
cost nothing in the thing I actually wanted the space to do.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other thing I got wrong
&lt;/h2&gt;

&lt;p&gt;Before any of that, I had a different theory: replace counts with ratios.&lt;br&gt;
A pass &lt;strong&gt;completion percentage&lt;/strong&gt; cannot scale with possession the way a pass&lt;br&gt;
&lt;strong&gt;count&lt;/strong&gt; does. Ratios should be immune by construction.&lt;/p&gt;

&lt;p&gt;They are not. Here is the share of variance in each metric explained by which&lt;br&gt;
club a player belongs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;carries per 90          34%   ← a count, as expected
ball receipts per 90    28%
passes per 90           24%
pass completion         21%   ← a ratio, and just as contaminated
completion under pressure 19%
...
touches in final third 3.2%   ← clean
shots per 90           3.6%
aerial duels won       3.9%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ratios split into two families. &lt;em&gt;Completion&lt;/em&gt; ratios inherit team style, because&lt;br&gt;
in a dominant side the passes available to you are easier ones. &lt;em&gt;Shape&lt;/em&gt; ratios —&lt;br&gt;
where on the pitch you touch the ball, how often you shoot, whether you win&lt;br&gt;
headers — are clean.&lt;/p&gt;

&lt;p&gt;The hypothesis was half right, which is worse than being wrong. Half right&lt;br&gt;
survives a casual look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the defect, not something near it
&lt;/h2&gt;

&lt;p&gt;The part I find most useful in hindsight has nothing to do with football.&lt;/p&gt;

&lt;p&gt;I originally tracked the contamination as &lt;em&gt;the share of a player's eight&lt;br&gt;
nearest neighbours who are his own club teammates&lt;/em&gt;. It read 3.0% against a 1.3%&lt;br&gt;
chance baseline. Barely more than twice random. It looked like a blemish.&lt;/p&gt;

&lt;p&gt;The possession correlation on the very same vectors was 0.695 against 0.000.&lt;br&gt;
The defect was enormous. My metric simply could not see it, because teammates&lt;br&gt;
are a tiny slice of any neighbour list and the statistic had almost no room to&lt;br&gt;
move.&lt;/p&gt;

&lt;p&gt;I nearly concluded there was nothing to fix.&lt;/p&gt;

&lt;p&gt;If you are going to correct something, pick a measurement of the thing itself,&lt;br&gt;
not of a symptom that correlates with it. A proxy that cannot move will tell&lt;br&gt;
you your problem is small.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought
&lt;/h2&gt;

&lt;p&gt;Mathieu dropped out of Piqué's top eight entirely. Piqué's nearest neighbour&lt;br&gt;
became Samuel Umtiti — who signed for Barcelona that summer, which is a&lt;br&gt;
pleasant coincidence rather than evidence, but a good sign.&lt;/p&gt;

&lt;p&gt;And the players nearest Lionel Messi stopped being other superstars and became&lt;br&gt;
Lorenzo Insigne, Hatem Ben Arfa and Papu Gómez. &lt;em&gt;Who plays like this at a club&lt;br&gt;
that does not have 67% of the ball&lt;/em&gt; is a scouting question. &lt;em&gt;Who else plays for&lt;br&gt;
Barcelona&lt;/em&gt; is not.&lt;/p&gt;

&lt;p&gt;It is still a partial fix — 0.450 against a 0.000 baseline. What remains is&lt;br&gt;
tactical style, which possession does not capture: a side that defends deep&lt;br&gt;
makes all of its defenders resemble each other regardless of how much they have&lt;br&gt;
the ball. I have written that down as a limitation rather than rounded it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on how this was built
&lt;/h2&gt;

&lt;p&gt;All of it was written with &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;,&lt;br&gt;
including the parts where it was wrong and I had to say so.&lt;/p&gt;

&lt;p&gt;The workflow turned out to be the surprising bit. I deployed the app early,&lt;br&gt;
behind a domain, and drove Claude Code by remote control from my phone. Which&lt;br&gt;
meant I could go for a walk, open the site on mobile, look at a real chart&lt;br&gt;
built from real data, and ask for a change from the trail. No laptop.&lt;/p&gt;

&lt;p&gt;That is also how the best bug of the project got found.&lt;/p&gt;

&lt;p&gt;The plain-language search lets you type &lt;em&gt;"a centre-back good with her feet who&lt;br&gt;
keeps the ball under pressure"&lt;/em&gt;. It returned eight centre-backs, under a&lt;br&gt;
summary that described the request accurately, and they looked completely&lt;br&gt;
reasonable. I glanced at a radar chart on my phone and noticed the second&lt;br&gt;
player's line sat low on exactly the axis I had asked about.&lt;/p&gt;

&lt;p&gt;Checking the numbers: the fourth result was in the &lt;strong&gt;41st percentile&lt;/strong&gt; for pass&lt;br&gt;
completion. Below average — for a query asking for high pass completion. The&lt;br&gt;
players who actually topped both requested dimensions were not in the list at&lt;br&gt;
all.&lt;/p&gt;

&lt;p&gt;The cause was the same shape of error as the one this whole post is about. The&lt;br&gt;
language layer names two or three dimensions and leaves the other fourteen at&lt;br&gt;
0.5, and that full vector was going into a cosine search. Fifteen dimensions at&lt;br&gt;
0.5 and two at 0.7 put about &lt;strong&gt;79% of the vector's norm in the dimensions&lt;br&gt;
nobody asked about&lt;/strong&gt;, so the ranking was mostly measuring "closest to average&lt;br&gt;
overall". It now ranks only on the dimensions the request actually named.&lt;/p&gt;

&lt;p&gt;No test caught it. The output was plausible, and plausible is what a test suite&lt;br&gt;
is worst at. It took a human looking at a picture.&lt;/p&gt;




&lt;p&gt;The code is at &lt;a href="https://github.com/canalenguillem/scoutvec" rel="noopener noreferrer"&gt;github.com/canalenguillem/scoutvec&lt;/a&gt;,&lt;br&gt;
built on &lt;a href="https://github.com/statsbomb/open-data" rel="noopener noreferrer"&gt;StatsBomb's open data&lt;/a&gt;. The&lt;br&gt;
longer write-up in that repo covers the rest of what the data taught me,&lt;br&gt;
including why percentiles have to be computed globally rather than within&lt;br&gt;
position, and why StatsBomb records an aerial duel lost as one kind of event&lt;br&gt;
and an aerial duel won as something else entirely.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>data</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
