Predict the FIFA World Cup 2026 Winner

Build an ML pipeline that simulates the 2026 World Cup 10,000 times.

Introduction

30 Second Summary

Every four years, billions of fans debate which team will lift the World Cup trophy. What if you could replace guesswork with data?

In this project, you will build a machine learning model that predicts the winner of the FIFA World Cup 2026 using 49,000+ historical match results, an Elo rating system, and Monte Carlo simulation.

What You'll Build

A complete World Cup 2026 prediction pipeline in a Google Colab notebook that loads historical data, computes Elo ratings, trains a classifier, and simulates the entire 48-team tournament 10,000 times to output championship probabilities.

By the end of this project, you'll have:

  • Elo ratings computed from scratch for all 48 qualified international teams, built from a dataset of 49,000+ historical matches.
  • A trained Gradient Boosting classifier that predicts match outcomes (Win, Draw, Loss) with calibrated probabilities.
  • A Monte Carlo simulation that runs the full 2026 tournament 10,000 times and produces championship probabilities for every qualified nation.
  • A live-update mechanism that locks in real tournament results and re-simulates remaining matches as the World Cup progresses.
  • Secret Mission: Visualize your predictions with a championship probability bar chart and a round-by-round advancement heatmap.

Are there any prerequisites?

You need basic Python knowledge (loops, functions, and DataFrames). No prior machine learning experience is required.

Set Up and Load the Data

Every prediction starts with data. To predict the 2026 FIFA World Cup winner, you need two things: historical match results to train your model, and the actual tournament structure to simulate.

In this step, you'll load a public dataset of 49,000+ international football matches into your notebook and define the official 2026 World Cup groups. This gives your model everything it needs to learn from history and simulate the future.

In this step, get ready to:
  • Verify that Google Colab has all required libraries pre-installed.
  • Load international match results from a public GitHub dataset.
  • Define the 48-team tournament groups and host nations.
Verify your Colab environment

Google Colab comes with all the major data science libraries pre-installed. Before writing any prediction logic, you'll confirm that pandas, NumPy, scikit-learn, and matplotlib are ready to go.

  • In your World Cup 2026 Predictor notebook, click the first empty cell and paste the following code:
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# Verify environment
print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print("Environment ready ✓")

What does this code do?

  • The import lines load the libraries you'll use throughout the project: pandas for data manipulation, numpy for numerical operations, sklearn for machine learning, and matplotlib for visualization.
  • warnings.filterwarnings('ignore') suppresses non-critical deprecation warnings that would clutter your notebook output.
  • The print statements confirm the libraries loaded correctly and show their version numbers.
  • Run the cell by pressing Shift+Enter.
  • You should see version numbers for pandas and numpy, followed by Environment ready ✓.

Seeing an ImportError?

This is rare in Google Colab since all libraries come pre-installed. If you see an error, make sure you're running the notebook in Google Colab (not a local Jupyter instance).

help me fix an ImportError in my Google Colab notebook for pandas, numpy, or scikit-learn

Load the international match results dataset

Your model needs historical match data to learn which teams tend to win. You'll load a public dataset from GitHub that contains every international football match ever played, then filter it to the modern era (2000 onwards) for more relevant predictions.

  • In a new cell below, paste the following code to load and filter the dataset:
# Load international match results from GitHub
url = "https://raw.githubusercontent.com/martj42/international_results/refs/heads/master/results.csv"
df = pd.read_csv(url, parse_dates=["date"])
print(f"\nDataset loaded: {df.shape[0]} matches from {df['date'].min().year} to {df['date'].max().year}")
print(f"Columns: {list(df.columns)}")

# Filter to modern era (2000 onwards) for training
df = df[df['date'] >= '2000-01-01'].copy()
print(f"Filtered to modern era: {df.shape[0]} matches")

What does this code do?

  • pd.read_csv(url, parse_dates=["date"]) downloads the CSV directly from GitHub and converts the date column to proper datetime objects.
  • The dataset contains columns for home_team, away_team, scores, tournament type, and whether the venue was neutral.
  • Filtering to 2000+ keeps only modern-era matches, which are far more predictive of current team strength than matches from the 1800s.
  • .copy() creates an independent DataFrame so pandas doesn't warn you when you modify it later.
  • Run the cell by pressing Shift+Enter.
  • You should see a message confirming the dataset loaded with thousands of matches, followed by the column names, and then a filtered count of modern-era matches.

Cell taking too long or erroring?

The dataset is about 3 MB, so loading may take a few seconds depending on your internet connection. If you see a connection error, check that your browser has internet access and try running the cell again.

help me troubleshoot why pd.read_csv is failing to load a CSV from a GitHub raw URL in Google Colab

Define the 2026 World Cup groups and qualified teams

Your model knows about historical matches, but it doesn't yet know which teams are in the 2026 World Cup or how the tournament is structured. You'll define the official group draw (12 groups of 4 teams each) and mark the host nations who get a home-field advantage.

  • In a new cell below, paste the following code to define the tournament structure:
# --- Define 2026 World Cup groups ---
groups_2026 = {
    'A': ['Mexico', 'South Korea', 'South Africa', 'Czechia'],
    'B': ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
    'C': ['Brazil', 'Morocco', 'Haiti', 'Scotland'],
    'D': ['United States', 'Paraguay', 'Australia', 'Turkey'],
    'E': ['Germany', 'Curaçao', 'Ivory Coast', 'Ecuador'],
    'F': ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
    'G': ['Belgium', 'Egypt', 'Iran', 'New Zealand'],
    'H': ['Spain', 'Cape Verde', 'Saudi Arabia', 'Uruguay'],
    'I': ['France', 'Senegal', 'Iraq', 'Norway'],
    'J': ['Argentina', 'Algeria', 'Austria', 'Jordan'],
    'K': ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
    'L': ['England', 'Croatia', 'Ghana', 'Panama'],
}

all_teams = [team for group in groups_2026.values() for team in group]
host_nations = ['United States', 'Mexico', 'Canada']
HOST_BONUS = 100

What does this code do?

  • groups_2026 is a dictionary mapping each group letter (A through L) to its 4 qualified teams, following the official FIFA draw from December 2025.
  • all_teams flattens all 48 teams into a single list so you can loop through them easily later.
  • host_nations identifies the USA, Mexico, and Canada as hosts. HOST_BONUS adds 100 Elo points to host teams when they play, reflecting the well-documented home-field advantage in international football.

The 2026 World Cup uses a new format: 48 teams in 12 groups of 4. The top 2 from each group plus the 8 best third-placed teams advance to a Round of 32 knockout stage.

  • Run the cell by pressing Shift+Enter.
  • You won't see any output from this cell. It defines variables that the next step uses to compute Elo ratings for every team.

Seeing a SyntaxError?

Double-check that every team name string is enclosed in matching quotes and that commas separate each list item. Special characters like the ç in Curaçao must be pasted exactly as shown.

help me fix a SyntaxError in my Python dictionary definition for World Cup groups

✔️ Awesome, I've got everything!

Great. Make sure all three cells have been run (you should see execution numbers like [1], [2], [3] to the left of each cell).

ⓧ I'd like to double check the full code

Here is all the code you should have across your three cells so far:

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# Verify environment
print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print("Environment ready ✓")

# Load international match results from GitHub
url = "https://raw.githubusercontent.com/martj42/international_results/refs/heads/master/results.csv"
df = pd.read_csv(url, parse_dates=["date"])
print(f"\nDataset loaded: {df.shape[0]} matches from {df['date'].min().year} to {df['date'].max().year}")
print(f"Columns: {list(df.columns)}")

# Filter to modern era (2000 onwards) for training
df = df[df['date'] >= '2000-01-01'].copy()
print(f"Filtered to modern era: {df.shape[0]} matches")

# --- Define 2026 World Cup groups ---
groups_2026 = {
    'A': ['Mexico', 'South Korea', 'South Africa', 'Czechia'],
    'B': ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
    'C': ['Brazil', 'Morocco', 'Haiti', 'Scotland'],
    'D': ['United States', 'Paraguay', 'Australia', 'Turkey'],
    'E': ['Germany', 'Curaçao', 'Ivory Coast', 'Ecuador'],
    'F': ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
    'G': ['Belgium', 'Egypt', 'Iran', 'New Zealand'],
    'H': ['Spain', 'Cape Verde', 'Saudi Arabia', 'Uruguay'],
    'I': ['France', 'Senegal', 'Iraq', 'Norway'],
    'J': ['Argentina', 'Algeria', 'Austria', 'Jordan'],
    'K': ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
    'L': ['England', 'Croatia', 'Ghana', 'Panama'],
}

all_teams = [team for group in groups_2026.values() for team in group]
host_nations = ['United States', 'Mexico', 'Canada']
HOST_BONUS = 100

Your data is loaded and your tournament structure is defined. Next up, you'll build an Elo rating system that computes the current strength of every qualified team from their historical match results.

Build the Elo Rating System

Your dataset is loaded and your tournament structure is defined. Now you need a way to measure how strong each team actually is.

