<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://littlecaps.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://littlecaps.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-06-08T05:51:48+08:00</updated><id>https://littlecaps.github.io/feed.xml</id><title type="html">Touchline</title><subtitle>Football data analysis — expected goals models, match and tactical analysis, scouting metrics, and data visualization.
</subtitle><author><name>Huiyang Mao</name><email>hymao@pmo.ac.cn</email></author><entry><title type="html">Watching the Match Is the Best Way to Understand Football — But Data Closes the Loop</title><link href="https://littlecaps.github.io/2026/06/07/data-closes-the-loop-arsenal-attack-patterns.html" rel="alternate" type="text/html" title="Watching the Match Is the Best Way to Understand Football — But Data Closes the Loop" /><published>2026-06-07T22:30:00+08:00</published><updated>2026-06-07T22:30:00+08:00</updated><id>https://littlecaps.github.io/2026/06/07/data-closes-the-loop-arsenal-attack-patterns</id><content type="html" xml:base="https://littlecaps.github.io/2026/06/07/data-closes-the-loop-arsenal-attack-patterns.html"><![CDATA[<p>Watching match footage is still the most authentic way to understand how a
team plays and what a player can truly do. Nothing replaces sitting through
90 minutes to feel the rhythm, the spacing, the moments of brilliance and
panic.</p>

<p>But video is expensive. Time-expensive. Attention-expensive. You can’t scout
every team, every match, every season this way.</p>

<!--more-->

<p>That’s where data earns its place — not to replace the eye test, but to make
decisions efficient. And if someone tells you “data is useless, only the
match matters,” what they really mean is they haven’t found the right data to
reflect what the match is showing.</p>

<p>Recently, I tested this idea on Arsenal’s 2024–25 season.</p>

<p>Using only public match data from Sofascore — no Opta, no StatsBomb, just
freely available stats — I broke down Arteta’s open-play structure into three
distinct attacking patterns:</p>

<ul>
  <li><strong>Pattern A · Right-Side Overload (~55%, primary)</strong> — White inverts high,
Ødegaard occupies the right halfspace, Saka isolates the left-back. The
chain ends in a cross or cut-back to the centre forward.</li>
  <li><strong>Pattern B · Left Isolation / Underlap (~25%)</strong> — Martinelli pinned wide,
Calafiori underlaps, low cut-back to Merino.</li>
  <li><strong>Pattern C · Vertical Transition (~20%)</strong> — Rice and Raya spring play
vertically; Havertz drops to link, the wingers attack the channels.</li>
</ul>

<p>For each pattern I drew three views: a role-link map (the shape of the
pattern), a per-player focus grid (each role’s linkages in isolation), and a
role-execution grading pitch (how well each role actually performed its task,
with the chain’s bottleneck ringed in black).</p>

<h2 id="pattern-a--right-side-overload-55">Pattern A · Right-Side Overload (~55%)</h2>

<p><img src="/assets/images/posts/arsenal-attack-patterns/rolemap_pattern_a.png" alt="Pattern A — role-link map" /></p>

<p>The primary pattern. The heavy links live on the right: Saliba’s carry into
White and Ødegaard, the Saka–Ødegaard–White triangle, then a cross or
cut-back. The per-player grid shows how concentrated the structure is —
Ødegaard and Saka’s panels are dense, Martinelli’s nearly empty.</p>

<p><img src="/assets/images/posts/arsenal-attack-patterns/focus_grid_pattern_a.png" alt="Pattern A — per-player linkages" /></p>

<p>Grading the execution role by role: Saliba A+, Rice A+, Saka A+, Ødegaard A.
The build and progression phases are elite. Then the chain reaches the box.</p>

<p><img src="/assets/images/posts/arsenal-attack-patterns/grading_pattern_a.png" alt="Pattern A — role-execution grading" /></p>

<p>The bottleneck — the dark ring — sits on the centre forward. Havertz grades C:
big-chance conversion poor. Every upstream role does its job; the last one
doesn’t.</p>

<h2 id="pattern-b--left-isolation--underlap-25">Pattern B · Left Isolation / Underlap (~25%)</h2>

