Predict World Cup 2026 with ML

Build an ensemble ML model to predict FIFA World Cup 2026 match outcomes.

Introduction

30 Second Summary

The World Cup has millions of fans debating who will win each match. What if you could use data from over a century of football history to make those predictions with actual probability percentages?

In this project, you will build a machine learning prediction system that analyzes historical international football matches and outputs win probabilities for FIFA World Cup 2026 knockout games.

What You'll Build

A Python-based prediction pipeline that downloads 49,000+ historical matches, engineers domain-specific features like Elo ratings and team form, trains a soft-voting ensemble classification model, and outputs probability-based predictions for upcoming World Cup knockout matches.

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

  • A feature engineering pipeline that computes Elo ratings, recent form, and head-to-head records from raw historical match data.
  • A trained ensemble model combining Random Forest and Gradient Boosting classifiers with soft voting.
  • A formatted predictions table showing win, draw, and loss probabilities for each remaining World Cup 2026 knockout match.
  • Secret Mission: Build a Monte Carlo tournament simulator that runs 1000 bracket simulations to estimate each team's probability of winning the World Cup.

Are there any prerequisites?

This project assumes a basic understanding of classification, regression, random forests, decision trees, bagging, and boosting. You do not need prior experience with sports analytics or Elo rating systems.

Before We Start

To predict World Cup match outcomes, you need a machine learning pipeline that pulls historical match data and trains an ensemble model on it.

In this step, you will set up your Python environment, configure the Kaggle CLI to download the dataset, and install the libraries that power your prediction model.

In this step, get ready to:
  • Verify your Python version and create a virtual environment.
  • Install and authenticate the Kaggle CLI.
  • Download the historical international football results dataset.
Check your Python version

The libraries you will install (scikit-learn and pandas) require Python 3.11 or higher.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running this command:
python3 --version

✔️ I see version 3.11 or higher

Your Python version is compatible. Move on to the next substep.

ⓧ I see a version lower than 3.11

Your system has an older Python that won't work with the libraries in this project. You need to install Python 3.11 or higher alongside your existing version.

  • On macOS, download and run the latest Python 3.11 installer from python.org/downloads. This installs Python alongside your existing version. After installing, use python3.11 instead of python3 for the rest of this project.
  • On Windows, download and run the Python 3.11 installer from python.org/downloads. Check the box labelled Add Python to PATH during installation. After installing, use py -3.11 or python3.11 for the rest of this project.
  • After installing, confirm the new version by running:
python3.11 --version

ⓧ Command not found

Python is not installed or not on your PATH. You need to install Python 3.11 or higher.

  • On macOS, download and run the latest Python 3.11 installer from python.org/downloads. The installer places a symlink at /usr/local/bin/ and adds Python to your shell path.
  • On Windows, download and run the Python 3.11 installer from python.org/downloads. Check the box labelled Add Python to PATH during installation.
  • After installing, close and reopen your terminal, then confirm by running:
python3 --version
Install the Kaggle CLI and configure authentication

The Kaggle CLI lets you download datasets directly from the terminal. It needs an API key file to authenticate with your Kaggle account.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir worldcup-predictor
cd worldcup-predictor
  • Create a virtual environment by running:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here won't conflict with other projects on your machine.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

  • Install the project dependencies by running:
pip install scikit-learn pandas numpy kaggle

What are these packages?

  • scikit-learn provides the machine learning classifiers (Random Forest, Gradient Boosting, Voting Classifier).
  • pandas handles data loading and manipulation.
  • numpy provides numerical operations for probability calculations.
  • kaggle is the Kaggle CLI tool for downloading datasets.

Seeing install errors?

If you see an error about incompatible Python versions, make sure you created the virtual environment with Python 3.11 or higher. Recreate it with python3.11 -m venv venv if needed.

Help me troubleshoot pip install errors

Now you need to set up Kaggle authentication so the CLI can download datasets on your behalf.

  • Open your browser and go to https://www.kaggle.com/settings.
  • Scroll down to the API section.
  • Click Create New API Token. This downloads a file called kaggle.json.
  • Move kaggle.json to the Kaggle config folder on your machine:

macOS

  • Create the config directory and move the file by running:
mkdir -p ~/.kaggle
mv ~/Downloads/kaggle.json ~/.kaggle/kaggle.json
chmod 600 ~/.kaggle/kaggle.json

Windows

  • Create the config directory and move the file by running:
mkdir "$env:USERPROFILE\.kaggle" -Force
mv "$env:USERPROFILE\Downloads\kaggle.json" "$env:USERPROFILE\.kaggle\kaggle.json"

Where does kaggle.json go?

The Kaggle CLI looks for your credentials at ~/.kaggle/kaggle.json on macOS/Linux and C:\Users\<username>\.kaggle\kaggle.json on Windows. This file contains your API username and key, so chmod 600 restricts access to only your user account.

Seeing 'Could not find kaggle.json'?

Double check that the file is named exactly kaggle.json (not kaggle.json.txt) and is in the correct directory. Make sure you created the .kaggle folder before moving the file in.

Help me fix a kaggle.json not found error

Download and verify the dataset

The dataset you will use contains 49,000+ historical international football match results dating back to 1872. It is hosted on Kaggle under a Creative Commons license.

  • Download and extract the dataset by running:
kaggle datasets download -d martj42/international-football-results-from-1872-to-2017 --unzip

What does this command do?

  • kaggle datasets download tells the Kaggle CLI to fetch a dataset.
  • -d martj42/international-football-results-from-1872-to-2017 specifies the dataset slug on Kaggle. The slug says "1872 to 2017" but the dataset is continuously updated.
  • --unzip extracts the CSV files automatically so you can use them right away.
  • Confirm the files exist by running:
ls

You should see results.csv, shootouts.csv, and goalscorers.csv in the output.

What are these files?

  • results.csv contains every international match result (date, teams, scores, tournament, venue). This is the main file you will use.
  • shootouts.csv contains penalty shootout details.
  • goalscorers.csv contains individual goalscorer records.

Download failed or 403 Forbidden?

Make sure your kaggle.json file is in the right location and contains valid credentials. Try running kaggle datasets list first to test authentication. If that fails, re-download your API token from Kaggle settings.

Help me fix a Kaggle CLI authentication error

Your environment is ready and the raw data is on your machine. Next up, you will load the data into Python and engineer the features that teach your model how to assess team strength.

Load Data and Engineer Features

Your dataset is ready, but raw match results alone won't teach a machine learning model much. A score of 3-1 doesn't capture whether a team has been on a winning streak, how they perform against top-ranked opponents, or how their strength has shifted over the last decade.

In this step, you'll build a feature engineering module that transforms raw match results into meaningful predictive signals. You'll implement an Elo rating system, compute rolling performance metrics, and assemble a full feature matrix that your ensemble model can learn from.