The Elo rating system was originally invented for chess in 1960. It assigns every player (or team) a numerical rating that updates after each game based on whether they performed better or worse than expected. FIFA uses a variant of Elo for its own rankings, and every serious sports forecaster (FiveThirtyEight, Opta, bookmakers) builds on the same core idea.

In this step, get ready to:
  • Implement the Elo rating formula functions (expected score, rating update, K-factor weighting).
  • Process all historical matches to compute current Elo ratings for every qualified team.
Implement the Elo update formula

The Elo system relies on three pieces of logic working together. First, you calculate the expected score for a matchup. Then you update each team's rating based on whether the actual result was better or worse than expected. Finally, you weight the update by how important the match was.

  • In a new cell in your notebook, define the three Elo functions by pasting this code:
def expected_score(rating_a, rating_b):
    """Calculate expected score using logistic formula."""
    # Convert rating difference into a probability between 0 and 1
    return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))

def update_elo(rating, expected, actual, k_factor):
    """Update Elo rating after a match."""
    # Shift the rating up if team outperformed, down if underperformed
    return rating + k_factor * (actual - expected)

def get_k_factor(tournament):
    """Assign K-factor based on tournament importance."""
    tournament_lower = tournament.lower()
    # World Cup matches carry the most weight
    if 'world cup' in tournament_lower and 'qualification' not in tournament_lower:
        return 60
    elif 'qualification' in tournament_lower or 'nations league' in tournament_lower:
        return 40
    # Continental tournaments (Euros, Copa America, etc.)
    elif any(t in tournament_lower for t in ['copa america', 'euro', 'african cup', 'asian cup', 'gold cup']):
        return 50
    else:
        return 20

What does this code do?

These three functions form the core of the Elo system:

  • `expected_score` uses the logistic formula to convert a rating gap into a win probability. A team with 200 more Elo points than its opponent has roughly a 75% expected score.
  • `update_elo` applies the standard update: new_rating = old_rating + K * (actual - expected). If a team wins when they were expected to lose, their rating jumps. If they win an easy match, it barely moves.
  • `get_k_factor` weights updates by match importance. A World Cup result (K=60) moves ratings three times more than a friendly (K=20). This ensures recent competitive results matter most.

Why Elo over FIFA rankings?

Elo ratings are computed directly from the same match data you already loaded. There is no external API to call and no dependency on a third party. FIFA's own ranking system is essentially an Elo variant anyway.

By building Elo from scratch, you understand exactly how team strength is measured. This makes your later predictions fully transparent and reproducible.

  • Run the cell.

You will not see any output yet. The functions are now defined and ready for the next cell to use them.

Seeing a syntax error?

Check that you have matching parentheses on the expected_score return line. The formula has nested brackets: 1 / (1 + 10 ** ((rating_b - rating_a) / 400)).

Also confirm that the any() call inside get_k_factor has a list [...] inside the for expression.

help me fix a syntax error in my Elo rating functions

Process all historical matches to compute current Elo ratings

With the formula in place, you can now replay every international match since 2000 in chronological order. Each match updates both teams' ratings, so by the end of the loop you have an up-to-date strength estimate for every national team in the world.

  • In a new cell, paste the following code to iterate through all matches and build the ratings dictionary:
# Start every team at 1500 (the Elo baseline)
elo_ratings = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']
    home_score = match['home_score']
    away_score = match['away_score']
    tournament = match['tournament']

    # First time seeing a team? Initialize at 1500
    if home not in elo_ratings:
        elo_ratings[home] = 1500
    if away not in elo_ratings:
        elo_ratings[away] = 1500

    # Determine actual scores (1 = win, 0.5 = draw, 0 = loss)
    if home_score > away_score:
        home_actual, away_actual = 1.0, 0.0
    elif home_score < away_score:
        home_actual, away_actual = 0.0, 1.0
    else:
        home_actual, away_actual = 0.5, 0.5

    # Calculate what we expected to happen
    home_expected = expected_score(elo_ratings[home], elo_ratings[away])
    away_expected = expected_score(elo_ratings[away], elo_ratings[home])

    # Update both teams' ratings
    k = get_k_factor(tournament)
    elo_ratings[home] = update_elo(elo_ratings[home], home_expected, home_actual, k)
    elo_ratings[away] = update_elo(elo_ratings[away], away_expected, away_actual, k)

What does this code do?

This loop replays history match by match. For every game, it looks up both teams' current ratings, calculates what should have happened, then adjusts ratings based on what actually happened.

Teams that consistently beat stronger opponents climb the ladder. Teams that lose to weaker opponents fall. After thousands of matches, the ratings converge on a stable picture of each team's true strength.

  • Run the cell.

This may take a few seconds to process all matches. You will not see output yet.

  • In a new cell, extract the ratings for the 48 qualified teams and display the top 10 by pasting this code:
# Extract Elo ratings for all 48 qualified teams
qualified_elos = {team: elo_ratings.get(team, 1500) for team in all_teams}
sorted_teams = sorted(qualified_elos.items(), key=lambda x: x[1], reverse=True)
print("\n\U0001f3c6 Top 10 Teams by Elo Rating:")
for i, (team, elo) in enumerate(sorted_teams[:10], 1):
    print(f"  {i}. {team}: {elo:.0f}")

What does this code do?

`qualified_elos` filters the full ratings dictionary down to only the 48 teams in all_teams. If a team name does not appear in the historical data (unlikely but possible), it defaults to 1500.

The sorted output gives you a sanity check. You should see historically dominant teams like Argentina, France, Brazil, or Spain near the top. If a tiny nation appears at #1, something went wrong in the loop.

  • Run the cell.
  • You should see a ranked list of the top 10 qualified teams with their Elo scores. Expect strong teams (likely Argentina, France, Spain, Brazil, England, Germany) clustered near the top with ratings between 1700 and 1900.

Seeing unexpected teams at the top?

If a team you would not expect appears in the top 10, check that the team names in groups_2026 match what the dataset uses. For example, the dataset may use "Korea Republic" instead of "South Korea", or "IR Iran" instead of "Iran". You can check with df[df['home_team'].str.contains('Korea')] to find the exact spelling.

help me debug why my Elo ratings look wrong

✔️ Awesome, I've got everything!

Great. Double check you have run all three cells and that your top 10 output looks reasonable.

ⓧ I'd like to double check the full code

Here is the full notebook code up to this point:

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print("Environment ready \u2713")

url = "https://raw.githubusercontent.com/martj42/international_results/refs/heads/master/results.csv"
df = pd.read_csv(url, parse_dates=["date"])
print(f"\nDataset loaded: {df.shape[0]} matches from {df['date'].min().year} to {df['date'].max().year}")
print(f"Columns: {list(df.columns)}")

df = df[df['date'] >= '2000-01-01'].copy()
print(f"Filtered to modern era: {df.shape[0]} matches")

groups_2026 = {
    'A': ['Mexico', 'South Korea', 'South Africa', 'Czechia'],
    'B': ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
    'C': ['Brazil', 'Morocco', 'Haiti', 'Scotland'],
    'D': ['United States', 'Paraguay', 'Australia', 'Turkey'],
    'E': ['Germany', 'Cura\u00e7ao', 'Ivory Coast', 'Ecuador'],
    'F': ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
    'G': ['Belgium', 'Egypt', 'Iran', 'New Zealand'],
    'H': ['Spain', 'Cape Verde', 'Saudi Arabia', 'Uruguay'],
    'I': ['France', 'Senegal', 'Iraq', 'Norway'],
    'J': ['Argentina', 'Algeria', 'Austria', 'Jordan'],
    'K': ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
    'L': ['England', 'Croatia', 'Ghana', 'Panama'],
}

all_teams = [team for group in groups_2026.values() for team in group]
host_nations = ['United States', 'Mexico', 'Canada']
HOST_BONUS = 100

def expected_score(rating_a, rating_b):
    """Calculate expected score using logistic formula."""
    return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))

def update_elo(rating, expected, actual, k_factor):
    """Update Elo rating after a match."""
    return rating + k_factor * (actual - expected)

def get_k_factor(tournament):
    """Assign K-factor based on tournament importance."""
    tournament_lower = tournament.lower()
    if 'world cup' in tournament_lower and 'qualification' not in tournament_lower:
        return 60
    elif 'qualification' in tournament_lower or 'nations league' in tournament_lower:
        return 40
    elif any(t in tournament_lower for t in ['copa america', 'euro', 'african cup', 'asian cup', 'gold cup']):
        return 50
    else:
        return 20

elo_ratings = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']
    home_score = match['home_score']
    away_score = match['away_score']
    tournament = match['tournament']

    if home not in elo_ratings:
        elo_ratings[home] = 1500
    if away not in elo_ratings:
        elo_ratings[away] = 1500

    if home_score > away_score:
        home_actual, away_actual = 1.0, 0.0
    elif home_score < away_score:
        home_actual, away_actual = 0.0, 1.0
    else:
        home_actual, away_actual = 0.5, 0.5

    home_expected = expected_score(elo_ratings[home], elo_ratings[away])
    away_expected = expected_score(elo_ratings[away], elo_ratings[home])

    k = get_k_factor(tournament)
    elo_ratings[home] = update_elo(elo_ratings[home], home_expected, home_actual, k)
    elo_ratings[away] = update_elo(elo_ratings[away], away_expected, away_actual, k)