<p><img src="/assets/images/posts/arsenal-attack-patterns/rolemap_pattern_b.png" alt="Pattern B — role-link map" /></p>

<p>The mirror-image idea: Martinelli held wide for the 1v1, Calafiori
underlapping into the channel, a low cut-back arriving for Merino. The link
weights show the Martinelli–Calafiori axis carrying the whole pattern.</p>

<p><img src="/assets/images/posts/arsenal-attack-patterns/focus_grid_pattern_b.png" alt="Pattern B — per-player linkages" /></p>

<p>This pattern has two breakers, and neither is in the build-up.</p>

<p><img src="/assets/images/posts/arsenal-attack-patterns/grading_pattern_b.png" alt="Pattern B — role-execution grading" /></p>

<p>Martinelli grades C (1v1 success and finishing down across 24–26), and
Calafiori grades C (underlap dynamism limited). The back seven all grade A
or better — the platform is fine; the left-side blade is blunt. Havertz, on
the near-post pin, again grades C.</p>

<h2 id="pattern-c--vertical-transition-20">Pattern C · Vertical Transition (~20%)</h2>

<p><img src="/assets/images/posts/arsenal-attack-patterns/rolemap_pattern_c.png" alt="Pattern C — role-link map" /></p>

<p>The direct route: Raya or Rice plays the first vertical pass, Havertz drops
to link, Saka and Martinelli run the channels, the 8s arrive late.</p>

<p><img src="/assets/images/posts/arsenal-attack-patterns/focus_grid_pattern_c.png" alt="Pattern C — per-player linkages" /></p>

<p>Raya B+, Rice A+ on the first pass, Saka A on depth runs. The hinge of the
whole pattern is the dropping striker — and that is exactly where it breaks.</p>

<p><img src="/assets/images/posts/arsenal-attack-patterns/grading_pattern_c.png" alt="Pattern C — role-execution grading" /></p>

<p>Havertz, the designated link, grades C: link play and spin inconsistent. The
launch pad is elite, the runners are ready, the connection point fumbles.</p>

<h2 id="the-finding">The Finding</h2>

<p>For each pattern, I mapped all 11 roles’ tasks and graded execution. The
finding was striking: in two of three patterns, the chain broke at the
<strong>same role — centre forward</strong>. The structure built elite chances, but the
finisher didn’t convert. In the third, the same player broke the chain in a
different function.</p>

<p>The data-driven conclusion was simple: <strong>Arsenal’s number-one recruitment
priority had to be an elite, ruthless No. 9.</strong></p>

<p>This summer, Arsenal signed Viktor Gyökeres.</p>

<p>And this season, they ended a 22-year wait to lift the Premier League title.</p>

<p>The data didn’t replace the football. It pointed exactly where the football
was already telling us to look — just faster, cheaper, and more
communicable.</p>

<p>If you find yourself saying “the data doesn’t capture what I’m seeing,” the
answer isn’t to abandon the data. It’s to design better data.</p>

<hr />

<p><em>Data: Sofascore public match data · Charts: synthetic role-link weights,
style adapted from The Athletic’s pass-network charts · Stack: Python +
matplotlib</em></p>]]></content><author><name>Huiyang Mao</name><email>hymao@pmo.ac.cn</email></author><category term="Analysis" /><summary type="html"><![CDATA[Watching match footage is still the most authentic way to understand how a team plays and what a player can truly do. Nothing replaces sitting through 90 minutes to feel the rhythm, the spacing, the moments of brilliance and panic. But video is expensive. Time-expensive. Attention-expensive. You can’t scout every team, every match, every season this way.]]></summary></entry><entry><title type="html">Reading a World Cup Final Through Passing Data</title><link href="https://littlecaps.github.io/2026/06/07/reading-a-world-cup-final-through-passing-data.html" rel="alternate" type="text/html" title="Reading a World Cup Final Through Passing Data" /><published>2026-06-07T21:00:00+08:00</published><updated>2026-06-07T21:00:00+08:00</updated><id>https://littlecaps.github.io/2026/06/07/reading-a-world-cup-final-through-passing-data</id><content type="html" xml:base="https://littlecaps.github.io/2026/06/07/reading-a-world-cup-final-through-passing-data.html"><![CDATA[<p>Reading a World Cup Final through passing data — and what data viz can (and
can’t) do.</p>