In this step, get ready to:
  • Build an Elo rating system that tracks team strength over time.
  • Compute rolling features like recent form, goal averages, and head-to-head records.
  • Assemble a complete feature matrix with target labels for every match.
Create the Elo rating system

The Elo rating system assigns a numeric strength rating to each team. After every match, ratings update based on how surprising the result was. A win against a strong opponent yields a large rating boost. A win against a weak opponent barely moves the needle.

What is an Elo rating?

Originally invented for chess, the Elo rating system is now used across sports to quantify team strength as a single number. Every team starts at a baseline (1500 in our case). After each match, the winner gains points and the loser sheds points.

The key insight is that the size of the rating change depends on expectation. An upset (weaker team wins) creates a large swing. A favored team winning barely moves ratings. This makes the system self-correcting over time.

  • In VS Code, create a new file called features.py in your project folder.
  • Add the imports, Elo constants, and the two core Elo utility functions by pasting this code:
import pandas as pd
import numpy as np
from collections import defaultdict

# Elo rating constants
INITIAL_ELO = 1500
K_FACTORS = {"FIFA World Cup": 40, "default": 20}


def expected_score(elo_a, elo_b):
    """Calculate expected score for team A against team B."""
    return 1 / (1 + 10 ** ((elo_b - elo_a) / 400))


def update_elo(elo_a, elo_b, score_a, k_factor):
    """Update Elo rating for team A after a match."""
    expected = expected_score(elo_a, elo_b)
    return elo_a + k_factor * (score_a - expected)

What does this code do?

  • INITIAL_ELO = 1500 sets every team's starting rating. 1500 is the standard baseline used across Elo implementations.
  • K_FACTORS controls how much a single match can move a team's rating. World Cup matches use K=40 (high stakes, larger swings). Other matches use K=20 (more stable updates).
  • expected_score() converts the rating difference between two teams into a win probability using the logistic formula. A 200-point gap yields roughly 76% expected score for the stronger team.
  • update_elo() adjusts a team's rating after a match. The update is proportional to how surprising the result was (actual score minus expected score, scaled by K).

You won't see any output yet. The next chunk builds the main function that uses these utilities.

  • Now add the compute_elo_ratings() function below the update_elo function. This iterates through every match chronologically and builds a complete Elo history:

def compute_elo_ratings(df):
    """Compute Elo ratings for all teams chronologically."""
    elo_ratings = defaultdict(lambda: INITIAL_ELO)
    elo_history = []

    for _, row in df.iterrows():
        home = row["home_team"]
        away = row["away_team"]
        tournament = row["tournament"]

        home_elo = elo_ratings[home]
        away_elo = elo_ratings[away]

        elo_history.append(
            {"date": row["date"], "home_team": home, "away_team": away, "home_elo": home_elo, "away_elo": away_elo}
        )

        # Determine actual score (1 = win, 0.5 = draw, 0 = loss)
        if row["home_score"] > row["away_score"]:
            home_score_val = 1.0
        elif row["home_score"] == row["away_score"]:
            home_score_val = 0.5
        else:
            home_score_val = 0.0

        k = K_FACTORS.get(tournament, K_FACTORS["default"])

        elo_ratings[home] = update_elo(home_elo, away_elo, home_score_val, k)
        elo_ratings[away] = update_elo(away_elo, home_elo, 1 - home_score_val, k)

    return elo_ratings, pd.DataFrame(elo_history)

What does this code do?

This function processes every match in chronological order and maintains a running Elo rating for each team.

  • defaultdict(lambda: INITIAL_ELO) ensures any team encountered for the first time automatically starts at 1500.
  • Before updating, it records each team's current Elo into elo_history so we can look up what a team's rating was at any point in time.
  • Match results are converted to scores: 1.0 for a win, 0.5 for a draw, 0.0 for a loss. Both teams get updated symmetrically.
  • The function returns both the final ratings dictionary and a DataFrame of the full Elo history.
  • Save features.py.
  • Verify the module loads correctly by running this in your terminal:
python -c "from features import compute_elo_ratings; print('Elo system OK')"

You should see Elo system OK printed with no errors.

Seeing a ModuleNotFoundError?

  • Make sure your terminal is in the same folder as features.py. Run ls to confirm you can see the file.
  • Check that your virtual environment is still active. You should see the environment name in your terminal prompt.

Help me debug my features.py import error

Compute rolling team features

Elo captures long-term strength, but football is streaky. A team that won their last 5 matches is in different form than one that lost 4 of 5. Rolling features capture this short-term momentum that Elo alone misses.

  • Add the compute_team_form() function below compute_elo_ratings in features.py:

def compute_team_form(df, team, before_date, n_matches=5):
    """Compute win rate for a team over their last n matches before a given date."""
    team_matches = df[(df["date"] < before_date) & ((df["home_team"] == team) | (df["away_team"] == team))].tail(
        n_matches
    )

    if len(team_matches) == 0:
        return 0.5

    wins = 0
    for _, match in team_matches.iterrows():
        if match["home_team"] == team and match["home_score"] > match["away_score"]:
            wins += 1
        elif match["away_team"] == team and match["away_score"] > match["home_score"]:
            wins += 1

    return wins / len(team_matches)

What does this code do?

This function looks at a team's last 5 matches before a given date and returns their win rate (0.0 to 1.0). It handles teams playing as either home or away.

If a team has no prior matches (e.g., a new team in the dataset), it returns 0.5 as a neutral default. The before_date parameter prevents data leakage by only looking at past results.

You won't see a change yet. The next function computes goal-scoring averages.

  • Add the compute_goals_avg() function below compute_team_form:

def compute_goals_avg(df, team, before_date, n_matches=10):
    """Compute average goals scored and conceded over last n matches."""
    team_matches = df[(df["date"] < before_date) & ((df["home_team"] == team) | (df["away_team"] == team))].tail(
        n_matches
    )

    if len(team_matches) == 0:
        return 1.0, 1.0

    scored = 0
    conceded = 0
    for _, match in team_matches.iterrows():
        if match["home_team"] == team:
            scored += match["home_score"]
            conceded += match["away_score"]
        else:
            scored += match["away_score"]
            conceded += match["home_score"]

    n = len(team_matches)
    return scored / n, conceded / n

What does this code do?

This function computes how many goals a team scores and concedes on average over their last 10 matches. It returns two values: average goals scored and average goals conceded.

A team averaging 2.5 goals scored and 0.8 conceded is in strong attacking form with a solid defense. These averages give the model a different signal than pure win/loss data.

You won't see a change yet. The next function computes head-to-head records.

  • Add the compute_h2h() function below compute_goals_avg:

def compute_h2h(df, team_a, team_b, before_date):
    """Compute head-to-head record between two teams."""
    h2h = df[
        (df["date"] < before_date)
        & (
            ((df["home_team"] == team_a) & (df["away_team"] == team_b))
            | ((df["home_team"] == team_b) & (df["away_team"] == team_a))
        )
    ]

    a_wins = 0
    draws = 0
    b_wins = 0

    for _, match in h2h.iterrows():
        if match["home_score"] > match["away_score"]:
            winner = match["home_team"]
        elif match["home_score"] == match["away_score"]:
            draws += 1
            continue
        else:
            winner = match["away_team"]

        if winner == team_a:
            a_wins += 1
        else:
            b_wins += 1

    total = len(h2h)
    if total == 0:
        return 0.33, 0.33, 0.33

    return a_wins / total, draws / total, b_wins / total

What does this code do?

This function looks at all historical encounters between two specific teams and returns the win/draw/loss rates as three values. Some matchups have strong historical patterns (e.g., one team consistently dominates another).

If two teams have never played each other, it returns equal probabilities (0.33 each). This neutral default prevents missing data from crashing the model.

  • Save features.py.
  • Verify the rolling feature functions load correctly by running:
python -c "from features import compute_team_form, compute_goals_avg, compute_h2h; print('Rolling features OK')"

You should see Rolling features OK printed with no errors.

Seeing a SyntaxError?

  • Check that each function definition starts with def and ends with a return statement. A missing colon after the function signature is the most common cause.
  • Make sure indentation is consistent. Python requires 4 spaces per indent level.

Help me fix a SyntaxError in my features.py file

Build the feature matrix

Individual feature functions are ready. Now you need a function that iterates through every match, computes all features for that matchup, and assembles them into a matrix that scikit-learn can train on.

  • Add the build_feature_matrix() function at the bottom of features.py:

def build_feature_matrix(df, elo_history_df):
    """Build the full feature matrix for all matches."""
    features = []
    targets = []

    df_sorted = df.sort_values("date").reset_index(drop=True)

    for idx, row in df_sorted.iterrows():
        if idx < 100:
            continue

        home = row["home_team"]
        away = row["away_team"]
        date = row["date"]

        # Get Elo at match time
        elo_row = elo_history_df[
            (elo_history_df["date"] == date) & (elo_history_df["home_team"] == home) & (elo_history_df["away_team"] == away)
        ]

        if len(elo_row) == 0:
            continue

        home_elo = elo_row.iloc[0]["home_elo"]
        away_elo = elo_row.iloc[0]["away_elo"]

        # Form features
        home_form = compute_team_form(df_sorted, home, date, n_matches=5)
        away_form = compute_team_form(df_sorted, away, date, n_matches=5)

        # Goals average
        home_scored_avg, home_conceded_avg = compute_goals_avg(df_sorted, home, date, n_matches=10)
        away_scored_avg, away_conceded_avg = compute_goals_avg(df_sorted, away, date, n_matches=10)

        # Head-to-head
        h2h_home_win, h2h_draw, h2h_away_win = compute_h2h(df_sorted, home, away, date)

        # Neutral venue
        is_neutral = 1 if row["neutral"] else 0

        # Tournament weight (World Cup matches weighted higher)
        is_world_cup = 1 if "FIFA World Cup" in str(row["tournament"]) else 0

        feature_row = {
            "home_elo": home_elo,
            "away_elo": away_elo,
            "elo_diff": home_elo - away_elo,
            "home_form": home_form,
            "away_form": away_form,
            "home_scored_avg": home_scored_avg,
            "home_conceded_avg": home_conceded_avg,
            "away_scored_avg": away_scored_avg,
            "away_conceded_avg": away_conceded_avg,
            "h2h_home_win_rate": h2h_home_win,
            "h2h_draw_rate": h2h_draw,
            "h2h_away_win_rate": h2h_away_win,
            "is_neutral": is_neutral,
            "is_world_cup": is_world_cup,
        }

        features.append(feature_row)

        # Target variable
        if row["home_score"] > row["away_score"]:
            targets.append("home_win")
        elif row["home_score"] == row["away_score"]:
            targets.append("draw")
        else:
            targets.append("away_win")

    return pd.DataFrame(features), pd.Series(targets)

What does this code do?

This function assembles the complete training dataset by computing every feature for every match. It skips the first 100 matches to ensure enough historical data exists for meaningful Elo ratings and rolling averages.

  • For each match, it looks up the Elo ratings at that point in time, computes form and goals averages from prior matches, and fetches the head-to-head record.
  • The target variable maps each match to one of three classes: home_win, draw, or away_win.
  • The 14 features include Elo-based (3), form-based (2), goal averages (4), head-to-head (3), and contextual (2) signals.

Why skip the first 100 matches?

Elo ratings need a warm-up period. In the first few dozen matches, every team is still at 1500 regardless of actual strength. The features would be meaningless noise. Skipping the first 100 ensures ratings have had time to differentiate strong teams from weak ones.

  • Save features.py.
  • Verify the complete module loads successfully by running:
python -c "from features import build_feature_matrix; print('Feature matrix builder OK')"

You should see Feature matrix builder OK printed. This confirms all functions and imports are valid.

Seeing a NameError or ImportError?

  • A NameError means a function is referenced before it's defined. Make sure expected_score, update_elo, compute_elo_ratings, compute_team_form, compute_goals_avg, and compute_h2h all appear above build_feature_matrix in the file.
  • Check that import pandas as pd is at the very top of the file. The build_feature_matrix function uses pd.DataFrame and pd.Series on the last line.

Help me debug an import or name error in features.py

✔️ Awesome, I've got everything!

Great. Double check you've saved features.py and the import test passes.

ⓧ I'd like to double check the full code

Here's the complete features.py file. Compare yours against this:

import pandas as pd
import numpy as np
from collections import defaultdict

# Elo rating constants
INITIAL_ELO = 1500
K_FACTORS = {"FIFA World Cup": 40, "default": 20}


def expected_score(elo_a, elo_b):
    """Calculate expected score for team A against team B."""
    return 1 / (1 + 10 ** ((elo_b - elo_a) / 400))


def update_elo(elo_a, elo_b, score_a, k_factor):
    """Update Elo rating for team A after a match."""
    expected = expected_score(elo_a, elo_b)
    return elo_a + k_factor * (score_a - expected)


def compute_elo_ratings(df):
    """Compute Elo ratings for all teams chronologically."""
    elo_ratings = defaultdict(lambda: INITIAL_ELO)
    elo_history = []

    for _, row in df.iterrows():
        home = row["home_team"]
        away = row["away_team"]
        tournament = row["tournament"]

        home_elo = elo_ratings[home]
        away_elo = elo_ratings[away]

        elo_history.append(
            {"date": row["date"], "home_team": home, "away_team": away, "home_elo": home_elo, "away_elo": away_elo}
        )

        # Determine actual score (1 = win, 0.5 = draw, 0 = loss)
        if row["home_score"] > row["away_score"]:
            home_score_val = 1.0
        elif row["home_score"] == row["away_score"]:
            home_score_val = 0.5
        else:
            home_score_val = 0.0

        k = K_FACTORS.get(tournament, K_FACTORS["default"])

        elo_ratings[home] = update_elo(home_elo, away_elo, home_score_val, k)
        elo_ratings[away] = update_elo(away_elo, home_elo, 1 - home_score_val, k)

    return elo_ratings, pd.DataFrame(elo_history)