qualified_elos = {team: elo_ratings.get(team, 1500) for team in all_teams}
sorted_teams = sorted(qualified_elos.items(), key=lambda x: x[1], reverse=True)
print("\n\U0001f3c6 Top 10 Teams by Elo Rating:")
for i, (team, elo) in enumerate(sorted_teams[:10], 1):
    print(f"  {i}. {team}: {elo:.0f}")

Your Elo ratings are ready. Next up, you will use these ratings as features to train a machine learning model that predicts match outcomes.

Engineer Features and Train the Model

Your Elo ratings now capture how strong each team is based on decades of match history. The next question is: can a machine learning model use those ratings (plus other signals) to predict who wins a given match?

In this step, you will transform raw match data into a structured feature matrix, then train a Gradient Boosting classifier to predict match outcomes. The model will output calibrated probabilities for Win, Draw, and Loss.

In this step, get ready to:
  • Engineer five predictive features from each historical match.
  • Train a GradientBoostingClassifier on chronologically split data.
  • Evaluate model accuracy and inspect the classification report.
Engineer predictive features for each historical match

A machine learning model cannot learn from raw match records directly. It needs numeric features that capture the factors influencing the outcome.

  • Create a new code cell in your notebook and build the feature matrix by pasting this code:
# Build feature matrix from historical matches
features_list = []
targets_list = []

# Track recent goals for rolling average
team_goals = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']

    if home not in elo_ratings or away not in elo_ratings:
        continue

    # Get Elo ratings for both teams
    home_elo = elo_ratings.get(home, 1500)
    away_elo = elo_ratings.get(away, 1500)

    # Features
    elo_diff = home_elo - away_elo
    neutral = 1 if match['neutral'] == True else 0
    tournament_weight = get_k_factor(match['tournament']) / 60.0

    # Rolling goal average (last 5 matches)
    home_avg_goals = np.mean(team_goals.get(home, [1.5])[-5:])
    away_avg_goals = np.mean(team_goals.get(away, [1.5])[-5:])

    features_list.append([elo_diff, home_avg_goals, away_avg_goals, neutral, tournament_weight])

    # Target: 2 = Home Win, 1 = Draw, 0 = Away Win
    if match['home_score'] > match['away_score']:
        targets_list.append(2)
    elif match['home_score'] == match['away_score']:
        targets_list.append(1)
    else:
        targets_list.append(0)

    # Update rolling goals
    if home not in team_goals:
        team_goals[home] = []
    if away not in team_goals:
        team_goals[away] = []
    team_goals[home].append(match['home_score'])
    team_goals[away].append(match['away_score'])

X = np.array(features_list)
y = np.array(targets_list)
print(f"\nFeature matrix shape: {X.shape}")
print(f"Class distribution: Home Win={np.sum(y==2)}, Draw={np.sum(y==1)}, Away Win={np.sum(y==0)}")

What does this code do?

The loop iterates through every match chronologically and extracts five features for each one:

  • elo_diff: the Elo gap between home and away team. A higher gap means a stronger favourite.
  • home_avg_goals and away_avg_goals: rolling average of goals scored over each team's last 5 matches. This captures recent offensive form.
  • neutral: whether the venue is neutral (1) or not (0). Home advantage disappears on neutral ground.
  • tournament_weight: how important the match is (World Cup = 1.0, friendly = 0.33). Teams perform differently under pressure.

The target variable encodes the outcome as 2 (Home Win), 1 (Draw), or 0 (Away Win). Teams with no prior goal history default to 1.5 goals as a sensible starting estimate.

  • Run the cell by pressing Shift+Enter.
  • You should see the feature matrix shape printed (thousands of matches, 5 features) along with the class distribution showing counts for Home Win, Draw, and Away Win.

Seeing a KeyError or empty output?

Make sure you ran the Elo computation cell from the previous step first. The elo_ratings dictionary must exist before this cell runs.

If you restarted the Colab runtime, re-run all cells from the top using Runtime > Run all.

help me debug my feature engineering cell

Train the Gradient Boosting classifier with a time-based split

Most ML tutorials split data randomly, but that would let the model peek at future results. A chronological split ensures the model only trains on older matches and tests on recent ones.

Why a chronological split instead of random?

International football evolves over time. Teams rise and fall, tactics change, and new players emerge. A random split would mix 2020 matches into training and 2005 matches into testing, letting the model "cheat" by seeing the future.

Splitting by time (train on the first 80% of matches, test on the most recent 20%) simulates how the model will actually be used: predicting future outcomes based only on past data.

  • Create a new code cell and train the model by pasting this code:
# Time-based split (train on older data, test on recent)
split_idx = int(len(X) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

# Train Gradient Boosting Classifier
model = GradientBoostingClassifier(
    n_estimators=200,
    max_depth=4,
    learning_rate=0.1,
    random_state=42
)
model.fit(X_train, y_train)

# Evaluate
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\n📊 Model Accuracy: {accuracy:.2%}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=['Away Win', 'Draw', 'Home Win']))

What does this code do?

split_idx takes the first 80% of matches (chronologically) for training and holds out the last 20% for testing. No future data leaks into training.

GradientBoostingClassifier builds an ensemble of 200 decision trees, each correcting the errors of the previous one. The max_depth=4 keeps individual trees shallow to prevent overfitting. The learning_rate=0.1 controls how aggressively each tree corrects errors.

The classification report shows precision, recall, and F1-score for each outcome class. The model also supports predict_proba which outputs calibrated probabilities. You will use those probabilities in the next step to simulate tournament outcomes.

  • Run the cell by pressing Shift+Enter.
  • You should see the model accuracy printed as a percentage, followed by a classification report table with columns for precision, recall, f1-score, and support.

Training takes too long or hangs?

Gradient Boosting with 200 estimators on thousands of samples should finish within 10-30 seconds in Colab. If it hangs, check that your feature matrix X is not unexpectedly large by running X.shape in a new cell.

If you see a shape with millions of rows, re-check that you filtered df to matches from 2000 onwards in Step 1.

help me debug my model training

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to this point:

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print("Environment ready ✓")

url = "https://raw.githubusercontent.com/martj42/international_results/refs/heads/master/results.csv"
df = pd.read_csv(url, parse_dates=["date"])
print(f"\nDataset loaded: {df.shape[0]} matches from {df['date'].min().year} to {df['date'].max().year}")
print(f"Columns: {list(df.columns)}")

df = df[df['date'] >= '2000-01-01'].copy()
print(f"Filtered to modern era: {df.shape[0]} matches")

groups_2026 = {
    'A': ['Mexico', 'South Korea', 'South Africa', 'Czechia'],
    'B': ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
    'C': ['Brazil', 'Morocco', 'Haiti', 'Scotland'],
    'D': ['United States', 'Paraguay', 'Australia', 'Turkey'],
    'E': ['Germany', 'Curaçao', 'Ivory Coast', 'Ecuador'],
    'F': ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
    'G': ['Belgium', 'Egypt', 'Iran', 'New Zealand'],
    'H': ['Spain', 'Cape Verde', 'Saudi Arabia', 'Uruguay'],
    'I': ['France', 'Senegal', 'Iraq', 'Norway'],
    'J': ['Argentina', 'Algeria', 'Austria', 'Jordan'],
    'K': ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
    'L': ['England', 'Croatia', 'Ghana', 'Panama'],
}

all_teams = [team for group in groups_2026.values() for team in group]
host_nations = ['United States', 'Mexico', 'Canada']
HOST_BONUS = 100

def expected_score(rating_a, rating_b):
    return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))

def update_elo(rating, expected, actual, k_factor):
    return rating + k_factor * (actual - expected)

def get_k_factor(tournament):
    tournament_lower = tournament.lower()
    if 'world cup' in tournament_lower and 'qualification' not in tournament_lower:
        return 60
    elif 'qualification' in tournament_lower or 'nations league' in tournament_lower:
        return 40
    elif any(t in tournament_lower for t in ['copa america', 'euro', 'african cup', 'asian cup', 'gold cup']):
        return 50
    else:
        return 20

elo_ratings = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']
    home_score = match['home_score']
    away_score = match['away_score']
    tournament = match['tournament']

    if home not in elo_ratings:
        elo_ratings[home] = 1500
    if away not in elo_ratings:
        elo_ratings[away] = 1500

    if home_score > away_score:
        home_actual, away_actual = 1.0, 0.0
    elif home_score < away_score:
        home_actual, away_actual = 0.0, 1.0
    else:
        home_actual, away_actual = 0.5, 0.5

    home_expected = expected_score(elo_ratings[home], elo_ratings[away])
    away_expected = expected_score(elo_ratings[away], elo_ratings[home])

    k = get_k_factor(tournament)
    elo_ratings[home] = update_elo(elo_ratings[home], home_expected, home_actual, k)
    elo_ratings[away] = update_elo(elo_ratings[away], away_expected, away_actual, k)

qualified_elos = {team: elo_ratings.get(team, 1500) for team in all_teams}
sorted_teams = sorted(qualified_elos.items(), key=lambda x: x[1], reverse=True)
print("\n🏆 Top 10 Teams by Elo Rating:")
for i, (team, elo) in enumerate(sorted_teams[:10], 1):
    print(f"  {i}. {team}: {elo:.0f}")