<p>I used StatsBomb’s open event data for the 2022 World Cup Final (Argentina 3–3
France) to build eight passing visualisations, trying to answer one question:</p>

<p><strong>How much of a team’s “style of play” can you actually recover from the
passing data of a single match?</strong></p>

<!--more-->

<h2 id="why-passing-data-and-why-is-it-so-hard-to-draw">Why Passing Data, and Why Is It So Hard to Draw?</h2>

<p>Nobody disagrees that passing data matters. A pass is the most frequent event
in football — this final alone produced 1,263 of them — and every pass carries
a start point, an end point, a height, a length, a direction, and an outcome.
That richness is exactly the problem. The moment you draw a chart, the
questions multiply: length, height, or direction? Start-point density or the
spatial distribution of average length? What goes on the colour axis —
frequency, length, forwardness? What does opacity mean?</p>

<p>There is no canonical answer. Every figure is one projection of the same event
stream, and a different projection tells a different story. A heatmap of pass
origins says “where did they have the ball”; the same cells coloured by mean
pass length says “what did they do with it from there”; colour them by
forwardness and you get intent. Same data, three different matches on the
page.</p>

<p>The longer I do this, the more I believe a visualisation’s real value is not
to replace an expert watching the tape, but to take the judgment they build
after hours of re-watching and surface it as quickly and intuitively as
possible — giving subjective expertise an objective substrate that can be
shared, debated, and falsified.</p>

<p>A good chart isn’t an answer. It’s evidence.</p>

<h2 id="the-figures">The Figures</h2>

<p>All plots use StatsBomb open event data, rendered with Python, matplotlib and
mplsoccer. Both teams attack left → right; each pitch shows passes by one
team, smoothed with a Gaussian kernel (σ ≈ 1.8 m) so the colour reads as a
local average rather than individual dots.</p>

<p><strong>Pass height.</strong> Colour is the average StatsBomb height category per cell
(ground → low → high), opacity is local pass density.</p>

<p><img src="/assets/images/posts/2022-wc-final/compare_height.png" alt="Pass height heatmap — Argentina vs France" /></p>

<p><strong>Pass direction.</strong> Colour is the average forwardness of passes starting in
each cell: +1 fully forward, 0 sideways, −1 backward.</p>

<p><img src="/assets/images/posts/2022-wc-final/compare_direction_field.png" alt="Pass direction field — Argentina vs France" /></p>

<p><strong>Pass length.</strong> Colour is mean pass length per cell, opacity again is
density — so a bright yellow blob means “from here, they hit it long, often.”</p>

<p><img src="/assets/images/posts/2022-wc-final/compare_blur.png" alt="Pass length blur heatmap — Argentina vs France" /></p>

<p><strong>Distributions.</strong> Overlaid pass-length histograms and CDFs, plus each team’s
length distribution split by direction sector (forward / sideways /
backward).</p>

<p><img src="/assets/images/posts/2022-wc-final/length_distribution.png" alt="Pass length distribution and directional split" /></p>

<p>So what did these figures say about this final?</p>

<h2 id="1-argentina-played-the-possession-version-of-a-final">1. Argentina Played the “Possession” Version of a Final</h2>

<p>693 passes vs France’s 570. Mean length 20.2 m (France 22.2). 69% on the
ground. Pass-start density bright across midfield <strong>and</strong> the attacking third
(164 attacking-third passes). On the forwardness field, “forward” hotspots are
scattered everywhere — patient build-up that can launch from anywhere.</p>

<p>That last point is the one I would not have guessed from memory. The popular
recollection of this final is Messi-centric: moments, not structure. The data
says the structure was there all along — Argentina sustained possession deep
into France’s half, recycled it short, and progressed from many different
zones rather than through one designated outlet. The shape of the histograms
backs this up: Argentina’s distribution piles up at 10–20 m, and their
backward passes are the shortest in the match (mean 15.7 m) — quick resets to
keep the machine turning, not panicked clearances.</p>

<h2 id="2-france-played-the-transition-version-of-a-final">2. France Played the “Transition” Version of a Final</h2>