def compute_team_form(df, team, before_date, n_matches=5):
    """Compute win rate for a team over their last n matches before a given date."""
    team_matches = df[(df["date"] < before_date) & ((df["home_team"] == team) | (df["away_team"] == team))].tail(
        n_matches
    )

    if len(team_matches) == 0:
        return 0.5

    wins = 0
    for _, match in team_matches.iterrows():
        if match["home_team"] == team and match["home_score"] > match["away_score"]:
            wins += 1
        elif match["away_team"] == team and match["away_score"] > match["home_score"]:
            wins += 1

    return wins / len(team_matches)


def compute_goals_avg(df, team, before_date, n_matches=10):
    """Compute average goals scored and conceded over last n matches."""
    team_matches = df[(df["date"] < before_date) & ((df["home_team"] == team) | (df["away_team"] == team))].tail(
        n_matches
    )

    if len(team_matches) == 0:
        return 1.0, 1.0

    scored = 0
    conceded = 0
    for _, match in team_matches.iterrows():
        if match["home_team"] == team:
            scored += match["home_score"]
            conceded += match["away_score"]
        else:
            scored += match["away_score"]
            conceded += match["home_score"]

    n = len(team_matches)
    return scored / n, conceded / n


def compute_h2h(df, team_a, team_b, before_date):
    """Compute head-to-head record between two teams."""
    h2h = df[
        (df["date"] < before_date)
        & (
            ((df["home_team"] == team_a) & (df["away_team"] == team_b))
            | ((df["home_team"] == team_b) & (df["away_team"] == team_a))
        )
    ]

    a_wins = 0
    draws = 0
    b_wins = 0

    for _, match in h2h.iterrows():
        if match["home_score"] > match["away_score"]:
            winner = match["home_team"]
        elif match["home_score"] == match["away_score"]:
            draws += 1
            continue
        else:
            winner = match["away_team"]

        if winner == team_a:
            a_wins += 1
        else:
            b_wins += 1

    total = len(h2h)
    if total == 0:
        return 0.33, 0.33, 0.33

    return a_wins / total, draws / total, b_wins / total


def build_feature_matrix(df, elo_history_df):
    """Build the full feature matrix for all matches."""
    features = []
    targets = []

    df_sorted = df.sort_values("date").reset_index(drop=True)

    for idx, row in df_sorted.iterrows():
        if idx < 100:
            continue

        home = row["home_team"]
        away = row["away_team"]
        date = row["date"]

        # Get Elo at match time
        elo_row = elo_history_df[
            (elo_history_df["date"] == date) & (elo_history_df["home_team"] == home) & (elo_history_df["away_team"] == away)
        ]

        if len(elo_row) == 0:
            continue

        home_elo = elo_row.iloc[0]["home_elo"]
        away_elo = elo_row.iloc[0]["away_elo"]

        # Form features
        home_form = compute_team_form(df_sorted, home, date, n_matches=5)
        away_form = compute_team_form(df_sorted, away, date, n_matches=5)

        # Goals average
        home_scored_avg, home_conceded_avg = compute_goals_avg(df_sorted, home, date, n_matches=10)
        away_scored_avg, away_conceded_avg = compute_goals_avg(df_sorted, away, date, n_matches=10)

        # Head-to-head
        h2h_home_win, h2h_draw, h2h_away_win = compute_h2h(df_sorted, home, away, date)

        # Neutral venue
        is_neutral = 1 if row["neutral"] else 0

        # Tournament weight (World Cup matches weighted higher)
        is_world_cup = 1 if "FIFA World Cup" in str(row["tournament"]) else 0

        feature_row = {
            "home_elo": home_elo,
            "away_elo": away_elo,
            "elo_diff": home_elo - away_elo,
            "home_form": home_form,
            "away_form": away_form,
            "home_scored_avg": home_scored_avg,
            "home_conceded_avg": home_conceded_avg,
            "away_scored_avg": away_scored_avg,
            "away_conceded_avg": away_conceded_avg,
            "h2h_home_win_rate": h2h_home_win,
            "h2h_draw_rate": h2h_draw,
            "h2h_away_win_rate": h2h_away_win,
            "is_neutral": is_neutral,
            "is_world_cup": is_world_cup,
        }

        features.append(feature_row)

        # Target variable
        if row["home_score"] > row["away_score"]:
            targets.append("home_win")
        elif row["home_score"] == row["away_score"]:
            targets.append("draw")
        else:
            targets.append("away_win")

    return pd.DataFrame(features), pd.Series(targets)

Your feature engineering module is complete. Next up, you'll build the prediction script that loads the CSV data, feeds it through these functions, and trains the ensemble model.

Build the Feature Matrix

Your feature engineering functions are ready in features.py. Now you need a script that actually loads the match data, runs those functions, and prepares training data for the model.

In this step, you will create predictor.py, the main prediction pipeline. It loads historical matches, computes Elo ratings, builds a feature matrix from every match, and splits the data temporally so the model never sees future results during training.

In this step, get ready to:
  • Create the prediction pipeline script with data loading and Elo computation.
  • Build the feature matrix by calling the functions from features.py.
  • Split data temporally to prevent data leakage.
Create the prediction pipeline and load data

The prediction pipeline needs to load results.csv, parse dates so the data can be sorted chronologically, and filter out matches before 1990. Pre-1990 football has different competitive structures that add noise to modern predictions.

  • In VS Code, create a new file called predictor.py in the same folder as features.py.
  • Add the imports and data loading code by pasting the following into predictor.py:
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier, VotingClassifier
from sklearn.metrics import accuracy_score, classification_report
from features import compute_elo_ratings, build_feature_matrix, compute_team_form, compute_goals_avg, compute_h2h

# ============================================================
# Step 1: Load and prepare data
# ============================================================
print("Loading data...")
df = pd.read_csv("results.csv")
df["date"] = pd.to_datetime(df["date"])
df = df[df["date"] >= "1990-01-01"].reset_index(drop=True)
df = df.sort_values("date").reset_index(drop=True)
print(f"Loaded {len(df)} matches from 1990 onward")

What does this code do?

  • The imports pull in scikit-learn classifiers you will use in the next step, plus the helper functions you wrote in features.py.
  • pd.to_datetime(df["date"]) converts the date strings into proper datetime objects so you can filter and sort by date.
  • Filtering to 1990-01-01 onward removes matches from eras with very different competitive dynamics.
  • reset_index(drop=True) after filtering and sorting ensures a clean 0-based index with no gaps.
Compute Elo ratings and build the feature matrix

With the data loaded, the next step is to compute Elo ratings for every team across all matches, then use those ratings along with form, goals, and head-to-head stats to build the full feature matrix.

  • Add the following code below the data loading section in predictor.py:
