<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Daniel Robb</title>
<link>https://danielrobb.co.uk/writing.html</link>
<atom:link href="https://danielrobb.co.uk/writing.xml" rel="self" type="application/rss+xml"/>
<description>Methodology posts on probabilistic sports forecasting.</description>
<image>
<url>https://danielrobb.co.uk/social-card.png</url>
<title>Daniel Robb</title>
<link>https://danielrobb.co.uk/writing.html</link>
<height>76</height>
<width>144</width>
</image>
<generator>quarto-1.10.18</generator>
<lastBuildDate>Sat, 15 Aug 2026 00:00:00 GMT</lastBuildDate>
<item>
  <title>A Poisson goal model for the Premier League</title>
  <link>https://danielrobb.co.uk/posts/poisson-goal-model/</link>
  <description><![CDATA[ 




<div class="layout-rail">
<div class="rail">
<div class="rail-meta">
<p><span class="rail-label">Data</span><span class="rail-value">football-data.co.uk</span><span class="rail-label">League</span><span class="rail-value">Premier League</span><span class="rail-label">Backtest</span><span class="rail-value">n = 1982</span><span class="rail-label">Skipped</span><span class="rail-value">298 (team unseen)</span></p>
</div>
</div>
<div class="prose">
<p>In a football match, goals are simply events that occur at some random, steady average rate. That is exactly what the Poisson distribution specialises in predicting. So for any given match, this model reduces to a simple question: what is each team’s average goal rate?</p>
<p>The dataset is nine seasons of Premier League results plus Bet365 closing odds. Each match in the dataset is immediately split into two rows, with both the home and away side appearing as “team” and “opponent”. An example:</p>
<div id="57f31d7f" class="cell" data-execution_count="2">
<div class="cell-output cell-output-display cell-output-markdown">
<table class="caption-top table table-sm table-striped small">
<thead>
<tr class="header">
<th>team</th>
<th>opponent</th>
<th>is_home</th>
<th>goals</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Arsenal</td>
<td>Chelsea</td>
<td>1</td>
<td>1</td>
</tr>
<tr class="even">
<td>Chelsea</td>
<td>Arsenal</td>
<td>0</td>
<td>0</td>
</tr>
</tbody>
</table>
<p><em>Arsenal 1–0 Chelsea, 2025-03-16</em></p>
</div>
</div>
<p>Because each team now appears as both the scorer and the conceder, a single regression can learn attack and defence for all 34 teams simultaneously.</p>
<p>A team’s goal rate is built from four distinct numbers: the baseline (0.312) — what an average team scores against an average team (about 1.3 goals); home advantage (0.192) — playing at home increases your goal rate by roughly 21%; attack — a number per team which, if positive, means they score more than average; defence — a number per team that, if positive, means they concede more than average.</p>
<p>So, in order to predict one fixture, we must calculate λ_home = exp(baseline + home_advantage + attack[home] + defence[away]) and λ_away = exp(baseline + attack[away] + defence[home]). The only difference between the formulas is that the away side does not get the home-advantage coefficient.</p>
<p>In order to convert these numbers into probabilities, we assume that each side’s goal count is Poisson-distributed with its own λ, and that the two sides are independent of each other. Under independence, the probability of any scoreline is just the two probabilities multiplied: P(2–1) = P(home scores 2) × P(away scores 1). Do that for every combination and you get a grid:</p>
<div id="2b00f7b4" class="cell" data-execution_count="3">
<section id="example-wolves-vs-brentford-1.28-1.33-expected-goals" class="level3 cell-output cell-output-display cell-output-markdown">
<h3 class="anchored" data-anchor-id="example-wolves-vs-brentford-1.28-1.33-expected-goals">Example: Wolves vs Brentford — 1.28 / 1.33 expected goals</h3>
</section>
</div>
<div id="a210a19e" class="cell" data-execution_count="4">
<div class="cell-output cell-output-display" data-execution_count="4">
<table class="scoreline-grid caption-top table table-sm table-striped small" aria-label="Scoreline probability matrix, Wolves vs Brentford">
<caption>Wolves (rows) vs Brentford (columns) — goals, cell % = P(scoreline)</caption>
<thead>
<tr class="header">
<th class="scoreline-corner" data-quarto-table-cell-role="th" scope="col"></th>
<th data-quarto-table-cell-role="th" scope="col">0</th>
<th data-quarto-table-cell-role="th" scope="col">1</th>
<th data-quarto-table-cell-role="th" scope="col">2</th>
<th data-quarto-table-cell-role="th" scope="col">3</th>
<th data-quarto-table-cell-role="th" scope="col">4</th>
<th data-quarto-table-cell-role="th" scope="col">5</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th" scope="row">0</th>
<td style="background: #d6698d; color: #17191C" title="0–0: 7.4%">7.4</td>
<td style="background: #cb3f6d; color: #F3F5F4" title="0–1: 9.8%">9.8</td>
<td style="background: #db7999; color: #17191C" title="0–2: 6.5%">6.5</td>
<td style="background: #ecb9c9; color: #17191C" title="0–3: 2.9%">2.9</td>
<td style="background: #f5dae3; color: #17191C" title="0–4: 0.9%">0.9</td>
<td style="background: #f8e7ec; color: #17191C" title="0–5: 0.3%">0.3</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th" scope="row">1</th>
<td style="background: #cc4571; color: #F3F5F4" title="1–0: 9.4%">9.4</td>
<td style="background: #a02c52; color: #F3F5F4" title="1–1: 12.5%">12.5</td>
<td style="background: #d25981; color: #17191C" title="1–2: 8.3%">8.3</td>
<td style="background: #e8aabf; color: #17191C" title="1–3: 3.7%">3.7</td>
<td style="background: #f4d6df; color: #17191C" title="1–4: 1.2%">1.2</td>
<td style="background: #f8e5eb; color: #17191C" title="1–5: 0.3%">0.3</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th" scope="row">2</th>
<td style="background: #dc809f; color: #17191C" title="2–0: 6.1%">6.1</td>
<td style="background: #d35e84; color: #17191C" title="2–1: 8.0%">8.0</td>
<td style="background: #e08da8; color: #17191C" title="2–2: 5.3%">5.3</td>
<td style="background: #eec2d0; color: #17191C" title="2–3: 2.4%">2.4</td>
<td style="background: #f6dde5; color: #17191C" title="2–4: 0.8%">0.8</td>
<td style="background: #f9e7ed; color: #17191C" title="2–5: 0.2%">0.2</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th" scope="row">3</th>
<td style="background: #edbdcd; color: #17191C" title="3–0: 2.6%">2.6</td>
<td style="background: #e9aec2; color: #17191C" title="3–1: 3.4%">3.4</td>
<td style="background: #efc3d1; color: #17191C" title="3–2: 2.3%">2.3</td>
<td style="background: #f5d9e2; color: #17191C" title="3–3: 1.0%">1.0</td>
<td style="background: #f8e5eb; color: #17191C" title="3–4: 0.3%">0.3</td>
<td style="background: #f9e9ef; color: #17191C" title="3–5: 0.1%">0.1</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th" scope="row">4</th>
<td style="background: #f6dce5; color: #17191C" title="4–0: 0.8%">0.8</td>
<td style="background: #f4d8e1; color: #17191C" title="4–1: 1.1%">1.1</td>
<td style="background: #f6dee6; color: #17191C" title="4–2: 0.7%">0.7</td>
<td style="background: #f8e5eb; color: #17191C" title="4–3: 0.3%">0.3</td>
<td style="background: #f9e9ee; color: #17191C" title="4–4: 0.1%">0.1</td>
<td style="background: #f9eaef; color: #17191C" title="4–5: 0.0%">0.0</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th" scope="row">5</th>
<td style="background: #f9e7ed; color: #17191C" title="5–0: 0.2%">0.2</td>
<td style="background: #f8e6ec; color: #17191C" title="5–1: 0.3%">0.3</td>
<td style="background: #f9e8ed; color: #17191C" title="5–2: 0.2%">0.2</td>
<td style="background: #f9e9ef; color: #17191C" title="5–3: 0.1%">0.1</td>
<td style="background: #f9eaef; color: #17191C" title="5–4: 0.0%">0.0</td>
<td style="background: #faebf0; color: #17191C" title="5–5: 0.0%">0.0</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>Sum the coloured regions and we get three market prices:</p>
<div id="dd40848c" class="cell" data-execution_count="5">
<div class="cell-output cell-output-display cell-output-markdown">
<p>P(Wolves win) = <strong>35.8%</strong>, P(draw) = <strong>26.3%</strong>, P(Brentford win) = <strong>37.8%</strong></p>
</div>
</div>
<p>Now, there is a flaw in this method: the independence assumption is an issue. In a real match, the actions (goals) of one team certainly affect the other — if a team goes 2–0 up away from home they will likely choose to sit back and not risk any more, or, inversely, a team losing 2–0 at home would play far more open and aggressive, chasing a goal. To fix this you would implement Dixon-Coles, which is a small correction factor for low-scoring games. To be clear, Dixon-Coles is not implemented in this model yet.</p>
<section id="try-another-fixture" class="level3">
<h3 class="anchored" data-anchor-id="try-another-fixture">Try another fixture</h3>
<p>Pick any two teams from the training set — expected goals, the scoreline grid, and the derived markets below recompute live from the model’s fitted coefficients, in a few KB of plain JavaScript (no page reload, no framework runtime).</p>
<div id="5d6cd0b4" class="cell" data-execution_count="6">
<div class="cell-output cell-output-display no-overflow-x">
<div class="fixture-picker" id="pgm-fixture-picker">
<label>Home<select id="pgm-home" class="fixture-select" aria-label="Home team"></select></label>
<span class="fixture-vs">v</span>
<label>Away<select id="pgm-away" class="fixture-select" aria-label="Away team"></select></label>
</div>
<div id="pgm-grid"></div>

<table id="pgm-markets" class="summary-table caption-top">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th">Market</th>
<th data-quarto-table-cell-role="th">Probability</th>
</tr>
</thead>
<tbody>
</tbody>
</table>

<p class="rail-label" id="pgm-status">Loading model coefficients…</p>
<script src="fixture-picker.js"></script>
</div>
</div>
<p>So… does it work? To see how we did, we compare ourselves to the Bet365 closing price for all of these matches — with one wrinkle: if you total the three markets Bet365 offers per game, they sum to roughly 105%, which is the bookmaker’s edge. We have to devig these prices to make the comparison with my model fair. The comparison uses two “scores”: log loss (which asks “how surprised were you by what actually happened?” — it punishes being confidently wrong very harshly) and Brier score (a squared error between your probability and the outcome).</p>
</section>
<div id="af207554" class="cell" data-fig-format="svg" data-execution_count="7">
<section id="model-vs.-market" class="level3 cell-output cell-output-display cell-output-markdown">
<h3 class="anchored" data-anchor-id="model-vs.-market">Model vs.&nbsp;market</h3>
</section>
<div class="cell-output cell-output-display cell-output-markdown">
<table class="summary-table">
<thead>
<tr>
<th>
Metric
</th>
<th>
Model
</th>
<th>
Market (Bet365, devigged)
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
n
</th>
<td>
1982
</td>
<td>
</td>
</tr>
<tr>
<th scope="row">
Log loss
</th>
<td>
1.004
</td>
<td>
0.961
</td>
</tr>
<tr>
<th scope="row">
Brier score
</th>
<td>
0.598
</td>
<td>
0.569
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>The market beat my model on both. This is expected — there are numerous limitations to this model beyond just missing Dixon-Coles. It includes no time-decay weighting and no injury or lineup information, yet it still landed within a few percent of a market that has millions of people betting on it every week.</p>
<div id="7255b919" class="cell" data-fig-format="svg" data-execution_count="8">
<section id="home-win-calibration" class="level3 cell-output cell-output-display cell-output-markdown">
<h3 class="anchored" data-anchor-id="home-win-calibration">Home-win calibration</h3>
</section>
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://danielrobb.co.uk/posts/poisson-goal-model/index_files/figure-html/cell-9-output-2.png" width="393" height="393" class="figure-img"></p>
</figure>
</div>
</div>
</div>
</div>
</div>



 ]]></description>
  <category>modelling</category>
  <category>football</category>
  <guid>https://danielrobb.co.uk/posts/poisson-goal-model/</guid>
  <pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
