Code
from model_utils import Workflow
from report_utils import predict_all_rounds, round_detail, scoreboardOne Poisson model per team, round by round
Miguel R.
July 5, 2026
The ensemble workflow starts from the exact same per-team Poisson models as the naive workflow, then adds a player layer on top.
For each team we compute the average Elo of its four best attackers and compare it against the average Elo of the opponent’s defensive block (four best defenders, best midfielder and best goalkeeper). The ratio of the two, raised to a damping exponent ((atk/def) ** 2.5, tuned on the completed rounds - see model vs reality), multiplies the predicted goals: a team whose attack outclasses the opposing defense gets a boost, and a team facing a stronger defensive block gets discounted.
The point of running both workflows on identical fixtures is to answer one question: does player-level information actually improve the prediction, or is the team-level history enough?
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.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/ensemble_<round>.csv, separate from the other workflow’s files, so the two approaches can always be compared side by side.
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).
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.
| team_1 | team_2 | predicted_score | real_score | winner_pred | winner_real | correct | |
|---|---|---|---|---|---|---|---|
| 0 | Mexico | South Africa | 3.3 - 0.23 | 2 - 0 | Mexico | Mexico | True |
| 1 | South Korea | Czechia | 1.8 - 1.31 | 2 - 1 | South Korea | South Korea | True |
| 2 | Canada | Bosnia and Herzegovina | 1.53 - 1.06 | 1 - 1 | Canada | Draw | False |
| 3 | Qatar | Switzerland | 0.21 - 7.41 | 1 - 1 | Switzerland | Draw | False |
| 4 | Brazil | Morocco | 1.99 - 0.98 | 1 - 1 | Brazil | Draw | False |
| 5 | Haiti | Scotland | 1.47 - 6.45 | 0 - 1 | Scotland | Scotland | True |
| 6 | USA | Paraguay | 4.09 - 0.75 | 4 - 1 | USA | USA | True |
| 7 | Australia | Türkiye | 0.54 - 2.66 | 2 - 0 | Türkiye | Australia | False |
| 8 | Germany | Curaçao | 19.67 - 0.2 | 7 - 1 | Germany | Germany | True |
| 9 | Côte d'Ivoire | Ecuador | 2.42 - 1.66 | 1 - 0 | Côte d'Ivoire | Côte d'Ivoire | True |
| 10 | Netherlands | Japan | 3.39 - 0.1 | 2 - 2 | Netherlands | Draw | False |
| 11 | Sweden | Tunisia | 1.18 - 0.4 | 5 - 1 | Sweden | Sweden | True |
| 12 | Belgium | Egypt | 1.37 - 0.26 | 1 - 1 | Belgium | Draw | False |
| 13 | IR Iran | New Zealand | 1.98 - 0.55 | 2 - 2 | IR Iran | Draw | False |
| 14 | Spain | Cabo Verde | 8.19 - 0.58 | 0 - 0 | Spain | Draw | False |
| 15 | Saudi Arabia | Uruguay | 0.22 - 9.01 | 1 - 1 | Uruguay | Draw | False |
| 16 | France | Senegal | 5.52 - 0.0 | 3 - 1 | France | France | True |
| 17 | Iraq | Norway | 0.0 - 0.89 | 1 - 4 | Norway | Norway | True |
| 18 | Argentina | Algeria | 2.27 - 4.07 | 3 - 0 | Algeria | Argentina | False |
| 19 | Austria | Jordan | 1.03 - 0.0 | 3 - 1 | Austria | Austria | True |
| 20 | Portugal | Congo DR | 31.55 - 0.0 | 1 - 1 | Portugal | Draw | False |
| 21 | Uzbekistan | Colombia | 0.44 - 1.9 | 1 - 3 | Colombia | Colombia | True |
| 22 | Ghana | Panama | 1.74 - 0.63 | 1 - 0 | Ghana | Ghana | True |
| 23 | England | Croatia | 11.39 - 1.77 | 4 - 2 | England | England | True |
By matchday 2 the favorites usually start separating from the rest of the group, which tends to make matches easier to call.
| team_1 | team_2 | predicted_score | real_score | winner_pred | winner_real | correct | |
|---|---|---|---|---|---|---|---|
| 0 | Czechia | South Africa | 1.52 - 0.01 | 1 - 1 | Czechia | Draw | False |
| 1 | Mexico | South Korea | 1.34 - 2.0 | 1 - 0 | South Korea | Mexico | False |
| 2 | Switzerland | Bosnia and Herzegovina | 1.56 - 1.49 | 4 - 1 | Switzerland | Switzerland | True |
| 3 | Canada | Qatar | 1.25 - 0.92 | 6 - 0 | Canada | Canada | True |
| 4 | Brazil | Haiti | 3.32 - 0.0 | 3 - 0 | Brazil | Brazil | True |
| 5 | Scotland | Morocco | 0.38 - 2.43 | 0 - 1 | Morocco | Morocco | True |
| 6 | Türkiye | Paraguay | 2.35 - 1.24 | 0 - 1 | Türkiye | Paraguay | False |
| 7 | USA | Australia | 2.75 - 0.31 | 2 - 0 | USA | USA | True |
| 8 | Germany | Côte d'Ivoire | 2.15 - 0.0 | 2 - 1 | Germany | Germany | True |
| 9 | Ecuador | Curaçao | 3.73 - 0.97 | 0 - 0 | Ecuador | Draw | False |
| 10 | Netherlands | Sweden | 4.81 - 0.85 | 5 - 1 | Netherlands | Netherlands | True |
| 11 | Tunisia | Japan | 0.15 - 8.75 | 0 - 4 | Japan | Japan | True |
| 12 | Belgium | IR Iran | 1.85 - 0.74 | 0 - 0 | Belgium | Draw | False |
| 13 | New Zealand | Egypt | 3.26 - 1.15 | 1 - 3 | New Zealand | Egypt | False |
| 14 | Spain | Saudi Arabia | 0.06 - 0.06 | 4 - 0 | Spain | Spain | True |
| 15 | Uruguay | Cabo Verde | 2.5 - 0.0 | 2 - 2 | Uruguay | Draw | False |
| 16 | France | Iraq | 7.17 - 0.49 | 3 - 0 | France | France | True |
| 17 | Norway | Senegal | 1.94 - 0.75 | 3 - 2 | Norway | Norway | True |
| 18 | Argentina | Austria | 2.92 - 2.38 | 2 - 0 | Argentina | Argentina | True |
| 19 | Jordan | Algeria | 0.39 - 0.21 | 1 - 2 | Jordan | Algeria | False |
| 20 | Portugal | Uzbekistan | 3.89 - 0.89 | 5 - 0 | Portugal | Portugal | True |
| 21 | Colombia | Congo DR | 1.95 - 0.0 | 1 - 0 | Colombia | Colombia | True |
| 22 | England | Ghana | 22.28 - 0.06 | 0 - 0 | England | Draw | False |
| 23 | Panama | Croatia | 0.0 - 4.37 | 0 - 1 | Croatia | Croatia | True |
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.
| team_1 | team_2 | predicted_score | real_score | winner_pred | winner_real | correct | |
|---|---|---|---|---|---|---|---|
| 0 | Czechia | Mexico | 0.6 - 2.62 | 0 - 3 | Mexico | Mexico | True |
| 1 | South Africa | South Korea | 0.02 - 4.11 | 1 - 0 | South Korea | South Africa | False |
| 2 | Switzerland | Canada | 0.0 - 1.83 | 2 - 1 | Canada | Switzerland | False |
| 3 | Bosnia and Herzegovina | Qatar | 1.13 - 0.3 | 3 - 1 | Bosnia and Herzegovina | Bosnia and Herzegovina | True |
| 4 | Scotland | Brazil | 0.63 - 1.83 | 0 - 3 | Brazil | Brazil | True |
| 5 | Morocco | Haiti | 2.54 - 0.0 | 4 - 2 | Morocco | Morocco | True |
| 6 | Türkiye | USA | 0.01 - 2.18 | 3 - 2 | USA | Türkiye | False |
| 7 | Paraguay | Australia | 1.28 - 0.16 | 0 - 0 | Paraguay | Draw | False |
| 8 | Curaçao | Côte d'Ivoire | 0.28 - 1.26 | 0 - 2 | Côte d'Ivoire | Côte d'Ivoire | True |
| 9 | Ecuador | Germany | 0.17 - 7.33 | 2 - 1 | Germany | Ecuador | False |
| 10 | Japan | Sweden | 0.95 - 1.46 | 1 - 1 | Sweden | Draw | False |
| 11 | Tunisia | Netherlands | 0.17 - 11.95 | 1 - 3 | Netherlands | Netherlands | True |
| 12 | Egypt | IR Iran | 0.72 - 1.58 | 1 - 1 | IR Iran | Draw | False |
| 13 | New Zealand | Belgium | 1.43 - 1.61 | 1 - 5 | Belgium | Belgium | True |
| 14 | Uruguay | Spain | 0.17 - 5.75 | 0 - 1 | Spain | Spain | True |
| 15 | Cabo Verde | Saudi Arabia | 0.45 - 0.77 | 0 - 0 | Saudi Arabia | Draw | False |
| 16 | Norway | France | 5.36 - 2.95 | 1 - 4 | Norway | France | False |
| 17 | Senegal | Iraq | 2.3 - 1.24 | 5 - 0 | Senegal | Senegal | True |
| 18 | Argentina | Jordan | 4.34 - 0.26 | 3 - 1 | Argentina | Argentina | True |
| 19 | Algeria | Austria | 0.52 - 1.48 | 3 - 3 | Austria | Draw | False |
| 20 | Colombia | Portugal | 3.06 - 3.16 | 0 - 0 | Portugal | Draw | False |
| 21 | Congo DR | Uzbekistan | 1.19 - 0.0 | 3 - 1 | Congo DR | Congo DR | True |
| 22 | Panama | England | 0.69 - 12.11 | 0 - 2 | England | England | True |
| 23 | Croatia | Ghana | 3.49 - 0.04 | 2 - 1 | Croatia | Croatia | True |
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.
| team_1 | team_2 | predicted_score | real_score | winner_pred | winner_real | correct | |
|---|---|---|---|---|---|---|---|
| 0 | South Africa | Canada | 0.19 - 2.79 | 0 - 1 | Canada | Canada | True |
| 1 | Brazil | Japan | 2.8 - 0.95 | 2 - 1 | Brazil | Brazil | True |
| 2 | Germany | Paraguay | 3.76 - 0.64 | 1 - 1 | Germany | Draw | False |
| 3 | Netherlands | Morocco | 4.88 - 2.7 | 1 - 1 | Netherlands | Draw | False |
| 4 | Côte d'Ivoire | Norway | 0.68 - 3.07 | 1 - 2 | Norway | Norway | True |
| 5 | France | Sweden | 3.19 - 0.38 | 3 - 0 | France | France | True |
| 6 | Mexico | Ecuador | 0.85 - 0.42 | 2 - 0 | Mexico | Mexico | True |
| 7 | England | Congo DR | 12.74 - 1.8 | 2 - 1 | England | England | True |
| 8 | Belgium | Senegal | 0.76 - 1.75 | 3 - 2 | Senegal | Belgium | False |
| 9 | USA | Bosnia and Herzegovina | 10.15 - 0.92 | 2 - 0 | USA | USA | True |
| 10 | Spain | Austria | 1.97 - 1.46 | 3 - 0 | Spain | Spain | True |
| 11 | Portugal | Croatia | 7.02 - 0.37 | 2 - 1 | Portugal | Portugal | True |
| 12 | Switzerland | Algeria | 0.0 - 0.84 | 2 - 0 | Algeria | Switzerland | False |
| 13 | Australia | Egypt | 0.2 - 0.6 | 1 - 1 | Egypt | Draw | False |
| 14 | Argentina | Cabo Verde | 4.18 - 0.39 | 3 - 2 | Argentina | Argentina | True |
| 15 | Colombia | Ghana | 0.53 - 0.08 | 1 - 0 | Colombia | Colombia | True |
Predictions for the current round. The evaluation below fills in automatically as matches are played and the ratings are re-scraped.
| team_1 | team_2 | predicted_score | predicted_winner | |
|---|---|---|---|---|
| 0 | Morocco | Canada | 3.53 - 0.44 | Morocco |
| 1 | France | Paraguay | 3.7 - 1.5 | France |
| 2 | Brazil | Norway | 2.54 - 2.33 | Brazil |
| 3 | Mexico | England | 1.23 - 2.19 | England |
| 4 | Spain | Portugal | 1.71 - 0.63 | Spain |
| 5 | USA | Belgium | 1.36 - 1.6 | Belgium |
| 6 | Argentina | Egypt | 3.39 - 0.18 | Argentina |
| 7 | Colombia | Switzerland | 0.44 - 1.92 | Switzerland |
Winner accuracy and mean absolute goal error per round, over the matches played so far.
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.