# ============================================================
# Step 2: Compute Elo ratings
# ============================================================
print("Computing Elo ratings...")
elo_ratings, elo_history = compute_elo_ratings(df)
print(f"Computed Elo for {len(elo_ratings)} teams")

# ============================================================
# Step 3: Build feature matrix
# ============================================================
print("Building feature matrix (this may take a few minutes)...")
X, y = build_feature_matrix(df, elo_history)
print(f"Feature matrix shape: {X.shape}")
print(f"Target distribution:\n{y.value_counts()}")

What does this code do?

  • compute_elo_ratings(df) iterates through every match chronologically, updating each team's Elo after each game. It returns both the final ratings dictionary and a history DataFrame.
  • build_feature_matrix(df, elo_history) assembles 14 features per match: home/away Elo, Elo difference, form (win rate last 5), goals scored/conceded averages, head-to-head records, neutral venue flag, and tournament weight.
  • X is your feature DataFrame and y is the target Series containing home_win, draw, or away_win for each match.

This step takes a while

Building the feature matrix iterates through thousands of matches, computing rolling stats for each one. Expect it to take 2-5 minutes depending on your machine. You will run the script after adding the train/test split in the next substep.

Split data temporally

In time-series problems, you cannot shuffle data randomly into train and test sets. A random split would let the model see future match results during training. This is called data leakage.

Instead, you split at a fixed date: train on everything before 2024, and validate on 2024-2026 matches.

  • Add the temporal split code at the bottom of predictor.py:
# ============================================================
# Step 4: Temporal train/test split
# ============================================================
dates_for_split = df.sort_values("date").reset_index(drop=True)["date"].iloc[100:]
split_mask = dates_for_split >= "2024-01-01"

split_idx = len(X) - split_mask.sum()

X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]

print(f"\nTrain size: {len(X_train)}, Test size: {len(X_test)}")

Why split at 2024?

This mirrors real-world prediction: you only have access to past data when making future predictions. By training on pre-2024 matches and validating on 2024-2026 matches, you test whether the model generalizes to truly unseen results.

The iloc[100:] offset aligns with the feature matrix, which skips the first 100 matches to ensure each team has enough history for meaningful features.

  • Save predictor.py.
  • Run the script in your terminal by running:
python predictor.py

You should see output showing the number of matches loaded, the number of teams with Elo ratings, the feature matrix shape, target distribution, and the train/test sizes.

Seeing an import error?

  • If you see ModuleNotFoundError: No module named 'sklearn', make sure your virtual environment is activated and you installed scikit-learn in the previous step.
  • If you see ModuleNotFoundError: No module named 'features', check that features.py is in the same folder as predictor.py and that you are running the command from that folder.
  • If the script hangs on "Building feature matrix", that is normal. It processes thousands of matches and can take several minutes.

Still stuck? Help me debug my predictor.py import or runtime error

✔️ Awesome, I've got everything!

Great. Double check you have saved predictor.py with all four sections (imports, data loading, Elo computation, feature matrix, and temporal split).

ⓧ I'd like to double check the full code

Here is the full predictor.py as it should look at the end of this step:

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier, VotingClassifier
from sklearn.metrics import accuracy_score, classification_report
from features import compute_elo_ratings, build_feature_matrix, compute_team_form, compute_goals_avg, compute_h2h

# ============================================================
# Step 1: Load and prepare data
# ============================================================
print("Loading data...")
df = pd.read_csv("results.csv")
df["date"] = pd.to_datetime(df["date"])
df = df[df["date"] >= "1990-01-01"].reset_index(drop=True)
df = df.sort_values("date").reset_index(drop=True)
print(f"Loaded {len(df)} matches from 1990 onward")

# ============================================================
# Step 2: Compute Elo ratings
# ============================================================
print("Computing Elo ratings...")
elo_ratings, elo_history = compute_elo_ratings(df)
print(f"Computed Elo for {len(elo_ratings)} teams")

# ============================================================
# Step 3: Build feature matrix
# ============================================================
print("Building feature matrix (this may take a few minutes)...")
X, y = build_feature_matrix(df, elo_history)
print(f"Feature matrix shape: {X.shape}")
print(f"Target distribution:\n{y.value_counts()}")

# ============================================================
# Step 4: Temporal train/test split
# ============================================================
dates_for_split = df.sort_values("date").reset_index(drop=True)["date"].iloc[100:]
split_mask = dates_for_split >= "2024-01-01"

split_idx = len(X) - split_mask.sum()

X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]

print(f"\nTrain size: {len(X_train)}, Test size: {len(X_test)}")

Your feature matrix is built and your data is split. Next up, you will train the ensemble model and see how well it predicts match outcomes.

Train the Ensemble Model

Your feature matrix is ready and split into training and test sets. Now it's time to build the model that actually predicts match outcomes.

A single classifier can only learn one pattern from the data. By combining a Random Forest (which reduces variance through bagging) with a Gradient Boosting classifier (which reduces bias through sequential correction), you get a stronger ensemble that outperforms either model alone.

In this step, get ready to:
  • Train a Random Forest and Gradient Boosting classifier, then combine them into a soft-voting ensemble.
  • Evaluate your model's accuracy against a naive baseline.
  • Print feature importances to understand which features drive predictions.
Train the classifiers and build the ensemble

A VotingClassifier with voting='soft' averages the predicted probabilities from each sub-model instead of just counting votes. This gives more nuanced predictions for uncertain matches.

  • In predictor.py, find the line that says print(f"\nTrain size: {len(X_train)}, Test size: {len(X_test)}").
  • Add the following code below that line to train the ensemble model:
# ============================================================
# Step 5: Train ensemble model
# ============================================================
print("\nTraining ensemble model...")
rf = RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1)
gb = GradientBoostingClassifier(n_estimators=150, learning_rate=0.08, max_depth=4, random_state=42)

ensemble = VotingClassifier(
    estimators=[("rf", rf), ("gb", gb)],
    voting="soft",
)

ensemble.fit(X_train, y_train)

What does this code do?

  • rf creates a Random Forest with 200 decision trees, each limited to depth 8. The n_jobs=-1 flag uses all your CPU cores for faster training.
  • gb creates a Gradient Boosting classifier with 150 estimators and a learning rate of 0.08. The lower learning rate and shallower depth (4) make it learn more gradually.
  • VotingClassifier wraps both models. With voting="soft", it averages predicted probabilities rather than counting class votes.
  • ensemble.fit(X_train, y_train) trains both sub-models on the training data.

You won't see any output yet. The next chunk adds evaluation code so you can see how well the model performs.

Why soft voting instead of hard voting?

Hard voting picks whichever class gets the most "votes" from sub-models. Soft voting averages the probability distributions instead.

For football prediction, this matters because a 60% home win vs 55% home win carries different confidence levels. Soft voting preserves that nuance.

Evaluate the model against a naive baseline