<p>41% of France’s defensive-third passes were ≥ 25 m long balls (Argentina:
31%). High-pass share 21% vs 18%, clustered on the left of their own half —
Upamecano and Varane spraying forward. The mean length of France’s
forward-direction passes was 25.7 m, three metres longer than Argentina’s.
And only 100 attacking-third passes — almost the entire attack relied on a
handful of direct, vertical transitions.</p>

<p>You can see it in one glance at the pass-length heatmap: the brightest yellow
on France’s pitch sits squarely around their own penalty area and left
channel. The launch zone. Argentina’s equivalent area is dimmer and more
purple — shorter, calmer exits. France’s by-direction histogram makes the
same point a different way: their forward passes have a visible second bump
out at 55–70 m that Argentina’s simply doesn’t have. Those are the balls
hunting Mbappé’s shoulder run.</p>

<h2 id="3-the-forwardness-field-tells-the-subtlest-story">3. The Forwardness Field Tells the Subtlest Story</h2>

<p>Both teams have nearly identical mean forwardness (+0.22 vs +0.24). If you
only read the summary statistic, you’d call them equally “direct” and move
on. But the spatial distribution is opposite: Argentina’s forward-red is
scattered all over the pitch; France’s is compressed into the defensive
flanks plus right midfield. Tchouaméni and the centre-backs were the launch
pad.</p>

<p>This is exactly the kind of finding that justifies drawing a map instead of
quoting a mean. Two identical averages, two completely different footballing
ideas underneath. It’s also a small warning about single-number team metrics
in general: directness, verticality, tempo — every one of them is a spatial
pattern flattened into a scalar, and the flattening is where the style gets
lost.</p>

<h2 id="what-the-33-actually-means">What the 3–3 Actually Means</h2>

<p>Argentina tried to grind it out through possession. France tried to steal it
through Mbappé’s speed in transition. One patient, one vertical. 3–3 plus
penalties is the cleanest evidence that neither philosophy could suppress the
other.</p>

<p>The usual caveats apply, and they matter. This is one match — the
highest-variance, highest-stakes match there is — and a final is a terrible
sample of a team’s “true” style. France spent long stretches chasing the
game, which mechanically inflates directness; Argentina spent long stretches
protecting a lead, which inflates possession share. Event data also can’t see
the off-ball: the press that forced a long ball doesn’t appear in the passing
table, only its consequence does. A one-game read is a hypothesis, not a
verdict.</p>

<p>But within those limits, the answer to my opening question is: more than I
expected. Eight projections of a single match’s passing recovered, without
any tactical annotation, the same characterisation a scout would give you —
Argentina’s distributed patience, France’s compressed verticality, the
centre-back launch pad, the Mbappé outlet. The data didn’t discover anything
the experts didn’t know. It made what they know <strong>visible, fast, and
checkable</strong> — which is the whole job.</p>

<p>A good visualisation isn’t there to make analysts look sophisticated. It’s
there to turn a pattern only a few experts can see on tape into something
almost anyone can grasp in 30 seconds. Data is cold; good figures amplify the
warm, intuitive things experts already feel.</p>

<hr />

<p><em>Data: StatsBomb open-data · Stack: Python + matplotlib + mplsoccer</em></p>]]></content><author><name>Huiyang Mao</name><email>hymao@pmo.ac.cn</email></author><category term="Analysis" /><summary type="html"><![CDATA[Reading a World Cup Final through passing data — and what data viz can (and can’t) do. I used StatsBomb’s open event data for the 2022 World Cup Final (Argentina 3–3 France) to build eight passing visualisations, trying to answer one question: How much of a team’s “style of play” can you actually recover from the passing data of a single match?]]></summary></entry><entry><title type="html">Building a Simple Expected Goals Model</title><link href="https://littlecaps.github.io/2026/06/06/building-a-simple-xg-model.html" rel="alternate" type="text/html" title="Building a Simple Expected Goals Model" /><published>2026-06-06T21:00:00+08:00</published><updated>2026-06-06T21:00:00+08:00</updated><id>https://littlecaps.github.io/2026/06/06/building-a-simple-xg-model</id><content type="html" xml:base="https://littlecaps.github.io/2026/06/06/building-a-simple-xg-model.html"><![CDATA[<p>Expected goals (xG) is the single most useful number in football analytics. It
answers a simple question: given where and how a shot was taken, how likely was
it to become a goal? Here’s how to build a first version from public event data.</p>

