Predictions: Naive Workflow

One Poisson model per team, round by round

Author

Miguel R.

Published

July 5, 2026

The naive workflow is the baseline of this project: everything the model knows comes from the team’s match history, and nothing else.

The core idea, explained in more depth on the home page, is that we do not predict a winner directly. We train one Poisson regression per team, modeling how many goals that team scores given the opponent’s Elo, the match location and the match type, with recent matches weighted more heavily. To predict a match we ask each team’s model how many goals it would score against the other, and compare the two answers.

1. Setup

All the heavy lifting lives in the project modules:

  • fixtures.py holds the tournament calendar (this is the only file that changes between rounds),
  • model_utils.py trains the per-team Poisson models,
  • report_utils.py generates, stores and scores the predictions.
Code
from model_utils import Workflow
from report_utils import predict_all_rounds, round_detail, scoreboard

2. Generating the predictions

One call predicts every round in the calendar. For each round, the model trains only on matches played before the round started, so these predictions are fully reproducible: re-running this notebook after the tournament produces the same numbers that were published in real time.

Each round is written to predictions/naive_<round>.csv, separate from the other workflow’s files, so the two approaches can always be compared side by side.

Code
predictions = predict_all_rounds(Workflow.NAIVE, "naive")

3. Round by round

For every completed round we show the predicted score next to the real one. correct marks whether the predicted winner matched the actual result (a real draw counts as a miss, since the model virtually never predicts one).

3.1 Group stage - Matchday 1

Twenty-four matches, and the model’s first contact with reality. Group-stage matches allow draws, but a Poisson model almost never predicts identical expected goals for both sides, so every real draw counts against us here.

Code
round_detail("naive", "first_round")
team_1 team_2 predicted_score real_score winner_pred winner_real correct
0 Mexico South Africa 1.99 - 0.36 2 - 0 Mexico Mexico True
1 South Korea Czechia 2.02 - 1.52 2 - 1 South Korea South Korea True
2 Canada Bosnia and Herzegovina 1.55 - 1.46 1 - 1 Canada Draw False
3 Qatar Switzerland 0.42 - 5.88 1 - 1 Switzerland Draw False
4 Brazil Morocco 1.8 - 1.22 1 - 1 Brazil Draw False
5 Haiti Scotland 2.39 - 4.92 0 - 1 Scotland Scotland True
6 USA Paraguay 4.04 - 1.01 4 - 1 USA USA True
7 Australia Türkiye 1.04 - 2.63 2 - 0 Türkiye Australia False
8 Germany Curaçao 11.49 - 0.44 7 - 1 Germany Germany True
9 Côte d'Ivoire Ecuador 2.68 - 2.31 1 - 0 Côte d'Ivoire Côte d'Ivoire True
10 Netherlands Japan 3.28 - 0.14 2 - 2 Netherlands Draw False
11 Sweden Tunisia 0.73 - 0.73 5 - 1 Tunisia Sweden False
12 Belgium Egypt 0.92 - 0.43 1 - 1 Belgium Draw False
13 IR Iran New Zealand 1.79 - 0.74 2 - 2 IR Iran Draw False
14 Spain Cabo Verde 4.94 - 1.49 0 - 0 Spain Draw False
15 Saudi Arabia Uruguay 0.3 - 10.09 1 - 1 Uruguay Draw False
16 France Senegal 4.86 - 0.0 3 - 1 France France True
17 Iraq Norway 0.0 - 0.39 1 - 4 Norway Norway True
18 Argentina Algeria 1.84 - 6.75 3 - 0 Algeria Argentina False
19 Austria Jordan 0.79 - 0.0 3 - 1 Austria Austria True
20 Portugal Congo DR 21.43 - 0.0 1 - 1 Portugal Draw False
21 Uzbekistan Colombia 0.83 - 1.44 1 - 3 Colombia Colombia True
22 Ghana Panama 1.62 - 1.08 1 - 0 Ghana Ghana True
23 England Croatia 9.58 - 2.55 4 - 2 England England True

3.2 Group stage - Matchday 2

By matchday 2 the favorites usually start separating from the rest of the group, which tends to make matches easier to call.