A model's accuracy only means something when compared to a baseline. The simplest baseline for football prediction is "always predict the home team wins." Your ensemble needs to beat that to prove it learned something useful.

  • Add the following evaluation code directly below the ensemble.fit(X_train, y_train) line:
# ============================================================
# Step 6: Evaluate
# ============================================================
y_pred = ensemble.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\nModel Accuracy: {accuracy:.3f}")

# Naive baseline: always predict home_win
naive_accuracy = (y_test == "home_win").mean()
print(f"Naive Baseline (always home_win): {naive_accuracy:.3f}")
print(f"Improvement over baseline: +{(accuracy - naive_accuracy)*100:.1f}%")

print("\nClassification Report:")
print(classification_report(y_test, y_pred))

What does this code do?

  • ensemble.predict(X_test) generates predictions on the held-out test set (matches from 2024 onward that the model never saw during training).
  • accuracy_score compares predictions to actual results and reports the percentage correct.
  • The naive baseline calculates how often the home team actually won in the test set. If your model can't beat this, it hasn't learned anything beyond the home advantage pattern.
  • classification_report shows precision, recall, and F1 score for each class (home_win, draw, away_win).

You won't see output yet. The next chunk adds one more section, then you'll run the full script.

Print feature importances

Feature importances tell you which inputs your model relies on most. This helps you understand whether the model is learning sensible patterns (like Elo difference) or overfitting to noise.

  • Add this code below the print(classification_report(y_test, y_pred)) line:
# Feature importances (from Random Forest)
rf_fitted = ensemble.named_estimators_["rf"]
importances = pd.Series(rf_fitted.feature_importances_, index=X.columns).sort_values(ascending=False)
print("\nTop Feature Importances:")
print(importances.head(8).to_string())

What does this code do?

  • ensemble.named_estimators_["rf"] retrieves the fitted Random Forest from inside the VotingClassifier.
  • feature_importances_ is a property on tree-based models that scores how much each feature contributed to reducing classification error.
  • The code sorts these scores descending and prints the top 8 features. You should expect Elo-based features to rank near the top.
  • Save predictor.py.
  • Run the script in your terminal by running:
python predictor.py
  • After the feature matrix builds (this may take a few minutes), you should see the model accuracy printed, followed by the naive baseline comparison, the classification report, and the top 8 feature importances.

What should I see?

Your model accuracy should be around 55-60%. That might sound low, but football prediction with three classes (home_win, draw, away_win) is inherently noisy. The key metric is improvement over the naive baseline.

You should also see elo_diff or home_elo ranking highly in feature importances. This confirms the model learned that Elo rating difference is a strong predictor.

Seeing an error when running the script?

  • If you see a NameError for X_train or ensemble, make sure you added the new code below the existing temporal split section, not inside it.
  • If you see an IndentationError, check that none of the new code is accidentally indented under the previous split section.
  • If the script runs but takes a very long time on the feature matrix, this is expected. The Elo and form calculations iterate through thousands of matches.

Still stuck? Help me debug an error when training my ensemble model in predictor.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved predictor.py and confirmed the model is outperforming the naive baseline.

ⓧ I'd like to double check the full code

Here is the complete predictor.py up to this point:

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier, VotingClassifier
from sklearn.metrics import accuracy_score, classification_report
from features import compute_elo_ratings, build_feature_matrix, compute_team_form, compute_goals_avg, compute_h2h

# ============================================================
# Step 1: Load and prepare data
# ============================================================
print("Loading data...")
df = pd.read_csv("results.csv")
df["date"] = pd.to_datetime(df["date"])
df = df[df["date"] >= "1990-01-01"].reset_index(drop=True)
df = df.sort_values("date").reset_index(drop=True)
print(f"Loaded {len(df)} matches from 1990 onward")

# ============================================================
# Step 2: Compute Elo ratings
# ============================================================
print("Computing Elo ratings...")
elo_ratings, elo_history = compute_elo_ratings(df)
print(f"Computed Elo for {len(elo_ratings)} teams")

# ============================================================
# Step 3: Build feature matrix
# ============================================================
print("Building feature matrix (this may take a few minutes)...")
X, y = build_feature_matrix(df, elo_history)
print(f"Feature matrix shape: {X.shape}")
print(f"Target distribution:\n{y.value_counts()}")

# ============================================================
# Step 4: Temporal train/test split
# ============================================================
dates_for_split = df.sort_values("date").reset_index(drop=True)["date"].iloc[100:]
split_mask = dates_for_split >= "2024-01-01"

split_idx = len(X) - split_mask.sum()

X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]

print(f"\nTrain size: {len(X_train)}, Test size: {len(X_test)}")

# ============================================================
# Step 5: Train ensemble model
# ============================================================
print("\nTraining ensemble model...")
rf = RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1)
gb = GradientBoostingClassifier(n_estimators=150, learning_rate=0.08, max_depth=4, random_state=42)

ensemble = VotingClassifier(
    estimators=[("rf", rf), ("gb", gb)],
    voting="soft",
)

ensemble.fit(X_train, y_train)

# ============================================================
# Step 6: Evaluate
# ============================================================
y_pred = ensemble.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\nModel Accuracy: {accuracy:.3f}")

# Naive baseline: always predict home_win
naive_accuracy = (y_test == "home_win").mean()
print(f"Naive Baseline (always home_win): {naive_accuracy:.3f}")
print(f"Improvement over baseline: +{(accuracy - naive_accuracy)*100:.1f}%")

print("\nClassification Report:")
print(classification_report(y_test, y_pred))

# Feature importances (from Random Forest)
rf_fitted = ensemble.named_estimators_["rf"]
importances = pd.Series(rf_fitted.feature_importances_, index=X.columns).sort_values(ascending=False)
print("\nTop Feature Importances:")
print(importances.head(8).to_string())

Your ensemble model is trained and evaluated. Next up, you'll use it to predict the outcomes of the remaining World Cup 2026 knockout matches.

Predict World Cup 2026 Knockout Matches

Your ensemble model is trained and evaluated. You know how accurate it is and which features matter most.

Now it's time to put the model to work on real upcoming matches. You'll define the remaining World Cup 2026 knockout fixtures, compute live features for each matchup, and generate probability-based predictions for every game.

In this step, get ready to:
  • Define the Round of 16 knockout fixtures with team names matching the dataset.
  • Build a prediction function that computes features and runs them through the trained ensemble.
  • Output a formatted table with win/draw/loss probabilities and a predicted winner for each match.
Define the knockout fixtures

The World Cup 2026 Round of 16 starts on July 4, 2026. You need a list of matchups that use team names exactly as they appear in results.csv so the feature functions can look up historical data.

  • In predictor.py, scroll to the bottom of the file (after the feature importances print).
  • Add the knockout fixtures section by pasting this code at the end of the file:
# ============================================================
# Step 7: Predict World Cup 2026 knockout matches
# ============================================================
print("\n" + "=" * 60)
print("FIFA WORLD CUP 2026 - KNOCKOUT STAGE PREDICTIONS")
print("=" * 60)

# Remaining knockout fixtures (Round of 16 onward)
knockout_fixtures = [
    ("Canada", "Morocco"),
    ("Paraguay", "France"),
    ("Brazil", "Norway"),
    ("Mexico", "England"),
    ("Portugal", "Spain"),
    ("USA", "Belgium"),
    ("Argentina", "Australia"),
    ("Colombia", "Ghana"),
]

What does this code do?

The knockout_fixtures list stores each Round of 16 matchup as a tuple of (home_team, away_team). The team names like "Canada" and "Morocco" match exactly what appears in the dataset's home_team and away_team columns.

If a team name doesn't match exactly, the feature functions won't find any historical data for that team and the predictions will default to neutral values.

Build the prediction function

For each match, you need to compute the same 14 features the model was trained on. The predict_match() function handles this by pulling each team's current Elo, form, goal averages, and head-to-head record, then running the assembled feature row through the ensemble.

  • Add the predict_match function below the knockout_fixtures list by pasting this code:

def predict_match(home_team, away_team):
    """Generate prediction for a single match."""
    date = pd.Timestamp("2026-07-04")

    home_elo = elo_ratings.get(home_team, 1500)
    away_elo = elo_ratings.get(away_team, 1500)

    home_form = compute_team_form(df, home_team, date, n_matches=5)
    away_form = compute_team_form(df, away_team, date, n_matches=5)

    home_scored_avg, home_conceded_avg = compute_goals_avg(df, home_team, date, n_matches=10)
    away_scored_avg, away_conceded_avg = compute_goals_avg(df, away_team, date, n_matches=10)

    h2h_home_win, h2h_draw, h2h_away_win = compute_h2h(df, home_team, away_team, date)

    feature_row = pd.DataFrame(
        [
            {
                "home_elo": home_elo,
                "away_elo": away_elo,
                "elo_diff": home_elo - away_elo,
                "home_form": home_form,
                "away_form": away_form,
                "home_scored_avg": home_scored_avg,
                "home_conceded_avg": home_conceded_avg,
                "away_scored_avg": away_scored_avg,
                "away_conceded_avg": away_conceded_avg,
                "h2h_home_win_rate": h2h_home_win,
                "h2h_draw_rate": h2h_draw,
                "h2h_away_win_rate": h2h_away_win,
                "is_neutral": 1,
                "is_world_cup": 1,
            }
        ]
    )

    proba = ensemble.predict_proba(feature_row)[0]
    classes = ensemble.classes_

    result = {}
    for cls, prob in zip(classes, proba):
        result[cls] = prob

    return result

What does this function do?

The function sets the prediction date to 2026-07-04 (when the Round of 16 starts). This tells the feature functions to only use data before that date, just like the model saw during training.

It pulls each team's latest Elo rating from the elo_ratings dictionary (defaulting to 1500 if a team isn't found), computes form, goals, and head-to-head features, then assembles them into a single-row DataFrame matching the training feature columns.

Both is_neutral and is_world_cup are set to 1 because all knockout matches are played at neutral World Cup venues. Finally, ensemble.predict_proba() returns the probability for each class (home_win, draw, away_win).

Why build a single-row DataFrame?

scikit-learn's predict_proba() expects a 2D array-like input. Wrapping the feature dictionary in a single-row DataFrame ensures the column names match what the model was trained on. This avoids feature-ordering bugs that can happen with raw numpy arrays.

Output the predictions table

The final piece loops through every fixture, calls predict_match(), and prints a formatted row with probabilities and the predicted winner.

  • Add the prediction output loop below the predict_match function by pasting this code:

# Print predictions
print(f"\n{'Match':<30} {'Home Win':>10} {'Draw':>10} {'Away Win':>10} {'Predicted':>12}")
print("-" * 75)

for home, away in knockout_fixtures:
    probs = predict_match(home, away)
    home_win_prob = probs.get("home_win", 0)
    draw_prob = probs.get("draw", 0)
    away_win_prob = probs.get("away_win", 0)

    # Predicted winner
    if home_win_prob > away_win_prob:
        predicted = home
    else:
        predicted = away

    match_name = f"{home} vs {away}"
    print(f"{match_name:<30} {home_win_prob:>9.1%} {draw_prob:>9.1%} {away_win_prob:>9.1%} {predicted:>12}")

print("\nNote: All knockout matches played on neutral venues.")
print("Draw probability is included but knockout matches go to extra time/penalties.")

What does this code do?

The loop iterates through each fixture tuple, unpacking the home and away team names. It calls predict_match() to get the probability dictionary, then extracts each class probability.

The predicted winner is whichever team has the higher win probability. Draw probability is still shown because the model was trained on 3-class data, but in reality knockout matches go to extra time and penalties.

  • Save predictor.py.
  • Run the full script by running this command in your terminal:
python predictor.py
  • After the model trains and evaluates (this may take a few minutes), you should see a table titled FIFA WORLD CUP 2026 - KNOCKOUT STAGE PREDICTIONS with 8 rows showing match names, probabilities, and predicted winners.

Not seeing predictions?

If you see a KeyError or empty probabilities, check that the team names in knockout_fixtures match the dataset exactly. Country names are case-sensitive and must use full names (e.g. "USA" not "United States").

If the script crashes during the feature matrix build, make sure features.py is saved in the same folder as predictor.py.

Help me debug my World Cup prediction script

✔️ Awesome, I've got everything!

Great. Double check you've saved predictor.py and the predictions table printed successfully.

ⓧ I'd like to double check the full code

Here is the complete predictor.py file. Compare yours against it:

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier, VotingClassifier
from sklearn.metrics import accuracy_score, classification_report
from features import compute_elo_ratings, build_feature_matrix, compute_team_form, compute_goals_avg, compute_h2h

# ============================================================
# Step 1: Load and prepare data
# ============================================================
print("Loading data...")
df = pd.read_csv("results.csv")
df["date"] = pd.to_datetime(df["date"])
df = df[df["date"] >= "1990-01-01"].reset_index(drop=True)
df = df.sort_values("date").reset_index(drop=True)
print(f"Loaded {len(df)} matches from 1990 onward")

# ============================================================
# Step 2: Compute Elo ratings
# ============================================================
print("Computing Elo ratings...")
elo_ratings, elo_history = compute_elo_ratings(df)
print(f"Computed Elo for {len(elo_ratings)} teams")

# ============================================================
# Step 3: Build feature matrix
# ============================================================
print("Building feature matrix (this may take a few minutes)...")
X, y = build_feature_matrix(df, elo_history)
print(f"Feature matrix shape: {X.shape}")
print(f"Target distribution:\n{y.value_counts()}")

# ============================================================
# Step 4: Temporal train/test split
# ============================================================
dates_for_split = df.sort_values("date").reset_index(drop=True)["date"].iloc[100:]
split_mask = dates_for_split >= "2024-01-01"