features_list = []
targets_list = []
team_goals = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']

    if home not in elo_ratings or away not in elo_ratings:
        continue

    home_elo = elo_ratings.get(home, 1500)
    away_elo = elo_ratings.get(away, 1500)

    elo_diff = home_elo - away_elo
    neutral = 1 if match['neutral'] == True else 0
    tournament_weight = get_k_factor(match['tournament']) / 60.0

    home_avg_goals = np.mean(team_goals.get(home, [1.5])[-5:])
    away_avg_goals = np.mean(team_goals.get(away, [1.5])[-5:])

    features_list.append([elo_diff, home_avg_goals, away_avg_goals, neutral, tournament_weight])

    if match['home_score'] > match['away_score']:
        targets_list.append(2)
    elif match['home_score'] == match['away_score']:
        targets_list.append(1)
    else:
        targets_list.append(0)

    if home not in team_goals:
        team_goals[home] = []
    if away not in team_goals:
        team_goals[away] = []
    team_goals[home].append(match['home_score'])
    team_goals[away].append(match['away_score'])

X = np.array(features_list)
y = np.array(targets_list)
print(f"\nFeature matrix shape: {X.shape}")
print(f"Class distribution: Home Win={np.sum(y==2)}, Draw={np.sum(y==1)}, Away Win={np.sum(y==0)}")

split_idx = int(len(X) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

model = GradientBoostingClassifier(
    n_estimators=200,
    max_depth=4,
    learning_rate=0.1,
    random_state=42
)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\n📊 Model Accuracy: {accuracy:.2%}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=['Away Win', 'Draw', 'Home Win']))

Your model can now predict match outcomes with calibrated probabilities. Next up, you will use those probabilities to simulate the entire 2026 World Cup 10,000 times and calculate each team's chance of lifting the trophy.

Run the Monte Carlo Tournament Simulation

Your model can now predict match outcomes with calibrated probabilities. The next question is: what does that mean for the entire tournament?

A single match prediction tells you who is likely to win one game. But a World Cup has 104 matches, and upsets compound. In this step, you will simulate the entire 2026 tournament thousands of times to calculate each team's probability of winning it all.

In this step, get ready to:
  • Write a match prediction function that uses the trained model to output win/draw/loss probabilities.
  • Simulate the full 48-team tournament 10,000 times using Monte Carlo methods.
  • Output championship probabilities for every qualified nation.
Write the match prediction function

The simulation needs a way to predict the outcome of any match between two teams. This function will be the engine that powers every simulated game in the tournament.

  • Create the predict_match function by pasting this code into a new cell:
def predict_match(team_a, team_b, neutral=True, knockout=False):
    """Predict match outcome probabilities using the trained model."""
    elo_a = qualified_elos.get(team_a, 1500)
    elo_b = qualified_elos.get(team_b, 1500)

    # Apply host bonus
    if team_a in host_nations:
        elo_a += HOST_BONUS
    if team_b in host_nations:
        elo_b += HOST_BONUS

    elo_diff = elo_a - elo_b
    avg_goals_a = np.mean(team_goals.get(team_a, [1.5])[-5:])
    avg_goals_b = np.mean(team_goals.get(team_b, [1.5])[-5:])
    neutral_flag = 1 if neutral else 0
    tournament_weight = 1.0  # World Cup = max importance

    features = np.array([[elo_diff, avg_goals_a, avg_goals_b, neutral_flag, tournament_weight]])
    probs = model.predict_proba(features)[0]

    # probs order matches model.classes_: [0=Away Win, 1=Draw, 2=Home Win]
    away_win_prob = probs[0]
    draw_prob = probs[1]
    home_win_prob = probs[2]

    if knockout:
        # Redistribute draw probability proportionally
        total = home_win_prob + away_win_prob
        home_win_prob = (home_win_prob / total) * (home_win_prob + draw_prob)
        away_win_prob = (away_win_prob / total) * (away_win_prob + draw_prob)
        draw_prob = 0.0

    return home_win_prob, draw_prob, away_win_prob

What does this function do?

This function takes two teams and returns three probabilities: home win, draw, and away win.

  • It looks up each team's Elo rating and adds a +100 bonus for host nations (USA, Mexico, Canada) to reflect home advantage.
  • It constructs the same 5 features the model was trained on (Elo difference, average goals, neutral venue, tournament weight) and calls predict_proba to get calibrated probabilities.
  • For knockout matches, draws are impossible. The function redistributes the draw probability proportionally between the two teams, so probabilities still sum to 1.0.

Why redistribute draw probability in knockouts?

In real knockout football, a draw after extra time leads to penalties. Rather than simulating penalties separately, professional forecasters redistribute the draw probability proportionally between the two teams. A stronger team absorbs a larger share of the draw probability, reflecting that they are more likely to win on penalties.

  • Run the cell. No output is expected yet since this is a function definition.

Seeing a NameError?

  • Make sure you ran all previous cells first. The function depends on qualified_elos, team_goals, host_nations, and model all existing in memory.
  • If you restarted your runtime, use **Runtime > Run all** to execute every cell from the top.

Why is my predict_match function throwing a NameError?

Simulate the full tournament 10,000 times

A single simulation of the tournament might crown an underdog due to random chance. By running 10,000 simulations, you get a stable probability distribution that reflects each team's true chances.

What is Monte Carlo simulation?

Monte Carlo simulation is a technique where you run a random process thousands of times and count the outcomes. Each individual run uses randomness (a coin flip weighted by probabilities), but across thousands of runs, the frequencies converge to the true probabilities.

This is exactly how professional forecasters like FiveThirtyEight and bookmakers generate tournament predictions. They do not say "Brazil will win." They say "Brazil has a 12% chance of winning."

  • Create the simulate_group function by pasting this code into a new cell:
def simulate_group(group_teams):
    """Simulate a group stage (round-robin) and return standings."""
    standings = {team: {'points': 0, 'gd': 0, 'goals': 0} for team in group_teams}

    # Each pair plays once
    for i in range(len(group_teams)):
        for j in range(i+1, len(group_teams)):
            team_a = group_teams[i]
            team_b = group_teams[j]
            hw, dw, aw = predict_match(team_a, team_b, neutral=True, knockout=False)

            rand = np.random.random()
            if rand < hw:
                standings[team_a]['points'] += 3
                standings[team_a]['gd'] += 1
                standings[team_a]['goals'] += 1
                standings[team_b]['gd'] -= 1
            elif rand < hw + dw:
                standings[team_a]['points'] += 1
                standings[team_b]['points'] += 1
            else:
                standings[team_b]['points'] += 3
                standings[team_b]['gd'] += 1
                standings[team_b]['goals'] += 1
                standings[team_a]['gd'] -= 1

    # Sort by points, then goal difference, then goals scored
    sorted_teams = sorted(
        standings.items(),
        key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']),
        reverse=True
    )
    return sorted_teams

What does this function do?

  • It takes 4 teams and simulates all 6 round-robin matches (each pair plays once).
  • For each match, it generates a random number and compares it against the predicted probabilities. If the random number falls in the "home win" range, team A gets 3 points. If it falls in the "draw" range, both get 1. Otherwise, team B gets 3.
  • The function returns teams sorted by points, then goal difference, then goals scored. This mirrors FIFA's actual tiebreaker rules.
  • Run the cell. No output expected.

Seeing a NameError for predict_match?

Make sure you ran the cell containing predict_match before this one. Colab cells must be run in order.

My simulate_group function cannot find predict_match. Help me fix my cell execution order.

  • Create the simulate_knockout function by pasting this code into the next cell:
def simulate_knockout(team_a, team_b):
    """Simulate a knockout match and return the winner."""
    hw, _, aw = predict_match(team_a, team_b, neutral=True, knockout=True)
    if np.random.random() < hw:
        return team_a
    else:
        return team_b

What does this function do?

This is a thin wrapper around predict_match with knockout=True. It calls the prediction function (which eliminates draws) and then flips a weighted coin to determine the winner. The stronger team wins more often, but upsets still happen.

  • Run the cell. No output expected.
  • Now create the simulate_tournament function that ties everything together. Paste this into a new cell:
def simulate_tournament():
    """Simulate the full 2026 World Cup once and return the winner."""
    # Group stage
    group_winners = []
    group_runners_up = []
    third_placed = []

    for group_name, teams in groups_2026.items():
        standings = simulate_group(teams)
        group_winners.append(standings[0][0])
        group_runners_up.append(standings[1][0])
        third_placed.append((standings[2][0], standings[2][1]))

    # Select 8 best third-placed teams
    third_placed.sort(key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']), reverse=True)
    best_third = [t[0] for t in third_placed[:8]]

    # Round of 32 (simplified bracket: winners vs third-placed, runners-up vs runners-up)
    round_of_32_teams = group_winners + group_runners_up + best_third
    np.random.shuffle(round_of_32_teams)

    # Knockout rounds
    current_round = round_of_32_teams
    while len(current_round) > 1:
        next_round = []
        for i in range(0, len(current_round), 2):
            if i + 1 < len(current_round):
                winner = simulate_knockout(current_round[i], current_round[i+1])
                next_round.append(winner)
            else:
                next_round.append(current_round[i])
        current_round = next_round

    return current_round[0]

What does this function do?

  • It simulates all 12 groups and collects the top 2 from each group plus the 8 best third-placed teams. That gives 32 teams advancing to the knockout stage.
  • The 2026 World Cup uses 12 groups of 4 with the top 2 plus 8 best third-placed teams advancing. This matches the official FIFA format.
  • For the knockout bracket, teams are shuffled randomly and paired off. The while loop keeps halving the field (Round of 32, Round of 16, Quarterfinals, Semifinals, Final) until one team remains.
  • Run the cell. No output expected.

Getting unexpected errors?

  • Check that you ran the cells for predict_match, simulate_group, and simulate_knockout before this one. simulate_tournament calls all three.
  • If you see a KeyError, check that groups_2026 is defined in an earlier cell and matches the team names exactly.

My simulate_tournament function is throwing an error. Help me debug it.

  • Now run the full Monte Carlo simulation by pasting this into a new cell:
# Run 10,000 tournament simulations
NUM_SIMULATIONS = 10000
print(f"\n⚽ Running {NUM_SIMULATIONS:,} tournament simulations...")

win_counts = {team: 0 for team in all_teams}
for i in range(NUM_SIMULATIONS):
    winner = simulate_tournament()
    win_counts[winner] += 1
    if (i + 1) % 2000 == 0:
        print(f"  Completed {i+1:,} simulations...")

# Calculate championship probabilities
win_probs = {team: count / NUM_SIMULATIONS * 100 for team, count in win_counts.items()}
sorted_probs = sorted(win_probs.items(), key=lambda x: x[1], reverse=True)

print(f"\n🏆 Championship Probabilities (Top 15):")
print("-" * 40)
for i, (team, prob) in enumerate(sorted_probs[:15], 1):
    bar = "█" * int(prob * 2)
    print(f"  {i:2d}. {team:<20s} {prob:5.1f}% {bar}")

What does this code do?

  • It runs the entire tournament simulation 10,000 times. Each run crowns a different champion based on weighted randomness.
  • The win_counts dictionary tallies how many times each team wins across all simulations.
  • Converting counts to percentages gives you each team's championship probability. A team that wins 1,500 out of 10,000 simulations has a 15% chance of lifting the trophy.
  • The progress messages print every 2,000 simulations so you know it is running.
  • Run the cell. This takes 1-3 minutes depending on your Colab runtime.
  • You should see progress updates followed by a ranked list of the top 15 teams with their championship probabilities as percentages and visual bars.

Simulation running too slowly?

  • Make sure your Colab runtime is connected (check the green checkmark in the top right). If disconnected, click **Reconnect**.
  • If it takes more than 5 minutes, temporarily reduce NUM_SIMULATIONS to 1000 to test that everything works, then run again with 10000 for final results.

My Monte Carlo simulation is extremely slow in Google Colab. Help me figure out why.

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook (Colab auto-saves, but you can press **Ctrl+S** to be sure).

ⓧ I'd like to double check the full code

Here is the complete code for your notebook up to this point:

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print("Environment ready ✓")

url = "https://raw.githubusercontent.com/martj42/international_results/refs/heads/master/results.csv"
df = pd.read_csv(url, parse_dates=["date"])
print(f"\nDataset loaded: {df.shape[0]} matches from {df['date'].min().year} to {df['date'].max().year}")
print(f"Columns: {list(df.columns)}")

df = df[df['date'] >= '2000-01-01'].copy()
print(f"Filtered to modern era: {df.shape[0]} matches")

groups_2026 = {
    'A': ['Mexico', 'South Korea', 'South Africa', 'Czechia'],
    'B': ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
    'C': ['Brazil', 'Morocco', 'Haiti', 'Scotland'],
    'D': ['United States', 'Paraguay', 'Australia', 'Turkey'],
    'E': ['Germany', 'Curaçao', 'Ivory Coast', 'Ecuador'],
    'F': ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
    'G': ['Belgium', 'Egypt', 'Iran', 'New Zealand'],
    'H': ['Spain', 'Cape Verde', 'Saudi Arabia', 'Uruguay'],
    'I': ['France', 'Senegal', 'Iraq', 'Norway'],
    'J': ['Argentina', 'Algeria', 'Austria', 'Jordan'],
    'K': ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
    'L': ['England', 'Croatia', 'Ghana', 'Panama'],
}

all_teams = [team for group in groups_2026.values() for team in group]
host_nations = ['United States', 'Mexico', 'Canada']
HOST_BONUS = 100

def expected_score(rating_a, rating_b):
    return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))

def update_elo(rating, expected, actual, k_factor):
    return rating + k_factor * (actual - expected)

def get_k_factor(tournament):
    tournament_lower = tournament.lower()
    if 'world cup' in tournament_lower and 'qualification' not in tournament_lower:
        return 60
    elif 'qualification' in tournament_lower or 'nations league' in tournament_lower:
        return 40
    elif any(t in tournament_lower for t in ['copa america', 'euro', 'african cup', 'asian cup', 'gold cup']):
        return 50
    else:
        return 20

elo_ratings = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']
    home_score = match['home_score']
    away_score = match['away_score']
    tournament = match['tournament']

    if home not in elo_ratings:
        elo_ratings[home] = 1500
    if away not in elo_ratings:
        elo_ratings[away] = 1500

    if home_score > away_score:
        home_actual, away_actual = 1.0, 0.0
    elif home_score < away_score:
        home_actual, away_actual = 0.0, 1.0
    else:
        home_actual, away_actual = 0.5, 0.5

    home_expected = expected_score(elo_ratings[home], elo_ratings[away])
    away_expected = expected_score(elo_ratings[away], elo_ratings[home])

    k = get_k_factor(tournament)
    elo_ratings[home] = update_elo(elo_ratings[home], home_expected, home_actual, k)
    elo_ratings[away] = update_elo(elo_ratings[away], away_expected, away_actual, k)

qualified_elos = {team: elo_ratings.get(team, 1500) for team in all_teams}
sorted_teams = sorted(qualified_elos.items(), key=lambda x: x[1], reverse=True)
print("\n🏆 Top 10 Teams by Elo Rating:")
for i, (team, elo) in enumerate(sorted_teams[:10], 1):
    print(f"  {i}. {team}: {elo:.0f}")

features_list = []
targets_list = []
team_goals = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']

    if home not in elo_ratings or away not in elo_ratings:
        continue

    home_elo = elo_ratings.get(home, 1500)
    away_elo = elo_ratings.get(away, 1500)

    elo_diff = home_elo - away_elo
    neutral = 1 if match['neutral'] == True else 0
    tournament_weight = get_k_factor(match['tournament']) / 60.0

    home_avg_goals = np.mean(team_goals.get(home, [1.5])[-5:])
    away_avg_goals = np.mean(team_goals.get(away, [1.5])[-5:])

    features_list.append([elo_diff, home_avg_goals, away_avg_goals, neutral, tournament_weight])

    if match['home_score'] > match['away_score']:
        targets_list.append(2)
    elif match['home_score'] == match['away_score']:
        targets_list.append(1)
    else:
        targets_list.append(0)

    if home not in team_goals:
        team_goals[home] = []
    if away not in team_goals:
        team_goals[away] = []
    team_goals[home].append(match['home_score'])
    team_goals[away].append(match['away_score'])

X = np.array(features_list)
y = np.array(targets_list)
print(f"\nFeature matrix shape: {X.shape}")
print(f"Class distribution: Home Win={np.sum(y==2)}, Draw={np.sum(y==1)}, Away Win={np.sum(y==0)}")

split_idx = int(len(X) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

model = GradientBoostingClassifier(
    n_estimators=200,
    max_depth=4,
    learning_rate=0.1,
    random_state=42
)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\n📊 Model Accuracy: {accuracy:.2%}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=['Away Win', 'Draw', 'Home Win']))

def predict_match(team_a, team_b, neutral=True, knockout=False):
    elo_a = qualified_elos.get(team_a, 1500)
    elo_b = qualified_elos.get(team_b, 1500)

    if team_a in host_nations:
        elo_a += HOST_BONUS
    if team_b in host_nations:
        elo_b += HOST_BONUS

    elo_diff = elo_a - elo_b
    avg_goals_a = np.mean(team_goals.get(team_a, [1.5])[-5:])
    avg_goals_b = np.mean(team_goals.get(team_b, [1.5])[-5:])
    neutral_flag = 1 if neutral else 0
    tournament_weight = 1.0

    features = np.array([[elo_diff, avg_goals_a, avg_goals_b, neutral_flag, tournament_weight]])
    probs = model.predict_proba(features)[0]

    away_win_prob = probs[0]
    draw_prob = probs[1]
    home_win_prob = probs[2]

    if knockout:
        total = home_win_prob + away_win_prob
        home_win_prob = (home_win_prob / total) * (home_win_prob + draw_prob)
        away_win_prob = (away_win_prob / total) * (away_win_prob + draw_prob)
        draw_prob = 0.0

    return home_win_prob, draw_prob, away_win_prob