Code
round_detail("naive", "second_round")
team_1 team_2 predicted_score real_score winner_pred winner_real correct
0 Czechia South Africa 1.22 - 0.01 1 - 1 Czechia Draw False
1 Mexico South Korea 1.17 - 2.58 1 - 0 South Korea Mexico False
2 Switzerland Bosnia and Herzegovina 1.4 - 2.33 4 - 1 Bosnia and Herzegovina Switzerland False
3 Canada Qatar 1.12 - 1.6 6 - 0 Qatar Canada False
4 Brazil Haiti 1.54 - 0.0 3 - 0 Brazil Brazil True
5 Scotland Morocco 0.56 - 2.36 0 - 1 Morocco Morocco True
6 Türkiye Paraguay 2.49 - 1.86 0 - 1 Türkiye Paraguay False
7 USA Australia 2.53 - 0.54 2 - 0 USA USA True
8 Germany Côte d'Ivoire 2.06 - 0.0 2 - 1 Germany Germany True
9 Ecuador Curaçao 3.16 - 1.77 0 - 0 Ecuador Draw False
10 Netherlands Sweden 4.78 - 1.05 5 - 1 Netherlands Netherlands True
11 Tunisia Japan 0.27 - 6.33 0 - 4 Japan Japan True
12 Belgium IR Iran 1.33 - 1.13 0 - 0 Belgium Draw False
13 New Zealand Egypt 4.03 - 1.12 1 - 3 New Zealand Egypt False
14 Spain Saudi Arabia 0.05 - 0.11 4 - 0 Saudi Arabia Spain False
15 Uruguay Cabo Verde 2.03 - 0.0 2 - 2 Uruguay Draw False
16 France Iraq 3.11 - 1.43 3 - 0 France France True
17 Norway Senegal 1.73 - 0.88 3 - 2 Norway Norway True
18 Argentina Austria 2.61 - 4.06 2 - 0 Austria Argentina False
19 Jordan Algeria 0.58 - 0.16 1 - 2 Jordan Algeria False
20 Portugal Uzbekistan 2.22 - 2.12 5 - 0 Portugal Portugal True
21 Colombia Congo DR 1.76 - 0.0 1 - 0 Colombia Colombia True
22 England Ghana 14.12 - 0.09 0 - 0 England Draw False
23 Panama Croatia 0.0 - 3.62 0 - 1 Croatia Croatia True

3.3 Group stage - Matchday 3

The hardest matchday to predict: teams that already qualified rotate their squads, and teams that are already out play with nothing to lose. None of that appears in the training data.

Code
round_detail("naive", "third_round")
team_1 team_2 predicted_score real_score winner_pred winner_real correct
0 Czechia Mexico 0.8 - 2.28 0 - 3 Mexico Mexico True
1 South Africa South Korea 0.02 - 3.2 1 - 0 South Korea South Africa False
2 Switzerland Canada 0.0 - 2.32 2 - 1 Canada Switzerland False
3 Bosnia and Herzegovina Qatar 1.25 - 0.47 3 - 1 Bosnia and Herzegovina Bosnia and Herzegovina True
4 Scotland Brazil 1.06 - 1.48 0 - 3 Brazil Brazil True
5 Morocco Haiti 1.42 - 0.0 4 - 2 Morocco Morocco True
6 Türkiye USA 0.02 - 2.7 3 - 2 USA Türkiye False
7 Paraguay Australia 1.42 - 0.25 0 - 0 Paraguay Draw False
8 Curaçao Côte d'Ivoire 0.5 - 0.85 0 - 2 Côte d'Ivoire Côte d'Ivoire True
9 Ecuador Germany 0.28 - 7.06 2 - 1 Germany Ecuador False
10 Japan Sweden 1.08 - 1.38 1 - 1 Sweden Draw False
11 Tunisia Netherlands 0.38 - 7.51 1 - 3 Netherlands Netherlands True
12 Egypt IR Iran 0.77 - 1.44 1 - 1 IR Iran Draw False
13 New Zealand Belgium 2.96 - 1.06 1 - 5 New Zealand Belgium False
14 Uruguay Spain 0.26 - 5.06 0 - 1 Spain Spain True
15 Cabo Verde Saudi Arabia 0.85 - 0.72 0 - 0 Cabo Verde Draw False
16 Norway France 6.59 - 2.81 1 - 4 Norway France False
17 Senegal Iraq 1.22 - 2.64 5 - 0 Iraq Senegal False
18 Argentina Jordan 2.12 - 0.52 3 - 1 Argentina Argentina True
19 Algeria Austria 0.71 - 1.88 3 - 3 Austria Draw False
20 Colombia Portugal 4.46 - 2.75 0 - 0 Colombia Draw False
21 Congo DR Uzbekistan 1.06 - 0.0 3 - 1 Congo DR Congo DR True
22 Panama England 1.74 - 6.51 0 - 2 England England True
23 Croatia Ghana 3.4 - 0.05 2 - 1 Croatia Croatia True

