<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Victoria Friss de Kereki on Medium]]></title>
        <description><![CDATA[Stories by Victoria Friss de Kereki on Medium]]></description>
        <link>https://medium.com/@vickyfrissdekereki?source=rss-afa41f9f1213------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*YtU09yfhZQ3Vp4JsroSxBQ.jpeg</url>
            <title>Stories by Victoria Friss de Kereki on Medium</title>
            <link>https://medium.com/@vickyfrissdekereki?source=rss-afa41f9f1213------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 00:54:56 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@vickyfrissdekereki/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Where Is Selma Bacha? What the Data Says About Her Role at Lyon]]></title>
            <link>https://medium.com/@vickyfrissdekereki/where-is-selma-bacha-what-the-data-says-about-her-role-at-lyon-33f0c81d5224?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/33f0c81d5224</guid>
            <category><![CDATA[football]]></category>
            <category><![CDATA[soccer-analytics]]></category>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[soccer]]></category>
            <category><![CDATA[data-analysis]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 08:31:01 GMT</pubDate>
            <atom:updated>2026-11-02T09:06:18.541Z</atom:updated>
            <content:encoded><![CDATA[<h4>Why a Ballon d’Or nominee missed our women’s football full-back rankings, and what SkillCorner data says she’s elite at</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yfCGEXZnshrMKZPCanlKdg.png" /></figure><p>During the Q&amp;A of <a href="https://skillcorner.com/articles/womens-football-dedicated-data-smarter-decisions"><em>Women’s Football: Dedicated Data, Smarter Decisions</em></a>, the webinar we hosted with <a href="https://skillcorner.com">SkillCorner</a> three weeks ago, one attendee left this comment:</p><blockquote>“very interesting data sets, not having Selma Bacha in any listings is surprising”</blockquote><p>We ran out of time to answer it live, but it stayed with me, because it’s a fair point, so thank you to whoever asked it. Bacha was one of the <a href="https://www.footmercato.net/a1168130093093242611-ballon-dor-2026-la-liste-des-30-nommes-dans-la-categorie-feminine">30 nominees for the 2026 Ballon d’Or Féminin</a> and <a href="https://fr.news.yahoo.com/sport/ballon-dor-2026-classement-complet-224133627.html">finished 20th</a>. She was the left back in UEFA’s <a href="https://www.uefa.com/womenschampionsleague/news/02a5-20b3ad844be4-9f004231e98e-1000--2025-26-women-s-champions-league-team-of-the-season/">Champions League Team of the Season</a>, made the Première Ligue Team of the Season, and had 12 assists across all competitions, as OL Lyonnes <a href="https://en.wikipedia.org/wiki/2025%E2%80%9326_OL_Lyonnes_season">won a domestic treble, went unbeaten in the league, and reached the Champions League final</a>. After Lyon knocked Arsenal out in the semi-final, Renée Slegers called her one of the <a href="https://www.uefa.com/womenschampionsleague/news/02a5-20879763c32e-6b822f2b15f4-1000--women-s-champions-league-semi-final-second-leg-report-highli/">“world-class players”</a> in Lyon’s side.</p><p>So how did she miss every list? It helps to know what we were looking for. We started the webinar with why women’s football needs <a href="https://skillcorner.com/articles/dedicated-physical-metrics-for-womens-football">its own physical thresholds</a>: reuse the men’s speed bands and you misclassify real running, high-speed running, and sprint efforts. Then we walked through two scouting case studies across 13 top-flight women’s leagues on five continents: <strong>the two-way physical full back</strong> and <strong>the box-to-box central midfielder</strong>.</p><p>For each one, we built a Physical Score from <a href="https://skillcorner.com/products/football/physical-data">SkillCorner physical data</a>. Then we paired it with a <a href="https://skillcorner.com/products/football/game-intelligence">Game Intelligence</a> Output Score, to check that the engine actually turns into something on the pitch, and combined the two into one index: 65% Physical Score, 35% Output Score. For the full-back case, we had one profile in mind: a full back with the engine to cover the whole flank, who turns it into runs forward and defending at the back. Ryan Williams (North Carolina Courage) came out on top of the full-back index, just ahead of Ellie Carpenter (Chelsea), with Gisele Thompson (Angel City FC) at #3 and Ona Batlle (Barcelona) at #4.</p><p><strong>So where was Selma Bacha in our rankings?</strong></p><h3>She was there, just not on screen</h3><p>Out of 478 full backs, Bacha ranked #61 on that full-back index, in the top 13%. The lists we showed live only went to the top 10.</p><p>So why does one of the best left backs in the world sit at #61? Because our index was built to find one kind of full back: the two-way runner who bombs forward and gets back. Two seasons ago, Bacha was much closer to that player. In 2025/26, she played differently, as <strong>a creator who delivers and drives forward</strong>, and she’s one of the best in the world at it.</p><p>Let’s look into it step by step, starting with what the index sees.</p><h3>What the index sees</h3><p>The chart below shows her percentile on each of the index’s 10 dimensions, five physical and five output.</p><figure><img alt="Bar chart of Selma Bacha’s percentile on the 10 dimensions of the full-back index against 478 full backs: high on Explosiveness, Top Speed, Defensive Engine, and Passing EPV, around the middle on Attacking Engine and Attacking Output, and lowest on Defensive Resilience and Possession EPV." src="https://cdn-images-1.medium.com/max/1024/1*9GGMVujGN18uH5xs1CjbRQ.png" /><figcaption><em>Selma Bacha’s percentile profile across the 10 dimensions of the full-back index, against all 478 full backs in the 13-league sample. Physical dimensions in light green, Output in dark green.</em></figcaption></figure><p>Her physical profile is strong. She’s quick off the mark, with Explosiveness at the 89th percentile, and fast, with Top Speed at the 80th. Her highest score is Defensive Engine, her high-intensity running without the ball, at the 92nd, though Lyon’s style helps there: full backs in teams that have the ball most of the time tend to run harder in the short spells when they don’t.</p><p>What holds her back is the attacking-runner side. Attacking Engine, her high-intensity running while Lyon have the ball, sits at the 56th percentile. Attacking Output, which counts <a href="https://skillcorner.com/articles/game-intelligence-off-ball-run-types">the overlaps and underlaps</a> where she receives the ball, is at the 63rd. In other words, the index rates her as a quick, strong athlete who <strong>doesn’t make the runs it was built to reward</strong>.</p><h3>What she does instead</h3><p>So if she isn’t the overlapping runner, what does she do? The chart below shows where she stands out among full backs, with and without the ball.</p><figure><img alt="Bar chart of Selma Bacha’s percentiles among 478 full backs, in four groups: carrying and getting on the ball (long carries 98th, forward long carries 94th), delivery into the box and to teammates’ runs (88th to 92nd), getting back and being hard to beat (74th to 92nd), and speed off the mark and out of a turn (80th to 95th)." src="https://cdn-images-1.medium.com/max/1024/1*1oLYRbXxB1VUK9IDP6WMsQ.png" /><figcaption><em>Where Bacha stands out among the 478 full backs, grouped by part of her game.</em></figcaption></figure><p>Put together, the chart describes a very specific kind of full back. With the ball, she’s <strong>less a runner than a carrier</strong>. Instead of overlapping to receive in the final third, <strong>she takes the ball herself and drives it forward</strong>, more often than almost any other full back.</p><p>The end product follows. Few full backs get the ball into the box as often, and when a teammate makes a run, she finds her. And it isn’t just Lyon’s possession doing the work: compared with full backs who get the ball as high up as she does, in teams with as much of it, <strong>she still carries more than expected</strong>, and delivers a little more too.</p><p>It’s also how Lyon see her. When they presented her case for the Ballon d’Or, they called her <a href="https://www.ol-lyonnes.com/en/actualites/ballon-dor-selma-bacha-letincelle-du-flanc-gauche/">“the spark on the left flank”</a> and pointed to her “incredibly precise crosses”, her ability to drive forward, and her pace. The data backs the drive and the pace, and she crosses more than nine in ten full backs, though, like most, only about a third of her crosses found a teammate.</p><p>Without the ball, the same athleticism goes into defending. When an attacker gets past her, she doesn’t give up on the play: she’s among the best at getting back between the attacker and her goal, and once she’s there, she’s <strong>rarely dribbled past</strong>. It happened twice in 177 engagements. That fits with how quick she is <a href="https://skillcorner.com/articles/introducing-skillcorners-change-of-direction-cod-metrics">out of a turn</a>: after changing direction, she gets back to high speed faster than 95% of full backs.</p><h3>Not the overlapping kind</h3><p>That’s a lot she does very well. So why didn’t she make our list? The answer is in what she does less, as the chart below shows.</p><figure><img alt="Bar chart of Selma Bacha’s percentiles among 478 full backs on off-ball runs: around the middle for all off-ball runs and for overlap plus underlap runs, and lower for runs in behind and dangerous off-ball runs." src="https://cdn-images-1.medium.com/max/1024/1*tMjlE3nK4j-rgy7GqRiTkg.png" /><figcaption><em>Where Bacha sits among the 478 full backs on off-ball runs: about as many as most, but fewer that go beyond the defence.</em></figcaption></figure><p>The index was looking for a runner: a full back who keeps going past or inside her winger to receive the ball high up. Bacha does that about as often as the typical full back, no more. It isn’t that she runs less: she makes as many off-ball runs as most full backs, and more of hers go inside, as underlaps. But fewer of them take her beyond the defence or into the box. <strong>She’s the one delivering the ball there, not the one arriving.</strong></p><p>And it isn’t about where she plays. She gets the ball further up the pitch than most full backs, and full backs who play that high usually make more of these runs, not fewer. It’s how she plays. <strong>She attacks with the ball at her feet, not by running past it</strong>.</p><p>But is that really her, or is it the team she plays in?</p><h3>Is it her, or is it Lyon?</h3><p>My first guess was Lyon: maybe a team that has the ball this much just doesn’t need its full backs to make those runs. But in the league matches where Bacha played left back, Lyon’s right back ran into the box more than twice as often as she did. Bacha did other jobs: she carried the ball more and played about twice as many passes into the box. <strong>Same team, same matches, different jobs.</strong></p><p>Lyon do explain her two lowest scores, though. The first is Defensive Resilience. Lyon have the ball so much that she has little defending to do: she only engaged an opponent on the ball 177 times. She was beaten seven times. The player on the ball got past her only twice, less often than for most full backs. The other five came from a pass to the player she was marking, more often than for most full backs. Five cases is a small sample, but it’s the one place where her defending looks weaker.</p><p><a href="https://skillcorner.com/articles/introducing-skillcorners-expected-possession-value-epv">Possession EPV</a> is the second, and her lowest score on paper. It adds up how much each of her possessions changes Lyon’s chance of scoring, from the moment she gets the ball to the moment she lets it go. In a team that always has the ball, a full back’s time on it is worth less: both her carries and the touches where she just receives it and moves it on add less than for full backs in teams with less of the ball, and those quick touches even take a little away. The chart below shows how Possession EPV changes with how much of the ball a team has.</p><figure><img alt="Scatter plot of Possession EPV per 30 minutes of team possession against team possession for 478 full backs. The smoothed trend stays flat up to about 55% possession and then falls. The full backs of Lyon, Bayern, and Barcelona, the teams with the most possession, mostly sit low, with Bacha, Svava, Batlle, and Camara labelled." src="https://cdn-images-1.medium.com/max/1024/1*FiNq3C1nnbkU6dVELaliog.png" /><figcaption><em>Possession EPV per 30 minutes of team possession against team possession, 478 full backs in 13 women’s leagues, 2025/26. The line is a smoothed trend. Lyon, Bayern, and Barcelona have the ball the most.</em></figcaption></figure><p>The chart shows the result: <strong>full backs in the most dominant teams almost all score low</strong>. Batlle, fourth on the webinar’s full-back index, ranks even lower than Bacha, and Bacha’s score is almost exactly the average for Lyon’s full backs, about what you’d expect for a team with that much of the ball. So it’s low against all full backs, but average for one in a team like Lyon. Where she adds value is in the pass at the end of her carries: her Passing EPV, back in the first chart, is at the 83rd percentile.</p><p>So has she always played this way?</p><h3>The Bacha of two seasons ago</h3><p>Not quite. Two seasons ago, she was much closer to the runner the index looks for. To compare like with like, I measured her 2023/24 numbers against the same standard as everything above: this season’s 478 full backs. She made about as many underlaps as she does now, but far more overlaps: more than almost any of them. That alone would put her near the top of the index’s Attacking Output, which counts the overlaps and underlaps where she receives the ball, as the chart below shows.</p><figure><img alt="Dumbbell chart comparing Selma Bacha’s 2023/24 and 2025/26 percentiles against 478 full backs. Overlap runs fall from the 94th to the 39th percentile and Attacking Output from the 93rd to the 63rd, while underlaps and total runs stay similar, long carries stay in the top 5%, and completed passes to teammates’ runs and explosiveness rise." src="https://cdn-images-1.medium.com/max/1024/1*rlAOa5gVTzYsmuSXEnWSCA.png" /><figcaption><em>Selma Bacha’s percentile with her 2023/24 numbers and with her 2025/26 numbers, both against the 478 full backs of 2025/26 in 13 leagues. Attacking Output is the index dimension: overlaps and underlaps where she receives the ball.</em></figcaption></figure><p>What hasn’t changed is her carrying: <strong>she was already one of the most frequent carriers among full backs two seasons ago</strong>, and she still is. What changed is what she does around them. Fewer of her runs now go into the box, and she covers less ground at high intensity when Lyon have the ball. Instead, she finds teammates’ runs much more often, and she’s now quicker to reach high speed.</p><p>Those numbers are even more striking because she played that season on a damaged ankle: she <a href="https://www.leparisien.fr/sports/football/equipe-de-france-feminine/football-selma-bacha-a-joue-plus-dun-an-avec-une-blessure-a-la-cheville-21-08-2024-3YZKK2FVSVGIDPU6QZDPNGQVYA.php">had surgery in August 2024</a>, right after the Olympics.</p><p>In 2025/26, Jonatan Giráldez’s first season in charge, she played differently. And it’s not the first time a star has gone missing from one of our lists for that reason.</p><h3>Haven’t we seen this before?</h3><p>We have. In <a href="https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football"><em>Contextual Scouting: A Pressing No. 8 in Women’s Football</em></a>, Alexia Putellas, who has just won her third Ballon d’Or Féminin, didn’t make the list of top pressing No. 8s. In Barcelona forwards led the press, so it wasn’t her job.</p><p>The two cases have more in common than that. Barcelona have the most possession in the sample and Lyon the third-most, and Lyon are now coached by <a href="https://www.outlookindia.com/sports/football/olympique-lyon-women-appoint-former-barcelona-boss-jonatan-giraldez-as-new-head-coach">Jonatan Giráldez,</a> who led Barcelona from 2021 to 2024. In both, a star’s numbers are shaped as much by her team and her place in it as by how good she is.</p><h3>So, where is Selma Bacha?</h3><p>I went in expecting a quick “Lyon have the ball” answer. That explains some of it, her two lowest scores, but not the main thing. Bacha didn’t make our list because the list was looking for an overlapping runner, and she no longer plays like one. Two seasons ago, on an injured ankle, her overlap numbers would have ranked among the highest of this season’s 478 full backs. In 2025/26, she was among the best at other things: carrying the ball forward, getting it into the box, and getting back goal side. <strong>Our index just wasn’t asking about it.</strong></p><p>Ask a different question and she comes out near the top. My <a href="https://medium.com/@vickyfrissdekereki/womens-ballon-d-or-2026-what-does-the-football-data-say-c811b90e364e">Women’s Ballon d’Or 2026 article</a> compared every outfield nominee with the players in her position, and of the four full backs on the shortlist, Bacha was <strong>the only one in the top 20% of her position on both physical and output</strong>.</p><p>As with Putellas, her numbers depend on her team and how she plays in it. With Bacha there’s an extra layer: how she plays has changed over time. A ranking is an answer to a specific question. As explained in <a href="https://skillcorner.com/articles/how-to-build-an-effective-player-index"><em>How to Build an Effective Player Index</em></a>, an index is a first filter: it starts from a target profile and narrows a large pool of players down to a shortlist. Before reading any list, it’s worth asking what question it answers, what the player does for her team, and how that has changed.</p><p><strong>Context is key.</strong></p><p><strong>If you haven’t read the piece the Putellas example comes from, it’s here:</strong></p><p><a href="https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football">Contextual Scouting: A Pressing No. 8 in Women&#39;s Football | SkillCorner</a></p><p><strong>If you missed the webinar, you can watch it here:</strong></p><p><a href="https://skillcorner.com/articles/womens-football-dedicated-data-smarter-decisions">Women&#39;s Football: Dedicated Data, Smarter Decisions | SkillCorner</a></p><p><strong>Enjoyed this analysis?</strong> Check out some of my other articles built with SkillCorner data:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/womens-ballon-d-or-2026-what-does-the-football-data-say-c811b90e364e">Women’s Ballon d’Or 2026: What Does the Football Data Say?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/brazil-and-mexico-womens-football-pressing-midfielder-stats-compared-to-europe-0221769be981">Brazil and Mexico Women’s Football: Pressing Midfielder Stats Compared to Europe</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=33f0c81d5224" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Men’s Ballon d’Or 2026: What Does the Football Data Say?]]></title>
            <link>https://medium.com/@vickyfrissdekereki/mens-ballon-d-or-2026-what-does-the-football-data-say-db9a0f057bfd?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/db9a0f057bfd</guid>
            <category><![CDATA[football]]></category>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[soccer]]></category>
            <category><![CDATA[soccer-analytics]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Fri, 23 Oct 2026 13:08:55 GMT</pubDate>
            <atom:updated>2026-10-23T13:08:55.082Z</atom:updated>
            <content:encoded><![CDATA[<h4>SkillCorner data on all 30 nominees, from Lamine Yamal to Haaland and Messi</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BDgzhRrbLM7-CVvhC5Yqgg.png" /></figure><p>At 39, <strong>Lionel Messi</strong> covers less ground than any other outfield player in the seven leagues with a Ballon d’Or nominee, in any position. Yet no Center Forward adds more value on the ball. Goals and trophies don’t show that kind of thing, but <a href="https://skillcorner.com/">SkillCorner</a> data does.</p><p>In <a href="https://medium.com/@vickyfrissdekereki/womens-ballon-d-or-2026-what-does-the-football-data-say-c811b90e364e">my previous article</a>, I used it to look at the Women’s Ballon d’Or shortlist: how fast the nominees are, how much they press, and what they do with the ball. This article does the same, but for the 30 names on the <a href="https://ballondor.com/news/posts/the-ballon-dor-2026-unveiling-of-nominees-imminent-check-when">men’s shortlist</a>, and then puts the two lists side by side. The aim isn’t to predict the winner, but to see how the nominees actually play.</p><p>The method hasn’t changed, so here is just the short version. I used SkillCorner’s <a href="https://skillcorner.com/us/products/football/physical-data">physical data</a> and <a href="https://skillcorner.com/us/products/game-intelligence-football">dynamic events data</a>, with the same fourteen metrics. Each player gets a <strong>percentile within his position group, pooled across the seven leagues the nominees play in</strong>: the <a href="https://www.premierleague.com/">Premier League</a>, <a href="https://www.laliga.com/en-GB">LaLiga</a>, <a href="https://www.bundesliga.com/en/bundesliga">Bundesliga</a>, <a href="https://ligue1.com/">Ligue 1</a>, <a href="https://www.legaseriea.it/en">Serie A</a>, <a href="https://www.spl.com.sa/en">Saudi Pro League</a>, and <a href="https://www.mlssoccer.com/">MLS</a>. Only domestic league matches from August 3, 2025 to July 19, 2026 count, as that is the Ballon d’Or reference period, and only players with at least five matches of 60 minutes or more are considered. Every step is explained in detail in my previous article. One thing is different. The women’s list had two goalkeepers, who were left out. The men’s list has none this year, so all 30 nominees are included.</p><p>Also keep in mind that SkillCorner uses <a href="https://skillcorner.com/articles/dedicated-physical-metrics-for-womens-football">speed and acceleration thresholds built for the women’s game</a>, different from the men’s, so raw physical numbers from the two articles don’t line up. Every comparison here uses percentiles, or the shortlists themselves.</p><p>First, a look at who made the list.</p><h3>Thirty names, one club in front</h3><p>One club leads the way, just like on the women’s list. <strong>Nine of the thirty nominees, almost a third of the list, played for Paris Saint-Germain in 2025/26</strong>, and four played for Bayern Munich. Since then, <strong>Ferran Torres</strong> has moved from Barcelona to PSG, which takes them to ten nominees, a <a href="https://www.psg.fr/en/content/ballon-d-or-2026-paris-saint-germain-sets-new-record-with-10-nominees-psg-club-news-2026-2027">record for a single club</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kohWJ04WubW2LYMRjXJy_A.png" /><figcaption>The 2026 Men’s Ballon d’Or shortlist at a glance</figcaption></figure><p>The nominees come from thirteen countries, five fewer than for the women. Still, both lists have twenty Europeans, and Spain leads both with six. Outside Europe, Brazil has three and Argentina two, with single nominees from Colombia, Ecuador, Mexico, Morocco, and Senegal.</p><figure><img alt="World map of the 30 nominees by nationality, with a zoom on Europe. Spain has 6, France 5, Portugal 4, England and Brazil 3 each, Argentina 2, and Norway, Georgia, Morocco, Senegal, Colombia, Ecuador, and Mexico 1 each." src="https://cdn-images-1.medium.com/max/1024/1*EP3qXllDGWK45u7lFHfK_g.png" /><figcaption>The 30 nominees by nationality</figcaption></figure><p>Their club football is spread over seven leagues, the same number as on the women’s list. Europe’s big five account for 27 of the 30. The other three play further away: <strong>Sadio Mané</strong> and <strong>Julián Quiñones</strong> in the Saudi Pro League, and <strong>Messi</strong> in MLS.</p><p>The positions, though, are spread differently. The men’s list is led by Center Forwards, with eight, and has six Central Defenders. The women’s list had ten Midfielders and only two Central Defenders.</p><p>But how do these thirty actually move on the pitch?</p><h3>The physical side: how the nominees move</h3><p>With the list set out, we now turn to the physical side. Five metrics cover how much a player runs, in total and at high intensity (Total Distance and HI Distance), how fast he gets (<a href="https://skillcorner.com/articles/skillcorner-releases-new-peak-velocity-metric">Peak Velocity</a>), how quickly he reaches high speed (<a href="https://skillcorner.com/articles/new-feature-explosive-accelerations">Time to HSR</a>), and how often he accelerates sharply (High Accelerations).</p><p>Two of these metrics go together more than any others: within each position, the players with the highest top speed also tend to reach high speed the quickest. That was just as true for the women, with a <a href="https://en.wikipedia.org/wiki/Pearson_correlation_coefficient">correlation</a> of −0.60 on both lists.</p><p>The chart below shows the five metrics, with every player in the seven leagues in grey and the nominees in green.</p><figure><img alt="Three panels comparing the 30 nominees (green) with every player in the seven leagues (grey): HI Distance against Total Distance, Time to HSR against Peak Velocity, and high accelerations per 90 by position." src="https://cdn-images-1.medium.com/max/1024/1*fGOZ9pHB40GfpE__YzLxAA.png" /><figcaption>HI Distance, Total Distance, Time to HSR, Peak Velocity &amp; High Accelerations. Each grey dot is a player in the seven leagues and each green dot a nominee. Data: SkillCorner.</figcaption></figure><p>The fastest nominee is <strong>Erling Haaland</strong>. His Peak Velocity, 35.5 km/h across his three fastest matches, is the highest of any Center Forward in the seven leagues and sixth among all players. He also reaches high speed quicker than any other nominee: 0.54 seconds. <strong>Kylian Mbappé</strong> is second on both.</p><p><strong>Rodri</strong>, who won the Ballon d’Or in 2024, covers the most ground: 11,433 m per 90, just ahead of PSG’s <strong>João Neves</strong>. That’s the 89th percentile among Midfielders, the position that covers the most ground. Bayern Munich’s <strong>Michael Olise</strong> covers the most at high intensity, 1,061 m per 90, a little more than PSG’s <strong>Achraf Hakimi</strong> (91st percentile among Full Backs).</p><p>At the other end sits <strong>Lionel Messi</strong>, the only player to win the Ballon d’Or eight times. He played about half his league matches as a Center Forward and the rest in midfield or out wide. Like everyone else, he is compared with players in his most common position. At 39, the oldest nominee covers less ground than any other Center Forward in the seven leagues, and his HI Distance is in their bottom 1%.</p><p>Acceleration splits two strikers. Barcelona’s <strong>Ferran Torres</strong> accelerates sharply 13.8 times per 90, the most on the shortlist (98th percentile among Center Forwards). <strong>Harry Kane</strong> does it 3.0 times per 90, less often than any other Center Forward in the seven leagues, where the median is 7.9.</p><p>But running is only one side of the game. The other is what they do with and without the ball.</p><h3>What the nominees do with and without the ball</h3><p>After the physical side, we now turn to the nine output metrics, in three groups: <strong>defending, on the ball, and off the ball</strong>. Most are counted per 30 minutes of a player’s team being in or out of possession, and each player is only judged on what’s part of his role. Central Defenders aren’t scored on getting into dangerous areas or on dangerous passes, and Wide Attackers and Center Forwards aren’t scored on line-breaking passes. You can find every definition in <a href="https://medium.com/@vickyfrissdekereki/womens-ballon-d-or-2026-what-does-the-football-data-say-c811b90e364e">my previous article</a>.</p><p>Defending comes first.</p><h4>Defending</h4><p>The defending metrics we look at here are on-ball engagements (pressing, challenging, or containing the ball carrier), regains, and interceptions.</p><figure><img alt="Three strip charts by position for on-ball engagements, regains, and interceptions, with every player in the seven leagues in grey, the nominees in green, and the median of each position marked." src="https://cdn-images-1.medium.com/max/1024/1*mMnvho9Rm35Yr8yajgPl-Q.png" /><figcaption>Defending metrics by position. Data: SkillCorner.</figcaption></figure><p>In midfield, Manchester United’s <strong>Bruno Fernandes</strong> engages the ball carrier more than any other nominee. He does it 79.7 times per 30 minutes his team is out of possession, about once every 23 seconds (94th percentile among Midfielders). At the back, Barcelona’s <strong>Pau Cubarsí</strong> and PSG’s <strong>Willian Pacho</strong> make the most interceptions on the shortlist, about 9.1 each per 30 minutes, both in the top 4% of Central Defenders. <strong>Pacho</strong>’s teammate <strong>Nuno Mendes</strong> makes more interceptions than 98% of Full Backs.</p><p>Regains are a share of a player’s engagements, so position matters. Center Forwards engage the ball carrier almost three times as often as Central Defenders, but win it back on only 12.5% of those engagements, against 25.3% for Central Defenders. Among the nominees, Arsenal’s <strong>William Saliba</strong> has the highest rate, 29.4%. <strong>Harry Kane</strong>’s 21.2% is the highest of any Center Forward in the seven leagues. <strong>Ousmane Dembélé</strong>, the reigning Ballon d’Or winner, is in the 98th percentile of his position, as are <strong>Sadio Mané</strong> and <strong>Luis Díaz</strong> among Wide Attackers.</p><p>Once the ball is won, though, mostly different names take over.</p><h4>On the ball</h4><p>On the ball, we look at <a href="https://skillcorner.com/articles/introducing-skillcorners-expected-possession-value-epv">EPV added</a> (how much a player’s carries and passes raise his team’s chance of scoring), line-breaking passes, and dangerous passes (at least a 2% chance of a goal within 10 seconds).</p><figure><img alt="Three strip charts by position for EPV added, line-breaking passes, and dangerous passes, with every player in the seven leagues in grey, the nominees in green, and rows that are not a core part of a role greyed out." src="https://cdn-images-1.medium.com/max/1024/1*QeZkUNxm_iQWQ4CEU11fpA.png" /><figcaption>On-the-ball metrics by position. Grey rows are not a core part of that role. Data: SkillCorner.</figcaption></figure><p>This is <strong>Lamine Yamal</strong>’s group. Barcelona’s 19-year-old adds the most <a href="https://skillcorner.com/articles/introducing-skillcorners-expected-possession-value-epv">EPV</a> of any nominee, 66.1 per 30 minutes of team in possession, more than 99.8% of Wide Attackers. <strong>Messi</strong> is next on EPV with 57.9, the most of any Center Forward. On dangerous passes he goes further: no player in the seven leagues, in any position, completes more.</p><p>In midfield, PSG’s <strong>Vitinha</strong> and <strong>Fabián Ruiz</strong>, along with <strong>Rodri</strong>, all complete more line-breaking passes than 98% of Midfielders. <strong>Bruno Fernandes</strong>, meanwhile, is in the 99th percentile on dangerous passes.</p><p>But a lot of what attackers do happens before they even get the ball.</p><h4>Off the ball</h4><p>Off the ball, the metrics we look at here are dangerous off-ball runs, runs received (the share of his runs where he gets the ball), and possessions ending in the box.</p><figure><img alt="Three strip charts by position for dangerous off-ball runs, runs received, and possessions ending in the box, with every player in the seven leagues in grey, the nominees in green, and rows that are not a core part of a role greyed out." src="https://cdn-images-1.medium.com/max/1024/1*31Ac4FF6gkw18UFo-1gIXA.png" /><figcaption>Off-the-ball metrics by position. Grey rows are not a core part of that role. Data: SkillCorner.</figcaption></figure><p>Center Forwards make a median of 21.7 dangerous runs per 30 minutes, about four times as many as Full Backs (5.2). <strong>Ferran Torres</strong> makes the most on the shortlist, 29.0, just ahead of Al Qadsiah’s <strong>Julián Quiñones</strong>.</p><p><strong>Messi</strong> and <strong>Lamine Yamal</strong> stand out in a different way: their runs are found more often than almost anyone’s at their positions. <strong>Messi</strong> receives the ball on 43% of his runs and <strong>Lamine Yamal</strong> on 42%, both above the 99th percentile. <strong>Lamine Yamal</strong> also has the most possessions ending in the box on the shortlist, 10.7 per 30 minutes, with <strong>Sadio Mané</strong> and <strong>Mbappé</strong> both in the 98th percentile.</p><p>So how many nominees stand out on at least one of these nine metrics?</p><h4>Standing out with or without the ball</h4><p>Put together, 21 of the 30 nominees reach the 90th percentile of their position on at least one of the nine metrics with and without the ball. The physical metrics aren’t part of this count. Nine do it in just one of the three groups, seven in two, and five in all three: <strong>Nuno Mendes</strong>, <strong>Bruno Fernandes</strong>, <strong>Rodri</strong>, <strong>Sadio Mané</strong>, and <strong>Dembélé</strong>.</p><p>Five of the nine who don’t are defenders: Arsenal’s <strong>Gabriel</strong> and <strong>William Saliba</strong>, Bayern Munich’s <strong>Dayot Upamecano</strong>, <strong>Hakimi</strong>, and Chelsea’s <strong>Marc Cucurella</strong>. In this analysis, Central Defenders are only scored on five of the nine metrics, which gives them fewer chances. <strong>Jude Bellingham</strong> misses by a hair. On possessions ending in the box, he is in the 89.97th percentile of Midfielders, just below the 90th. <strong>Haaland</strong>, <strong>Julián Quiñones</strong>, and Inter Milan’s <strong>Lautaro Martínez</strong> complete the nine. That’s a reminder that the metrics we look at here are only a selection.</p><p>So how do the nominees look when the physical and output sides are put together?</p><h3>Physical and output together</h3><p>So far, we have looked at the two sides separately. Here they come together. Each player gets a physical score and an output score from 0 to 100, where 50 is the middle of his position.</p><figure><img alt="Five scatter plots, one per position, of physical score against output score, with every player at that position in grey and the nominees in green and named." src="https://cdn-images-1.medium.com/max/1024/1*I3i1O6d2hsbFSvkkOXYdUQ.png" /><figcaption>Physical score against output score for the 30 nominees and every player at their position in the seven leagues. Data: SkillCorner.</figcaption></figure><p>Output is again where the nominees stand out. Of the 30, 28 are in the top half of their position, with a median score of 92. On physical, 23 are, with a median of 73.</p><p><strong>Upamecano</strong> (97 physical, 98 output) and <strong>Michael Olise</strong> (91 and 99) are high on both. The widest gaps run one way for <strong>Fabián Ruiz</strong> (8 physical, 99 output), <strong>Lamine Yamal</strong> (18 and 98), and <strong>Messi</strong> (0 and 77). They run the other way for <strong>Ferran Torres</strong> (83 and 34), followed by <strong>Gabriel</strong>, <strong>Saliba</strong>, and <strong>Haaland</strong>.</p><p><strong>Haaland</strong> shows what these scores leave out. He isn’t a target man who holds the ball up for others, but a penalty-box finisher whose main job is to score. He is very good at what that role needs, like speed, and is less involved in the build-up. It shows in the numbers: he receives the ball on only 13% of his off-ball runs (5th percentile among Center Forwards), and his dangerous passes are in the 28th percentile. That leaves his output score at 50, right in the middle of his position.</p><p>Finishing, though, isn’t one of these metrics. How do the nominees do on goals and assists?</p><h3>Goals and assists</h3><p>The fourteen metrics describe how the nominees play, not what they end up producing. The obvious place to look for that is goals and assists. For the 27 nominees in Europe’s big five leagues, each player is compared with others in his position, per 90 minutes.</p><p>This is where <strong>Haaland</strong>’s role pays off: 27 league goals in 35 games. <strong>Harry Kane</strong> goes one better, with 36 in 31, the most goals per 90 of any Center Forward in the five leagues. On assists, <strong>Michael Olise</strong>, <strong>Dembélé</strong>, and <strong>Bruno Fernandes</strong> each lead their position. In total, 18 of the 27 are in the top 10% of their position for goals or assists. Seven of the nine who aren’t are defenders, much as on the output metrics.</p><p>The three nominees outside Europe score plenty too. <strong>Julián Quiñones</strong> was the Saudi Pro League’s top scorer, with 33 goals in 31 games. <strong>Sadio Mané</strong> added 10 goals and 6 assists in 25 games for Al Nassr. And <strong>Messi</strong> scored 29 goals in 30 MLS games, playoffs included.</p><p>What stands out when we look at a few players up close?</p><h3>Six players up close</h3><p>Up to now, we have looked at the whole list at once. Now we zoom in on six nominees whose numbers stand out in different ways, one from each position plus a second Center Forward. Clubs are 2025/26 clubs, and ages are as of July 19, 2026.</p><ul><li><strong>Lamine Yamal</strong> (<em>Barcelona · Wide Attacker · Spain, 19</em>): <strong>The youngest player on the shortlist, and the one who adds the most EPV, 66.1 per 30 minutes of team in possession.</strong> He also has the most possessions ending in the box, and 42% of his runs end with him receiving the ball. Physically, though, he covers less ground than 95% of Wide Attackers.</li><li><strong>Lionel Messi</strong> (<em>Inter Miami · Center Forward · Argentina, 39</em>): <strong>The oldest nominee, with the lowest physical score of any Center Forward in the seven leagues. </strong>On the ball, no Center Forward adds more EPV, and no player in the seven leagues completes more dangerous passes, 10.6 per 30 minutes of team in possession.</li><li><strong>Erling Haaland</strong> (<em>Manchester City · Center Forward · Norway, 25</em>): <strong>The fastest nominee and the quickest to high speed, both in the top 1% of Center Forwards.</strong> His output score sits right in the middle of his position, but he scored 27 league goals in 35 games.</li><li><strong>Nuno Mendes</strong> (<em>Paris Saint-Germain · Full Back · Portugal, 24</em>): <strong>The highest output score on the shortlist. </strong>He makes more interceptions than 98% of Full Backs, adds more EPV than 97% of them, and is in their top 10% on dangerous passes and runs received. His Peak Velocity is in the 95th percentile.</li><li><strong>Bruno Fernandes</strong> (<em>Manchester United · Midfield · Portugal, 31</em>): <strong>The second-highest output score, and the nominee who engages the ball carrier most often.</strong> His passes and carries add 40.0 EPV per 30 minutes of team in possession, and no Midfielder in the five leagues has more assists per 90. His regain rate, though, is low for a Midfielder.</li><li><strong>Dayot Upamecano</strong> (<em>Bayern Munich · Central Defender · France, 27</em>): <strong>The highest physical score on the shortlist. </strong>He covers more ground than 96% of Central Defenders, and on the ball he is in their top 20% on line-breaking passes and EPV.</li></ul><p>Finally, how do these thirty compare with the women’s nominees?</p><h3>Men and women, side by side</h3><p>The men’s list has been looked at on its own until now. Now we put it next to the women’s. Measured the same way, the two lists have a lot in common. Each is led by one club: Paris Saint-Germain on the men’s side, with nine nominees in 2025/26, and Barcelona on the women’s, with eight. Each has twenty Europeans, six of them Spanish. And on both, output matters more than physical: the median output score is 92 for the men and 89 for the women, against 73 and 68 on physical. Even the physical metrics behave the same way. Peak Velocity and Time to HSR are linked just as strongly, and Midfielders cover the most ground on both.</p><p>The differences are mostly about who is on each list. The men’s list has no goalkeepers, more Central Defenders and Center Forwards, and fewer Midfielders. That shows in the numbers. Fewer of the men’s nominees reach the 90th percentile on an output metric, 21 of 30 against 24 of 28. Fewer are also in the top 10% for goals or assists, 18 of 27 against at least 24 of 28 for the women. In both cases, most of the men’s nominees who miss out are defenders. They also shine in different places. The women’s list stood out most on possessions ending in the box, with 14 of 28 in the 90th percentile or above. The men’s list stands out most on EPV, with 9 of 30.</p><p>And one story repeats. On the women’s list, <strong>Alexia Putellas</strong> had the third-lowest physical score but the fifth-highest output score. On the men’s, <strong>Lionel Messi</strong> has the lowest physical score of any Center Forward in the seven leagues, and still adds more EPV than any of them.</p><h3>Closing thoughts</h3><p>These numbers don’t say who should win. They cover a few parts of how the nominees played in their domestic leagues in 2025/26. They also leave plenty out. SkillCorner measures far more than the metrics we look at here, and none of this covers trophies, big nights in finals, the Champions League, or this summer’s World Cup.</p><p>Within those limits, the men’s list tells a story much like the women’s list. The nominees stand out in very different ways, and a strong physical profile and strong output don’t always go together. On both lists, though, nearly every nominee is in the top half of his or her position on output, while the physical picture is more mixed.</p><p><strong>The winners will be announced next Monday, October 26th. Who would you pick?</strong></p><p>Enjoyed this analysis? Check out some of my other articles built with SkillCorner data:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/womens-ballon-d-or-2026-what-does-the-football-data-say-c811b90e364e">Women’s Ballon d’Or 2026: What Does the Football Data Say?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/brazil-and-mexico-womens-football-pressing-midfielder-stats-compared-to-europe-0221769be981">Brazil and Mexico Women’s Football: Pressing Midfielder Stats Compared to Europe</a></li><li><a href="https://medium.com/@vickyfrissdekereki/what-do-passing-numbers-say-about-football-styles-around-the-world-4e23318dabcb">What Do Passing Numbers Say About Football Styles Around the World?</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=db9a0f057bfd" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Women’s Ballon d’Or 2026: What Does the Football Data Say?]]></title>
            <link>https://medium.com/@vickyfrissdekereki/womens-ballon-d-or-2026-what-does-the-football-data-say-c811b90e364e?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/c811b90e364e</guid>
            <category><![CDATA[soccer-analytics]]></category>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[soccer]]></category>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[football]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Wed, 14 Oct 2026 12:20:14 GMT</pubDate>
            <atom:updated>2026-10-14T12:20:14.087Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>Speed, pressing, passing, and off-ball runs: SkillCorner data on all outfield nominees, beyond goals and trophies</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K59C4AyWj1UxsMTEnYJPcw.png" /></figure><p>Thirty names made this year’s <a href="https://ballondor.com/news/posts/womens-ballon-dor-discover-the-ballon-dor-2026-nominees">Women’s Ballon d’Or shortlist</a>, announced in September. The case for each one often comes down to trophies, goals, a signature performance everyone remembers. All of that matters, but it also misses most of what happens in a match: how fast a player is, how often she presses, the runs she makes without the ball. Tracking data shows that side of her season.</p><p>To see it, I used <a href="https://skillcorner.com/">SkillCorner</a>’s <a href="https://skillcorner.com/us/products/football/physical-data">physical data</a> and <a href="https://skillcorner.com/us/products/game-intelligence-football">dynamic events data</a>, both built from tracking data: how players move, and what they do when their team is in and out of possession. I looked at the outfield nominees, leaving out the goalkeepers, across the domestic league matches of their 2025/26 club season.</p><p><strong>This isn’t a ranking, and I’m not trying to say who should win.</strong> I’m looking at a few parts of each player’s game over the same season. SkillCorner data covers much more than the fourteen metrics used here, and goals, assists, trophies, and big-match moments are a different part of the story. But even these fourteen show plenty: who covers the most ground, who is the fastest, who wins the ball back, and who creates danger. It even shows that one of the shortlist’s lowest physical profiles corresponds to one of its strongest players on the ball. First, though, a look at who made the list.</p><h3>The shortlist itself</h3><p>While the full list of nominees can be found on the <a href="https://ballondor.com/news/posts/womens-ballon-dor-discover-the-ballon-dor-2026-nominees">Ballon d’Or website</a>, one detail stands out straight away. <strong>Eight of the thirty (more than a quarter of the list) played for Barcelona in 2025/26</strong>, and five played for Manchester City.</p><figure><img alt="The 2026 Women’s Ballon d’Or shortlist at a glance" src="https://cdn-images-1.medium.com/max/1024/1*ZmQ-etSmLbN4MbMLBti8lA.png" /><figcaption>The 2026 Women’s Ballon d’Or shortlist at a glance</figcaption></figure><p>By contrast, nationalities are far more spread out. Eighteen national teams are represented, with Japan on three, and single nominees from countries like Haiti, Jamaica, Malawi, and Zambia. Europe still has the most, twenty of the thirty, and six of them are Spanish.</p><figure><img alt="World map of the 30 nominees by nationality, with a zoomed inset of Europe. Spain has six nominees, France, the Netherlands, and Japan three each, England two, and thirteen other countries one each, including the United States, Mexico, Haiti, Jamaica, Brazil, Zambia, and Malawi." src="https://cdn-images-1.medium.com/max/1024/1*10kFU_HbJ5cbC3Yh5ocfSw.png" /><figcaption>The 30 nominees by nationality</figcaption></figure><p>However, these players from eighteen nations play their club football in just seven leagues. Comparing them across leagues and positions is harder than it looks.</p><h3>Comparing players who don’t play the same game</h3><p>The thirty nominated players don’t share a team or a role, so their raw numbers don’t compare directly. A Center Forward and a Central Defender are asked to do very different things.</p><p>So, whenever I score a player, I use a <strong>percentile within her position group, pooled across all seven leagues that have a nominee</strong>: <a href="https://www.wslfootball.com/">WSL</a>, <a href="http://ligaf.es">Liga F</a>, <a href="https://frauen-bundesliga.dfb.de/">Frauen-Bundesliga</a>, <a href="https://epreuves.fff.fr/competition/engagement/4-arkema-premiere-ligue/phase/1/1/accueil">Première Ligue</a>, <a href="https://www.figc.it/it/serie-a-women/tutte-le-competizioni">Serie A</a>, <a href="https://www.nwslsoccer.com/">NWSL</a>, and <a href="https://en.wikipedia.org/wiki/Liga_Femenil">Liga MX Femenil</a>. A Center Forward, for example, is compared with every other Center Forward in those seven leagues, and the 90th percentile means she’s ahead of 90% of them. Leagues play differently, but this way every nominee is measured against the same standard.</p><p>Only players with at least five matches of 60 minutes or more are considered. That leaves out short appearances off the bench, where a few minutes can make per-minute numbers look far higher or lower than they really are. It also makes it less likely that one unusually good or bad game decides a player’s numbers. The data runs from August 3, 2025 to July 19, 2026, the Ballon d’Or’s own reference period, and only includes domestic league matches, not international duty or continental cups. The charts show raw values, so the real spread can be seen.</p><p>Two of the thirty, <strong>Cata Coll</strong> and <strong>Lorena</strong>, are goalkeepers. A goalkeeper’s job is different enough from an outfield player’s that I left both out of the rest of the analysis. From here on, the focus is on the other 28, starting with their physical numbers.</p><h3>The physical side: how do outfield nominees move</h3><p>I’ll first look at five physical metrics from <a href="https://skillcorner.com/articles/dedicated-physical-metrics-for-womens-football">SkillCorner’s new Women’s Physical Data</a>, built specifically for the women’s game:</p><ul><li><strong>Total Distance</strong>: how much ground she covers, per 90 minutes.</li><li><strong>HI Distance</strong>: how much ground she covers at high intensity, meaning high-speed running (HSR) plus sprinting, per 90 minutes.</li><li><a href="https://skillcorner.com/articles/skillcorner-releases-new-peak-velocity-metric"><strong>Peak Velocity</strong></a>: the top speed she reaches in a match, in km/h, averaged over her three fastest matches.</li><li><a href="https://skillcorner.com/articles/new-feature-explosive-accelerations"><strong>Time to HSR</strong></a>: how many seconds she takes to accelerate into high-speed running, averaged over her three quickest matches. Lower is better.</li><li><strong>High Accelerations</strong>: how many times she speeds up sharply, per 90 minutes.</li></ul><p>I picked these five as a broad overview of a player’s main physical qualities: how much work she gets through, how much of it is at high intensity, how fast she can go, how quickly she gets there, and how often she speeds up sharply. Every position needs these to some degree, even if each one leans on them differently. They also avoid repeating each other, which many common physical metrics do. Within position groups, the strongest link between any two of the five is between Peak Velocity and Time to HSR, a <a href="https://en.wikipedia.org/wiki/Pearson_correlation_coefficient">correlation</a> of −0.60: players with a higher top speed tend to reach high speed more quickly. On this scale, which runs from −1 to 1, 0 means there’s no link at all, and the further from 0, the stronger the link.</p><p>The chart below shows the five metrics, with every player in the seven leagues in grey and the nominees in green.</p><figure><img alt="Three panels comparing the 28 outfield nominees (green) with every player in the seven leagues (grey): HI Distance against Total Distance, Time to HSR against Peak Velocity, and high accelerations by position. Temwa Chawinga leads the nominees on HI Distance and Peak Velocity, Barbra Banda is the quickest to high speed, and Melchie Dumornay makes the most high accelerations." src="https://cdn-images-1.medium.com/max/1024/1*PQo28nKrz74tTyQRz8MH8A.png" /><figcaption>HI Distance, Total Distance, Time to HSR, Peak Velocity &amp; High Accelerations. Each grey dot is a player in the seven leagues and each green dot an outfield nominee. Data: SkillCorner.</figcaption></figure><p>In the left chart, KC Current’s <strong>Temwa Chawinga </strong>covers the most high-intensity ground on the shortlist, 985 m per 90. Wide Attackers like her cover more high-intensity ground than any other position, and she is still in the 92nd percentile among them. Bayern Munich’s <strong>Momoko Tanikawa</strong> covers the most ground overall, 10,547 m per 90. Midfielders like her cover the most ground of any position, and she is in the 89th percentile among them.</p><p>In the middle chart, <strong>Chawinga</strong> also has the highest Peak Velocity of any player in the seven leagues, <strong>33.0 km/h</strong> across her three fastest matches. Orlando Pride center forward <strong>Barbra Banda</strong> has the quickest Time to HSR on the shortlist, 0.61 seconds, with Barcelona’s 22-year-old Dutch full back <strong>Esmee Brugts </strong>and <strong>Chawinga</strong> right behind her.</p><p><strong>Alexia Putellas</strong> sits in the opposite corner of the first two charts, with the slowest Time to HSR on the shortlist and one of its lowest HI Distances. However, among Midfielders, her Time to HSR is near the middle (40th percentile). Also, her low HI Distance may partly reflect her role: pressing high takes a lot of high-speed running, and as I explain in <a href="https://www.skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football">my article on pressing midfielders in women’s football</a>, Barcelona leave that mostly to the forwards. Bayern Munich’s <strong>Pernille Harder</strong>, by contrast, presses as much as some center forwards even when she plays in midfield.</p><p>In the right chart, OL Lyonnes’ <strong>Melchie Dumornay</strong> speeds up sharply 18.1 times per 90 minutes. That’s more than any other Midfielder in the seven leagues, and almost three times the midfield median of 6.2.</p><h3>What these players do in and out of possession</h3><p>Running is only part of the picture. For what players do with and without the ball, <strong>I used nine metrics from SkillCorner’s dynamic events data, in three groups: defending, on the ball, and off the ball.</strong> I’ll call these output metrics. Some count how often a player does something, others how well it works. The counts are per 30 minutes of her team having the ball, or of her team not having the ball, so players on dominant teams aren’t flattered or penalised by how much possession their team has.</p><p>Since positions are asked to do very different things, as the charts below show, I compare each player only with others in her position, and only on what’s really part of her role. I don’t judge Central Defenders on getting into dangerous areas or on dangerous passes, or Wide Attackers and Center Forwards on <a href="https://skillcorner.com/articles/gi-line-breaking">line-breaking passes</a>. Those rows are greyed out in the charts.</p><h4>Defending</h4><p><a href="https://skillcorner.com/articles/gi-oop">On-ball engagements</a> (every time she presses, challenges, or contains the ball carrier) and interceptions, per 30 minutes of team out of possession. Plus regains, that is the share of her engagements that end with her team winning the ball back.</p><figure><img alt="Three strip charts by position for on-ball engagements, regains, and interceptions, with nominees in green over every player in the seven leagues in grey. Patri Guijarro leads all Midfielders on regains and interceptions, and Mapi León makes the most interceptions of any nominee." src="https://cdn-images-1.medium.com/max/1024/1*IhXwIVQ5Xts8dOxVxe9Kvg.png" /><figcaption>Defending metrics by position. Data: SkillCorner.</figcaption></figure><p>In midfield, Barcelona’s <strong>Patri Guijarro</strong> has the highest regain rate and the most interceptions of any Midfielder in the seven leagues. At the back, Barcelona’s <strong>Mapi León</strong> makes the most interceptions on the shortlist, 15.3 per 30 minutes of team out of possession (99.4th percentile among Central Defenders). <strong>Melchie Dumornay</strong> and <strong>Momoko Tanikawa</strong>, meanwhile, are both in the top 5% of Midfielders on engagements.</p><p>Further forward, regain rates are lower overall, but some nominees still stand out within their position: Barcelona’s <strong>Claudia Pina</strong> (99th percentile) and Bayern Munich’s <strong>Klara Bühl</strong> (98th) among Wide Attackers, and <strong>Pina</strong>’s teammate <strong>Ewa Pajor</strong> (95th) among Center Forwards.</p><h4>On the ball</h4><p>Three metrics here. The first is <a href="https://skillcorner.com/articles/introducing-skillcorners-expected-possession-value-epv">EPV (Expected Possession Value)</a>: SkillCorner’s estimate of a team’s chance of scoring in the next 30 seconds, or before the next stoppage. I count how much she adds to it with her carries and passes. It’s positive when her actions make her team more likely to score, and negative when they make things worse.</p><p>The other two metrics are completed line-breaking passes (through or around any of the opponent’s defensive lines) and completed dangerous passes (with at least a 2% chance of leading to a goal within 10 seconds). All three are per 30 minutes of team in possession.</p><figure><img alt="Three strip charts by position for EPV added, line-breaking passes, and dangerous passes, with nominees in green over every player in the seven leagues in grey. Barbra Banda and Klara Bühl add the most EPV of any nominee, and Alexia Putellas completes the most dangerous passes of any Midfielder." src="https://cdn-images-1.medium.com/max/1024/1*PJ76lWZdRWLO1zjcPJ7_fg.png" /><figcaption>On-the-ball metrics by position. Grey rows are not a core part of that role. Data: SkillCorner.</figcaption></figure><p>At the back again, <strong>Mapi León</strong> and OL Lyonnes’ <strong>Wendie Renard</strong> both add more EPV than 99% of Central Defenders, and Arsenal’s <strong>Mariona Caldentey</strong> completes more line-breaking passes than 99% of Midfielders. Overall, <strong>Barbra Banda</strong> and <strong>Bühl</strong> add the most EPV of any nominee, around 50 per 30 minutes of team in possession.</p><p>Dangerous passes, on the other hand, is where midfielders and attackers stand out. Two-time Ballon d’Or winner <strong>Alexia Putellas</strong> completes more of them than any other Midfielder in the seven leagues, and <strong>Bühl</strong> more than any other Wide Attacker. <strong>Esmee Brugts</strong>, <strong>Claudia Pina</strong>, and <strong>Patri Guijarro</strong> are also all in the 99th percentile or above.</p><h4>Off the ball</h4><p><a href="https://skillcorner.com/articles/game-intelligence-off-ball-run-types">Dangerous off-ball runs</a> and possessions ending in the box (she has the ball there, or plays a teammate in there, no matter where she first got it), per 30 minutes of team in possession. Plus runs received, that is the share of her off-ball runs where she receives the ball.</p><p>This is where positions differ the most. Central Defenders, for example, make a median of 0.28 dangerous runs per 30 minutes, against 17.2 for Center Forwards.</p><figure><img alt="Three strip charts by position for dangerous off-ball runs, runs received, and possessions ending in the box, with nominees in green over every player in the seven leagues in grey. Temwa Chawinga makes the most dangerous runs of any Wide Attacker, and Klara Bühl leads all Wide Attackers on possessions ending in the box." src="https://cdn-images-1.medium.com/max/1024/1*99zBcoNztshhYJdYgInNKw.png" /><figcaption>Off-the-ball metrics by position. Grey rows are not a core part of that role. Data: SkillCorner.</figcaption></figure><p>Once again, <strong>Temwa Chawinga</strong> makes more dangerous runs than any other Wide Attacker in the seven leagues. In midfield, North Carolina Courage’s <strong>Manaka Matsukubo</strong> is in the 98th percentile of Midfielders on dangerous runs and the 95th on runs received. Further back, <strong>Esmee Brugts</strong> makes more dangerous runs than 95% of Full Backs.</p><p>Possessions ending in the box is where the most nominees stand out: 14 of the 28 are in the 90th percentile or above for their position. <strong>Bühl</strong> is top of all Wide Attackers, and <strong>Alexia Putellas</strong> is in the 99.5th percentile among Midfielders.</p><h4>Different ways to stand out</h4><p>All in all, the nominees stand out in different ways, and no single metric captures them all. Of the 28, 24 reach the 90th percentile of their position on at least one metric. Nine of them do it in metrics in only one of the three groups (defending, on the ball, or off the ball), nine in two groups, and just six in all three: <strong>Patri Guijarro</strong>, <strong>Dumornay</strong>, <strong>Pina</strong>, <strong>Bühl</strong>, <strong>Brugts</strong>, and <strong>Caroline Graham Hansen</strong>.</p><p>The four who don’t are Club América’s <strong>Scarlett Camberos</strong> (89th, regains), Arsenal’s <strong>Alessia Russo</strong> (89th, on-ball engagements), Manchester City’s <strong>Alex Greenwood</strong> (84th, possessions ending in the box), and Bayern Munich’s <strong>Pernille Harder</strong> (82nd, possessions ending in the box).</p><p>The next step is to put the physical and output sides together.</p><h3>Physical and output, side by side</h3><p>To combine physical and output, I averaged each player’s five physical percentiles, and the output percentiles that fit her position (five to nine). I then turned each average back into a percentile within her position. That gives every player a physical score and an output score from 0 to 100, where 50 is the middle of her position. The following charts show each position separately.</p><figure><img alt="Five scatter plots, one per position, of physical score against output score from 0 to 100, with nominees in green over every player at that position in grey. All nominees sit in the top half on output. Klara Bühl is high on both, while Wendie Renard, Claudia Pina, and Alexia Putellas score far higher on output than on physical." src="https://cdn-images-1.medium.com/max/1024/1*MvCLZ0bBbwE7M94BRupl7g.png" /><figcaption>Physical score against output score for the 28 outfield nominees and every player at their position in the seven leagues. Data: SkillCorner.</figcaption></figure><p>The first thing that stands out is output. All 28 nominees are in the top half of their position, with a median score of 89. On physical, 20 of them are, and the median is 68.</p><p><strong>Klara Bühl</strong> sits high on both (94 physical, 100 output). Three players show the widest gap one way: <strong>Wendie Renard</strong> (20 physical, 96 output), <strong>Claudia Pina</strong> (26 and 100), and <strong>Alexia Putellas</strong> (28 and 98). Gotham FC’s <strong>Rose Lavelle</strong> shows the widest gap the other way, 97 against 73, followed by <strong>Barbra Banda</strong>, <strong>Temwa Chawinga</strong>, and Manchester City’s <strong>Kerstin Casparij</strong>.</p><p>Inter Milan’s <strong>Tessa Wullaert</strong> sits below the middle on physical and a little above it on output, 35 and 61, and her case shows what an average can hide. She makes few dangerous off-ball runs (6th percentile among Center Forwards), but 30% of her runs end with her receiving the ball, the 95th percentile. Her passes also add more EPV than most Center Forwards’ (90th percentile).</p><p>Averages like these only go so far, so the next section looks at six players in more detail.</p><h3>Six players up close</h3><p>A closer look at six nominees whose numbers stand out in different ways. Clubs are 2025/26 clubs, and ages are as of July 19, 2026.</p><ul><li><strong>Melchie Dumornay </strong>(<em>OL Lyonnes · Midfield · Haiti, 22</em>): <strong>The highest physical score on the shortlist, 99, and the most active out of possession. </strong>Her Peak Velocity, HI Distance, and high accelerations are all in the top 2% of Midfielders. No nominee engages the ball carrier more often: 77 times per 30 minutes of team out of possession, just ahead of <strong>Momoko Tanikawa</strong>.</li><li><strong>Temwa Chawinga</strong> (<em>KC Current · Wide Attacker · Malawi, 27</em>): <strong>The highest top speed in the seven leagues, 33.0 km/h.</strong> She also makes more dangerous off-ball runs than any nominee except <strong>Ewa Pajor</strong>. Only 20% of her runs end with her receiving the ball, though.</li><li><strong>Barbra Banda </strong>(<em>Orlando Pride · Center Forward · Zambia, 26</em>): <strong>The shortlist’s quickest to high speed</strong>, even though she covers the least ground of any nominee. On the ball, she adds more EPV than any other nominee, 50.4 per 30 minutes of team in possession, and has the most possessions ending in the box, 11.6 per 30.</li><li><strong>Wendie Renard</strong> (<em>OL Lyonnes · Central Defender · France, 35</em>): <strong>The oldest player on the shortlist</strong>, turning 36 the day after the reference window closes. She doesn’t engage often for a Central Defender, but she stands out on the ball: she adds 39.8 EPV per 30 minutes of team in possession, the 99.7th percentile.</li><li><strong>Kerstin Casparij</strong> (<em>Manchester City · Full Back · Netherlands, 25</em>): <strong>In the top 15% of Full Backs on four of the five physical metrics. </strong>The one exception is high accelerations. She is also in the top 15% of Full Backs on on-ball engagements and dangerous off-ball runs.</li><li><strong>Alexia Putellas </strong>(<em>Barcelona · Midfield · Spain, 32</em>): <strong>The third-lowest physical score on the shortlist, 28, but the fifth-highest output score, 98.</strong> Her passes add 18.2 EPV per 30 minutes of team in possession.</li></ul><h3>What this does and doesn’t tell you</h3><p>Taken together, these numbers don’t tell you who should win, and they aren’t meant to rank anyone. They cover a few parts of how the 28 outfield nominees played in 2025/26: how they move, defend, pass, and attack, each compared with other players in the same position across seven leagues.</p><p>They also leave a lot out. SkillCorner’s data covers far more than the fourteen metrics here: pressing by phase of play, passes that break each of the defensive lines, every type of off-ball run, and more. And some of what the award rewards is a different part of the story: trophies, a standout performance in a big final, and national team games. I also only looked at domestic leagues, not international competitions like the Champions League.</p><p>Goals and assists aren’t part of these fourteen metrics either, but they point the same way as the output scores: at least 24 of the 28 are in the top 10% of their position for goals or assists.</p><h3>Closing thoughts</h3><p>Within those limits, a few things stand out. The 28 players have very different profiles, and the positions ask for such different things that comparing raw numbers across positions wouldn’t be fair. A strong physical profile and strong output don’t always go together: <strong>Klara Bühl</strong> sits high on both, while several others lean clearly one way or the other. And as we saw earlier, 24 of the 28 are in the top 10% of their position on at least one of the output metrics used for their position, though not always the same one.</p><p>All 28 are also in the top half of their position on output, with a median score of 89, against 68 on physical. So strong output seems to be part of what gets a player on the list, but a strong physical profile seems to matter less, at least for now.</p><p><strong>So who will lift the trophy? That’s for the voters to decide.</strong> What the numbers do show is how many different ways there are to have a standout season. <strong>Who would you pick?</strong></p><p><strong>Enjoyed this analysis?</strong> Check out some of my other articles built with <a href="https://skillcorner.com/">SkillCorner</a> data:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/brazil-and-mexico-womens-football-pressing-midfielder-stats-compared-to-europe-0221769be981">Brazil and Mexico Women’s Football: Pressing Midfielder Stats Compared to Europe</a></li><li><a href="https://medium.com/@vickyfrissdekereki/what-do-passing-numbers-say-about-football-styles-around-the-world-4e23318dabcb">What Do Passing Numbers Say About Football Styles Around the World?</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c811b90e364e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[30 Minutes to 90 Seconds: 4 Fixes to Optimise a Data Processing Pipeline]]></title>
            <link>https://medium.com/@vickyfrissdekereki/30-minutes-to-90-seconds-4-fixes-to-optimise-a-data-processing-pipeline-a8e81740fab0?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/a8e81740fab0</guid>
            <category><![CDATA[app-development]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[github]]></category>
            <category><![CDATA[streamlit]]></category>
            <category><![CDATA[data-science]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Wed, 09 Sep 2026 11:48:26 GMT</pubDate>
            <atom:updated>2026-09-09T11:48:26.811Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>Making a daily football prediction pipeline run 20x faster on GitHub Actions</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RssL4bv8sKIvkO9fuAXFZQ.png" /></figure><p>A GitHub Actions job that runs every morning, simulating football leagues, was never exactly fast. But it got a little slower every time I added another league to the simulator behind it, <strong>until it finally crossed the 30-minute mark</strong>.</p><p>That job is the “run” step behind <a href="https://football-league-simulator.streamlit.app/">the football prediction pipeline</a> I wrote about setting up in my <a href="https://medium.com/@vickyfrissdekereki/4-steps-to-automate-streamlit-app-updates-with-github-actions-77b7d88475b4"><em>4 Steps to Automate Streamlit App Updates with GitHub Actions</em></a> article. It scrapes nine leagues, blends betting odds with a Poisson model (the modelling itself is covered in my <a href="https://medium.com/@vickyfrissdekereki/predicting-football-league-final-positions-revisited-betting-odds-probabilistic-modelling-893cc938801c"><em>Predicting Football League Final Positions, Revisited: Betting Odds, Probabilistic Modelling &amp; Simulation</em></a> article, and runs 10,000 Monte Carlo simulations before the dashboard redeploys. The ninth league, Portugal’s Primeira Liga, was the one that made the daily run take over 30 minutes.</p><p>Nothing about that felt urgent: GitHub Actions doesn’t charge me at all for it (Actions minutes are free on a public repo), and the dashboard only needs to refresh once a day. But “felt wrong” is usually worth a few minutes of actually looking, so I looked.</p><h3>Find the bottleneck before you fix it</h3><p>The obvious first idea is “run things in parallel”, which is right in spirit but risky to apply blindly without knowing which part of the pipeline was actually slow. Before touching any code, I wanted real numbers.</p><p>GitHub Actions times every step for free:</p><pre>Checkout repository ................ 5s<br>Set up Python ...................... 1s<br>Install dependencies ............... 28s<br>Install Playwright browser ......... 21s<br>Run simulations .................... 30m 53s<br>Commit and push updated data ....... 1s</pre><p><strong>One step, the actual pipeline script, was almost 31 of the 32 minutes</strong>, but “run simulations” is itself four distinct phases (scrape standings, fetch odds, fetch fixtures and history, run the <a href="https://en.wikipedia.org/wiki/Monte_Carlo_method">Monte Carlo simulation</a>), and the log doesn’t break those down further.</p><p>So I profiled the one phase I could isolate cleanly and reproduce locally: the Monte Carlo simulation itself, run against the real remaining fixtures for each league. It was not negligible. Ten thousand simulations for a single league, looping over every remaining fixture in pure Python, took a couple of minutes on their own, and it visibly sped up later in the season as fewer fixtures were left to simulate. Across nine leagues run one after another, that alone accounted for a large share of the 25 minutes, with the rest coming from the network calls: scraping, odds, fixtures, and results, across nine leagues.</p><p>Four fixes came out of digging into that.</p><h3>The four fixes, at a glance</h3><p><strong>Three completely different bottlenecks needed three completely different, sometimes opposite, treatments. </strong>A fourth made the simulation itself faster:</p><ol><li>Scraping all nine leagues concurrently</li><li>Pacing requests to football-data.org instead of parallelising them</li><li>Running each league’s simulation in its own process, in parallel</li><li>Vectorising the simulation loop</li></ol><p>Each one gets its own section below: what was actually wrong, what changed, and how much it helped.</p><h4>1. Scraping all nine leagues concurrently</h4><p>Standings for all nine leagues come from ESPN, scraped with <a href="https://developer.chrome.com/docs/chromium/new-headless">headless Chromium</a> rather than a plain HTTP request: ESPN blocks simple, non-browser requests as likely bot traffic, especially from a cloud server’s IP address. Nine leagues, scraped one after another, each involving a full browser page load, adds up.</p><p>Nine independent page loads should just run at the same time. I rewrote the scraper using <a href="https://playwright.dev/python/docs/library#async-vs-sync-api">Playwright</a>’s async API, a browser-automation library that, in async mode, can drive several browser pages at once instead of one after another:</p><pre>async def _scrape_standings_async(leagues_codes):<br>    async with async_playwright() as p:<br>        browser = await p.chromium.launch(headless=True)<br>        try:<br>            # Capped at 3, not 9 - looking like a burst of bot<br>            # traffic from one IP risks a harder block than a<br>            # slightly slower, steadier set of requests does.<br>            semaphore = asyncio.Semaphore(3)<br>            results = await asyncio.gather(<br>                *[<br>                    _scrape_one_league(<br>                        browser, semaphore, code, name, year<br>                    )<br>                    for code, (name, year) in leagues_codes.items()<br>                ],<br>                return_exceptions=True,<br>            )<br>        finally:<br>            await browser.close()<br>    return results</pre><p>That cap at 3 concurrent requests, instead of letting all 9 fire at once, was deliberate. The whole reason this scraper needs a real browser at all is that ESPN’s bot detection is already suspicious of automated traffic from this kind of IP address. Hammering it with 9 requests at once seemed like a good way to escalate from “slow to load” to “blocked outright”. Three wasn’t a measured ceiling, just a conservative guess, comfortably clear of whatever ESPN’s actual threshold is, and it turned out to be enough: <strong>nine leagues at a time now finish in about 38 seconds</strong>, including one real retry along the way, down from a few minutes of doing it one at a time.</p><h4>2. Pacing requests to football-data.org instead of parallelising them</h4><p>Fixtures and historical results both come from football-data.org, hit sequentially from two different functions (load_fixtures() and fetch_past_season_results()) for nine leagues. That&#39;s roughly 30 requests, fired back to back, against a free tier that allows about 10 requests per minute <em>total</em>, shared across every endpoint. The log showed exactly what that looked like:</p><pre>Fetching seriea_italy season 2026...<br>⚠️ API call failed: 429 Client Error: for url:<br>   https://api.football-data.org/v4/competitions/SA/<br>   matches?season=2026&amp;status=FINISHED.<br>   Retrying in 1 minute(s)...</pre><p>Neither function knew the other existed. load_fixtures() would burn through most of the budget, and fetch_past_season_results()&#39;s very first calls would tip it over into exactly that er. The existing retry logic handled it the only way it could without knowing the actual limit: sleep a fixed 60 seconds and try again, having already wasted one request finding out the limit was hit.</p><p>Parallelising these calls would have made this actively worse: more requests arriving in the same window, more errors, more wasted minutes. What this needed was pacing, not concurrency: track the timestamp of every football-data.org request across <em>both</em> functions, and proactively wait just long enough to stay under the limit, rather than firing blindly and reacting after the fact.</p><pre>_FOOTBALL_DATA_REQUEST_TIMES = collections.deque()<br>_FOOTBALL_DATA_MAX_PER_MINUTE = 9<br><br>def throttle_football_data():<br>    now = time.time()<br>    while (_FOOTBALL_DATA_REQUEST_TIMES<br>           and now - _FOOTBALL_DATA_REQUEST_TIMES[0] &gt; 60):<br>        _FOOTBALL_DATA_REQUEST_TIMES.popleft()<br>    if len(_FOOTBALL_DATA_REQUEST_TIMES) &gt;= _FOOTBALL_DATA_MAX_PER_MINUTE:<br>        sleep_for = 60 - (now - _FOOTBALL_DATA_REQUEST_TIMES[0]) + 0.5<br>        if sleep_for &gt; 0:<br>            print(f&quot;Pacing requests -- waiting {sleep_for:.1f}s&quot;)<br>            time.sleep(sleep_for)<br>    _FOOTBALL_DATA_REQUEST_TIMES.append(time.time())</pre><p>One call to throttle_football_data() before every request to that API, in both functions, and the errors stopped entirely, verified with a 20-call simulated test that confirmed zero violations of the limit across any rolling 60-second window. <strong>This was, by a wide margin, the single biggest win in the whole exercise</strong>. A triggered rate limit wasn&#39;t costing seconds, it was costing whole minutes of blind sleeping, sometimes more than once per run.</p><h4>3. Running each league’s simulation in its own process, in parallel</h4><p>The Monte Carlo simulation was running five leagues’ worth of 10,000 simulations one after another, on a machine with several CPU cores sitting idle the whole time. Unlike the two problems above, this one really was a clean case for parallel processing: each league’s simulation is completely independent of the others, with nothing to wait on and nothing to coordinate between them.</p><pre>max_workers = min(len(leagues), os.cpu_count() or 1)<br><br>ctx = multiprocessing.get_context(&quot;fork&quot;)<br><br>with ProcessPoolExecutor(<br>    max_workers=max_workers, mp_context=ctx<br>) as executor:<br>    futures = {<br>        league: executor.submit(<br>            run_simulations, fixtures, table, n_sim, league<br>        )<br>        for league, (fixtures, table) in prepared.items()<br>    }<br>    for league, future in futures.items():<br>        print(f&quot;\n=== {league.replace(&#39;_&#39;, &#39; &#39;).title()} ===&quot;)<br>        pos_counts, pos_pct = future.result()<br>        position_distribution_all[league] = pos_counts<br>        position_distribution_pct_all[league] = pos_pct<br>        print(f&quot;Finished simulations for {league} ✅&quot;)</pre><p>Standard Python has a well-known limitation that gets in the way here, called the <a href="https://docs.python.org/3/glossary.html#term-global-interpreter-lock">Global Interpreter Lock</a>: it can split work into smaller pieces within the same program, but only ever works on one piece at a time, no matter how many cores the machine has. This isn’t specific to this pipeline; it’s true of any standard Python program. This is <a href="https://web.mit.edu/6.005/www/fa14/classes/17-concurrency/"><em>concurrency</em></a>: like nine people trying to get things done by sharing a single desk, so taking turns doesn’t get more done at once. The fix is to instead achieve <a href="https://github-pages.ucl.ac.uk/research-computing-with-cpp/08openmp/01_parallel_programming.html"><em>parallelism</em></a>: give each league its own desk to work at instead of sharing one. This pipeline’s GitHub Actions runner only has four desks, though (four CPU cores), so four leagues can genuinely work at once while the rest wait their turn for a free one, still far better than all nine queuing for the same single desk.</p><p>Making that work correctly, not just quickly, took one small, specific fix: telling Python precisely how to create those new processes, so each one could find and reuse code already loaded in the main process.</p><p>Running all nine leagues this way, capped at four at a time to match the four CPU cores on GitHub Actions’ standard runner, <strong>measured a 2.75x speedup</strong> against running them one after another on the exact same, real fixture data.</p><h4>4. Vectorising the simulation loop</h4><p>Fix 3 parallelised <em>across</em> leagues, but it didn’t make any single league’s simulation faster. The problem was how each individual simulation ran. Each of the 10,000 runs independently replays the rest of the season from today’s point forward. It draws a random outcome for every remaining match, one by one, then tallies up that simulation’s final table. Multiply that by 10,000 runs for a single league, and it adds up fast: <strong>over three and a half million individual match outcomes drawn one at a time for the Premier League alone, one league out of nine</strong>.</p><p>The fix: stop calculating one match, one season, at a time, and <a href="https://numpy.org/doc/stable/user/whatisnumpy.html#why-is-numpy-fast">vectorise</a> the whole thing instead. Build one big grid of random numbers, one row per simulated season and one column per remaining match, then compare that whole grid at once against each match’s win/draw/loss chances to decide every outcome in a single step:</p><pre># Each match&#39;s win/draw/loss chance stays the same across all 10,000<br># simulations, so every outcome can be drawn in one batch, not a loop.<br><br>cum = np.cumsum(base_probs, axis=1)<br><br>u = rng.random((n_sim, n_fix))<br><br>outcome = np.where(<br>    u &lt; cum[:, 0], 0, np.where(u &lt; cum[:, 1], 1, 2)<br>).astype(np.int8)<br><br>home_pts = np.where(<br>    outcome == 0, 3, np.where(outcome == 1, 1, 0)<br>).astype(float)<br><br>away_pts = np.where(<br>    outcome == 2, 3, np.where(outcome == 1, 1, 0)<br>).astype(float)</pre><p>Adding up the points and working out final league positions happens the same way now: as one batch calculation across all 10,000 simulations at once, instead of one season at a time. <strong>The result: a 90x speedup</strong>, double-checked against the old, slower version to make sure the actual predictions came out the same.</p><p>It also changed the right answer for fix 3’s per-league process limit: that limit made sense when each league’s simulation took a couple of minutes, but once each one takes about two seconds, capping it just adds unnecessary waiting.</p><pre>max_workers = len(leagues)</pre><p>All nine leagues now run their vectorised simulations in a single batch instead of CPU-sized groups. <strong>The whole step now takes about two seconds</strong>, no more and no less than the single slowest league on its own, with nothing left queuing behind another batch.</p><h3>The result</h3><p>Here’s what those four fixes actually add up to: nine leagues, 10,000 simulations each, same model, same output. The “run simulations” step, the one script doing all of it, now finishes in 87 seconds, down from around 30 minutes before any of this. Below is that same job running the last few days, straight from GitHub’s own run history.</p><figure><img alt="The same workflow, live on GitHub: every run for the past two days, all green, all done in under three minutes." src="https://cdn-images-1.medium.com/max/1024/1*_sq2qIhqUVgiw-8EQOpPRw.png" /><figcaption><em>The same workflow, </em><a href="https://github.com/vickyfriss/football-league-predictions/actions/workflows/daily_simulations.yml"><em>live on GitHub</em></a><em>: every run for the past two days, all green, all done in under three minutes</em></figcaption></figure><p>That’s what it looks like in practice, day to day. Reduced to the two numbers that actually matter, the change looks like this.</p><figure><img alt="Only the two numbers that actually changed because of these fixes. Checkout, dependency install, and the browser install move around run to run, but none of them were ever the target, which is exactly why the total only drops to about 2m 40s even though the actual computation is 90x quicker." src="https://cdn-images-1.medium.com/max/1024/1*6bXfRl90EkA1xFqmfGu_tw.png" /><figcaption><em>Only the two numbers that actually changed because of these fixes. Checkout, dependency install, and the browser install move around run to run, but none of them were ever the target, which is exactly why the total only drops to about 2m 40s even though the actual computation is 90x quicker.</em></figcaption></figure><p>The scheduled job’s total barely moved on this pass: checkout, dependencies, and the browser install already add up to about a minute of fixed cost that vectorising the simulation can’t touch. That’s fine. The job only runs once a day, so a minute of fixed setup cost was never going to be the thing worth chasing. What changed is the part that actually was the problem: the simulation step itself, the 30 minutes that started this whole search for a bottleneck, is now over in about two seconds.</p><h3>Final thoughts</h3><p>The lesson that outlasts the specific fixes: <strong>making something work and making something work fast are two different jobs.</strong> I got this pipeline faster twice, in two rounds a few weeks apart, and each round was solving a different kind of slow.</p><p>The first round was about doing several different things well, not just doing more of the same thing faster. Three phases (scraping ESPN, calling football-data.org, running the simulation) were all slow, but for three different reasons, so each one needed a different treatment: capped concurrency for the scraping, pacing instead of concurrency for the API calls, one process per league for the simulation. Treating all three the same way, more concurrency everywhere, would have made at least two of them worse.</p><p>The second round was about making one specific piece of work cheaper, not running more of it side by side. Splitting the simulation across processes let all nine leagues run at once, but it didn’t make any single league’s simulation any quicker. The slow part was a loop stepping through matches one at a time, and four copies of a slow loop running in parallel are still four slow loops. Only rewriting that loop as one batch of maths fixed it.</p><p>Both bottlenecks stayed invisible until I stopped guessing and measured: a rate limit two unrelated functions were quietly sharing, and a loop whose overhead only shows up once it runs millions of times. <strong>The habit worth keeping: “already optimised” just means it was fast the last time you measured it, not that it always will be.</strong></p><p>The four-step pattern from the original piece hasn’t changed: it still runs, logs, commits, and redeploys. This was always about keeping “run” the boring one: the step nobody has to think about.</p><p><em>The full pipeline is open source: </em><a href="https://github.com/vickyfriss/football-league-predictions"><em>github.com/vickyfriss/football-league-predictions</em></a><em>. Live results for all nine leagues, updated daily, at </em><a href="https://football-league-simulator.streamlit.app/"><em>football-league-simulator.streamlit.app</em></a><em>.</em></p><p><strong>Enjoyed this analysis?</strong> Check out some of my other related articles:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/predicting-football-league-final-positions-revisited-betting-odds-probabilistic-modelling-893cc938801c">Predicting Football League Final Positions, Revisited: Betting Odds, Probabilistic Modelling &amp;…</a></li><li><a href="https://medium.com/@vickyfrissdekereki/championship-final-day-predictions-2025-26-who-gets-promoted-to-the-premier-league-8b058f613663">Championship Final Day Predictions 2025/26: Who Gets Promoted to the Premier League?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/northampton-saints-vs-bath-rugby-a-probabilistic-turning-point-in-the-2025-26-premiership-season-e66c1abac430">Northampton Saints vs Bath Rugby: A Probabilistic Turning Point in the 2025/26 Premiership Season</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a8e81740fab0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Predicting Football League Final Positions, Revisited: Betting Odds, Probabilistic Modelling &…]]></title>
            <link>https://medium.com/@vickyfrissdekereki/predicting-football-league-final-positions-revisited-betting-odds-probabilistic-modelling-893cc938801c?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/893cc938801c</guid>
            <category><![CDATA[soccer-analytics]]></category>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[soccer]]></category>
            <category><![CDATA[forecasting]]></category>
            <category><![CDATA[sports-analytics]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Thu, 03 Sep 2026 11:42:46 GMT</pubDate>
            <atom:updated>2026-09-03T11:44:34.022Z</atom:updated>
            <content:encoded><![CDATA[<h3>Predicting Football League Final Positions, Revisited: Betting Odds, Probabilistic Modelling &amp; Simulation</h3><h4><em>How a Python tool predicts, every day, who wins the title and who gets relegated across the Premier League, La Liga, Serie A and six more football leagues</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cvCB_Y-52FQs7A9bur8bGQ.png" /><figcaption><em>The </em><a href="https://football-league-simulator.streamlit.app/"><em>live dashboard</em></a><em>: pick a league, and see the probability of every team finishing in every position, updated daily</em></figcaption></figure><p>How confident should you actually be that your team wins the title or goes down? A 46% chance Arsenal top the Premier League table. A 29% chance Tottenham Hotspur go down. <strong>These aren’t gut feelings: they’re what falls out of a model that plays out every remaining match, in every one of nine football leagues, 10,000 times over, every single morning.</strong></p><p>I built the <a href="https://medium.com/@vickyfrissdekereki/predicting-premier-league-final-positions-using-betting-odds-probabilistic-modelling-simulation-2720ec335c3c">first version of this model</a> back in January, for Premier League only, run by hand whenever I felt like checking in on it. It’s since grown into a <a href="https://football-league-simulator.streamlit.app/">live system</a> that pulls fresh data every morning and updates all nine leagues on its own, no manual step. The results sit on a public dashboard instead of being buried in a notebook. Here’s exactly how it works, and what it’s saying right now.</p><h3>The Idea</h3><p>A table only tells you what’s already happened. It says nothing about what’s likely to happen next, and it definitely doesn’t tell you how confident you should be about where a team ends up. Arsenal sitting top after one matchday and Arsenal sitting top after thirty matchdays are completely different statements, even though the table looks similar.</p><p>So instead of asking “what will the final table look like”, the model asks a different question: <strong>if every team keeps playing roughly the way it’s been playing, how likely is each one to finish in every position?</strong> That question has an answer you can actually compute, and it comes with something a single predicted table never gives you: <strong>an honest sense of how sure or unsure you should be.</strong></p><h3>How a Season Gets Simulated, Step by Step</h3><p>For all nine leagues (English Premier League, English Championship, Italian Serie A, Spanish La Liga, German Bundesliga, French Ligue 1, Dutch Eredivisie, Portuguese Primeira Liga, and Brazilian Série A), the same five steps run every day, as shown in the following image.</p><figure><img alt="The five-step pipeline, repeated for all nine leagues, every single day" src="https://cdn-images-1.medium.com/max/1024/1*0hmcPH7NKlzWhZdAusADBg.png" /><figcaption><em>The five-step pipeline, repeated for all nine leagues, every single day</em></figcaption></figure><p>Each step feeds directly into the next: gathered data becomes team ratings, ratings become match probabilities, and those probabilities get simulated thousands of times over before being aggregated into the percentages sitting on the dashboard. Here’s what each one actually involves.</p><h4>1. Gather Data</h4><p>Every league draws from three sources, each doing a different job:</p><ul><li><a href="https://www.espn.com/soccer/standings/_/league/ENG.1/season/2026"><strong>ESPN</strong></a><strong>:</strong> current league standings, scraped fresh for all nine leagues.</li><li><a href="https://www.football-data.org/"><strong>football-data.org</strong></a><strong>:</strong> the remaining fixture list, plus the current season and one prior season of match results, used to train the statistical model.</li><li><a href="https://the-odds-api.com/"><strong>The Odds API</strong></a><strong>: </strong>betting odds for upcoming matches, aggregated across multiple bookmakers and used directly as a match’s outcome probabilities whenever a fixture is close enough to kick-off to be priced.</li></ul><h4>2. Rate Teams</h4><p>Every team gets an <strong>attack strength</strong> and a <strong>defense strength</strong>: its own goals scored and conceded per match, each divided by the league average, so a rating of 1.0 is exactly average, and 1.3 means scoring (or conceding) 30% more than a typical team in that league. That match history is the same current-season-plus-one-prior-season window gathered in step 1, with recent matches counting for more than older ones, weighted on a sliding scale so the freshest result in the sample counts roughly twice as much as the oldest.</p><figure><img alt="Real attack and defense strength for six Premier League teams today, before any shrinkage." src="https://cdn-images-1.medium.com/max/1024/1*Eah24l8wJ00VRGncPRQfNw.png" /><figcaption><em>Real attack and defense strength for six Premier League teams today, before any shrinkage</em></figcaption></figure><p><strong>Both of the next two refinements are new since the original piece</strong>, which started mid-season and never needed them. Early in a season, though, there just isn’t much of this season’s own data yet. Since the match history is that current-season-plus-one-prior-season window, <strong>an early rating is really last season’s rating wearing this season’s name</strong>, sometimes handing the reigning champion an almost certain repeat title instead of reflecting how uncertain a freshly-started season actually is. So, to prevent this, every team’s raw rating gets pulled towards a target. This shrinkage fades as more of its own matches come in: each side keeps 50% of the gap between its raw rating and the target from kickoff, and is fully trusted on its own results by the halfway point of the season. For an established side, that target is just the plain league average — exactly 1.0, since attack and defense are each defined as a rate relative to that same average.</p><p>However, <strong>newly promoted or relegated teams need a more specific target</strong>, since they have no history in their new league. This target comes from how such teams have actually performed historically. The 14 teams promoted into one of Europe’s “big five” leagues this season were, without exception, below the league average on attack, and most conceded more than average too. Relegated teams show the opposite pattern, though a noisier one — the three sides that dropped out of the Premier League in 2024/25 mostly came back well above the Championship average on attack, except Leicester, who underperformed even that level. That sets a newly-arrived team’s shrinkage target at roughly three-quarters of the league average on attack and one and a quarter times it on defense if promoted, and the reverse if relegated. <strong>The model starts the season with less trust in these teams own results than in those of established sides (</strong>just 10% at kickoff, since a newly-arrived team has no track record in this league at all to lean on yet). But both schedules converge at the same point, fully trusting a team’s own matches by the halfway mark of the season either way. Full analysis and caveats in <a href="https://github.com/vickyfriss/Football-analytics/blob/main/202608%20-%201%20-%20Priors%20for%20Promoted%20and%20Relegated%20Teams.ipynb">the notebook behind these numbers</a> on GitHub.</p><figure><img alt="The same six teams, raw and after shrinkage — 2 matches into the season" src="https://cdn-images-1.medium.com/max/1024/1*cLG9x0RxOkZ-wpEaz-_YDA.png" /><figcaption><em>The same six teams, raw and after shrinkage — 2 matches into the season</em></figcaption></figure><p>The shrinkage target for an established side is exactly 1.0 — the mathematical league average, not an approximation. Aston Villa, sitting almost exactly at league-average already, barely moves (1.02/1.02 raw to 1.01/1.01 shrunk). The others move less than a straight 50/50 blend would suggest, since two matches already buys some trust: Manchester City’s attack eases from a raw 1.50 to a shrunk 1.27, and Arsenal’s defense from an implausibly good 0.49 to a still-strong 0.72. Coventry City, on the promoted-team schedule, starts from less trust in its own results and leans harder on the prior described above: two goalless defeats alone would rate them as having literally no attacking threat, so shrinkage pulls that up to 0.62 rather than 0.00. Both schedules converge at the same point, though — by half a season of a team’s own matches, shrinkage disappears entirely and its rating is exactly its raw one, promoted or not. With a rating fixed for every team, the next step turns those ratings, and the betting market’s own numbers, into a probability for each remaining fixture.</p><h4>3. Model Each Match</h4><p>There are two ways to get the win/draw/lose probabilities for a given match: <strong>straight from the betting market, when a fixture is close enough to kick-off to be priced, or from step 2’s own ratings, when it isn’t.</strong></p><p>Odds themselves need a small adjustment before they’re usable. Add up the implied probabilities behind the odds for all outcomes of a match and the total comes to slightly more than 100%. The difference is the <strong>bookmaker’s built-in margin</strong>, not a real probability. Stripping it out proportionally is what turns a betting price into a genuine probability the simulation can use.</p><p>Take this weekend’s Arsenal vs Chelsea fixture, one of the Premier League’s biggest games. The Odds API returns plenty of real bookmakers for it. The following image presents five of them, with their real current prices.</p><figure><img alt="Five real bookmakers’ current odds for Arsenal vs Chelsea, de-vigged into genuine probabilities and averaged" src="https://cdn-images-1.medium.com/max/1024/1*yNfw6_EyVHqQHDjJTHEShg.png" /><figcaption><em>Five real bookmakers’ current odds for Arsenal vs Chelsea, de-vigged into genuine probabilities and averaged</em></figcaption></figure><p>Each bookmaker’s three prices, flipped into probabilities (1 divided by the odds), add up to a little over 100%: William Hill to 107.4%, the others similar. That extra sliver is each one’s margin. Scale each bookmaker’s three numbers down proportionally so they land on exactly 100%, and what’s left in the “de-vigged” columns is that bookmaker’s genuine view. Average that across all the bookmakers the API returns for this match, not just the 5 shown, and the simulation ends up using <strong>55.9% Arsenal, 25.0% draw, 19.1% Chelsea</strong>, a blended market view built from more sources than any single bookmaker’s price.</p><p><strong>Where a fixture is too far out to be priced, the model falls back on the ratings from step 2 instead.</strong> Multiply a home team’s attack rating by the away team’s defense rating, and the league’s average home goals per game (already lifted by that <strong>league’s own home advantage</strong>, a recency-weighted measure of how many more goals home teams actually score than away teams in its match history) and you get that <strong>team’s expected goal count for the fixture</strong> (the away team’s expected goals use the plain away-goals average instead, with no such boost). Goals in football are rare, largely independent events, which makes them a good fit for a <a href="https://en.wikipedia.org/wiki/Poisson_distribution"><strong>Poisson distribution</strong></a>, a standard statistical tool for modelling how many times a rare, independent event happens in a fixed window, given only its average rate. Feed it a single expected-goal number, and it turns that into a full probability distribution over scorelines, from 0–0 through to the freak 5–3.</p><p>Here’s that worked through for a fixture beyond the market’s reach: Nottingham Forest vs Coventry City on 19 September, more than two weeks out and not yet priced by a single bookmaker, exactly the situation the goal model exists for. The league’s average is roughly 1.90 home goals and 1.39 away goals per game; Coventry’s promoted-team priors bring their expected goals down to 0.83, while Forest’s own attack rating and Coventry’s weak defensive prior combine to push Forest’s expected goals up to 2.34, well above an average home performance, as shown in the following image.</p><figure><img alt="Expected scoreline probabilities for Nottingham Forest vs Coventry City, from the Poisson goal model alone" src="https://cdn-images-1.medium.com/max/1024/1*S-uXqdqdIbCN4EoGXxv__Q.png" /><figcaption><em>Expected scoreline probabilities for Nottingham Forest vs Coventry City, from the Poisson goal model alone</em></figcaption></figure><p>Add up probabilities and <strong>Forest win 70.8% of the time, a draw happens 17.5% of the time, and Coventry win 11.7%.</strong> All this comes from just two numbers and a standard probability distribution, weeks before a bookmaker would touch this fixture. Every fixture now has a probability attached, whether pulled straight from the market or produced by the goal model — exactly what the simulation in step 4 needs to get started.</p><h4>4. Simulate the Season</h4><p>With those probabilities set, this step “plays” the remainder of the season once, drawing a random scoreline for each remaining match from its probabilities, and then <strong>repeats this 10,000 times over</strong>.</p><p>Those 10,000 finished seasons are the raw material the last step turns into the percentages on the dashboard.</p><h4>5. Aggregate</h4><p>The model counts how often each team finished in each position across all of the simulated seasons, and turns that count into a percentage. The position percentages sitting in the dashboard are what falls out the other end — that’s the whole pipeline, end to end. The only piece left is how it gets run automatically, every single morning.</p><h3>Why It Runs Every Day Now</h3><p>The simulation logic itself hasn’t changed much since January. What’s changed is that this used to be something I ran by hand in a notebook when I wanted an update. Now a <a href="https://github.com/features/actions"><strong>GitHub Actions</strong></a><strong> workflow</strong>, a scheduled job that runs on GitHub’s own servers rather than my laptop, fires automatically every morning: it pulls the day’s fresh standings, fixtures and odds for all nine leagues, runs every step from team ratings through to the full simulation, and commits the results straight back to the project. The <a href="https://football-league-simulator.streamlit.app/">dashboard</a> reads whatever that morning’s run left behind, so by the time anyone opens it, the latest position probabilities for whichever league they want are already sitting there, without anyone needing to touch a script.</p><h3>Why This Is Harder Than It Sounds</h3><p>None of this is infallible, and it’s worth being direct about where the weak points are, starting with the ones baked in from the very beginning.</p><p>The first time you try to join data from three different providers, you discover that <strong>“the same team” is a surprisingly slippery concept</strong>. “Internazionale” in one dataset is “FC Internazionale Milano” in another and “Inter Milan” in a third. Multiply that across nine leagues and a few hundred clubs, and a huge share of the actual work isn’t modelling at all: it’s making sure “Wolverhampton Wanderers”, “Wolverhampton Wanderers FC”, and “Wolves” are all treated as one team everywhere they appear. It’s also a permanent, low-level risk rather than a one-time fix: three data providers who’ll never agree on how to spell a club’s name means a missed alias can silently make a team’s fixtures invisible to the model rather than causing an obvious error.</p><p>The second problem is newer, and it’s the main thing that’s changed since the original piece: <strong>promotion and relegation</strong>. Every close season, a chunk of each league’s roster is replaced by teams stepping up from the division below, or dropping down from the one above. Betting odds cover only a near-term window, a couple of weeks out at most, for every team alike, promoted sides included. Beyond that window, every team falls back on the goal model, and that’s where a newly promoted team is genuinely stuck: an established side still has last season’s results to lean on, but a promoted team has zero match history in this league at all. The model has nothing to anchor a rating to. This barely mattered in the original piece, which started mid-season, by which point even a newly promoted team already had enough matches in its new league to be rated on its own results. Running from matchday 1 of a brand new season is what turns this from an edge case into a problem that needs solving on day one. The fix, the promoted and relegated priors described in step 2 above, isn’t a guess: it comes from combining real historical results for teams that actually made those exact moves, worked out in <a href="https://github.com/vickyfriss/Football-analytics/blob/main/202608%20-%201%20-%20Priors%20for%20Promoted%20and%20Relegated%20Teams.ipynb">the notebook behind those numbers</a> on GitHub. Even with that fix, those teams are still working from a small sample and a historical prior rather than a season of their own evidence, so <strong>their early-season numbers should be read with a bit more caution than an established team’s</strong>.</p><p>More fundamentally, <strong>the whole approach rests on one assumption: that a team’s underlying strength this week is a reasonable guide to its strength next week</strong>. Most of the time that’s true. It breaks when a team changes suddenly for reasons no goals-based rating can see coming, a managerial change, most obviously, but also a long-term injury to a key player, or a takeover that changes how a club spends. The model has no way to know Manchester United changed manager last season until enough matches under the new manager have accumulated to move the rating, which means it’s always looking slightly in the rear-view mirror during exactly the moments a team is changing fastest.</p><h3>What the Results Say</h3><p>Because the leagues are all mid-season at different points, the current numbers make the uncertainty point better than any explanation could.</p><figure><img alt="Championship simulation results, four matches into the season" src="https://cdn-images-1.medium.com/max/1024/1*qrsq6E1beZnLvt5dd-xz6A.png" /><figcaption><em>Championship simulation results, four matches into the season</em></figcaption></figure><p>Four matches into the <strong>Championship</strong> season, the table’s two extremes line up almost exactly with the model’s own priors. <strong>Wolverhampton Wanderers and West Ham United, two of the three sides relegated from the Premier League over the summer, occupy the model’s top two spots for the title, at 42.3% and 19.7%</strong>, exactly the boost the relegated-team prior described above is built to give them.</p><p><strong>Southampton</strong> complicates that tidy story from a different angle: an established Championship side rather than a newly-arrived one, its rating still reflects a genuinely strong season last year, one good enough to reach the play-off final — before they were expelled from it and denied promotion outright as a sanction for <a href="https://www.espn.com/soccer/story/_/id/48818162/southampton-middlesbrough-spygate-efl-championship-playoff-explainer-analysis-tom-hamilton">spying on rival clubs’ training sessions</a>. The same case is why this season opened with a four-point deduction, baked into the three points and 18th-place finish they’re sitting on right now. Strip that penalty away and the model still sees the team that reached a play-off final last year, which is why they’re <strong>rated the third most likely champions at 10.8%, the clearest reminder yet that four games — and a fresh points penalty — say almost nothing</strong>.</p><p>At the bottom, it’s the reverse shape entirely: Lincoln City, one of three sides promoted up from League One, carries the highest bottom-three finish risk in the division, at 70.4%, with fellow promoted clubs Bolton Wanderers and Cardiff City close behind at 58.7% and 54.8%.</p><p>Contrast that with <strong>Brazil’s Série A</strong>, deep into its season at matchday 25.</p><figure><img alt="Série A simulation results, 25 matchdays into the season" src="https://cdn-images-1.medium.com/max/1024/1*8eo7q3iSYXe6w8BgtDSKRw.png" /><figcaption><em>Série A simulation results, 25 matchdays into the season</em></figcaption></figure><p>There, <strong>Flamengo lead the title race at 62.7%</strong>, just ahead of Palmeiras despite sitting one point behind them in the actual table, and <strong>the team bottom of the table has a relegation probability that has hardened to 98.3%</strong>. Twenty-plus matches of real results have pushed that number about as close to certainty as football gets. Watching the same kind of estimate move from a rough, prior-driven guess in August to a near-certainty by autumn is the most convincing evidence that the approach works: <strong>the model isn’t claiming false confidence early on, and it isn’t clinging to old assumptions once the real data arrives</strong>.</p><h3>Why This Is Interesting</h3><p>A single predicted table always looks more confident than it should. A probability distribution across every position forces you to be honest about what you actually know. In week one, the model is already highly confident about some teams, a runaway leader, a team already cut adrift at the bottom. <strong>But for most teams, especially in a crowded mid-table, it genuinely isn’t, and it says so.</strong></p><p><strong>This got a real test last season</strong>, mid-race, not just as a theoretical claim. <strong>Arsenal</strong> spent early April at around 97% in the simulation, comfortably in control. A shock 1–2 loss at Bournemouth on 11 April, followed a day later by <strong>Manchester City</strong> thrashing Chelsea 3–0, pulled that down to around 81% within two days. On 19 April, City beat Arsenal 2–1 directly, the exact fixture I had <a href="https://medium.com/@vickyfrissdekereki/manchester-city-vs-arsenal-how-this-match-could-shift-premier-league-2025-26-title-odds-7a838e44c197">previewed in advance</a> with pre-match title winning odds of Arsenal 81%, City 19%. Once the result was in, I <a href="https://medium.com/@vickyfrissdekereki/premier-league-predictions-after-manchester-city-vs-arsenal-83eb15b44dc0">followed up</a>: Arsenal had dropped to 65%; City had risen to 35%. <strong>City kept winning and briefly overtook Arsenal as outright favourites on 23 April</strong>, before Arsenal’s form from early May onward pulled the race back under control. City’s draw at Bournemouth on 19 May was, in effect, the moment their challenge ended: with one match still to play, Arsenal were crowned Premier League champions. The title was already, mathematically, won.</p><figure><img alt="Arsenal and Manchester City’s title probability across the 2025/26 run-in, from April to Arsenal’s title confirmation" src="https://cdn-images-1.medium.com/max/1024/1*h9SXpDQZxWQ3GLCezslBgw.png" /><figcaption><em>Arsenal and Manchester City’s title probability across the 2025/26 run-in, from April to Arsenal’s title confirmation</em></figcaption></figure><p><strong>That’s the real test of a probability: not whether one season “proves” it right, but whether it moves the way reality actually delivers, match by match, swing by swing.</strong></p><h3>Why This Matters</h3><p>The original version of this was a personal exercise: one league, run when I felt like checking in on it. What it’s become is closer to a small public tool: nine leagues, refreshed daily without any manual step, viewable by anyone at any time through <a href="https://football-league-simulator.streamlit.app/">the dashboard</a> rather than a one-off blog post. That shift, <strong>from a model run once to a system that keeps itself current</strong>, is arguably the bigger achievement here than any single modelling choice.</p><p>That’s really what all of this comes down to: not a single confident guess dressed up as certainty, but an honest, constantly updating answer to the question every fan already has, however early or late the season is: <strong>how likely is this, really?</strong></p><p><em>The full pipeline, all nine leagues, is open source: </em><a href="https://github.com/vickyfriss/football-league-predictions"><em>github.com/vickyfriss/football-league-predictions</em></a><em>. Live results, updated daily, at </em><a href="https://football-league-simulator.streamlit.app/"><em>football-league-simulator.streamlit.app</em></a><em>.</em></p><p><strong>Enjoyed this analysis?</strong> Check out my other data-driven football analysis articles:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/what-do-passing-numbers-say-about-football-styles-around-the-world-4e23318dabcb">What Do Passing Numbers Say About Football Styles Around the World?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/brazil-and-mexico-womens-football-pressing-midfielder-stats-compared-to-europe-0221769be981">Brazil and Mexico Women’s Football: Pressing Midfielder Stats Compared to Europe</a></li><li><a href="https://medium.com/@vickyfrissdekereki/championship-final-day-predictions-2025-26-who-gets-promoted-to-the-premier-league-8b058f613663">Championship Final Day Predictions 2025/26: Who Gets Promoted to the Premier League?</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=893cc938801c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Brazil and Mexico Women’s Football: Pressing Midfielder Stats Compared to Europe]]></title>
            <link>https://medium.com/@vickyfrissdekereki/brazil-and-mexico-womens-football-pressing-midfielder-stats-compared-to-europe-0221769be981?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/0221769be981</guid>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[soccer-analytics]]></category>
            <category><![CDATA[soccer]]></category>
            <category><![CDATA[football]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Wed, 26 Aug 2026 07:31:01 GMT</pubDate>
            <atom:updated>2026-08-26T07:31:01.358Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A data-driven comparison of Brasileirão Feminino and Liga MX Femenil pressing midfielders against Europe, using SkillCorner data</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cIvxYTxciZpFliyhLm4TFA.png" /></figure><p>Two weeks ago, I was benchmarking <strong>pressing No 8s against the Big 5 leagues plus Scandinavia</strong> for a SkillCorner piece: <a href="https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football"><em>Contextual Scouting: A Pressing №8 In Women’s Football</em></a>. A question kept coming up in my head the whole time: <strong>what would this look like for players in Brazil and Mexico?</strong></p><p>I couldn’t stop thinking about it. Brazil is hosting the 2027 World Cup, the first ever on South American soil, backed by <a href="https://onefootball.com/en/news/with-the-2027-world-cup-in-sight-cbf-outlines-womens-football-in-brazil-43146737">a CBF investment plan of more than $130 million in women’s football through 2029</a>. Liga MX Femenil keeps quietly breaking its own attendance record, most recently <a href="https://urbanpitch.com/liga-mx-femenils-exponential-growth/">58,156 people at Estadio Azteca for a domestic game</a>. And Jenni Hermoso won a World Cup with Spain in 2023 while <a href="https://www.espn.com/soccer/soccer-transfers/story/4689576/barcelona-forward-jennifer-hermoso-joins-pachuca-on-free-transfer">still on Pachuca’s books</a>.</p><p>So, on the side, I took the exact same index (same three pillars: Physical 30%, Pressing 35%, Offensive 35%, same weights, same z-scoring, nothing rebuilt or reweighted) and pointed it at Brazil’s Brasileirão Feminino and Mexico’s Liga MX Femenil. <strong>Who from those two leagues would actually hold up against the same European bar, right now?</strong></p><h3>A quick recap of how the index works</h3><p>This is the same index as before, with nothing changed: full methodology in <a href="https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football"><em>Contextual Scouting: A Pressing №8 In Women’s Football</em></a>. Every player gets scored as a percentile against Europe’s own population on three pillars:</p><ul><li><strong>Physical (30%)</strong>: four qualities from <a href="https://skillcorner.com/articles/dedicated-physical-metrics-for-womens-football">SkillCorner’s women’s-specific physical data</a>. Explosiveness (how fast she reaches a sprint), Peak Velocity (her top speed), Agility (change-of-direction volume), and a High-Intensity Engine (sprint distance sustained across 90 minutes).</li><li><strong>Pressing (35%)</strong>: Volume (how often she presses, weighted toward the high block), Intensity (high-speed running specifically out of possession), and Efficiency (how often those presses actually win the ball back or kill the passing lane).</li><li><strong>Offensive (35%)</strong>: split 50/50 between on-ball output (progressive carries and dangerous passes) and off-ball threat (the runs that create separation and chances).</li></ul><p>Those three scores blend into one number: the Pressing Midfielder Index.</p><figure><img alt="Pressing Midfielder Index — Image from Contextual Scouting: A Pressing №8 In Women’s Football" src="https://cdn-images-1.medium.com/max/1024/0*Mn4GDq0k_h-2FyMB.png" /><figcaption>Pressing Midfielder Index — Image from <a href="https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football">Contextual Scouting: A Pressing No. 8 In Women’s Football</a></figcaption></figure><h3>Okay, but do Brazil and Mexico actually trail Europe?</h3><p>Before I scored a single player, I wanted to sanity-check the leagues themselves: the same raw metrics the index runs on, averaged across each league’s qualifying midfielders only (not the team as a whole), no scoring yet, just the raw numbers. Turns out the picture is a lot more specific than a blanket “weaker league” label suggests.</p><figure><img alt="Four raw league-level metrics, midfielders only, across all ten leagues in the study — Brazil and Mexico highlighted in green against the eight-league European reference set" src="https://cdn-images-1.medium.com/max/1024/1*NepI0fkW-saZGoNZxrfGYQ.png" /><figcaption><em>Four raw league-level metrics, midfielders only, across all ten leagues in the study — Brazil and Mexico highlighted in green against the eight-league European reference set</em></figcaption></figure><p>Here’s how each of the four breaks down:</p><ul><li>On the bottom-left, on the Pressing pillar’s <strong>Intensity</strong> metric (high-speed runs and sprints per 30 minutes out of possession), Brazil’s midfielders average <strong>30.5</strong>, just <strong>6%</strong> behind the WSL’s <strong>32.4</strong> — though that places them in a tight mid-table cluster rather than clearly ahead of the pack: four leagues (Toppserien, A-Liga, Première Ligue, Damallsvenskan) all sit within a single unit of Brazil’s number. Mexico trails by far more, at <strong>23.4</strong>, a <strong>28%</strong> gap and the lowest figure in the sample.</li><li>On the top-right, <strong>High-Intensity Engine</strong> (the Physical pillar’s high-speed-running and sprint distance per 90) tells a rougher story: Brazil’s <strong>452 metres</strong> is <strong>20%</strong> below the WSL’s <strong>564</strong>, and Mexico’s <strong>397 metres</strong> is <strong>30%</strong> below.</li><li>On the top-left, <strong>Volume</strong> (the Pressing pillar’s raw count of pressing engagements) is skewed by the WSL’s outlier <strong>13.2</strong> — next-highest is Germany at 10.6. Against the full field, Mexico’s <strong>9.2</strong> is genuinely mid-pack (<strong>6th of 10</strong>), while Brazil’s <strong>8.2</strong> trails most of the sample (<strong>9th of 10</strong>), just above Sweden’s Damallsvenskan (7.8), the actual lowest. This measures discrete pressing actions, not general running, so it’s a different question from Intensity above.</li><li>On the bottom-right, on the <strong>Offensive Index</strong> (the single blended on-ball + off-ball score), the picture flips entirely: Mexico’s midfielders average <strong>51.3</strong>, edging out the WSL’s own <strong>50.9</strong>, with Brazil’s <strong>50.2</strong> just behind. Not one story, but four.</li></ul><p>Does that Physical gap hold beyond midfielders? Pooling all outfield positions (goalkeepers excluded, since their physical output isn’t comparable to outfield running) and averaging by league says yes.</p><figure><img alt="High-intensity running distance per 90, averaged across all five outfield position groups rather than midfielders alone — goalkeepers excluded" src="https://cdn-images-1.medium.com/max/889/1*GfFu5lHP-ZxwW_jI9qIBCQ.png" /><figcaption><em>High-intensity running distance per 90, averaged across all five outfield position groups rather than midfielders alone — goalkeepers excluded</em></figcaption></figure><p>Brazil (502.15m) and Mexico (472.88m) still trail every European league, including Spain’s own lowest mark (570.68m). Unlike Offensive Output, which flips in their favor once positions are pooled, this gap isn’t midfielder-specific: it holds across the whole outfield.</p><h3>So who actually clears the bar?</h3><p>Here’s how: every Brasileirão Feminino and Liga MX Femenil midfielder clearing the same 5-match floor as Europe (55 Brazilian across 2025+2026, 76 Mexican, 131 total) gets projected onto the frozen European index — same z-scoring, same weights, but never folded into Europe’s own mean or standard deviation. A player who changed clubs mid-study still shows up twice, tagged by team; one who stayed put has her two seasons pooled into a single, minutes-weighted row instead.</p><p>The table below is where each of the top 10 would actually slot into that same 373-player European field (Europeans only — Brazil and Mexico never count toward that 373), not just an abstract score. Each player’s placement is computed independently against those fixed 373, so one player’s slot doesn’t move another’s.</p><figure><img alt="Top 10: Brazil &amp; Mexico Pressing Midfielders vs. the European Pressing Midfielder Index" src="https://cdn-images-1.medium.com/max/1024/1*hdpFf1EnyG5pw4iKFFOa8A.png" /><figcaption>Top 10: Brazil &amp; Mexico Pressing Midfielders vs. the European Pressing Midfielder Index</figcaption></figure><p>The shortlist splits six Mexican names to four Brazilian, and the top of it has real stories behind it.</p><p><strong>Kiana Palacios</strong> tops the board outright, and it isn’t a surprise pick: she’s Club América’s all-time leading Femenil goalscorer and lead Mexico to Pan American Games gold in 2023. She’s since moved to Utah Royals FC in the NWSL (<a href="https://www.espn.com/soccer/story/_/id/48090861/utah-royals-mexico-kiana-palacios-club-america-transfer">reportedly an $800,000 fee</a>, March 2026), so this ranking reflects her Liga MX Femenil form. Right behind her, <strong>Ana Carla</strong> spent seven seasons at Flamengo (2015–2021), winning the 2019 Campeonato Carioca there, then Santos (2022–2024), winning the 2024 Copa Paulista, before joining Red Bull Bragantino in January 2025.</p><p><strong>Lourdes Martínez</strong> and <strong>Brenda Pinheiro</strong> take the next two spots. Martínez is a Mexico youth international whose qualifying matches here are with Monterrey (Rayadas), after a stint at Puebla earlier in 2025. Pinheiro was a Bragantino mainstay for five seasons and 120+ matches, though she’s since signed with Ferroviária for 2026, so this ranking reflects her Bragantino form.</p><p>Fifth is <strong>Solange Lemos</strong>, a capped Uruguay international who was one of Cruz Azul’s standout performers before also moving on, to León, for this year’s Clausura.</p><p>Further down the board, two names stand out on reputation alone. <strong>Alice Soto</strong>, at CF Monterrey, was reportedly the youngest player ever registered with the Mexican federation, signed by Pachuca at 12, and won the 2023 Liga MX Femenil Golden Ball (Best Young Player) &amp; CONCACAF U-20 Championship Golden Ball. And <strong>Nancy Antonio</strong> of Club América just won her seventh Liga MX Femenil title in May 2026, tying the all-time record.</p><p>Pairwise views make the pattern easiest to see, so two of them are presented below: Pressing vs Physical, and Physical vs Offensive. Both plot the full 373 Europeans (green) against the projected Brazilian and Mexican pool (blue), with the real top 5 Europeans and the same top 10 combined Brazilian and Mexican names from the ranking table above picked out in solid colour.</p><figure><img alt="Pressing percentile vs. Physical percentile for all 373 European midfielders (green) and the projected Brazilian/Mexican pool (blue)" src="https://cdn-images-1.medium.com/max/1024/1*JS6D5Ob0riBsxbBkDTvl9A.png" /><figcaption><em>Pressing percentile vs. Physical percentile for all 373 European midfielders (green) and the projected Brazilian/Mexican pool (blue)</em></figcaption></figure><p>In the first chart, Palacios sits near the top on Physical but comparatively further back on Pressing, still inside Europe’s top quartile there but clearly her weakest pillar, exactly the shape her ranking below reflects.</p><figure><img alt="Physical percentile vs. Offensive percentile for the same population — showing where physical strength does, and doesn’t, carry over into on-ball output" src="https://cdn-images-1.medium.com/max/1024/1*wY9dNfOEWFLuCxIFRO08Ew.png" /><figcaption><em>Physical percentile vs. Offensive percentile for the same population — showing where physical strength does, and doesn’t, carry over into on-ball output</em></figcaption></figure><p>In the second, that same physical strength carries over against Offensive too.</p><p>Projected onto the European index, <strong>Palacios</strong> would rank <strong>12th of 373</strong> players: the <strong>97th percentile</strong>, and the only Brazilian or Mexican name in this whole exercise who’d crack Europe’s actual <strong>top 20</strong>; nobody reaches the <strong>top 10</strong>. Her own profile leans one way, elite Physical and strong Offensive, with Pressing actually her weakest pillar by comparison, while <strong>Ana Carla</strong> right behind her leans differently, strong on Physical (93rd percentile) and Pressing (89th).</p><p>Further down the board, <strong>Duda Sampaio</strong> of Corinthians is basically the mirror image of Palacios: weak Physical, strong everywhere else, and worth its own closer look.</p><h3>Why doesn’t Duda Sampaio rank higher?</h3><p><strong>Duda Sampaio</strong> is exactly the name anyone who follows Brazilian football would expect near the top of this list, and she doesn’t crack it: <strong>17th of the 131</strong> Brazilian and Mexican midfielders scored here, just outside the top 10. It’s not her Pressing or Offensive output: both are genuinely strong, comfortably above most of the names ahead of her. It’s a real, undisguised weakness on Physical, and one bad pillar out of three is enough to cap an otherwise elite profile.</p><figure><img alt="Duda Sampaio’s Physical, Pressing, and Offensive percentiles against the same frozen 373-player European field, alongside the blended Euro Rank that combines them" src="https://cdn-images-1.medium.com/max/939/1*vI0cefAp_dIqMfL4VgdbKQ.png" /><figcaption><em>Duda Sampaio’s Physical, Pressing, and Offensive percentiles against the same frozen 373-player European field, alongside the blended Euro Rank that combines them</em></figcaption></figure><p>Projected onto the frozen European index, that still puts her in the <strong>78th position</strong> of a 373-player field, a genuinely good outcome, just not the exceptional one her reputation might lead you to expect.</p><h3>Wait, where’s Ana Vitória?</h3><p><strong>Ana Vitória</strong>, a Brazil international with an <strong>Olympic silver medal</strong> from Paris 2024 and a CV built at Benfica, PSG, and Atlético Madrid, returned to Corinthians, the club where she first broke through, in January of this year. On name recognition alone, she’s exactly who I’d have expected to see near the top of this list.</p><p>She isn’t in it, and not because she scored poorly. Her Corinthians return falls inside 2026, one of the study’s two pooled Brazilian seasons, so it’s not a season-scope issue, just not enough tracked matches yet to clear the study’s 5-match qualifying floor. Worth revisiting in a few weeks, once enough matches are tracked to score her like everyone else here.</p><h3>What I actually take away from this</h3><p>I went in expecting a short “yeah, Europe’s still ahead” postscript. It didn’t stay that simple.</p><p>The <strong>Physical and Volume gaps are real</strong>, not a small-sample fluke: Brasileirão Feminino and Liga MX Femenil aren’t secretly on par with the WSL or any Big-5 league. But Mexico’s Offensive Index actually edges the WSL’s, and <strong>Kiana Palacios</strong>’s current Liga MX Femenil numbers clear that European bar on their own terms, no fresh transfer needed, even after an earlier spell at Real Sociedad that predates this data. <strong>Duda Sampaio</strong>’s numbers do too, on two pillars out of three, capped only by Physical. <strong>Kenti Robles</strong> and <strong>Andressa Alves</strong> answered that same adaptation question the old-fashioned way instead: long, sustained European careers, not a single index score.</p><p>The index doesn’t say whether a player would adapt to Europe. <strong>It says whether her numbers already belong there</strong>, and for a few names out of Brazil and Mexico, they already do.</p><p>Brazil hosts the World Cup in 2027 with a $130 million investment plan behind it, and Mexico’s own league final drew 58,156 fans to Estadio Azteca, a competition attendance record. Both leagues are growing fast enough that this list won’t stay the same for long. Worth knowing, two years out, exactly whose numbers are already good enough.</p><p><strong>If you haven’t read the piece this one grew out of, it’s here: </strong><a href="https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football">https://skillcorner.com/articles/contextual-scouting-a-pressing-no-8-in-womens-football</a></p><p><strong>Enjoyed this analysis?</strong> Check out some of my other articles built with <a href="https://skillcorner.com/">SkillCorner</a> data:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/what-do-passing-numbers-say-about-football-styles-around-the-world-4e23318dabcb">What Do Passing Numbers Say About Football Styles Around the World?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/premier-league-passing-stats-2024-25-who-are-the-best-passers-9112cd4b143f">Premier League Passing Stats 2024/25: Who Are the Best Passers?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/haaland-vs-isak-vs-watkins-premier-league-striker-stats-comparison-2024-25-a626cbd13ea9">Haaland vs Isak vs Watkins: Premier League Striker Stats Comparison (2024/25)</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0221769be981" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What Do Passing Numbers Say About Football Styles Around the World?]]></title>
            <link>https://medium.com/@vickyfrissdekereki/what-do-passing-numbers-say-about-football-styles-around-the-world-4e23318dabcb?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/4e23318dabcb</guid>
            <category><![CDATA[soccer-analytics]]></category>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[soccer]]></category>
            <category><![CDATA[data-visualization]]></category>
            <category><![CDATA[football-analytics]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Wed, 08 Jul 2026 11:13:02 GMT</pubDate>
            <atom:updated>2026-07-08T11:47:19.610Z</atom:updated>
            <content:encoded><![CDATA[<h4>A data-driven exploration of football playing styles across Europe, the Americas, and the Middle East</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HV8-qdwu6VrX_z3g32nfuA.png" /></figure><p>Last week, while doing some research for a new article, I fell into a detour I didn’t plan. I’d been looking at some passing numbers across European leagues when the obvious question hit me: <em>what would this look like if I zoomed out? </em>Not just Europe — but South America, North America, the Middle East too. All of it, mapped against each other.</p><p>So I pulled data from <a href="https://skillcorner.com/">SkillCorner</a> for the last finished season (2025 for calendar-year leagues, 2025/2026 for mid-year formats) across leagues on four continents and mapped them all in one place.</p><p>I’m South American. I grew up watching a very particular kind of football. So I had a fairly confident picture of where some of these leagues would land.</p><p>I was right about some of it. Other results I didn’t see coming at all.</p><h3>How many passes are exchanged in a typical match?</h3><p>Quite a lot — and quite differently depending on where you watch. The pass counts here come from SkillCorner’s dynamic events data and cover all attempts, successful and unsuccessful. Across the leagues in this dataset, the range runs from just under 650 to just over 850 per game. But that number alone doesn’t tell the full story, because some leagues simply play more football than others. <strong>Effective Play Time (EPT)</strong> — the minutes the ball is actually in play, excluding VAR stoppages and dead balls — varies significantly across competitions, and that alone inflates pass counts. The chart below plots both together, so we can separate style from playing time.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1006/1*lHBXGMIm-BzPo8Zf-m51ng.png" /><figcaption><em>Tier 1 Leagues — Passes Attempted per Game vs Effective Play Time. Each dot is one league. Each league’s figure is an average of its teams’ individual averages, so all clubs are represented equally.</em></figcaption></figure><p>The split between continents is striking. Top European leagues cluster in the upper half — more passes, more live time. Most South American leagues sit noticeably below them. The gap is real and consistent.</p><p>The dashed line shows the average relationship between the two axes. Leagues sitting above it have more live play than their pass count would suggest — they play a lot of football, but passing is less intensive during that time. Leagues below the line do the opposite: they pack more passes into less live time. <strong>Norway</strong> is a clear example above — the highest EPT of any league, but doesn’t convert all that live time into proportionally more passes. <strong>USA</strong> and <strong>France</strong> sit below, passing a bit more intensely than the average relationship would predict.</p><p>Within that picture, one exception stands out: <strong>Brazil</strong> floats free of the South American cluster and lands alongside England, Spain, and Italy — and sits below the line, meaning it passes more intensely than its EPT would suggest. <em>Jogo bonito</em> isn’t just branding.</p><p>The South American cluster sits in the lower-left: fewer passes, less live time. Within that group, <strong>Uruguay</strong> stands out for sitting above the regression line: given how much live football they actually play, you’d expect considerably more passes than they exchange.</p><p>Then there’s <strong>MLS</strong>. Rather than sitting with the other American leagues, it lands firmly in the top-right — above the Premier League, above La Liga, above Serie A — with the <strong>highest pass attempts per game in the whole dataset</strong>. Not where most people would place it.</p><p>Worth noting: this isn’t a quality ranking. More passes doesn’t mean better football. But it does say something real about how the game is being played.</p><p>But passes per game still conflates style with playing time. To isolate the former, we need to control for possession.</p><h3>What happens when teams actually have the ball?</h3><p>The second chart shifts the question. Not how much passing — but <em>what kind</em>. When teams have the ball, are they trying to keep it, or move it forward fast?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/925/1*ATgkluj_AkTX1OU8So7CIg.png" /><figcaption><em>Tier 1 Leagues — Pass Volume vs Direct &amp; Counter Play. X-axis: passes attempted per 30 minutes of a team’s own time in possession. Y-axis: proportion of possession time spent in direct, transition, quick break, or chaotic phases — as opposed to structured buildup. Each dot is one league.</em></figcaption></figure><p>By measuring <strong>passes per 30 minutes of Time In Possession</strong> rather than per game, we strip out the EPT effect and focus purely on what teams do when they actually have the ball. The y-axis captures how <em>direct</em> that tends to be. Bottom-right is structured and patient. Top-left is quick and forward-oriented.</p><p><strong>Uruguay</strong> sits in the furthest top-left corner: the highest proportion of direct/chaotic play in the dataset, around 25%, and also the lowest number of passes attempted per 30 minutes TIP. As a Uruguayan, I can’t say this surprises me. It’s the football I grew up watching: direct, intense, built on duels rather than circulation. <strong>Paraguay</strong> and <strong>Argentina</strong> are close behind, which tells you this is a South American pattern as much as a Uruguayan one. The ball is there to be moved forward, not circulated.</p><p>At the opposite corner — highest passing intensity, lowest proportion of direct play — sit <strong>USA</strong>, <strong>France</strong>, and <strong>Canada</strong>, much the same group that appeared at the top of the first chart. And <strong>Brazil</strong> is there too, once again separating itself from the rest of South America. <strong>Mexico</strong> also sits closer to the European cluster than to its South American neighbours — more patient in possession than its geography might suggest.</p><p>The <strong>Middle East</strong> leagues are an interesting case. Qatar sits in the middle of the pack — moderate passing intensity, somewhat structured. UAE is similar, closer to the European mid-range. Saudi Arabia is the standout: one of the lowest proportions of direct play in the entire chart, comparable to France and Canada, though with noticeably lower passing intensity. Structured in possession, but playing fewer passes while in it.</p><p>What the scatters don’t show is how much variation hides inside each of those dots.</p><h3>The same league, completely different teams</h3><p>Those league averages are one number per dot. But behind each dot there are dozens of teams, all playing very differently. The chart below shows the full distribution for a selection of leagues, ordered from highest to lowest average.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0qrcXv4Xnywhn6U3TYa6IQ.png" /><figcaption><em>Passes Attempted per 30 min TIP — Team Distribution by League. Each dot is one team. Solid line = league average, dashed = median.</em></figcaption></figure><p><strong>MLS</strong> and <strong>France</strong> share the highest average, both sitting just over 457. For MLS, that’s driven across a wide range of clubs: Los Angeles FC and San Diego FC reach 511 and 507, on par with Bayern Munich and above PSG and Barcelona.</p><p><strong>Spain</strong> and <strong>Argentina</strong> have the widest spreads (129 and 146). In both cases, one club at the bottom is largely responsible. For Spain, it’s Getafe at 376 against Barcelona at 505. The median sits at 439, which tells a very different story from the tiki-taka reputation. For Argentina, it’s CD Riestra at 323, by far the lowest value in the entire chart, dragging the average well below where River Plate (469) would suggest.</p><p><strong>Germany</strong> shows a similar internal contrast: Bayern München at 510, SC Freiburg at 395. <strong>Norway</strong> is the most homogeneous: a spread of just 63, the tightest of any league shown.</p><p><strong>Uruguay’s</strong> distribution confirms what the scatters already showed: the whole league sits low (average 390), not because of one outlier, but consistently across clubs.</p><h3>Harder passes, fewer completed — the compounding effect</h3><p>We’ve already seen that South American leagues attempt fewer passes. This chart adds another layer: the passes they do attempt are also harder to complete.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DRqfokafzmUURVOq2NQpEA.png" /><figcaption><em>Tier 1 Leagues — Actual Completion vs xPass. </em>X-axis: average xPass (%) — the expected completion rate based on pass difficulty. Y-axis: actual pass completion %. Only passes with a calculated xPass value are included. Each dot is one league.</figcaption></figure><p><strong>xPass</strong> is SkillCorner’s model for estimating the probability of completing a pass to a player at a given moment. It only applies when a pass target can be identified — so as passes with no clear recipient aren’t considered, we excluded those from both axes. That means some of the hardest passes in direct-style leagues may not be in the picture, and the real gap could be even larger than what the chart shows.</p><p>For the leagues at the bottom of this chart, everything compounds. They already attempt fewer passes than most. Those passes are also among the hardest — which makes sense: more time spent in direct and counter-attacking phases, as we saw in a previous chart, means more forward balls played under pressure, riskier connections. And with lower completion, the gap in actual completed passes is considerably larger than the raw attempt numbers alone would suggest. Fewer attempts, harder passes, worse accuracy — all pointing in the same direction. <strong>Uruguay, Paraguay</strong> and <strong>Argentina</strong> take this furthest. <strong>Switzerland</strong>, <strong>Russia</strong>, and <strong>Austria</strong> sit in the same bottom cluster — and just like in the direct play chart, they’re closer to the South American group than to the European mainstream.</p><p>At the other end, <strong>France, USA</strong> and <strong>Canada </strong>sit at the very top — the highest completion and the easiest passes of any league. <strong>Brazil</strong> and <strong>Saudi Arabia</strong> are in that upper cluster too, consistent with where both appeared in the direct play chart.</p><p>To dig into this further, two more metrics help interpret what’s happening. <strong>Difficult passes</strong> are those where the expected completion rate falls below 65%, as modelled by xPass. <strong>Fast passes</strong> combine two types: <strong>one-touch passes</strong> (passed on the very first touch) and <strong>quick passes</strong> (passed within one second of receiving, but with at least a brief moment of control).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/925/1*KLc2IY-dk6jA9iy6MbIFsQ.png" /><figcaption><em>Tier 1 Leagues — Pass Difficulty vs Fast Passing (one-touch or quick pass). X-axis: proportion of attempted passes classified as difficult (xPass completion below 65%). Y-axis: proportion of passes that are one-touch or quick. Each dot is one league.</em></figcaption></figure><p>The pattern holds broadly, but with some interesting exceptions. <strong>Argentina</strong>, <strong>Paraguay</strong>, <strong>Russia</strong>, and <strong>Austria</strong> sit in the top-right, combining a high share of difficult passes with a high proportion made quickly, releasing the ball fast to difficult targets. <strong>Uruguay</strong> is the furthest right on difficulty but sits somewhat lower on fast passing, pointing more to long direct balls than rapid short connections. At the other end, <strong>Canada</strong> and <strong>USA</strong> combine easy passes with a relatively high proportion of fast ones. <strong>France</strong> makes easy passes too, but somewhat more patiently. <strong>Saudi Arabia</strong> is the notable outlier: among the easiest passes of any league, and also the lowest rate of fast passing in the entire chart.</p><h3>What does it all mean?</h3><p>These numbers — pass volume, playing time, possession style, pass difficulty, and how quickly teams release the ball — are stylistic signals, not quality rankings. But taken together, they start to reveal something real about how football is played in different parts of the world.</p><p>The continental divide is clear, but so are the exceptions. Brazil sits with Europe, not South America. MLS sits with France and Germany, not with the rest of the Americas. And the most direct leagues don’t just pass less — they attempt harder passes and complete fewer of them too. The differences go deeper than a single number.</p><p>The data doesn’t say which style is better. But it does show just how different the game looks depending on where it’s played.</p><p><strong>Enjoyed this analysis?</strong> Check out some of my other articles built with <a href="https://skillcorner.com/">SkillCorner</a> data:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/premier-league-passing-stats-2024-25-who-are-the-best-passers-9112cd4b143f">Premier League Passing Stats 2024/25: Who Are the Best Passers?</a></li><li><a href="https://medium.com/@vickyfrissdekereki/haaland-vs-isak-vs-watkins-premier-league-striker-stats-comparison-2024-25-a626cbd13ea9">Haaland vs Isak vs Watkins: Premier League Striker Stats Comparison (2024/25)</a></li><li><a href="https://medium.com/@vickyfrissdekereki/fernandes-vs-de-bruyne-vs-%C3%B8degaard-premier-league-midfielder-stats-comparison-2024-25-46c4f7e36a74">Fernandes vs De Bruyne vs Ødegaard: Premier League Midfielder Stats Comparison (2024/25)</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4e23318dabcb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Passing Networks in Python: A Step-by-Step Guide]]></title>
            <link>https://medium.com/@vickyfrissdekereki/building-passing-networks-in-python-a-step-by-step-guide-1c2239c52800?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/1c2239c52800</guid>
            <category><![CDATA[sports-analytics]]></category>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[data-visualization]]></category>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[visualization]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Fri, 22 May 2026 22:00:39 GMT</pubDate>
            <atom:updated>2026-05-22T22:00:39.704Z</atom:updated>
            <content:encoded><![CDATA[<h4>How to construct, align, and visualise team passing structures using StatsBomb event data</h4><figure><img alt="Cover image for Building Passing Networks in Python: A Step-by-Step Guide showing the Bayern Munich Passing Network 2015/16" src="https://cdn-images-1.medium.com/max/1024/1*Vkl-59_XGiK6GK8_L4RnIQ.png" /></figure><p>Passing networks are one of the most expressive tools in football analytics. Rather than looking at individual passes in isolation, they reveal <strong>how a team is organised in possession</strong> — which players connect most frequently, where the ball tends to flow, and how the team’s shape manifests in the data. A network built from a single match gives a snapshot of structure; one built from an entire season reveals something closer to tactical identity.</p><p>In this tutorial, I build a full pipeline for constructing passing networks from scratch using event data from the <strong>2015/16 Bundesliga season</strong>, provided by <a href="https://github.com/statsbomb/open-data">StatsBomb’s Open Data repository</a>. This covers everything from data extraction and cleaning through to spatial alignment across matches, season-level aggregation, and a flexible plotting system — all driven by a single function call.</p><p>The focus is on the methodology, not performance evaluation.</p><p>🔗 <strong>Full Python code</strong> available on <a href="https://github.com/vickyfriss/Football-analytics/blob/main/202605%20-%202%20-%20How%20to%20build%20Passing%20Networks.ipynb">my GitHub</a>.</p><h3>Step 0: Setup</h3><p>The packages used here cover data manipulation, visualisation, football data access, and optimisation.</p><pre># Standard library<br>import warnings<br><br># Data manipulation<br>import numpy as np<br>import pandas as pd<br><br># Visualisation<br>import matplotlib.pyplot as plt<br>from mplsoccer import Pitch<br><br># Football data<br>from statsbombpy import sb<br><br># Optimisation<br>from scipy.optimize import linear_sum_assignment<br><br># Progress bar<br>from tqdm import tqdm<br><br>warnings.filterwarnings(&quot;ignore&quot;, message=&quot;credentials were not supplied&quot;)</pre><p><a href="https://mplsoccer.readthedocs.io/en/latest/">mplsoccer</a> provides the pitch surface used in all visualisations. <a href="https://support.hudl.com/s/article/use-statsbombpy-python?language=en_US&amp;topic=Statsbomb_Python_Tools">statsbombpy</a> is the official Python client for accessing StatsBomb&#39;s open event data. linear_sum_assignment from <a href="https://scipy.org/">scipy</a> implements the <a href="https://cp-algorithms.com/graph/hungarian-algorithm.html">Hungarian algorithm</a>, used later for spatial alignment. <a href="https://tqdm.github.io/">tqdm</a> wraps the match download loop with a progress bar. The warning filter suppresses a credential notice that appears when using the free open data tier without an API key.</p><p>I also define a set of constants at the top of the notebook. Centralising these avoids magic numbers buried in functions and makes thresholds easy to adjust.</p><pre>PITCH_X_MAX = 120<br>PITCH_Y_MAX = 80<br>MIN_STARTERS = 11<br>WATERMARK_NAME     = &quot;Victoria Friss de Kereki&quot;<br>WATERMARK_SUBTITLE = &quot;Sports Analytics &amp; Data Science&quot;<br>FULL_MATCH_THRESHOLD = 100</pre><p>PITCH_X_MAX and PITCH_Y_MAX are StatsBomb&#39;s standard pitch dimensions. MIN_STARTERS is a data quality threshold — a match is only included if all 11 starting players can be identified in the lineup data. FULL_MATCH_THRESHOLD filters out cups and partial datasets when exploring available competitions.</p><h3>Step 1: Exploring Available Competitions</h3><p>Before extracting data, I check what’s available in the StatsBomb free tier and identify which competitions are complete enough to be worth using.</p><pre>pd.set_option(&#39;display.max_columns&#39;, None)<br>pd.set_option(&#39;display.width&#39;, None)<br><br>free_comps = sb.competitions()<br><br>comps = free_comps[free_comps[&quot;competition_gender&quot;] == &quot;male&quot;].copy()<br><br>comps[&quot;season_start&quot;]=comps[&quot;season_name&quot;].str.split(&quot;/&quot;).str[0].astype(int)<br><br>comps = comps[comps[&quot;season_start&quot;] &gt;= 2015].copy()</pre><p>I filter to male competitions from 2015 onwards. The season start year is extracted by splitting strings like &quot;2015/2016&quot; and taking the first part. Next, I count how many matches are available for each competition and keep only those above the threshold:</p><pre>match_counts = []<br><br>for comp_id, season_id in zip(comps[&quot;competition_id&quot;], comps[&quot;season_id&quot;]):<br>    try:<br>        matches = sb.matches(competition_id=comp_id, season_id=season_id)<br>        matches_count = len(matches)<br>    except:<br>        matches_count = 0<br><br>    match_counts.append({<br>        &quot;competition_id&quot;: comp_id,<br>        &quot;season_id&quot;: season_id,<br>        &quot;matches_count&quot;: matches_count<br>    })<br><br>match_counts_df = pd.DataFrame(match_counts)<br><br>metadata_cols = [&quot;competition_id&quot;, &quot;season_id&quot;, &quot;competition_name&quot;,<br>                 &quot;season_name&quot;, &quot;country_name&quot;]<br><br>match_counts_df = match_counts_df.merge(<br>    comps[metadata_cols],<br>    on=[&quot;competition_id&quot;, &quot;season_id&quot;],<br>    how=&quot;left&quot;<br>)<br><br>full_comps = match_counts_df[<br>    match_counts_df[&quot;matches_count&quot;] &gt;= FULL_MATCH_THRESHOLD<br>].sort_values(&quot;matches_count&quot;, ascending=False).reset_index(drop=True)</pre><p>Running this gives a ranked overview of available datasets. Filtered to competitions with at least 100 matches, the output looks like this:</p><figure><img alt="Full competitions available in the StatsBomb free tier (2015 onwards, ≥100 matches" src="https://cdn-images-1.medium.com/max/526/1*7tgLoLFH0kSfCmU-B5wUgg.png" /><figcaption>Full competitions available in the StatsBomb free tier (2015 onwards, ≥100 matches</figcaption></figure><p>Five of the six entries are from 2015/16, which turns out to be the best-covered season in the free tier across all major European leagues.</p><p>The 2015/16 season is particularly well-covered across European leagues. For this project I use <strong>1. Bundesliga 2015/16</strong> (competition_id=9, season_id=27), which contains 306 matches — enough to build meaningful season-level networks for all 18 teams.</p><p>I also pull the match list and identify the two Der Klassiker fixtures, which I’ll use later to demonstrate the single-match plot:</p><pre>comp = 9<br>season = 27<br><br>matches_df = sb.matches(competition_id=comp, season_id=season)<br><br>klassiker = matches_df[<br>    (<br>        (matches_df[&quot;home_team&quot;] == &quot;Bayern Munich&quot;) &amp;<br>        (matches_df[&quot;away_team&quot;] == &quot;Borussia Dortmund&quot;)<br>    )<br>    |<br>    (<br>        (matches_df[&quot;home_team&quot;] == &quot;Borussia Dortmund&quot;) &amp;<br>        (matches_df[&quot;away_team&quot;] == &quot;Bayern Munich&quot;)<br>    )<br>]</pre><p>Filtering for Der Klassiker returns the two fixtures between Bayern and Dortmund that season:</p><figure><img alt="The two Der Klassiker fixtures in the 2015/16 Bundesliga season, with their StatsBomb match IDs" src="https://cdn-images-1.medium.com/max/610/1*y5Wl9f7VRaZ7OhCukBGYWA.png" /><figcaption>The two Der Klassiker fixtures in the 2015/16 Bundesliga season, with their StatsBomb match IDs</figcaption></figure><p>Two contrasting fixtures — a tight 0–0 draw in Dortmund and a 5–1 thrashing in Munich — which makes them a good pair for demonstrating what the network can and can’t show about a team’s structure under very different conditions.</p><h3>Step 2: Extracting and Saving the Pass Data</h3><p>StatsBomb data is accessed match by match, so extracting a full season means looping over all 306 fixtures. <strong>This takes around 8 minutes</strong>, so the result is saved to CSV and loaded from there for all subsequent work. The CSV isn’t in the GitHub repo due to its size, but the code below reproduces it exactly.</p><pre>all_passes = []<br><br>for match_id in tqdm(matches_df[&quot;match_id&quot;], desc=&quot;Extracting passes&quot;):<br>    events = sb.events(match_id=match_id)<br>    passes = events[events[&quot;type&quot;] == &quot;Pass&quot;].copy()<br>    passes[&quot;match_id&quot;] = match_id<br>    all_passes.append(passes)<br><br>passes_df = pd.concat(all_passes, ignore_index=True)</pre><p>Each iteration downloads the full event stream for one match, filters to pass events, tags rows with the match_id, and appends them to the list. After concatenation, I merge in match-level metadata so every pass row carries the full context of the game it came from:</p><pre>matches_meta = matches_df[[<br>    &quot;match_id&quot;, &quot;match_date&quot;, &quot;season&quot;, &quot;competition&quot;,<br>    &quot;home_team&quot;, &quot;away_team&quot;, &quot;home_score&quot;, &quot;away_score&quot;<br>]].copy()<br><br>passes_df = passes_df.merge(matches_meta, on=&quot;match_id&quot;, how=&quot;left&quot;)<br><br>passes_df.to_csv(<br>    &quot;Datasets/passes_bundesliga_201516_statsbomb_all.csv&quot;, index=False)</pre><p>The resulting dataset contains approximately <strong>295,000 passes</strong> across the 306 Bundesliga matches.</p><h3>Step 3: Loading and Cleaning the Data</h3><p>Once the CSV exists, all subsequent work loads from it directly. The two coordinate columns — location and pass_end_location — are stored as stringified lists in the CSV, so they need to be parsed back into Python lists on read using ast.literal_eval.</p><p>I split this into two functions. clean_passes handles coordinate extraction and data quality; load_passes wraps the CSV read and calls it immediately.</p><pre>def clean_passes(passes_df: pd.DataFrame) -&gt; pd.DataFrame:<br>    &quot;&quot;&quot;Extract coordinates, remove invalid passes, and clip to pitch bounds.&quot;&quot;&quot;<br><br>    passes = passes_df.copy()<br><br>    def _coords(col, idx):<br>        return col.apply(lambda v: v[idx] if isinstance(v, (list, tuple)) else None)<br><br>    passes[&quot;start_x&quot;] = _coords(passes[&quot;location&quot;], 0)<br>    passes[&quot;start_y&quot;] = _coords(passes[&quot;location&quot;], 1)<br>    passes[&quot;end_x&quot;]   = _coords(passes[&quot;pass_end_location&quot;], 0)<br>    passes[&quot;end_y&quot;]   = _coords(passes[&quot;pass_end_location&quot;], 1)<br><br>    passes = passes[passes[&quot;pass_outcome&quot;] != &quot;Injury Clearance&quot;].copy()<br><br>    for col, max_val in [(&quot;start_x&quot;, PITCH_X_MAX), (&quot;end_x&quot;, PITCH_X_MAX),<br>                         (&quot;start_y&quot;, PITCH_Y_MAX), (&quot;end_y&quot;, PITCH_Y_MAX)]:<br>        passes[col] = passes[col].clip(lower=0, upper=max_val)<br><br>    return passes<br><br>def load_passes(path: str) -&gt; pd.DataFrame:<br>    &quot;&quot;&quot;Load passes from a saved CSV and return<br>    a cleaned DataFrame ready for plotting.&quot;&quot;&quot;<br><br>    import ast<br><br>    passes_df = pd.read_csv(<br>        path,<br>        low_memory=False,<br>        converters={<br>            &quot;location&quot;:          lambda x: ast.literal_eval(x) if pd.notna(x) else None,<br>            &quot;pass_end_location&quot;: lambda x: ast.literal_eval(x) if pd.notna(x) else None,<br>        }<br>    )<br><br>    return clean_passes(passes_df)</pre><p>StatsBomb encodes locations as [x, y] lists, so the inner _coords helper extracts index 0 (x) or index 1 (y) from each row. Injury clearances are excluded because they are not actual passes and would distort average positions. The clipping step handles rare edge cases where coordinates fall marginally outside the pitch rectangle due to data imprecision.</p><p>From this point on, the entire workflow starts from one line:</p><pre>passes = load_passes(&quot;Datasets/passes_bundesliga_201516_statsbomb_all.csv&quot;)</pre><h3>Step 4: Helpers</h3><p>Two small functions are used throughout the rest of the code and are worth defining upfront.</p><pre>def _norm(x) -&gt; str | None:<br>    &quot;&quot;&quot;Lowercase and strip a player name for consistent matching.&quot;&quot;&quot;<br>    return str(x).strip().lower() if pd.notna(x) else None<br><br>def format_nickname(name: str) -&gt; str:<br>    &quot;&quot;&quot;Capitalise each word of a name, preserving common particles.&quot;&quot;&quot;<br>    if pd.isna(name):<br>        return name<br>    particles = {&quot;de&quot;, &quot;da&quot;, &quot;del&quot;, &quot;van&quot;, &quot;von&quot;}<br>    words = str(name).split()<br>    if all(w[0].isupper() or w.lower() in particles for w in words):<br>        return name<br>    return &quot; &quot;.join(w.lower() if w.lower() in particles else w.capitalize()<br>                    for w in words)</pre><p>_norm lowercases and strips player names for consistent matching — the same player can appear slightly differently across the player and pass_recipient columns. format_nickname handles display labels: StatsBomb sometimes stores nicknames in all-lowercase, so this normalises capitalisation for plot labels while preserving particles like &quot;de&quot; and &quot;van&quot;.</p><h3>Step 5: Building a Match-Level Network</h3><p>A passing network for a single team in a single match requires two things: knowing who started, and knowing where each starter typically operated on the ball.</p><h4>5.1 Getting the starting XI</h4><pre>def get_starting_xi(match_id: int,<br>                    team: str) -&gt; pd.DataFrame | None:<br>    &quot;&quot;&quot;Return a DataFrame with columns [player, nickname] for the starting XI.&quot;&quot;&quot;<br>    <br>    try:<br>        lineup = sb.lineups(match_id=match_id)[team]<br>    except Exception:<br>        return None<br><br>    df = lineup.explode(&quot;positions&quot;)<br><br>    pos = pd.json_normalize(df[&quot;positions&quot;])<br><br>    df = pd.concat(<br>        [df.drop(columns=&quot;positions&quot;).reset_index(drop=True), pos],<br>        axis=1<br>    )<br><br>    df[&quot;player&quot;] = df[&quot;player_name&quot;].apply(_norm)<br>    df[&quot;nickname&quot;] = df[&quot;player_nickname&quot;]<br>    df[&quot;from&quot;] = df[&quot;from&quot;].fillna(&quot;0:00&quot;)<br><br>    df = df.sort_values(&quot;from&quot;)<br><br>    starters = df.head(11)[[&quot;player&quot;, &quot;nickname&quot;]]<br><br>    return starters if len(starters) &gt;= MIN_STARTERS else None</pre><p>StatsBomb lineup data stores each player’s position history as a list of dictionaries — one entry per role change during the match. Exploding and normalising that list gives a flat table with one row per position stint. Sorting by the from column (the time each position started) and taking the first 11 rows identifies who was on the pitch from kick-off. If all 11 starters can&#39;t be confirmed, None is returned and the match is excluded downstream.</p><h4>5.2 Computing average positions</h4><pre>def build_match_network(match_id: int, team: str, passes: pd.DataFrame):<br>    &quot;&quot;&quot;<br>    Build average node positions for one team in one match.<br>    Returns (node_pos_df, pass_df, xi_df) or None if data is insufficient.<br>    &quot;&quot;&quot;<br>    <br>    df = passes[<br>        (passes[&quot;match_id&quot;] == match_id) &amp; (passes[&quot;team&quot;] == team)<br>    ].copy()<br><br>    if df.empty:<br>        return None<br><br>    df[&quot;p&quot;] = df[&quot;player&quot;].apply(_norm)<br>    df[&quot;r&quot;] = df[&quot;pass_recipient&quot;].apply(_norm)<br><br>    xi = get_starting_xi(match_id, team)<br><br>    if xi is None:<br>        return None<br><br>    starters = xi[&quot;player&quot;].tolist()<br><br>    locs = pd.concat([<br>        df[df[&quot;p&quot;].isin(starters)][[&quot;p&quot;, &quot;start_x&quot;, &quot;start_y&quot;]].rename(<br>            columns={&quot;p&quot;: &quot;player&quot;, &quot;start_x&quot;: &quot;x&quot;, &quot;start_y&quot;: &quot;y&quot;}<br>        ),<br>        df[df[&quot;r&quot;].isin(starters)][[&quot;r&quot;, &quot;end_x&quot;, &quot;end_y&quot;]].rename(<br>            columns={&quot;r&quot;: &quot;player&quot;, &quot;end_x&quot;: &quot;x&quot;, &quot;end_y&quot;: &quot;y&quot;}<br>        ),<br>    ])<br><br>    avg = locs.groupby(&quot;player&quot;).agg(<br>        x=(&quot;x&quot;, &quot;mean&quot;),<br>        y=(&quot;y&quot;, &quot;mean&quot;)<br>    ).reset_index()<br><br>    return (avg, df, xi) if len(avg) &gt;= MIN_STARTERS else None</pre><p>The key idea here is that <strong>a player’s average position is estimated from all their on-ball actions</strong> — both when they pass (the pass start location) and when they receive (the pass end location). Using both gives a more stable estimate than passes made alone, particularly for players who receive a lot but contribute less in build-up.</p><h4>5.3 Building the edges</h4><pre>def build_edges(pass_df: pd.DataFrame,<br>                node_pos: pd.DataFrame) -&gt; pd.DataFrame:<br>    &quot;&quot;&quot;Aggregate pass counts between node pairs and attach coordinates.&quot;&quot;&quot;<br><br>    edges = (<br>        pass_df.groupby([&quot;p&quot;, &quot;r&quot;])<br>        .size()<br>        .reset_index(name=&quot;weight&quot;)<br>        .rename(columns={&quot;p&quot;: &quot;slot_from&quot;, &quot;r&quot;: &quot;slot_to&quot;})<br>    )<br><br>    edges = edges[edges[&quot;slot_from&quot;] != edges[&quot;slot_to&quot;]]<br><br>    edges = (edges<br>             .merge(node_pos.rename(columns={&quot;player&quot;: &quot;slot_from&quot;,<br>                                             &quot;x&quot;: &quot;x_start&quot;,<br>                                             &quot;y&quot;: &quot;y_start&quot;}),<br>                    on=&quot;slot_from&quot;)<br>             .merge(node_pos.rename(columns={&quot;player&quot;: &quot;slot_to&quot;,<br>                                             &quot;x&quot;: &quot;x_end&quot;,<br>                                             &quot;y&quot;: &quot;y_end&quot;}),<br>                    on=&quot;slot_to&quot;))<br><br>    return edges</pre><p>Edges are pass counts between pairs of players, with self-passes removed. Each edge is then enriched with the spatial coordinates of both endpoints, which are needed to draw the connecting lines on the pitch.</p><h3>Step 6: Aligning Networks Across Matches</h3><p>Building a season-level network means combining node positions from up to 34 matches. The core challenge is that <strong>each match has a different starting XI</strong> — players rotate in and out due to injury, suspension, or tactical choice. There is no stable one-to-one correspondence between nodes across matches: the node representing the left centre-back in match 1 might be a completely different player in match 17.</p><p>The solution is to stop thinking in terms of individual players and instead think in terms of <strong>positional slots</strong> — abstract roles like “left centre-back” or “right-sided midfielder” that exist independently of who fills them. Before aggregating, each match’s node layout is mapped to a shared reference layout using the Hungarian algorithm, which assigns each node to the closest node in the reference in a globally optimal way.</p><p>The <strong>reference</strong> is simply the node layout from the first match processed for that team — the 11 average positions from that game become the template that all subsequent matches are aligned to. This is an arbitrary choice: a different first match would produce a different reference and potentially different slot assignments. A more robust approach would use a manually defined positional template based on the team’s most common formation, but the first-match reference works well enough in practice and requires no manual input.</p><pre>def _align_nodes(match_nodes: list[pd.DataFrame],<br>                 reference: pd.DataFrame) -&gt; list[pd.DataFrame]:<br>    &quot;&quot;&quot;Use the Hungarian algorithm to align each match&#39;s nodes<br>    to a shared reference layout.&quot;&quot;&quot;<br><br>    aligned = []<br><br>    for nodes in match_nodes:<br>        if len(nodes) &lt; len(reference):<br>            continue<br>        cost = np.linalg.norm(<br>            nodes[[&quot;x&quot;, &quot;y&quot;]].values[:, None, :] -<br>            reference[[&quot;x&quot;, &quot;y&quot;]].values[None, :, :],<br>            axis=2<br>        )<br>        r, c = linear_sum_assignment(cost)<br>        nodes = nodes.iloc[r].copy()<br>        nodes[&quot;slot&quot;] = c<br>        aligned.append(nodes)<br><br>    return aligned</pre><p>The cost matrix holds the Euclidean distance between every node in the match and every node in the reference. linear_sum_assignment finds the one-to-one assignment that minimises total distance — this is the Hungarian algorithm, which solves the classic assignment problem in polynomial time. Each node is then tagged with its assigned slot: a positional role that is consistent across all matches, regardless of which player occupied it.</p><p>This is also why MIN_STARTERS = 11 matters: the alignment requires exactly 11 nodes per match to map cleanly onto the 11-slot reference. A match where fewer starters can be identified from the lineup data would produce a mismatched cost matrix and unreliable slot assignments, so it is excluded entirely.</p><h3>Step 7: Building a Season-Level Network</h3><p>With alignment in place, <strong>the season aggregation brings everything together</strong>: collect match-level networks, align them, average positions by slot, then assign the most representative player to each slot.</p><pre>def build_season_network(passes: pd.DataFrame, team: str, season: str):<br>    &quot;&quot;&quot;<br>    Aggregate all match-level networks for a team into a season-level network.<br>    Returns (node_pos_df, edges_df) or None.<br>    &quot;&quot;&quot;<br><br>    df = passes[(passes[&quot;team&quot;] == team) &amp; (passes[&quot;season&quot;] == season)]<br>    if df.empty:<br>        return None<br><br>    match_ids = df[&quot;match_id&quot;].dropna().unique()<br>    match_nodes, match_passes, xi_list = [], [], []<br><br>    for m in match_ids:<br>        res = build_match_network(m, team, passes)<br>        if res is None:<br>            continue<br>        nodes, match_df, xi = res<br>        match_nodes.append(nodes)<br>        match_passes.append(match_df)<br>        xi_list.append(xi)<br><br>    if not match_nodes:<br>        return None<br><br>    reference = match_nodes[0].copy()<br>    reference[&quot;slot&quot;] = range(len(reference))<br><br>    aligned = _align_nodes(match_nodes, reference)<br><br>    if not aligned:<br>        return None<br><br>    nodes_df = pd.concat(aligned, ignore_index=True)</pre><p>Once aligned, each node has a slot label — a positional role that is consistent across matches. The average coordinates per slot across all matches become the node&#39;s location in the season network:</p><pre>    # Average position per slot<br>    node_pos = (nodes_df.groupby(&quot;slot&quot;)<br>                .agg(x=(&quot;x&quot;, &quot;mean&quot;), y=(&quot;y&quot;, &quot;mean&quot;))<br>                .reset_index())</pre><p>Before assigning player labels to those slots, the nickname map is built. StatsBomb stores a player_nickname field in lineup data — a shorter or more recognisable name for each player (e.g. &quot;Xabi Alonso&quot; instead of &quot;Xabier Alonso Olano&quot;). The map is collected across all match lineups, deduplicated by player, and stored as a dictionary so it can be looked up quickly when labelling nodes:</p><pre>    # Build nickname map from all lineups<br>    xi_all = pd.concat(xi_list, ignore_index=True)<br>    nickname_map = (<br>        xi_all.dropna(subset=[&quot;nickname&quot;])<br>        .drop_duplicates(&quot;player&quot;)<br>        .set_index(&quot;player&quot;)[&quot;nickname&quot;]<br>        .to_dict()<br>    )</pre><p>Next, the most representative player for each slot is identified. A player-slot count matrix tracks how many times each player appeared in each slot across all matches. The Hungarian algorithm is applied again — this time to maximise total appearance count rather than minimise distance, giving a one-to-one assignment of players to slots:</p><pre>    players = nodes_df[&quot;player&quot;].unique()<br>    slots   = sorted(nodes_df[&quot;slot&quot;].unique())<br>    matrix  = np.zeros((len(players), len(slots)))<br>    p_idx   = {p: i for i, p in enumerate(players)}<br>    s_idx   = {s: i for i, s in enumerate(slots)}<br><br>    counts = nodes_df.groupby([&quot;player&quot;, &quot;slot&quot;]).size().reset_index(name=&quot;n&quot;)<br><br>    for _, row in counts.iterrows():<br>        matrix[p_idx[row[&quot;player&quot;]], s_idx[row[&quot;slot&quot;]]] = row[&quot;n&quot;]<br><br>    r_ind, c_ind = linear_sum_assignment(-matrix)<br>    slot_to_player = {slots[j]: players[i] for i, j in zip(r_ind, c_ind)}<br><br>    node_pos[&quot;player&quot;] = node_pos[&quot;slot&quot;].map(slot_to_player)<br><br>    node_pos[&quot;label&quot;]  = (node_pos[&quot;player&quot;]<br>                          .map(nickname_map)<br>                          .fillna(node_pos[&quot;player&quot;])<br>                          .apply(format_nickname))</pre><p>Finally, season-level edges are built by mapping each pass to the passer’s and recipient’s most common slot, then aggregating across all matches:</p><pre>    player_to_slot = nodes_df.groupby(&quot;player&quot;)[&quot;slot&quot;].agg(lambda x: x.mode()[0])<br><br>    all_passes = pd.concat(match_passes, ignore_index=True)<br>    all_passes[&quot;slot_from&quot;] = all_passes[&quot;p&quot;].map(player_to_slot)<br>    all_passes[&quot;slot_to&quot;]   = all_passes[&quot;r&quot;].map(player_to_slot)<br><br>    edges = (<br>        all_passes.groupby([&quot;slot_from&quot;, &quot;slot_to&quot;])<br>        .size()<br>        .reset_index(name=&quot;weight&quot;)<br>        .dropna()<br>    )<br><br>    edges = edges[edges[&quot;slot_from&quot;] != edges[&quot;slot_to&quot;]]<br><br>    edges = (edges<br>             .merge(node_pos, left_on=&quot;slot_from&quot;, right_on=&quot;slot&quot;)<br>             .merge(node_pos, left_on=&quot;slot_to&quot;, right_on=&quot;slot&quot;,<br>                    suffixes=(&quot;_start&quot;, &quot;_end&quot;)))<br><br>    return node_pos, edges</pre><h3>Step 8: Drawing the Network</h3><p>All visualisations share a single core drawing function. It takes an existing axis, a pitch object, node positions, and edges, and renders the complete network onto them.</p><pre>def _draw_network(ax, pitch, node_pos: pd.DataFrame, edges: pd.DataFrame,<br>                  node_size: int = 240, label_size: int = 8,<br>                  watermark: bool = False) -&gt; None:<br>    &quot;&quot;&quot;Draw nodes and weighted edges onto an existing pitch axis.&quot;&quot;&quot;<br>    pitch.draw(ax=ax)<br><br>    if edges is not None and not edges.empty:<br>        max_w = edges[&quot;weight&quot;].max()<br>        for _, r in edges.iterrows():<br>            pitch.lines(<br>                r.x_start, r.y_start, r.x_end, r.y_end,<br>                lw=(r[&quot;weight&quot;] / max_w) * 6,<br>                color=&quot;red&quot;,<br>                alpha=0.3 + (r[&quot;weight&quot;] / max_w) * 0.6,<br>                ax=ax, zorder=1<br>            )<br>    pitch.scatter(node_pos.x, node_pos.y,<br>                  s=node_size, color=&quot;blue&quot;, edgecolor=&quot;black&quot;,<br>                  linewidth=1.2, ax=ax, zorder=3)<br>    label_col = &quot;label&quot; if &quot;label&quot; in node_pos.columns else &quot;player&quot;<br>    for _, r in node_pos.iterrows():<br>        ax.text(r.x, r.y, format_nickname(r[label_col]),<br>                ha=&quot;center&quot;, va=&quot;center&quot;,<br>                fontsize=label_size, fontweight=&quot;bold&quot;,<br>                bbox=dict(facecolor=&quot;white&quot;, alpha=0.6,<br>                          edgecolor=&quot;none&quot;, boxstyle=&quot;round,pad=0.2&quot;),<br>                zorder=4)<br>    if watermark:<br>        ax.text(PITCH_X_MAX - 1, 75.5, WATERMARK_NAME,<br>                ha=&quot;right&quot;, va=&quot;bottom&quot;,<br>                fontsize=10, color=&quot;#888888&quot;,<br>                zorder=5)<br>        ax.text(PITCH_X_MAX - 1, 79, WATERMARK_SUBTITLE,<br>                ha=&quot;right&quot;, va=&quot;bottom&quot;,<br>                fontsize=9, color=&quot;#aaaaaa&quot;,<br>                zorder=5)</pre><p>Edge width and opacity are both scaled relative to the maximum pass count in the network, so the most frequent passing relationship always renders at full weight (lw=6, alpha=0.9) and all others scale proportionally. This makes dominant connections immediately legible without losing the context of less frequent ones. Nodes sit above edges (zorder=3), and player labels sit above nodes (zorder=4) with a semi-transparent white background to stay readable even when overlapping edge lines.</p><p>The watermark flag controls whether the author credit is rendered inside the pitch — used for single and two-panel plots. For grid layouts it is placed at figure level instead (covered in the next step).</p><h3>Step 9: Responsive Grid Layout</h3><p>When displaying multiple networks at once, the layout needs to adapt to the number of plots. _make_grid handles this with three tiers:</p><pre>def _make_grid(n_items: int, n_cols: int):<br>    &quot;&quot;&quot;Return (fig, axes_list, node_size, label_size, title_size)<br>    for a responsive grid.&quot;&quot;&quot;<br><br>    if n_items == 1:<br>        fig, ax = plt.subplots(1, 1, figsize=(10, 6))<br>        return fig, [ax], 400, 10, 16<br><br>    if n_items == 2:<br>        fig, axes = plt.subplots(1, 2, figsize=(14, 5))<br>        return fig, list(axes), 320, 13, 14<br><br>    n_rows = int(np.ceil(n_items / n_cols))<br><br>    fig, axes = plt.subplots(n_rows, n_cols, figsize=(18, 4 * n_rows))<br><br>    return fig, list(axes.flatten()), 240, 8, 12</pre><p>A single network gets a larger figure with bigger nodes and labels. Two networks side by side get a medium size. Three or more switch to a compact grid with smaller elements to keep everything legible at scale. The same function produces well-proportioned output whether it is rendering a single season network or all 18 Bundesliga teams on one page.</p><p>_draw_grid loops over the results, calls _draw_network for each panel, hides unused axes, and places the watermark at figure level for grid layouts:</p><pre>def _draw_grid(results: list, main_title: str, n_cols: int = 4) -&gt; None:<br>    &quot;&quot;&quot;Render a list of (subtitle, node_pos, edges) tuples in a responsive grid.&quot;&quot;&quot;<br><br>    if not results:<br>        print(&quot;No valid results.&quot;)<br>        return<br><br>    fig, axes, node_size, label_size, title_size = _make_grid(len(results), n_cols)<br><br>    pitch = Pitch(pitch_type=&quot;statsbomb&quot;, line_color=&quot;black&quot;, linewidth=2)<br><br>    inside_watermark = len(results) &lt;= 2<br><br>    for i, (subtitle, node_pos, edges) in enumerate(results):<br>        _draw_network(axes[i], pitch, node_pos, edges, node_size, label_size,<br>                      watermark=inside_watermark)<br>        if subtitle:<br>            axes[i].set_title(<br>                subtitle, fontsize=title_size,<br>                fontweight=&quot;bold&quot;, pad=0.95)<br><br>    for j in range(i + 1, len(axes)):<br>        axes[j].axis(&quot;off&quot;)<br><br>    fig.suptitle(main_title, fontsize=18, fontweight=&quot;bold&quot;, y=0.95)<br><br>    plt.subplots_adjust(left=0.03, right=0.97, top=0.93,<br>                        bottom=0.03, wspace=0.02, hspace=0.1)<br><br>    if not inside_watermark:<br>        fig.text(0.94, 0.045, WATERMARK_NAME,<br>                 ha=&quot;right&quot;, fontsize=11, color=&quot;#888888&quot;)<br>        fig.text(0.94, 0.040, WATERMARK_SUBTITLE,<br>                 ha=&quot;right&quot;, fontsize=9.5, color=&quot;#aaaaaa&quot;)<br><br>    plt.show()</pre><h3>Step 10: Putting It All Together</h3><p>All of the above is exposed through a single entry point. plot_network inspects the arguments provided and routes to the right internal function.</p><pre>def plot_network(<br>    passes: pd.DataFrame,<br>    team: str = None,<br>    season: str = None,<br>    match_id: int = None,<br>    match_level: bool = False,<br>    n_cols: int = 4<br>) -&gt; None:<br>    &quot;&quot;&quot;<br>    Universal entry point for all passing network plots.<br>    Parameters<br>    ----------<br>    passes      : Cleaned passes DataFrame (output of load_passes).<br>    team        : Team name. None = all teams (season view).<br>    season      : Season string e.g. &quot;2015/2016&quot;.<br>    match_id    : If provided (and match_level=False), plots both teams <br>                  for that match.<br>    match_level : If True with a team, plots every match for that team <br>                  in a grid.<br>    n_cols      : Number of columns for grid layouts.<br>    &quot;&quot;&quot;<br><br>    # Single match - both teams side by side<br>    if match_id is not None and not match_level:<br>        _plot_single_match(match_id, passes)<br>        return<br><br>    # All matches for one team<br>    if match_level and team is not None:<br>        _plot_team_matches(passes, team, season, n_cols)<br>        return<br><br>    # Season view - one team or all teams<br>    teams = [team] if team else sorted(<br>        passes[passes[&quot;season&quot;] == season][&quot;team&quot;].dropna().unique()<br>    )<br>    results = []<br>    for t in teams:<br>        res = build_season_network(passes, t, season)<br>        if res is not None:<br>            subtitle = &quot;&quot; if team else t<br>            results.append((subtitle, *res))<br>    title = (f&quot;{team} Passing Network - {season}&quot;<br>             if team else f&quot;Passing Networks by Team - {season}&quot;)<br>    _draw_grid(results, title, n_cols)</pre><p>For the single-match view, _plot_single_match mirrors the away team&#39;s coordinates so both sides are shown attacking left-to-right — making side-by-side comparison intuitive without any manual adjustment:</p><pre>def _plot_single_match(match_id: int, passes: pd.DataFrame) -&gt; None:<br>    &quot;&quot;&quot;Plot passing networks for both teams in a single match.&quot;&quot;&quot;<br>    row = passes[passes[&quot;match_id&quot;] == match_id].iloc[0]<br>    home, away  = row[&quot;home_team&quot;], row[&quot;away_team&quot;]<br>    score       = f&quot;{row[&#39;home_score&#39;]}–{row[&#39;away_score&#39;]}&quot;<br>    date        = pd.to_datetime(row[&quot;match_date&quot;]).strftime(&quot;%d %b %Y&quot;)<br>    comp        = str(row.get(&quot;competition&quot;, &quot;&quot;)).split(&quot;. &quot;, 1)[-1]<br>    season      = row.get(&quot;season&quot;, &quot;&quot;)<br> <br>    fig, axes = plt.subplots(1, 2, figsize=(14, 6))<br>    pitch = Pitch(pitch_type=&quot;statsbomb&quot;, line_color=&quot;black&quot;, linewidth=2)<br> <br>    for ax, team in zip(axes, [home, away]):<br>        res = build_match_network(match_id, team, passes)<br>        if res is None:<br>            ax.axis(&quot;off&quot;)<br>            continue<br> <br>        node_pos, match_df, _ = res<br>        edges = build_edges(match_df, node_pos)<br> <br>        # Mirror the away team so both attack left-to-right<br>        if team == away:<br>            node_pos = node_pos.copy()<br>            edges    = edges.copy()<br>            node_pos[&quot;x&quot;] = PITCH_X_MAX - node_pos[&quot;x&quot;]<br>            node_pos[&quot;y&quot;] = PITCH_Y_MAX - node_pos[&quot;y&quot;]<br>            for col in (&quot;x_start&quot;, &quot;x_end&quot;):<br>                edges[col] = PITCH_X_MAX - edges[col]<br>            for col in (&quot;y_start&quot;, &quot;y_end&quot;):<br>                edges[col] = PITCH_Y_MAX - edges[col]<br> <br>        _draw_network(ax, pitch, node_pos, edges, node_size=300, label_size=8,<br>                      watermark=True)<br>        ax.set_title(team, fontsize=13, fontweight=&quot;bold&quot;)<br> <br>    fig.suptitle(f&quot;{home} {score} {away}&quot;, fontsize=16, fontweight=&quot;bold&quot;, y=0.98)<br><br>    plt.figtext(0.5, 0.915, f&quot;{date}  •  {comp}  ({season})&quot;,<br>                ha=&quot;center&quot;, fontsize=12, color=&quot;gray&quot;)<br>    plt.tight_layout()<br>    plt.show()</pre><p>And _plot_team_matches handles the per-match grid for a single team, sorting chronologically and labelling each panel with the opponent, scoreline, and date:</p><pre>def _plot_team_matches(<br>        passes: pd.DataFrame, team: str,<br>        season: str, n_cols: int) -&gt; None:<br><br>    &quot;&quot;&quot;Plot a match-level grid for every game a team played in a season.&quot;&quot;&quot;<br><br>    df = passes[(passes[&quot;team&quot;] == team) &amp; (passes[&quot;season&quot;] == season)]<br><br>    match_ids = df.sort_values(&quot;match_date&quot;)[&quot;match_id&quot;].dropna().unique()<br><br>    results = []<br><br>    for m in match_ids:<br>        res = build_match_network(m, team, passes)<br>        if res is None:<br>            continue<br>        node_pos, match_df, _ = res<br>        edges = build_edges(match_df, node_pos)<br>        row      = passes[passes[&quot;match_id&quot;] == m].iloc[0]<br>        opponent = row[&quot;away_team&quot;] if row[&quot;home_team&quot;] == team else row[&quot;home_team&quot;]<br>        score    = f&quot;{row[&#39;home_score&#39;]}–{row[&#39;away_score&#39;]}&quot;<br>        date     = pd.to_datetime(row[&quot;match_date&quot;]).strftime(&quot;%d %b %Y&quot;)<br>        results.append((f&quot;{opponent} ({score})\n{date}&quot;, node_pos, edges))<br><br>    row  = passes[passes[&quot;team&quot;] == team].iloc[0]<br><br>    comp = str(row.get(&quot;competition&quot;, &quot;&quot;)).split(&quot;. &quot;, 1)[-1]<br><br>    _draw_grid(results, f&quot;{team} - Match Networks  ({comp}  {season})&quot;, n_cols)</pre><h3>Results: What the Plots Look Like</h3><p>With everything in place, the full workflow starts from one line:</p><pre>passes = load_passes(&quot;Datasets/passes_bundesliga_201516_statsbomb_all.csv&quot;)</pre><p>And then any plot is a single call.</p><h4><strong>Der Klassiker — Borussia Dortmund 0–0 Bayern Munich (05 Mar 2016)</strong></h4><pre>plot_network(passes, match_id=3890481)</pre><figure><img alt="Passing networks for both teams in the 0–0 draw at Signal-Iduna-Park (05 Mar 2016)" src="https://cdn-images-1.medium.com/max/1024/1*aietZkm-0EZudto0ffKg_g.png" /><figcaption>Passing networks for both teams in the 0–0 draw at Signal-Iduna-Park (05 Mar 2016)</figcaption></figure><p>The <a href="https://bleacherreport.com/articles/2622147-borussia-dortmund-vs-bayern-munich-score-reaction-from-2016-bundesliga-match">Bleacher Report</a> describes it as a highly entertaining goalless draw, with Bayern creating the better chances but wasting them, and Aubameyang twice denied by Neuer. Within each team’s network, the relative structure is what stands out: Xabi Alonso is the dominant hub for Bayern, with Neuer visibly connected to the back line. Aubameyang barely features in Dortmund’s network despite his chances on the day — consistent with a striker who relied on runs in behind rather than combining with teammates in possession.</p><h4><strong>Der Klassiker — Bayern Munich 5–1 Borussia Dortmund (04 Oct 2015)</strong></h4><pre>plot_network(passes, match_id=3890329)</pre><figure><img alt="Passing networks for both teams in the 5–1 Allianz Arena fixture (04 Oct 2015)" src="https://cdn-images-1.medium.com/max/1024/1*7TgQva3v-jJ62iY1WmaU7g.png" /><figcaption>Passing networks for both teams in the 5–1 Allianz Arena fixture (04 Oct 2015)</figcaption></figure><p>Müller scored twice before half-time and Lewandowski added two more after the break, with <a href="https://www.espn.com/soccer/report?gameId=427055">ESPN</a> describing Bayern as stamping their authority on the Bundesliga. Their network reflects a team playing with shape and control — Xabi Alonso sits centrally with connections spreading across the pitch, the back line is tightly linked, and the structure is well-distributed from goalkeeper to attack. Dortmund’s network looks more compressed: connections cluster in the defensive and midfield third, and Aubameyang is isolated at the top with relatively little flowing through the forward line — consistent with a team that spent much of the match without the ball.</p><h4><strong>Bayern Munich — Season Network (2015/16)</strong></h4><pre>plot_network(passes, team=&quot;Bayern Munich&quot;, season=&quot;2015/2016&quot;)</pre><figure><img alt="Bayern Munich’s aggregated passing network across the 2015/16 Bundesliga season. Node positions reflect each player’s average role over the season; edge thickness reflects cumulative pass volume between them." src="https://cdn-images-1.medium.com/max/622/1*W1j7yP6hN4jaaeiuazKO2Q.png" /><figcaption>Bayern Munich’s aggregated passing network across the 2015/16 Bundesliga season</figcaption></figure><p>At season level, what stands out is how well-distributed the shape is across the pitch. The outfield players span both halves, with the defensive and midfield block sitting around the centre line and Lewandowski, Douglas Costa, Müller, and Coman pushed high into the opposition half. This isn’t a team sitting deep and recycling — the average positions reflect a side that controlled games high up the pitch. Neuer sits far to the back and relatively isolated, with only thin connections to the back line.</p><p>The thickest edges run through the central cluster of Xabi Alonso, Thiago Alcántara, Kimmich, and Alaba — these four form the clear spine of the network, connected to almost every other player. Xabi Alonso and Thiago Alcántara, in particular, sit at the heart of everything, consistent with Guardiola’s double pivot distributing possession in all directions.</p><h4><strong>Bayern Munich — All Matches (2015/16)</strong></h4><pre>plot_network(passes, team=&quot;Bayern Munich&quot;, season=&quot;2015/2016&quot;,<br>             match_level=True, n_cols=3)</pre><figure><img alt="Match-by-match passing networks for Bayern Munich across all 34 Bundesliga fixtures, sorted chronologically." src="https://cdn-images-1.medium.com/max/1024/1*5quFjk9QSAUXjSPKWVU1Tw.png" /><figcaption>Match-by-match passing networks for Bayern Munich across all 34 Bundesliga fixtures, sorted chronologically.</figcaption></figure><p>Laying out all 34 matches side by side makes it easy to spot games where the structure looks noticeably different — a narrower shape, a missing player’s usual position, or an unusually direct set of connections. These often correspond to tactical adjustments, notable absences, or matches where the scoreline forced a change in approach.</p><h4><strong>All 18 Bundesliga Teams — Season Networks (2015/16)</strong></h4><pre>plot_network(passes, season=&quot;2015/2016&quot;, n_cols=3)</pre><figure><img alt="Season-level passing networks for all 18 1. Bundesliga teams in 2015/16" src="https://cdn-images-1.medium.com/max/1024/1*e9jpQ-n8uSKRKT-qYBf9CQ.png" /><figcaption>Season-level passing networks for all 18 1. Bundesliga teams in 2015/16</figcaption></figure><p>A few things stand out when scanning across the 18 teams. Hannover 96 show one of the most centralised passing structures — most edges flow through one or two dominant central nodes. Schalke are a contrast — the edges are more evenly spread across players, with no single dominant hub, suggesting a more balanced distribution of passing responsibility across the team. Darmstadt’s thick edges between the goalkeeper Mathenia and the forwards hint at a more direct style. Borussia Dortmund’s Roman Bürki is the opposite — isolated with thin connections.</p><h3>What These Networks Reveal</h3><p>A passing network is fundamentally a graph — nodes are players at their average positions, edges are passing relationships weighted by frequency. When rendered on a pitch, the spatial layout reflects where each player typically operated, and edge thickness shows which passing combinations were most frequent relative to the team’s own maximum.</p><p>What you can read directly from the network: which players were central to the passing structure, how evenly passing responsibility was distributed, how high or deep the team’s shape sat on average, and whether the goalkeeper was actively involved in build-up. What you cannot read directly: absolute pass volumes, whether a style was intentional, or how the team performed.</p><p>At season level, individual match variation smooths out and a more stable picture of team structure emerges. Player positions reflect average territorial roles across all games; edge weights reflect cumulative passing tendencies. This makes it easier to distinguish habitual patterns from one-off tactical adjustments — and to compare structural identities across an entire league in a single view.</p><h3>Final Thoughts</h3><p>One thing I find interesting about passing networks is how much of the work is invisible in the final image. The visualisation looks simple — dots and lines on a pitch. But getting those dots and lines to mean something across 34 matches, with different line-ups every game, requires thinking carefully about alignment, slot assignment, and what you’re actually aggregating.</p><p>The networks here are descriptive. They don’t tell you who played better, which partnerships were effective, or why a shape looked the way it did. <strong>What they do is make structure visible in a way that raw data never quite manages — and that’s a useful starting point for deeper questions.</strong></p><p><em>Want to explore more football analytics in Python? Check out some of my other articles:</em></p><ul><li><a href="https://medium.com/@vickyfrissdekereki/10-steps-to-build-football-formation-visualisations-in-python-using-statsbomb-data-0e082e099db9">10 Steps to Build Football Formation Visualisations in Python Using StatsBomb Data</a></li><li><a href="https://medium.com/@vickyfrissdekereki/10-steps-to-build-football-pass-clustering-visualisations-in-python-f7eda820bb6f">10 Steps to Build Football Pass Clustering Visualisations in Python</a></li><li><a href="https://medium.com/@vickyfrissdekereki/building-football-pass-maps-in-python-a-step-by-step-guide-c843656111ed">Building Football Pass Maps in Python: A Step-by-Step Guide</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1c2239c52800" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[FC Barcelona Tactical Evolution (2004–2021) — Part 3: Centralisation Analysis]]></title>
            <link>https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-3-centralisation-analysis-fee3837f610b?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/fee3837f610b</guid>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[sports-analytics]]></category>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[network-analysis]]></category>
            <category><![CDATA[soccer-analytics]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Thu, 14 May 2026 09:01:00 GMT</pubDate>
            <atom:updated>2026-05-14T12:21:24.366Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A data-driven exploration of how control of possession evolved across Barcelona’s La Liga seasons</em></h4><figure><img alt="Cover image for FC Barcelona Tactical Evolution (2004–2021) — Part 3: Centralisation Analysis showing the 2015/16 passing network with Xavi" src="https://cdn-images-1.medium.com/max/1024/1*MByZI73gK1aARjaniS4Jdw.png" /></figure><p>In <a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-2-passing-networks-9a000efd71d5">Part 2</a>, we used passing networks to understand how possession flowed through <strong>FC Barcelona</strong>. We saw that while the team’s positional structure remained remarkably stable, the <em>distribution of involvement</em> within that structure changed over time. But visual analysis has limits.</p><p>Passing networks make patterns visible — they show who is more connected, who acts as a hub, and how possession is organised. However, they do not tell us <em>how much</em> more central one player is compared to the rest, or how that balance shifts across seasons in a measurable way.</p><p>To move beyond interpretation, this article introduces a formal measure of network centralisation, allowing us to track how control of possession is distributed — and how it evolves over time.</p><p>🔗 <strong>Python code &amp; notebook:</strong> available on <a href="https://github.com/vickyfriss/Football-analytics/blob/main/202604%20-%203%20-%20Analysing%20Barcelona%20Evolution%20Through%20Pass%20Clustering%20Visualisations.ipynb">my GitHub</a><strong>.</strong></p><h3>Measuring centralisation</h3><p>Centralisation captures how evenly passing involvement is distributed across players.</p><ul><li>When possession is shared evenly → <strong>low centralisation</strong></li><li>When a few players dominate → <strong>high centralisation</strong></li></ul><p>The approach used here follows the framework popularised in football analytics by David Sumpter in <a href="https://www.amazon.com/Soccermatics-Mathematical-Adventures-Pro-Bloomsbury/dp/1472924142"><em>Soccermatics</em></a>, building on <a href="https://www.sciencedirect.com/science/article/abs/pii/S0378873312000500">earlier work by Thomas Grund</a> and the broader literature on social network analysis.</p><p>The idea is to compare each player’s involvement in passing to that of the most involved player in the team.</p><p>For each player, we compute the difference between passes they received and those of the most involved player. These differences are then summed across all players and normalised by the total number of passes in the team.</p><p>This yields the centralisation measure:</p><figure><img alt="Centralisation formula" src="https://cdn-images-1.medium.com/max/456/1*ew5A5T2oEzl2BR0yNcZ6lg.png" /><figcaption>Centralisation formula</figcaption></figure><p>where Pᵢ corresponds to each player, and<em>P</em>ₘₐₓ​=max(<em>P</em>ᵢ) is the highest individual involvement (player with the maximum number of passes received).</p><h3>Interpreting the metric</h3><p>The centralisation index ranges between 0 and 1:</p><ul><li><strong>0 → fully distributed network</strong></li><li><strong>1 → highly centralised network</strong></li></ul><p>In practical terms, lower values indicate a more even distribution of passing involvement across players, while higher values indicate concentration around a small number of key individuals.</p><p>At the extremes, the interpretation becomes clearer. A value of 0 would correspond to a perfectly balanced system in which every player receives exactly the same number of passes per 90. While this is theoretically possible, it is highly unlikely in real matches given positional roles and tactical structure.</p><p>At the other end, a value of 1 would represent a fully centralised network in which a single player receives all passes, and all other players receive none. This scenario is practically impossible in football, as it would imply that possession is entirely channelled through one individual without any redistribution.</p><p>Because the metric is normalised by total passes, it is directly comparable across seasons, allowing changes in control to be tracked over time regardless of differences in match volume or tempo. The measure is based exclusively on completed passes, ensuring that it reflects how possession is actually distributed on the pitch, rather than merely the volume of passing attempts.</p><p>Centralisation should not be interpreted purely as a descriptive statistic. Thomas Grund shows that more evenly distributed passing structures are often associated with stronger team performance, while David Sumpter highlights that decentralised teams tend to be less predictable and harder to defend against.</p><p>However, the relationship is not deterministic. Even relatively small changes in centralisation can reflect meaningful tactical differences in how teams organise control and responsibility.</p><h3>Worked example: centralisation in a single match</h3><p>To make the metric more concrete, it is useful to examine how centralisation is computed in a single match, using Barcelona vs Getafe (12 March 2016, 6–0) as a worked example.</p><figure><img alt="Player passing involvement (Barcelona vs Getafe, 12 March 2016, 6–0)" src="https://cdn-images-1.medium.com/max/600/1*EUeATWG1W7NNa3mTGdy6Ng.png" /><figcaption>Player passing involvement (Barcelona vs Getafe, 12 March 2016, 6–0)</figcaption></figure><p>In the last column of the table above, each player’s involvement is expressed on a per-90 basis to account for substitutions and unequal playing time. This ensures comparability across players. For example, Andrés Iniesta records 61 receptions in around 60 minutes, which translates into a higher per-90 involvement than several full-match players.</p><p>We define <em>Pᵢ​</em> as passes received per 90, and identify the most involved player in the team. In this match, Neymar, with 100 passes per 90, represents the peak of the distribution, so Pₘₐₓ = 100.</p><p>The next step is comparing all players to this reference point. The differences Pₘₐₓ - <em>P</em>ᵢ​ capture how far each player sits from the main hub of the network.</p><figure><img alt="Contribution to centralisation (distance from maximum involvement)" src="https://cdn-images-1.medium.com/max/832/1*GDzTzd1laTwSOtIDJOLP6g.png" /><figcaption>Contribution to centralisation (distance from maximum involvement)</figcaption></figure><p>Summing these differences across the team gives: ∑(Pₘₐₓ - <em>P</em>ᵢ​) = 425.54. This total is then normalised by both the number of players and the overall level of involvement, as shown in the formula presented in the previous section. With N = 13 and ∑Pᵢ​ = 874.46, this yields C = 0.0406.</p><p>This relatively low value indicates that, although Neymar is the most involved player, the overall distribution of passing remains fairly balanced. The gaps between players are not large enough to produce a strongly centralised structure.</p><p>With this example in place, the interpretation of centralisation at season level becomes clearer.</p><h3>Methodological note: constructing the centralisation measure</h3><p>The worked example above illustrates how centralisation is computed within a single match. A few additional methodological choices are important for interpreting the results across matches and seasons.</p><p>The metric is calculated at match level using completed passes, with each player’s involvement expressed on a per-90 basis. This ensures that differences in playing time — due to substitutions or rotation — do not distort the distribution of control.</p><p>To maintain a consistent network structure, only players exceeding a minimum playing time threshold are included. This avoids brief appearances artificially inflating or compressing centralisation values.</p><p>Goalkeepers are excluded from the calculation, as their passing role is structurally different from that of outfield players and would otherwise dampen variation in the network.</p><p>Centralisation is computed separately for each match and then aggregated at season level. This ensures that the final measure reflects consistent patterns of possession distribution, rather than being driven by individual matches or squad selection effects.</p><h3>Centralisation over time</h3><p>Before examining how Barcelona evolves, it is important to first understand what the centralisation metric looks like in a real competitive context. Without this reference, the values are difficult to interpret in isolation. To anchor the measure, we begin by looking at a full La Liga 2015/16 season.</p><figure><img alt="Centralisation across all La Liga teams (2015/16 season)" src="https://cdn-images-1.medium.com/max/652/1*DSheaa0RA178N0KpRMO6rQ.png" /><figcaption>Centralisation across all La Liga teams (2015/16 season)</figcaption></figure><p>This league-wide distribution shows a clear spread in how teams organise possession. Centralisation values vary meaningfully across clubs: some teams operate around more distributed passing structures, while others concentrate involvement more strongly within a smaller number of players.</p><p>Importantly, Barcelona sits on the lower end of this distribution, with an average centralisation of approximately <strong>0.0418</strong> that season, compared to league values that typically range closer to <strong>0.05–0.07</strong>. This places them among the more evenly distributed possession systems in the league, even though all teams still operate within a relatively constrained tactical range.</p><p>This context is essential: centralisation is not an absolute scale of “good” or “bad”, but a relative measure of how control is organised within a team’s passing structure.</p><p>With this reference in place, we can now interpret Barcelona’s evolution over time.</p><h3>Barcelona centralisation index by season (2004–2021)</h3><p>The table below shows Barcelona’s centralisation index across seasons, allowing changes in the distribution of control to be tracked over time.</p><figure><img alt="Barcelona centralisation index by season (2004–2021)" src="https://cdn-images-1.medium.com/max/657/1*KSfH9DshfHbw62eJUY96Vg.png" /><figcaption>Barcelona centralisation index by season (2004–2021)</figcaption></figure><p>Rather than following a single linear trend, Barcelona’s centralisation evolves through a series of gradual adjustments, reflecting changes in how control is distributed within a largely stable structure.</p><p>In the early seasons (2004–2008), centralisation remains moderate, averaging between 0.045 and 0.055. Passing involvement is relatively well distributed, with no single dominant hub consistently emerging. Responsibility is shared across multiple players, including figures such as Deco and Ronaldinho, rather than being channelled through a fixed organising point.</p><p>From 2008 to 2012, centralisation increases and stabilises at a higher level, reaching 0.0612 at its peak. This represents the most concentrated phase in terms of control distribution. During this period, <strong>Xavi</strong> becomes the clear organisational hub of the team, consistently acting as the primary reference point in possession. The structure remains stable, but influence becomes more clearly anchored around his role in midfield circulation.</p><p>From 2013 onwards, this concentration begins to ease. Centralisation declines, reaching 0.0444 in 2014/15 and 0.0418 in 2015/16 — among the lowest values observed across the full period. This shift reflects a redistribution of control, with influence spreading more evenly across players such as <strong>Iniesta</strong> and <strong>Neymar</strong>, rather than being dominated by a single midfield hub.</p><p>In the later seasons (2016–2021), centralisation stabilises within a relatively narrow range. The system settles into a consistent pattern: possession remains structured, but not heavily concentrated. During this phase, the role of the hub becomes more fluid. <strong>Messi</strong> increasingly acts as a focal point in advanced areas, while players such as <strong>Busquets</strong> organise deeper circulation. Rather than a single fixed centre, control is shared across clearly defined roles.</p><p>When viewed alongside the passing networks presented in <a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-2-passing-networks-9a000efd71d5">Part 2</a>, this evolution becomes more coherent. The overall positional structure remains remarkably stable across seasons, but the identity of the main hub — and how strongly influence is concentrated around it — changes over time. Centralisation captures this shift quantitatively: not a transformation of shape, but a redistribution of influence within it.</p><h3>Linking structure and control</h3><p>Across the three parts of this analysis, we build a layered understanding of the same system:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-1-pass-clustering-analysis-e753df7b29b3"><strong>Part 1 (Clustering)</strong></a><strong>:</strong> how passing patterns are structured spatially</li><li><a href="https://medium.com/@vickyfrissdekereki/9a000efd71d5"><strong>Part 2 (Networks)</strong></a><strong>:</strong> how players are connected within those patterns</li><li><strong>Part 3 (Centralisation):</strong> how control is distributed across those connections</li></ul><p>Each layer moves the analysis from spatial organisation, to relational structure, to concentration of influence. Taken together, these perspectives show that structure and control are not the same thing. A team can maintain a consistent positional framework while significantly altering how responsibility is distributed within it.</p><h3>Final insight: control within structure</h3><p>Across this period, Barcelona do not radically change how they are positioned on the pitch. What changes is how control is distributed within that structure.</p><p>At times, possession is more evenly shared across the team. At others, it is more clearly organised around a dominant hub — whether that is Xavi in midfield, or later Messi and Busquets in different phases of play.</p><p>These shifts are subtle, but measurable. Centralisation shows that even within a stable positional system, the balance of influence can move significantly.</p><p>When combined with the passing networks from <a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-2-passing-networks-9a000efd71d5">Part 2</a>, a clearer picture emerges: Barcelona’s evolution is not about structural reinvention, but about reassigning responsibility within the same framework.</p><p>In that sense, tactical identity is not replaced over time — it is continuously redistributed.</p><p><strong>Enjoyed this analysis?</strong> Check out my other data-driven football analysis articles:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-1-pass-clustering-analysis-e753df7b29b3">FC Barcelona Tactical Evolution (2004–2021) — Part 1: Pass Clustering Analysis</a></li><li><a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-2-passing-networks-9a000efd71d5">FC Barcelona Tactical Evolution (2004–2021) — Part 2: Passing Networks</a></li><li><a href="https://medium.com/@vickyfrissdekereki/10-steps-to-build-football-formation-visualisations-in-python-using-statsbomb-data-0e082e099db9">10 Steps to Build Football Formation Visualisations in Python Using StatsBomb Data</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fee3837f610b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[FC Barcelona Tactical Evolution (2004–2021) — Part 2: Passing Networks]]></title>
            <link>https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-2-passing-networks-9a000efd71d5?source=rss-afa41f9f1213------2</link>
            <guid isPermaLink="false">https://medium.com/p/9a000efd71d5</guid>
            <category><![CDATA[data-visualization]]></category>
            <category><![CDATA[soccer-analytics]]></category>
            <category><![CDATA[football-analytics]]></category>
            <category><![CDATA[network-analysis]]></category>
            <category><![CDATA[soccer]]></category>
            <dc:creator><![CDATA[Victoria Friss de Kereki]]></dc:creator>
            <pubDate>Mon, 11 May 2026 09:01:00 GMT</pubDate>
            <atom:updated>2026-05-15T17:10:30.593Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A data-driven exploration of how player roles and possession structure evolved across Barcelona’s La Liga seasons</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8bHVm7EIrD9QySKdRpuLHw.png" /></figure><p>In <a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-1-pass-clustering-analysis-e753df7b29b3">Part 1</a>, we explored how <strong>FC Barcelona</strong>’s passing structure evolved through pass clustering, revealing how spatial patterns changed across seasons.</p><p>But structure alone does not tell the full story.</p><p>Understanding how a team plays also requires analysing how control is distributed within that structure — how possession is allocated across players, and how that distribution evolves over time.</p><p><strong>Passing networks</strong> provide this perspective. If clustering explains <em>what</em> happens on the pitch, networks explain <em>who makes it happen</em>.</p><p>🔗 <strong>Python code &amp; notebook:</strong> available on <a href="https://github.com/vickyfriss/Football-analytics/blob/main/202604%20-%203%20-%20Analysing%20Barcelona%20Evolution%20Through%20Pass%20Clustering%20Visualisations.ipynb">my GitHub</a><strong>.</strong></p><h3>From structure to control</h3><p>A passing network represents players as nodes and passes as connections between them. Instead of focusing on the types of passes, it captures how possession flows through the team.</p><p>This shift reframes the analysis. Rather than describing how Barcelona use space, we begin to understand how the team organises control within that space.</p><p>Across seasons, this reveals whether possession is shared collectively or channelled through specific players — and how that balance changes over time.</p><h3>How to read a passing network</h3><p>We begin with a single example to establish how passing networks should be interpreted.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/798/1*o5-aEqXtfzpwgN-ufRxyhw.png" /><figcaption>Barcelona 2005/06 passing network</figcaption></figure><p>In this representation:</p><ul><li>Each node represents a <strong>role within the team</strong>, not a fixed player</li><li>Node location reflects the <strong>average position of that role</strong></li><li>Edges represent completed passes between roles</li><li>Thicker and more opaque links indicate more frequent exchanges</li></ul><p>Rather than fixing players to positions, roles emerge from behaviour: players are grouped based on where they operate on the pitch. The network is not a formation diagram — it is a behavioural representation of how roles emerge and interact.</p><p>Substitutions are naturally incorporated into this framework. Players who occupy the same role — whether across different matches or within the same match — are mapped to the same node. This allows the network to reflect role continuity rather than individual presence.</p><p>In the 2005/06 example above, Ronaldinho (left), Samuel Eto’o (centre), and Lionel Messi (right) appear almost aligned across a similar line, reflecting how the attacking unit functions collectively rather than as isolated roles.</p><p>This provides a snapshot of how possession is organised: which roles connect play, how the ball circulates, and how involvement is distributed.</p><h3>Barcelona passing networks across seasons</h3><p>We can now extend this analysis across the full timeline.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mmdjkweY_A1TehkTNd3kCA.png" /><figcaption>Barcelona passing networks across seasons</figcaption></figure><p>Across seasons, the overall structure of Barcelona’s passing networks remains remarkably consistent. A clear defensive base is always visible, typically built around two centre-backs, with Carles Puyol frequently anchoring the back line in the earlier years.</p><p>While this structure is stable, subtle variations emerge, for example:</p><ul><li>In some seasons (e.g. 2011/12), the defensive line appears more compact</li><li>In others (e.g. 2015/16), it becomes more stretched</li></ul><p>These differences reflect small but meaningful changes in build-up shape.</p><h3>Central role in possession</h3><p>Beyond the defensive line, a clear central hub consistently appears. From around 2006/07 to 2013/14, this role is largely occupied by Xavi Hernández, who acts as the primary connector between phases of play, as seen in the 2010/11 passing network presented below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/710/1*BdeAFxpyKLuvhGc6gtQs5g.png" /><figcaption>Barcelona 2010/11 passing network</figcaption></figure><p>In this example, Xavi sits at the centre of the network, deeply connected to those around him — far more than any other player. His links extend across the entire structure: combining constantly with Sergio Busquets and Andrés Iniesta in midfield, connecting with Lionel Messi in advanced areas, and linking wide with Dani Alves. His influence even reaches further back into the defensive line, with regular connections to players such as Eric Abidal and Gerard Piqué.</p><p>This pattern reflects his role as the primary organiser of possession — constantly receiving, recycling, and redistributing the ball to maintain control and tempo. In later seasons, this responsibility shifts more frequently towards Andrés Iniesta.</p><p>This highlights a key idea: the structure remains stable, but <strong>who controls it changes over time</strong>.</p><h3>Attack structured around Messi</h3><p>In attacking areas, the network becomes increasingly shaped around Lionel Messi. Advanced connections consistently converge towards him, possession is funnelled into his zones, and he acts as the main reference point in attack.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*kb7uHEaMKQvtfkiPTj-Krg.png" /><figcaption>Barcelona 2011/12 passing network</figcaption></figure><p>In this example, Messi occupies a central position within the attacking structure, with a dense web of connections linking him closely to midfield and to the other forward roles. Passes into the final third are frequently routed through him, and combinations in advanced areas tend to revolve around his involvement.</p><p>Unlike a traditional forward, Messi is not just an end point but a connector. He drops deeper to combine with midfield — particularly Xavi and Andrés Iniesta — while Dani Alves provides width and forward support. From these deeper positions, Messi progresses play and reappears in attacking zones, often receiving between the lines and accelerating the attack.</p><p>The result is an attacking structure organised not simply around space, but around a player capable of both orchestrating and finishing actions. In effect, Barcelona play through Messi.</p><h3>From centralised attack to shared responsibility</h3><p>This attacking dynamic began to evolve with the arrivals of Neymar in 2013 and Luis Suárez in 2014.</p><p>With multiple elite attacking profiles, the structure in the final third becomes more distributed. Possession is no longer channelled as consistently through a single focal point, and attacking responsibility is shared more evenly across the forward line. The network reflects this shift, with connections spreading more broadly between roles rather than converging predominantly towards one player.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*y-3Uv9D7ZfmvyGzySNaNfA.png" /><figcaption>Barcelona 2014/15 passing network</figcaption></figure><p>In this example, the attacking network is less concentrated around a single node. Connections in advanced areas are shared more evenly between roles, reflecting a front line in which multiple players contribute to progression and chance creation.</p><p>However, Lionel Messi remains central to the system. Even within a more balanced attacking structure, he continues to act as a key reference point in advanced areas — linking play, receiving in dangerous spaces, and shaping the final phase of possession.</p><p>The result is not a complete departure from earlier patterns, but a rebalancing: from a highly centralised attacking structure to one that distributes responsibility more widely, while still orbiting around Messi’s influence.</p><h3>What passing networks reveal</h3><p>Across seasons, Barcelona’s passing networks highlight two consistent themes: a stable positional structure and a changing distribution of involvement.</p><p>While the overall shape of the team remains largely consistent, the way possession is organised within that structure evolves. Certain players become more central to circulation, shaping the rhythm, direction, and progression of play. Control is not fixed — it shifts depending on personnel and tactical context.</p><p>Passing networks make this visible. They move the analysis beyond where the team plays, towards understanding who drives how it plays.</p><p>This leads to a natural next step: can this distribution of control be measured objectively? In <a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-3-centralisation-analysis-fee3837f610b"><strong>Part 3</strong></a>, we build on this by moving from visual interpretation to quantitative analysis, introducing a formal measure of network centralisation.</p><p><strong>Enjoyed this analysis?</strong> Check out my other data-driven football analysis articles:</p><ul><li><a href="https://medium.com/@vickyfrissdekereki/fc-barcelona-tactical-evolution-2004-2021-part-1-pass-clustering-analysis-e753df7b29b3">FC Barcelona Tactical Evolution (2004–2021) — Part 1: Pass Clustering Analysis</a></li><li><a href="https://medium.com/@vickyfrissdekereki/10-steps-to-build-football-formation-visualisations-in-python-using-statsbomb-data-0e082e099db9">10 Steps to Build Football Formation Visualisations in Python Using StatsBomb Data</a></li><li><a href="https://medium.com/@vickyfrissdekereki/manchester-city-vs-arsenal-how-this-match-could-shift-premier-league-2025-26-title-odds-7a838e44c197">Manchester City vs Arsenal: How This Match Could Shift Premier League 2025/26 Title Odds</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9a000efd71d5" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>