def simulate_group(group_teams):
    standings = {team: {'points': 0, 'gd': 0, 'goals': 0} for team in group_teams}

    for i in range(len(group_teams)):
        for j in range(i+1, len(group_teams)):
            team_a = group_teams[i]
            team_b = group_teams[j]
            hw, dw, aw = predict_match(team_a, team_b, neutral=True, knockout=False)

            rand = np.random.random()
            if rand < hw:
                standings[team_a]['points'] += 3
                standings[team_a]['gd'] += 1
                standings[team_a]['goals'] += 1
                standings[team_b]['gd'] -= 1
            elif rand < hw + dw:
                standings[team_a]['points'] += 1
                standings[team_b]['points'] += 1
            else:
                standings[team_b]['points'] += 3
                standings[team_b]['gd'] += 1
                standings[team_b]['goals'] += 1
                standings[team_a]['gd'] -= 1

    sorted_teams = sorted(
        standings.items(),
        key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']),
        reverse=True
    )
    return sorted_teams

def simulate_knockout(team_a, team_b):
    hw, _, aw = predict_match(team_a, team_b, neutral=True, knockout=True)
    if np.random.random() < hw:
        return team_a
    else:
        return team_b

def simulate_tournament():
    group_winners = []
    group_runners_up = []
    third_placed = []

    for group_name, teams in groups_2026.items():
        standings = simulate_group(teams)
        group_winners.append(standings[0][0])
        group_runners_up.append(standings[1][0])
        third_placed.append((standings[2][0], standings[2][1]))

    third_placed.sort(key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']), reverse=True)
    best_third = [t[0] for t in third_placed[:8]]

    round_of_32_teams = group_winners + group_runners_up + best_third
    np.random.shuffle(round_of_32_teams)

    current_round = round_of_32_teams
    while len(current_round) > 1:
        next_round = []
        for i in range(0, len(current_round), 2):
            if i + 1 < len(current_round):
                winner = simulate_knockout(current_round[i], current_round[i+1])
                next_round.append(winner)
            else:
                next_round.append(current_round[i])
        current_round = next_round

    return current_round[0]

NUM_SIMULATIONS = 10000
print(f"\n⚽ Running {NUM_SIMULATIONS:,} tournament simulations...")

win_counts = {team: 0 for team in all_teams}
for i in range(NUM_SIMULATIONS):
    winner = simulate_tournament()
    win_counts[winner] += 1
    if (i + 1) % 2000 == 0:
        print(f"  Completed {i+1:,} simulations...")

win_probs = {team: count / NUM_SIMULATIONS * 100 for team, count in win_counts.items()}
sorted_probs = sorted(win_probs.items(), key=lambda x: x[1], reverse=True)

print(f"\n🏆 Championship Probabilities (Top 15):")
print("-" * 40)
for i, (team, prob) in enumerate(sorted_probs[:15], 1):
    bar = "█" * int(prob * 2)
    print(f"  {i:2d}. {team:<20s} {prob:5.1f}% {bar}")

You now have a complete probability distribution for the 2026 World Cup. Next up, you will build a live-update mechanism so you can lock in real results as the tournament progresses and watch your predictions evolve.

Build the Live Update Loop

Your model can now predict which team is most likely to win the 2026 World Cup. But a prediction made before the tournament starts becomes stale the moment the first whistle blows.

In this step, you will build a live-update mechanism that locks in real match results as the tournament progresses. Instead of simulating every game, the model uses actual scores for matches already played and only simulates the remaining ones. This is how professional forecasters like FiveThirtyEight keep their predictions fresh throughout a tournament.

In this step, get ready to:
  • Build a live-update simulation function that respects actual results.
  • Test the system with a hypothetical Day 1 result and observe probability shifts.
Build the live-update mechanism

Right now, simulate_tournament() simulates every single match from scratch. You need a version that checks whether a match has already been played in real life before deciding to simulate it.

  • Create a new code cell and add the live-update results dictionary and simulation function by pasting the following code:
# Dictionary to store actual results as the tournament progresses
actual_results = {}
# Format: actual_results[('Team A', 'Team B')] = ('Team A', score_a, score_b)
# Example: actual_results[('Mexico', 'South Africa')] = ('Mexico', 2, 1)

def simulate_tournament_live(actual_results):
    """Simulate tournament with actual results locked in."""
    group_winners = []
    group_runners_up = []
    third_placed = []

    for group_name, teams in groups_2026.items():
        standings = {team: {'points': 0, 'gd': 0, 'goals': 0} for team in teams}

        for i in range(len(teams)):
            for j in range(i+1, len(teams)):
                team_a = teams[i]
                team_b = teams[j]

                # Check if this match has an actual result
                key = (team_a, team_b)
                key_rev = (team_b, team_a)

                if key in actual_results:
                    _, score_a, score_b = actual_results[key]
                    if score_a > score_b:
                        standings[team_a]['points'] += 3
                    elif score_a == score_b:
                        standings[team_a]['points'] += 1
                        standings[team_b]['points'] += 1
                    else:
                        standings[team_b]['points'] += 3
                    standings[team_a]['gd'] += score_a - score_b
                    standings[team_b]['gd'] += score_b - score_a
                    standings[team_a]['goals'] += score_a
                    standings[team_b]['goals'] += score_b
                elif key_rev in actual_results:
                    _, score_b_actual, score_a_actual = actual_results[key_rev]
                    if score_a_actual > score_b_actual:
                        standings[team_a]['points'] += 3
                    elif score_a_actual == score_b_actual:
                        standings[team_a]['points'] += 1
                        standings[team_b]['points'] += 1
                    else:
                        standings[team_b]['points'] += 3
                    standings[team_a]['gd'] += score_a_actual - score_b_actual
                    standings[team_b]['gd'] += score_b_actual - score_a_actual
                    standings[team_a]['goals'] += score_a_actual
                    standings[team_b]['goals'] += score_b_actual
                else:
                    # Simulate this match
                    hw, dw, aw = predict_match(team_a, team_b, neutral=True, knockout=False)
                    rand = np.random.random()
                    if rand < hw:
                        standings[team_a]['points'] += 3
                        standings[team_a]['gd'] += 1
                        standings[team_a]['goals'] += 1
                        standings[team_b]['gd'] -= 1
                    elif rand < hw + dw:
                        standings[team_a]['points'] += 1
                        standings[team_b]['points'] += 1
                    else:
                        standings[team_b]['points'] += 3
                        standings[team_b]['gd'] += 1
                        standings[team_b]['goals'] += 1
                        standings[team_a]['gd'] -= 1

        sorted_teams = sorted(
            standings.items(),
            key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']),
            reverse=True
        )
        group_winners.append(sorted_teams[0][0])
        group_runners_up.append(sorted_teams[1][0])
        third_placed.append((sorted_teams[2][0], sorted_teams[2][1]))

    third_placed.sort(key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']), reverse=True)
    best_third = [t[0] for t in third_placed[:8]]

    round_of_32_teams = group_winners + group_runners_up + best_third
    np.random.shuffle(round_of_32_teams)

    current_round = round_of_32_teams
    while len(current_round) > 1:
        next_round = []
        for i in range(0, len(current_round), 2):
            if i + 1 < len(current_round):
                winner = simulate_knockout(current_round[i], current_round[i+1])
                next_round.append(winner)
            else:
                next_round.append(current_round[i])
        current_round = next_round

    return current_round[0]

What does this code do?

The actual_results dictionary stores real match scores as tuples. Each key is a pair of team names, and the value is a tuple of (winner_name, score_a, score_b).

The simulate_tournament_live() function mirrors your original simulate_tournament() function, but with one key difference. Before simulating each group match, it checks both key and key_rev (reversed team order) in the results dictionary. If a real result exists, it uses the actual score instead of running the model.

Matches that have not been played yet fall through to the else branch and get simulated as before. This means your predictions get sharper with every real result you enter.

  • Run the cell. You should see no output yet since this cell only defines the dictionary and function.

Seeing a NameError?

If you see an error about groups_2026 or predict_match not being defined, make sure you have run all previous cells in order. In Google Colab, click Runtime then Run all to re-execute everything from the top.

Help me fix a NameError in my live update function.

Test the live update with example results

The live-update mechanism is ready. Now you will test it by entering a hypothetical opening match result and watching how the championship probabilities shift in response.

  • Create a new code cell and enter the live update demo by pasting the following code:
print("\n\U0001f4e1 LIVE UPDATE DEMO")
print("=" * 50)

# Hypothetical Day 1 result: Mexico beats South Africa 2-1
actual_results[('Mexico', 'South Africa')] = ('Mexico', 2, 1)
print("Entered result: Mexico 2-1 South Africa")

# Re-run simulation with actual results
print(f"\nRe-running {NUM_SIMULATIONS:,} simulations with live results...")
win_counts_live = {team: 0 for team in all_teams}
for i in range(NUM_SIMULATIONS):
    winner = simulate_tournament_live(actual_results)
    win_counts_live[winner] += 1

win_probs_live = {team: count / NUM_SIMULATIONS * 100 for team, count in win_counts_live.items()}
sorted_probs_live = sorted(win_probs_live.items(), key=lambda x: x[1], reverse=True)

print(f"\n\U0001f3c6 Updated Championship Probabilities (Top 10):")
print("-" * 40)
for i, (team, prob) in enumerate(sorted_probs_live[:10], 1):
    change = prob - win_probs.get(team, 0)
    arrow = "\u2191" if change > 0.1 else "\u2193" if change < -0.1 else "\u2192"
    print(f"  {i:2d}. {team:<20s} {prob:5.1f}% {arrow} ({change:+.1f}%)")

print("\n\u2705 To update during the tournament:")
print("   1. Add results: actual_results[('Team A', 'Team B')] = ('Team A', score_a, score_b)")
print("   2. Re-run the simulation cells above")

What does this code do?

This cell enters a hypothetical result for the World Cup opening match (Mexico 2-1 South Africa) into the actual_results dictionary. It then re-runs all 10,000 simulations using simulate_tournament_live() so that every simulation treats this match as settled fact.

The output compares each team's updated probability against their pre-tournament probability from win_probs. Arrows indicate whether a team's chances went up, down, or stayed the same.

  • Run the cell. You should see the "LIVE UPDATE DEMO" header, the entered result confirmation, and an updated Top 10 probability table with change arrows next to each team.

Mexico's championship probability should tick up slightly because locking in a group-stage win removes one source of uncertainty for them.

How do I update this during the real tournament?

The FIFA World Cup 2026 runs from 11 June to 19 July 2026. As each match finishes, add a new line to your actual_results dictionary following the format in the comments. Then re-run the simulation cell to see how the probabilities change.

Each real result you lock in removes randomness from one match, making your remaining predictions tighter and more confident.

Simulation taking too long?

The live simulation runs 10,000 iterations just like the original. If the cell takes more than a minute, check that you are not accidentally running it inside a loop from a previous cell. Each simulation cell should be self-contained.

Help me debug slow simulation performance in Google Colab.

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook in Google Colab.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to and including this step:

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print("Environment ready \u2713")

url = "https://raw.githubusercontent.com/martj42/international_results/refs/heads/master/results.csv"
df = pd.read_csv(url, parse_dates=["date"])
print(f"\nDataset loaded: {df.shape[0]} matches from {df['date'].min().year} to {df['date'].max().year}")
print(f"Columns: {list(df.columns)}")

df = df[df['date'] >= '2000-01-01'].copy()
print(f"Filtered to modern era: {df.shape[0]} matches")

groups_2026 = {
    'A': ['Mexico', 'South Korea', 'South Africa', 'Czechia'],
    'B': ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
    'C': ['Brazil', 'Morocco', 'Haiti', 'Scotland'],
    'D': ['United States', 'Paraguay', 'Australia', 'Turkey'],
    'E': ['Germany', 'Cura\u00e7ao', 'Ivory Coast', 'Ecuador'],
    'F': ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
    'G': ['Belgium', 'Egypt', 'Iran', 'New Zealand'],
    'H': ['Spain', 'Cape Verde', 'Saudi Arabia', 'Uruguay'],
    'I': ['France', 'Senegal', 'Iraq', 'Norway'],
    'J': ['Argentina', 'Algeria', 'Austria', 'Jordan'],
    'K': ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
    'L': ['England', 'Croatia', 'Ghana', 'Panama'],
}

all_teams = [team for group in groups_2026.values() for team in group]
host_nations = ['United States', 'Mexico', 'Canada']
HOST_BONUS = 100

def expected_score(rating_a, rating_b):
    return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))