3.4 Round of 32

First knockout round. A caveat on scoring: the match history records the score after 90 or 120 minutes, so a match decided on penalties is recorded as a draw and counts against the model even when its pick advanced.

Code
round_detail("naive", "round_of_32")
team_1 team_2 predicted_score real_score winner_pred winner_real correct
0 South Africa Canada 0.29 - 2.02 0 - 1 Canada Canada True
1 Brazil Japan 2.24 - 1.37 2 - 1 Brazil Brazil True
2 Germany Paraguay 3.15 - 1.04 1 - 1 Germany Draw False
3 Netherlands Morocco 5.35 - 3.36 1 - 1 Netherlands Draw False
4 Côte d'Ivoire Norway 0.81 - 2.76 1 - 2 Norway Norway True
5 France Sweden 2.8 - 0.51 3 - 0 France France True
6 Mexico Ecuador 0.89 - 0.57 2 - 0 Mexico Mexico True
7 England Congo DR 7.58 - 2.98 2 - 1 England England True
8 Belgium Senegal 0.78 - 2.05 3 - 2 Senegal Belgium False
9 USA Bosnia and Herzegovina 9.35 - 1.39 2 - 0 USA USA True
10 Spain Austria 1.58 - 2.93 3 - 0 Austria Spain False
11 Portugal Croatia 6.74 - 0.54 2 - 1 Portugal Portugal True
12 Switzerland Algeria 0.0 - 1.19 2 - 0 Algeria Switzerland False
13 Australia Egypt 0.23 - 0.74 1 - 1 Egypt Draw False
14 Argentina Cabo Verde 2.8 - 0.86 3 - 2 Argentina Argentina True
15 Colombia Ghana 0.51 - 0.1 1 - 0 Colombia Colombia True

3.5 Round of 16

Predictions for the current round. The evaluation below fills in automatically as matches are played and the ratings are re-scraped.

Code
round_detail("naive", "round_of_16")
team_1 team_2 predicted_score predicted_winner
0 Morocco Canada 3.16 - 0.59 Morocco
1 France Paraguay 2.86 - 2.82 France
2 Brazil Norway 2.25 - 2.62 Norway
3 Mexico England 1.59 - 1.58 Mexico
4 Spain Portugal 1.79 - 0.76 Spain
5 USA Belgium 1.66 - 1.63 USA
6 Argentina Egypt 1.99 - 0.33 Argentina
7 Colombia Switzerland 0.51 - 2.15 Switzerland

4. Scoreboard

Winner accuracy and mean absolute goal error per round, over the matches played so far.

Code
scoreboard(prefixes=("naive",))
round matches naive_accuracy naive_goal_mae
0 Group stage - Matchday 1 24 0.500 2.15
1 Group stage - Matchday 2 24 0.458 1.62
2 Group stage - Matchday 3 24 0.458 1.83
3 Round of 32 16 0.625 1.51

5. Discussion

Two patterns are worth calling out.

First, the deeper the tournament goes, the closer the expected goals of the two sides get. That is not a bug: the knockout bracket filters out weak teams, so the survivors are increasingly similar in strength, and the model’s honest answer is “this one is close to a coin flip”. Many of these matches will realistically be decided in extra time or on penalties, which no goals-based model can call.

Second, winner accuracy and goal error move in opposite directions as the stakes rise. Calling the winner gets harder (closer matchups), while the scores the model misses by grow because knockout football produces more extreme results than the friendlies and qualifiers it trained on.