World Cup 2026 Match Predictor: Day 3- Ensemble + Interactive Analysis
Yesterday, our Poisson model surprised us: France vs Morocco came out as Morocco favored 51%. Today we do "ensembling" analysis.
Projects in this week’s series:
This week, we build a World Cup 2026 Match Predictor — a Streamlit web app that predicts upcoming matches using real football-analytics techniques.
Day 1: Elo-based Predictor
Day 2: Poisson Goal Model
Day 3: Ensemble + Interactive Analysis (Today)
Today’s Project
Welcome to the finale — the models learn to talk to each other.
Yesterday, our Poisson model surprised us: France vs Morocco came out as Morocco favored 51%, driven by Morocco’s elite defensive record over the last 3 years. That reveal was uncomfortable — most football fans would pick France without hesitation.
Here’s what we didn’t show yesterday: Elo, our Day 1 model, disagrees with Poisson on that match. Elo (trained on 150 years of history) picks France at 56%. Poisson picks Morocco at 51%. Which one is right?
Both. And neither. They’re capturing different signals — long-term class vs. recent form — and the truth almost always lies between them.
Today we combine them into an ensemble — a weighted average of both models’ probabilities. We also add:
Confidence indicator — 🟢 High when models agree, 🔴 Low when they don’t
Click-to-expand analysis — see both models side-by-side, recent form badges (W-W-D-L-W), head-to-head record
Same UI shell as Days 1 and 2. Smarter, more honest predictions underneath.
The Big Idea Behind Ensembles
Ensembling is one of the most important ideas in modern statistics and machine learning: combining multiple models produces predictions that are more accurate than any single model.
Why does this work? Because different models make different kinds of mistakes. If model A tends to overrate historically strong teams and model B tends to overrate recent form, then averaging them cancels out both biases. The classic academic result is that ensembles reduce variance without adding bias — as long as the individual models are reasonably good and independent in their errors, the average almost always beats each one.
Random forests do this by averaging hundreds of decision trees. Gradient boosting stacks weak models. Even the Netflix Prize was won by an ensemble of dozens of models. The idea is universal.
For our app: Elo and Poisson are wonderfully independent. Elo bakes in every result since 1872 and moves slowly; Poisson only sees the last 3 years. When they agree on a match, we should be very confident. When they disagree, we should express uncertainty. That’s what today’s app does.
Project Task
Extend yesterday’s app so it:
Combines Elo and Poisson predictions into a weighted-average ensemble
Adds a third outcome (draw probability) to the Elo model so the two are directly comparable
Computes an agreement score between the two models (total-variation distance)
Shows a confidence indicator (🟢/🟡/🔴) with a plain-English description
Adds a click-to-expand section on each match card showing:
Both models’ individual predictions in a table
Recent form as W-D-L colored badges for each team’s last 5 matches
Head-to-head history between the two teams
Ranks the sidebar by a combined ensemble rank (average of Elo rank + attack rank)
Expected Output
Running the app:
streamlit run predictor_ensemble.py
Now the user has the full picture. France has won 5 straight, but Morocco has drawn Brazil and the Netherlands — both models’ predictions are defensible; the ensemble captures the genuine uncertainty; the confidence indicator communicates that honestly.
Compare with a different match — Norway vs England:
🟢 High confidence — The two models strongly agree.
Both models pick England at ~51%. Agreement 96%. The app tells you when to trust it and when to hedge.
Setup Instructions
No new dependencies:
pip install streamlit requests
streamlit run predictor_ensemble.py
Understanding Adding Draws to Elo
Yesterday’s Elo model gave us just two numbers: P(team1 wins), P(team2 wins). No draws. That’s a real limitation — international football sees roughly 25-30% draws, so pretending they don’t exist inflates the win probabilities.
Adding draws to Elo turns out to be surprisingly simple with a trick: draw probability grows as the teams get closer in rating. Two teams of equal Elo? Around 28% chance of a draw. Two teams with a huge gap? Almost no chance of a draw (the stronger team just wins).
def elo_predict(ratings, team1, team2, neutral=True):
r1 = ratings.get(team1, INITIAL_ELO) + (0 if neutral else HOME_ADVANTAGE)
r2 = ratings.get(team2, INITIAL_ELO)
p1_raw = expected_score(r1, r2) # standard Elo win probability
p2_raw = 1.0 - p1_raw
# How close are the teams? 1.0 = identical, 0.0 = 100% vs 0%
closeness = 1.0 - abs(p1_raw - 0.5) * 2
p_draw = 0.28 * closeness
# Scale down win probabilities to make room for the draw
p_win1 = p1_raw * (1.0 - p_draw)
p_win2 = p2_raw * (1.0 - p_draw)
return p_win1, p_draw, p_win2
Three moves worth understanding:
closeness = 1.0 - abs(p1_raw - 0.5) * 2— a clean formula that returns 1.0 when the teams are perfectly balanced (p_raw = 0.5) and 0.0 when it’s a total mismatch (p_raw = 0 or 1). Perfect for scaling draw probability.p_draw = 0.28 * closeness— the 0.28 is the “peak draw rate” for perfectly balanced teams. It’s an empirical constant from international football.Scaling
p_win1 *= (1 - p_draw)— we’re not adding a draw; we’re reallocating probability from wins to draws. That way our three numbers still sum to exactly 1.0.
The three probabilities sum to p1_raw * (1-p_draw) + p_draw + p2_raw * (1-p_draw) = (1-p_draw)(p1_raw + p2_raw) + p_draw = (1-p_draw) + p_draw = 1.0. ✓
Understanding the Ensemble Combination
The ensemble is the simplest possible thing: a weighted average of the two models’ probabilities:
def ensemble_predict(elo_probs, poisson_probs, w_elo=0.5, w_poi=0.5):
p1 = w_elo * elo_probs[0] + w_poi * poisson_probs[0]
pd = w_elo * elo_probs[1] + w_poi * poisson_probs[1]
p2 = w_elo * elo_probs[2] + w_poi * poisson_probs[2]
return p1, pd, p2
Because both inputs sum to 1.0, and the weights sum to 1.0, the ensemble automatically sums to 1.0 too. No normalization needed. Beautiful.
Choosing the weights matters. 50/50 is a good default — treat both models as equally valid. But you could bias toward one:
Weight Elo higher if you trust long-term class more (0.7 / 0.3 → Elo-dominant)
Weight Poisson higher during a hot tournament run when recent form matters more (0.3 / 0.7)
Learn the weights by testing on past tournaments and picking whatever gave the highest accuracy — the classic ML approach
For teaching purposes, 50/50 is honest: “we don’t know which model is right; we’ll trust them equally.”
Understanding Model Agreement
To measure how much the two models agree, we use total variation distance — a standard measure between two probability distributions:
def model_agreement(elo_probs, poisson_probs):
tv = 0.5 * sum(abs(e - p) for e, p in zip(elo_probs, poisson_probs))
return 1.0 - tv
Total variation distance (TV) is half the sum of absolute differences. It ranges from 0 (identical) to 1 (totally opposed). We flip it (1 - tv) so higher = more agreement — reads intuitively.
In practice:
Both models pick France at 55% → TV ≈ 0.05 → agreement ≈ 95% → 🟢 High confidence
Elo picks France, Poisson picks Morocco → TV ≈ 0.35 → agreement ≈ 65% → 🔴 Low confidence
The threshold values (0.90, 0.75) that separate green/yellow/red are chosen by feel — the goal is honest communication of uncertainty, not statistical precision.
Understanding Recent Form Badges
To show a team’s last 5 matches, we compute a W/D/L character per match:
def recent_form(team, form_index, n=5):
matches = form_index.get(team, [])[:n]
results = []
for m in matches:
is_home = m["home_team"] == team
my_score = int(m["home_score"] if is_home else m["away_score"])
opp_score = int(m["away_score"] if is_home else m["home_score"])
opp = m["away_team"] if is_home else m["home_team"]
if my_score > opp_score: r = "W"
elif my_score < opp_score: r = "L"
else: r = "D"
results.append((r, opp, f"{my_score}-{opp_score}", m["date"]))
return results
The trick is that each match has a home team and away team, but the team we’re asking about could be either. The is_home = m["home_team"] == team check normalizes: we always want our team’s score and their opponent’s score, regardless of who was nominally at home.
Then we render each result as a colored badge (green W, yellow D, red L) using inline HTML:
colors = {"W": "#22c55e", "D": "#eab308", "L": "#ef4444"}
badges = []
for r, opp, score, dt in form_results:
badges.append(
f"<span style='background:{colors[r]}; color:white; "
f"padding:2px 8px; border-radius:4px; font-weight:bold; "
f"margin-right:4px;' title='vs {opp} ({score}) on {dt}'>{r}</span>"
)
The title="..." attribute means users can hover any badge to see the match details. Small UX detail, big polish.
Understanding Building the Recent Form Index
Looking up a team’s recent matches quickly requires an index — a precomputed dict of team → list of matches, sorted newest first:
@st.cache_data(ttl=3600)
def build_recent_form_index(matches):
idx = defaultdict(list)
for m in matches:
if m["date"] < FORM_WINDOW_START:
continue
idx[m["home_team"]].append(m)
idx[m["away_team"]].append(m)
for team in idx:
idx[team].sort(key=lambda m: m["date"], reverse=True)
return dict(idx)
We iterate once through history, adding each match to both teams’ lists. Then sort each list by date descending. Lookup is O(1) — no more scanning through 49,000 matches for every card. Same “walk-once, index, then instant-lookup” pattern that shows up in every real data application.
Understanding the Ensemble Ranking Sidebar
The sidebar now ranks by a combined score — average of Elo rank and attack strength rank:
by_elo = sorted(wc_teams_list, key=lambda t: -ratings[t])
by_atk = sorted(wc_teams_list, key=lambda t: -strength[t]["attack"])
elo_rank = {t: i for i, t in enumerate(by_elo)}
atk_rank = {t: i for i, t in enumerate(by_atk)}
combined = sorted(wc_teams_list,
key=lambda t: elo_rank[t] + atk_rank[t])
Ranking by rank (rather than by raw value) is a subtle but important choice. Elo values range from ~1400 to ~2200; attack strengths range from ~0.5 to ~2.0. Averaging them directly would let Elo dominate purely due to scale.
Averaging ranks avoids this entirely. A team ranked 3rd by Elo and 7th by attack has a combined score of 10. A team ranked 1st by Elo and 20th by attack scores 21. Rank-based combinations are dimensionless — no scale bias, no magic normalization constants. Same trick works for any leaderboard that combines fundamentally different metrics.
Understanding Why This Is the Right Finale
Look at the arc of the week:
Day 1 built the Elo engine — the classic rating system.
Day 2 added a Poisson goal model — a fundamentally different lens.
Day 3 combines them into an ensemble with honest uncertainty.
Each layer added information without discarding the previous one. The Day 1 code still runs. The Day 2 code still runs. Day 3 is just a coordinator that talks to both.
This is the shape of every real ML system. You don’t build one model; you build several, and combine them. You don’t discard old models; you keep them running for comparison. You don’t hide model disagreement from users; you show it, because that’s what honest communication of uncertainty looks like.
What You’ve Accomplished This Week
🎉 Congratulations! You’ve built a complete World Cup Match Predictor:
Day 1: Elo rating system trained on 49,000+ historical matches
Day 2: Poisson goal model with expected goals and W/D/L splits
Day 3: Ensemble prediction with confidence indicators and click-to-expand analysis
You’ve built a real, working ensemble ML system. That’s not a beginner project. 🚀
View Code Evolution
Compare today’s ensemble with Day 1’s Elo predictor and Day 2’s Poisson model — and see how each layer added a new perspective without discarding the previous one. Same UI, three genuinely different models, one honest prediction.
Keep reading with a 7-day free trial
Subscribe to Daily Python Projects to keep reading this post and get 7 days of free access to the full post archives.