def update_elo(rating, expected, actual, k_factor):
    return rating + k_factor * (actual - expected)

def get_k_factor(tournament):
    tournament_lower = tournament.lower()
    if 'world cup' in tournament_lower and 'qualification' not in tournament_lower:
        return 60
    elif 'qualification' in tournament_lower or 'nations league' in tournament_lower:
        return 40
    elif any(t in tournament_lower for t in ['copa america', 'euro', 'african cup', 'asian cup', 'gold cup']):
        return 50
    else:
        return 20

elo_ratings = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']
    home_score = match['home_score']
    away_score = match['away_score']
    tournament = match['tournament']

    if home not in elo_ratings:
        elo_ratings[home] = 1500
    if away not in elo_ratings:
        elo_ratings[away] = 1500

    if home_score > away_score:
        home_actual, away_actual = 1.0, 0.0
    elif home_score < away_score:
        home_actual, away_actual = 0.0, 1.0
    else:
        home_actual, away_actual = 0.5, 0.5

    home_expected = expected_score(elo_ratings[home], elo_ratings[away])
    away_expected = expected_score(elo_ratings[away], elo_ratings[home])

    k = get_k_factor(tournament)
    elo_ratings[home] = update_elo(elo_ratings[home], home_expected, home_actual, k)
    elo_ratings[away] = update_elo(elo_ratings[away], away_expected, away_actual, k)

qualified_elos = {team: elo_ratings.get(team, 1500) for team in all_teams}
sorted_teams = sorted(qualified_elos.items(), key=lambda x: x[1], reverse=True)
print("\n\U0001f3c6 Top 10 Teams by Elo Rating:")
for i, (team, elo) in enumerate(sorted_teams[:10], 1):
    print(f"  {i}. {team}: {elo:.0f}")

features_list = []
targets_list = []
team_goals = {}

for _, match in df.sort_values('date').iterrows():
    home = match['home_team']
    away = match['away_team']

    if home not in elo_ratings or away not in elo_ratings:
        continue

    home_elo = elo_ratings.get(home, 1500)
    away_elo = elo_ratings.get(away, 1500)

    elo_diff = home_elo - away_elo
    neutral = 1 if match['neutral'] == True else 0
    tournament_weight = get_k_factor(match['tournament']) / 60.0

    home_avg_goals = np.mean(team_goals.get(home, [1.5])[-5:])
    away_avg_goals = np.mean(team_goals.get(away, [1.5])[-5:])

    features_list.append([elo_diff, home_avg_goals, away_avg_goals, neutral, tournament_weight])

    if match['home_score'] > match['away_score']:
        targets_list.append(2)
    elif match['home_score'] == match['away_score']:
        targets_list.append(1)
    else:
        targets_list.append(0)

    if home not in team_goals:
        team_goals[home] = []
    if away not in team_goals:
        team_goals[away] = []
    team_goals[home].append(match['home_score'])
    team_goals[away].append(match['away_score'])

X = np.array(features_list)
y = np.array(targets_list)
print(f"\nFeature matrix shape: {X.shape}")
print(f"Class distribution: Home Win={np.sum(y==2)}, Draw={np.sum(y==1)}, Away Win={np.sum(y==0)}")

split_idx = int(len(X) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

model = GradientBoostingClassifier(
    n_estimators=200,
    max_depth=4,
    learning_rate=0.1,
    random_state=42
)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\n\U0001f4ca Model Accuracy: {accuracy:.2%}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=['Away Win', 'Draw', 'Home Win']))

def predict_match(team_a, team_b, neutral=True, knockout=False):
    elo_a = qualified_elos.get(team_a, 1500)
    elo_b = qualified_elos.get(team_b, 1500)

    if team_a in host_nations:
        elo_a += HOST_BONUS
    if team_b in host_nations:
        elo_b += HOST_BONUS

    elo_diff = elo_a - elo_b
    avg_goals_a = np.mean(team_goals.get(team_a, [1.5])[-5:])
    avg_goals_b = np.mean(team_goals.get(team_b, [1.5])[-5:])
    neutral_flag = 1 if neutral else 0
    tournament_weight = 1.0

    features = np.array([[elo_diff, avg_goals_a, avg_goals_b, neutral_flag, tournament_weight]])
    probs = model.predict_proba(features)[0]

    away_win_prob = probs[0]
    draw_prob = probs[1]
    home_win_prob = probs[2]

    if knockout:
        total = home_win_prob + away_win_prob
        home_win_prob = (home_win_prob / total) * (home_win_prob + draw_prob)
        away_win_prob = (away_win_prob / total) * (away_win_prob + draw_prob)
        draw_prob = 0.0

    return home_win_prob, draw_prob, away_win_prob

def simulate_group(group_teams):
    standings = {team: {'points': 0, 'gd': 0, 'goals': 0} for team in group_teams}

    for i in range(len(group_teams)):
        for j in range(i+1, len(group_teams)):
            team_a = group_teams[i]
            team_b = group_teams[j]
            hw, dw, aw = predict_match(team_a, team_b, neutral=True, knockout=False)

            rand = np.random.random()
            if rand < hw:
                standings[team_a]['points'] += 3
                standings[team_a]['gd'] += 1
                standings[team_a]['goals'] += 1
                standings[team_b]['gd'] -= 1
            elif rand < hw + dw:
                standings[team_a]['points'] += 1
                standings[team_b]['points'] += 1
            else:
                standings[team_b]['points'] += 3
                standings[team_b]['gd'] += 1
                standings[team_b]['goals'] += 1
                standings[team_a]['gd'] -= 1

    sorted_teams = sorted(
        standings.items(),
        key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']),
        reverse=True
    )
    return sorted_teams