split_idx = len(X) - split_mask.sum()

X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]

print(f"\nTrain size: {len(X_train)}, Test size: {len(X_test)}")

# ============================================================
# Step 5: Train ensemble model
# ============================================================
print("\nTraining ensemble model...")
rf = RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1)
gb = GradientBoostingClassifier(n_estimators=150, learning_rate=0.08, max_depth=4, random_state=42)

ensemble = VotingClassifier(
    estimators=[("rf", rf), ("gb", gb)],
    voting="soft",
)

ensemble.fit(X_train, y_train)

# ============================================================
# Step 6: Evaluate
# ============================================================
y_pred = ensemble.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\nModel Accuracy: {accuracy:.3f}")

naive_accuracy = (y_test == "home_win").mean()
print(f"Naive Baseline (always home_win): {naive_accuracy:.3f}")
print(f"Improvement over baseline: +{(accuracy - naive_accuracy)*100:.1f}%")

print("\nClassification Report:")
print(classification_report(y_test, y_pred))

rf_fitted = ensemble.named_estimators_["rf"]
importances = pd.Series(rf_fitted.feature_importances_, index=X.columns).sort_values(ascending=False)
print("\nTop Feature Importances:")
print(importances.head(8).to_string())

# ============================================================
# Step 7: Predict World Cup 2026 knockout matches
# ============================================================
print("\n" + "=" * 60)
print("FIFA WORLD CUP 2026 - KNOCKOUT STAGE PREDICTIONS")
print("=" * 60)

knockout_fixtures = [
    ("Canada", "Morocco"),
    ("Paraguay", "France"),
    ("Brazil", "Norway"),
    ("Mexico", "England"),
    ("Portugal", "Spain"),
    ("USA", "Belgium"),
    ("Argentina", "Australia"),
    ("Colombia", "Ghana"),
]


def predict_match(home_team, away_team):
    """Generate prediction for a single match."""
    date = pd.Timestamp("2026-07-04")

    home_elo = elo_ratings.get(home_team, 1500)
    away_elo = elo_ratings.get(away_team, 1500)

    home_form = compute_team_form(df, home_team, date, n_matches=5)
    away_form = compute_team_form(df, away_team, date, n_matches=5)

    home_scored_avg, home_conceded_avg = compute_goals_avg(df, home_team, date, n_matches=10)
    away_scored_avg, away_conceded_avg = compute_goals_avg(df, away_team, date, n_matches=10)

    h2h_home_win, h2h_draw, h2h_away_win = compute_h2h(df, home_team, away_team, date)

    feature_row = pd.DataFrame(
        [
            {
                "home_elo": home_elo,
                "away_elo": away_elo,
                "elo_diff": home_elo - away_elo,
                "home_form": home_form,
                "away_form": away_form,
                "home_scored_avg": home_scored_avg,
                "home_conceded_avg": home_conceded_avg,
                "away_scored_avg": away_scored_avg,
                "away_conceded_avg": away_conceded_avg,
                "h2h_home_win_rate": h2h_home_win,
                "h2h_draw_rate": h2h_draw,
                "h2h_away_win_rate": h2h_away_win,
                "is_neutral": 1,
                "is_world_cup": 1,
            }
        ]
    )

    proba = ensemble.predict_proba(feature_row)[0]
    classes = ensemble.classes_

    result = {}
    for cls, prob in zip(classes, proba):
        result[cls] = prob

    return result


print(f"\n{'Match':<30} {'Home Win':>10} {'Draw':>10} {'Away Win':>10} {'Predicted':>12}")
print("-" * 75)

for home, away in knockout_fixtures:
    probs = predict_match(home, away)
    home_win_prob = probs.get("home_win", 0)
    draw_prob = probs.get("draw", 0)
    away_win_prob = probs.get("away_win", 0)

    # Predicted winner
    if home_win_prob > away_win_prob:
        predicted = home
    else:
        predicted = away

    match_name = f"{home} vs {away}"
    print(f"{match_name:<30} {home_win_prob:>9.1%} {draw_prob:>9.1%} {away_win_prob:>9.1%} {predicted:>12}")

print("\nNote: All knockout matches played on neutral venues.")
print("Draw probability is included but knockout matches go to extra time/penalties.")

You now have a working end-to-end ML prediction system. From raw historical data to probability-based World Cup predictions, your pipeline covers feature engineering, temporal splitting, ensemble training, and inference on new matchups. Next up, the Secret Mission challenges you to take these match-by-match predictions and simulate the entire tournament bracket.

Secret mission

Monte Carlo Tournament Simulator

Your model predicts individual matches, but who wins the whole tournament? Build a Monte Carlo simulator that runs the entire bracket 1000 times to estimate each team's championship probability.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with no paid cloud services. The dataset is free under a CC0 license and all Python libraries are open-source. There are no ongoing costs to worry about.

Resources you used:

  • Python scripts: features.py, predictor.py, simulator.py.
  • Dataset CSV files: results.csv, shootouts.csv, goalscorers.csv.
  • Python virtual environment with installed packages (scikit-learn, pandas, numpy, kaggle).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your model or updating predictions as more World Cup matches are played.

  • Your project folder stays on your Desktop with all scripts and data files intact.
  • The virtual environment remains ready to reactivate whenever you want to run your predictions again.
  • The dataset files take up minimal disk space (a few MB).

Pause - I'll come back to this later

Deactivate your virtual environment to free up your terminal session. Your files stay on disk for when you return.

  • Deactivate the virtual environment by running this in your terminal:
deactivate
  • Close VS Code.
  • When you return, reactivate the environment and run your scripts again.

Delete - I don't want to use this again

Remove all project files, the virtual environment, and the downloaded dataset from your machine.

  • Deactivate the virtual environment first:
deactivate
  • Navigate to your Desktop and delete the project folder (the folder containing features.py and predictor.py).

macOS

cd ~/Desktop
rm -rf worldcup-predictor

Windows

cd ~\Desktop
rmdir /s /q worldcup-predictor

This removes all Python scripts, CSV data files, and the virtual environment in one step.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete machine learning pipeline that predicts FIFA World Cup 2026 knockout match outcomes using 49,000+ historical international football results.

You've learned how to:

  • Built an Elo rating system from scratch that tracks team strength over time, then engineered domain-specific features like form, head-to-head records, and goal averages into a predictive feature matrix.
  • Trained a soft-voting ensemble combining Random Forest and Gradient Boosting classifiers, evaluated against a naive baseline, and used a temporal train/test split to prevent data leakage.
  • Generated probability-based predictions for remaining World Cup 2026 knockout matches, outputting win/draw/loss percentages for each fixture.
  • Secret Mission: Built a Monte Carlo tournament simulator that runs 1000 bracket simulations to estimate each team's probability of winning the World Cup.

Ready to quiz yourself?