<!--more-->

<h2 id="the-idea">The Idea</h2>

<p>A goal is a rare, noisy event. A team can play well and lose; a single deflected
shot can decide a match. xG smooths out that noise by scoring each chance on its
quality rather than its outcome. Sum a team’s xG over a match and you get a
better estimate of how many goals they <em>should</em> have scored than the scoreline
alone.</p>

<h2 id="the-features-that-matter">The Features That Matter</h2>

<p>Most of the predictive power in a basic model comes from a handful of features:</p>

<ul>
  <li><strong>Distance to goal</strong> — the strongest single predictor.</li>
  <li><strong>Angle to goal</strong> — how much of the goal mouth the shooter can actually see.</li>
  <li><strong>Body part</strong> — header vs. foot.</li>
  <li><strong>Play pattern</strong> — open play, set piece, fast break, penalty.</li>
</ul>

<p>You can compute distance and angle directly from the shot’s <code class="language-plaintext highlighter-rouge">(x, y)</code> coordinates
in event data. StatsBomb’s open data is a good place to start.</p>

<h2 id="a-minimal-model">A Minimal Model</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>
<span class="kn">from</span> <span class="nn">sklearn.linear_model</span> <span class="kn">import</span> <span class="n">LogisticRegression</span>

<span class="c1"># shots: DataFrame with x, y, is_header, is_open_play, goal (0/1)
</span><span class="n">shots</span><span class="p">[</span><span class="s">"distance"</span><span class="p">]</span> <span class="o">=</span> <span class="p">((</span><span class="mi">120</span> <span class="o">-</span> <span class="n">shots</span><span class="p">.</span><span class="n">x</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span> <span class="o">+</span> <span class="p">(</span><span class="mi">40</span> <span class="o">-</span> <span class="n">shots</span><span class="p">.</span><span class="n">y</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span><span class="p">)</span> <span class="o">**</span> <span class="mf">0.5</span>

<span class="n">X</span> <span class="o">=</span> <span class="n">shots</span><span class="p">[[</span><span class="s">"distance"</span><span class="p">,</span> <span class="s">"is_header"</span><span class="p">,</span> <span class="s">"is_open_play"</span><span class="p">]]</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">shots</span><span class="p">[</span><span class="s">"goal"</span><span class="p">]</span>

<span class="n">model</span> <span class="o">=</span> <span class="n">LogisticRegression</span><span class="p">(</span><span class="n">max_iter</span><span class="o">=</span><span class="mi">1000</span><span class="p">).</span><span class="n">fit</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
<span class="n">shots</span><span class="p">[</span><span class="s">"xg"</span><span class="p">]</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict_proba</span><span class="p">(</span><span class="n">X</span><span class="p">)[:,</span> <span class="mi">1</span><span class="p">]</span>
</code></pre></div></div>

<p>Logistic regression is the right starting point: the output is already a
probability, the coefficients are interpretable, and it’s hard to overfit. Once
this baseline works, gradient-boosted trees usually add a few points of
calibration — but get the baseline honest first.</p>

<h2 id="reading-the-output">Reading the Output</h2>

<p>The number to trust is not any single shot’s xG — it’s the aggregate. Over a
season, a striker who consistently outperforms their xG is either an elite
finisher or due for regression. Telling those two apart is where the analysis
starts, and it’s a theme I’ll keep coming back to.</p>

<p>This is a starter post for the <strong>Data &amp; Models</strong> track — more on calibration,
tracking data, and possession value to come.</p>]]></content><author><name>Huiyang Mao</name><email>hymao@pmo.ac.cn</email></author><category term="Data" /><summary type="html"><![CDATA[Expected goals (xG) is the single most useful number in football analytics. It answers a simple question: given where and how a shot was taken, how likely was it to become a goal? Here’s how to build a first version from public event data.]]></summary></entry></feed>