def simulate_knockout(team_a, team_b):
    hw, _, aw = predict_match(team_a, team_b, neutral=True, knockout=True)
    if np.random.random() < hw:
        return team_a
    else:
        return team_b

def simulate_tournament():
    group_winners = []
    group_runners_up = []
    third_placed = []

    for group_name, teams in groups_2026.items():
        standings = simulate_group(teams)
        group_winners.append(standings[0][0])
        group_runners_up.append(standings[1][0])
        third_placed.append((standings[2][0], standings[2][1]))

    third_placed.sort(key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']), reverse=True)
    best_third = [t[0] for t in third_placed[:8]]

    round_of_32_teams = group_winners + group_runners_up + best_third
    np.random.shuffle(round_of_32_teams)

    current_round = round_of_32_teams
    while len(current_round) > 1:
        next_round = []
        for i in range(0, len(current_round), 2):
            if i + 1 < len(current_round):
                winner = simulate_knockout(current_round[i], current_round[i+1])
                next_round.append(winner)
            else:
                next_round.append(current_round[i])
        current_round = next_round

    return current_round[0]

NUM_SIMULATIONS = 10000
print(f"\n\u26bd Running {NUM_SIMULATIONS:,} tournament simulations...")

win_counts = {team: 0 for team in all_teams}
for i in range(NUM_SIMULATIONS):
    winner = simulate_tournament()
    win_counts[winner] += 1
    if (i + 1) % 2000 == 0:
        print(f"  Completed {i+1:,} simulations...")

win_probs = {team: count / NUM_SIMULATIONS * 100 for team, count in win_counts.items()}
sorted_probs = sorted(win_probs.items(), key=lambda x: x[1], reverse=True)

print(f"\n\U0001f3c6 Championship Probabilities (Top 15):")
print("-" * 40)
for i, (team, prob) in enumerate(sorted_probs[:15], 1):
    bar = "\u2588" * int(prob * 2)
    print(f"  {i:2d}. {team:<20s} {prob:5.1f}% {bar}")

actual_results = {}

def simulate_tournament_live(actual_results):
    """Simulate tournament with actual results locked in."""
    group_winners = []
    group_runners_up = []
    third_placed = []

    for group_name, teams in groups_2026.items():
        standings = {team: {'points': 0, 'gd': 0, 'goals': 0} for team in teams}

        for i in range(len(teams)):
            for j in range(i+1, len(teams)):
                team_a = teams[i]
                team_b = teams[j]

                key = (team_a, team_b)
                key_rev = (team_b, team_a)

                if key in actual_results:
                    _, score_a, score_b = actual_results[key]
                    if score_a > score_b:
                        standings[team_a]['points'] += 3
                    elif score_a == score_b:
                        standings[team_a]['points'] += 1
                        standings[team_b]['points'] += 1
                    else:
                        standings[team_b]['points'] += 3
                    standings[team_a]['gd'] += score_a - score_b
                    standings[team_b]['gd'] += score_b - score_a
                    standings[team_a]['goals'] += score_a
                    standings[team_b]['goals'] += score_b
                elif key_rev in actual_results:
                    _, score_b_actual, score_a_actual = actual_results[key_rev]
                    if score_a_actual > score_b_actual:
                        standings[team_a]['points'] += 3
                    elif score_a_actual == score_b_actual:
                        standings[team_a]['points'] += 1
                        standings[team_b]['points'] += 1
                    else:
                        standings[team_b]['points'] += 3
                    standings[team_a]['gd'] += score_a_actual - score_b_actual
                    standings[team_b]['gd'] += score_b_actual - score_a_actual
                    standings[team_a]['goals'] += score_a_actual
                    standings[team_b]['goals'] += score_b_actual
                else:
                    hw, dw, aw = predict_match(team_a, team_b, neutral=True, knockout=False)
                    rand = np.random.random()
                    if rand < hw:
                        standings[team_a]['points'] += 3
                        standings[team_a]['gd'] += 1
                        standings[team_a]['goals'] += 1
                        standings[team_b]['gd'] -= 1
                    elif rand < hw + dw:
                        standings[team_a]['points'] += 1
                        standings[team_b]['points'] += 1
                    else:
                        standings[team_b]['points'] += 3
                        standings[team_b]['gd'] += 1
                        standings[team_b]['goals'] += 1
                        standings[team_a]['gd'] -= 1

        sorted_teams = sorted(
            standings.items(),
            key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']),
            reverse=True
        )
        group_winners.append(sorted_teams[0][0])
        group_runners_up.append(sorted_teams[1][0])
        third_placed.append((sorted_teams[2][0], sorted_teams[2][1]))

    third_placed.sort(key=lambda x: (x[1]['points'], x[1]['gd'], x[1]['goals']), reverse=True)
    best_third = [t[0] for t in third_placed[:8]]

    round_of_32_teams = group_winners + group_runners_up + best_third
    np.random.shuffle(round_of_32_teams)

    current_round = round_of_32_teams
    while len(current_round) > 1:
        next_round = []
        for i in range(0, len(current_round), 2):
            if i + 1 < len(current_round):
                winner = simulate_knockout(current_round[i], current_round[i+1])
                next_round.append(winner)
            else:
                next_round.append(current_round[i])
        current_round = next_round

    return current_round[0]

print("\n\U0001f4e1 LIVE UPDATE DEMO")
print("=" * 50)

actual_results[('Mexico', 'South Africa')] = ('Mexico', 2, 1)
print("Entered result: Mexico 2-1 South Africa")

print(f"\nRe-running {NUM_SIMULATIONS:,} simulations with live results...")
win_counts_live = {team: 0 for team in all_teams}
for i in range(NUM_SIMULATIONS):
    winner = simulate_tournament_live(actual_results)
    win_counts_live[winner] += 1

win_probs_live = {team: count / NUM_SIMULATIONS * 100 for team, count in win_counts_live.items()}
sorted_probs_live = sorted(win_probs_live.items(), key=lambda x: x[1], reverse=True)

print(f"\n\U0001f3c6 Updated Championship Probabilities (Top 10):")
print("-" * 40)
for i, (team, prob) in enumerate(sorted_probs_live[:10], 1):
    change = prob - win_probs.get(team, 0)
    arrow = "\u2191" if change > 0.1 else "\u2193" if change < -0.1 else "\u2192"
    print(f"  {i:2d}. {team:<20s} {prob:5.1f}% {arrow} ({change:+.1f}%)")

print("\n\u2705 To update during the tournament:")
print("   1. Add results: actual_results[('Team A', 'Team B')] = ('Team A', score_a, score_b)")
print("   2. Re-run the simulation cells above")

Your prediction model is now a living system. As real World Cup results roll in, you can lock them into the notebook and watch your championship probabilities update in real time. Next up, the Secret Mission challenges you to visualize these predictions with professional charts.

Secret mission

Visualize Your Predictions

Your model outputs raw numbers, but numbers alone don't tell a story. In this secret mission, you'll build professional visualizations that make your predictions instantly readable. Create a confederation-colored bar chart, a round-by-round advancement heatmap, and a "Biggest Movers" comparison that shows how a single result shifts the entire tournament forecast.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely in Google Colab using free, pre-installed libraries and a public dataset. There are no ongoing costs or paid resources to worry about.

Decide whether to keep your notebook for future tournament updates, or delete it entirely.

Resources you used:

  • Google Colab notebook ("World Cup 2026 Predictor") stored in your Google Drive.
  • Saved image files (championship_probabilities.png and advancement_heatmap.png) in the Colab runtime's temporary storage.

Keep everything running

No action needed. Choose this if you plan to re-run your predictions as the 2026 World Cup progresses.

  • Your notebook stays in Google Drive and is ready to use whenever you want to enter new match results.
  • The Colab runtime disconnects automatically after inactivity, but your notebook code is always saved.
  • Re-running all cells will regenerate the model, simulations, and visualizations from scratch.

Pause - I'll come back to this later

Disconnect the Colab runtime to free up the session, while keeping your notebook code intact.

  • In Google Colab, click Runtime in the top menu bar.
  • Click Disconnect and delete runtime.
  • This clears the temporary storage (including saved PNG files) but keeps your notebook code in Google Drive.
  • When you return, click Runtime then Run all to rebuild everything.

Delete - I don't want to use this again

Remove the notebook from your Google Drive entirely.

  • In Google Colab, click File in the top menu bar.
  • Click Locate in Drive to open the file in Google Drive.
  • Right-click the notebook file and select Move to trash.
  • This permanently removes the notebook and all its code from your Google Drive.

That's a Wrap!

That's a Wrap!

Nice work. You just built a real machine learning sports prediction system from scratch.

You went from 49,000+ raw match results all the way to calibrated championship probabilities for every team in the 2026 FIFA World Cup.

Here's what you learned:

  • Built an Elo rating system from historical international football matches to quantify team strength.
  • Trained a Gradient Boosting classifier to predict match outcomes and output calibrated probabilities using feature engineering.
  • Simulated the full 2026 FIFA World Cup 10,000 times using Monte Carlo methods and built a live-update mechanism to track evolving predictions as real results come in.
  • Secret Mission: Visualized championship probabilities with a confederation-colored bar chart and a round-by-round advancement heatmap.

Ready to quiz yourself?