Predict FIFA World Cup 2026 Outcomes

Train a classifier to predict World Cup 2026 match outcomes from real data.

Introduction

30 Second Summary

The 2026 FIFA World Cup is happening right now, and every match generates a mountain of data. What if you could use that data to figure out which statistics actually predict whether a team wins or loses?

In this project, you will build a complete data science notebook that loads real World Cup match data, explores tournament patterns through visualizations, and trains a machine learning classifier to predict match outcomes.

What You'll Build

A Jupyter Notebook that takes raw FIFA World Cup 2026 match data and runs the full data science workflow: exploration, feature engineering, modeling, and evaluation.

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

  • Polished EDA visualizations revealing World Cup tournament patterns like xG accuracy, top scorers, and home advantage.
  • A trained Random Forest classifier that predicts match outcomes (win, draw, or loss) from team performance statistics.
  • A clear understanding of which match statistics most strongly predict winning, visualized through feature importance plots.
  • Secret Mission: Use your model to predict the results of upcoming tournament matches.

Are there any prerequisites?

Basic Python familiarity is all you need. The project refreshes pandas, scikit-learn, and visualization skills as you go.

Set Up Your Notebook and Load the Data

To predict World Cup match outcomes, you first need data. The 2026 FIFA World Cup is underway, and a public dataset on GitHub tracks every match, player, and event in real time.

In this step, you'll create a Jupyter notebook and load six CSV files directly from GitHub into pandas DataFrames. These six tables form a relational dataset that connects matches to teams, teams to players, and players to individual events.

In this step, get ready to:
  • Create a Jupyter notebook called world_cup_2026_analysis.ipynb.
  • Load six CSV files from GitHub into pandas DataFrames.
  • Filter the matches to completed games only.
Create a new notebook and import libraries

Every data science project starts with a clean notebook and the right tools imported. You'll import your data manipulation, visualization, and machine learning libraries all at once so they're ready when you need them.

  • In your Jupyter Notebook browser tab, click New in the upper-right corner of the dashboard.
  • Select Python 3 (or Python 3 (ipykernel)) from the dropdown menu.
  • A new notebook opens in a separate browser tab. Click the title Untitled at the top of the page.
  • In the rename dialog, type world_cup_2026_analysis and click Rename.

Your notebook starts with one empty code cell. First, add a title by changing this cell to a Markdown cell.

  • Click the cell type dropdown in the toolbar (it shows Code by default) and select Markdown.
  • Type the following into the cell:
# FIFA World Cup 2026 Analysis
Analyzing the ongoing 2026 FIFA World Cup using real match data.
  • Press Shift+Enter to render the markdown and create a new empty cell below.

Now import all the libraries you'll need for this project.

  • Import your data science toolkit by pasting this code into the new cell:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

sns.set_style("whitegrid")

What does this code do?

  • pandas and numpy handle data manipulation and numeric operations.
  • matplotlib and seaborn create visualizations.
  • The sklearn imports bring in machine learning tools you'll use in later steps (classifiers, evaluation metrics, and data splitting).
  • sns.set_style("whitegrid") applies a clean visual theme to all plots in the notebook.
  • Press Shift+Enter to run the cell. It should execute without errors and a new empty cell appears below.

Seeing a ModuleNotFoundError?

This means your notebook is not using the Anaconda Python environment. Make sure you launched Jupyter Notebook from Anaconda Navigator (not a standalone install). Anaconda includes pandas, numpy, matplotlib, seaborn, and scikit-learn pre-installed.

help me fix a ModuleNotFoundError in my Jupyter notebook launched from Anaconda

Load the CSV files from GitHub

The dataset lives in a public GitHub repository under a CC0-1.0 license. You can load each CSV directly into a DataFrame using the raw file URL, with no authentication needed.

  • Load all six data files by pasting this code into the next empty cell:
BASE_URL = "https://raw.githubusercontent.com/mominullptr/FIFA-World-Cup-2026-Dataset/main/"

matches = pd.read_csv(BASE_URL + "matches_detailed.csv")
match_stats = pd.read_csv(BASE_URL + "match_team_stats.csv")
players = pd.read_csv(BASE_URL + "squads_and_players.csv")
player_stats = pd.read_csv(BASE_URL + "player_stats.csv")
events = pd.read_csv(BASE_URL + "match_events.csv")
teams = pd.read_csv(BASE_URL + "teams.csv")

What does this code do?

BASE_URL stores the GitHub raw content path so you don't repeat it six times. Each pd.read_csv() call downloads a CSV file from GitHub and parses it into a DataFrame.

The six tables form a relational dataset. Matches link to teams, teams link to players, and players link to individual match events like goals and assists.

  • Press Shift+Enter to run the cell. This may take a few seconds as pandas downloads the files from GitHub.

Seeing a connection error?

If you see a URLError or timeout, check your internet connection. The URLs point to a public GitHub repository and require network access.

Try opening this URL directly in your browser to confirm the data is accessible:

https://raw.githubusercontent.com/mominullptr/FIFA-World-Cup-2026-Dataset/main/matches_detailed.csv

help me debug a connection error when loading CSV files from GitHub in pandas

Filter to completed matches and inspect the data

The tournament is still in progress. Some matches in the dataset have a status of "Scheduled" and contain no scores yet. You need to filter these out before any analysis.

  • Filter and inspect the loaded data by pasting this code into the next empty cell:
# Filter to completed matches only
completed = matches[matches["status"] == "Completed"].copy()
print(f"Completed matches: {len(completed)}")
print(f"Total players: {len(players)}")
print(f"Match events: {len(events)}")

What does this code do?

The filter matches["status"] == "Completed" keeps only rows where the match has been played. The .copy() creates an independent DataFrame so future edits don't accidentally modify the original matches data.

The print statements confirm your data loaded correctly by showing the size of each key table.

  • Press Shift+Enter to run the cell. You should see output similar to:
Completed matches: 92
Total players: 1248
Match events: ...

What if my numbers are slightly different?

The dataset is updated as the tournament progresses. If you run this after more matches have been played, you may see a higher number of completed matches. That's perfectly fine.

Explore the data further

You can add extra cells to inspect any DataFrame interactively using .shape, .info(), or .head(). For example, try matches.head() in a new cell to preview the first five rows.

Seeing 0 completed matches?

Check that the status column exists in your matches DataFrame by running matches.columns.tolist() in a new cell. The filter is case-sensitive, so "Completed" must match exactly.

help me debug why my pandas filter returns 0 rows when filtering on status column

✔️ Awesome, I've got everything!

Great. Make sure your notebook is saved (Ctrl+S or Cmd+S) before moving on.

ⓧ I'd like to double check the full code

Here's everything your notebook should contain across all cells at the end of this step:

# %% [markdown]
# # FIFA World Cup 2026 Analysis
# Analyzing the ongoing 2026 FIFA World Cup using real match data.

# %% Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

sns.set_style("whitegrid")

# %% Step 1: Load Data
BASE_URL = "https://raw.githubusercontent.com/mominullptr/FIFA-World-Cup-2026-Dataset/main/"

matches = pd.read_csv(BASE_URL + "matches_detailed.csv")
match_stats = pd.read_csv(BASE_URL + "match_team_stats.csv")
players = pd.read_csv(BASE_URL + "squads_and_players.csv")
player_stats = pd.read_csv(BASE_URL + "player_stats.csv")
events = pd.read_csv(BASE_URL + "match_events.csv")
teams = pd.read_csv(BASE_URL + "teams.csv")

# Filter to completed matches only
completed = matches[matches["status"] == "Completed"].copy()
print(f"Completed matches: {len(completed)}")
print(f"Total players: {len(players)}")
print(f"Match events: {len(events)}")

Your data is loaded and ready to explore. Next up, you'll create visualizations that reveal hidden patterns in the World Cup tournament data.

Explore the Tournament Through Visualizations

You have six DataFrames loaded and 92 completed matches filtered and ready to explore.

Raw numbers only tell part of the story. Visualizations reveal patterns that summary statistics hide. In this step, you will create three charts that surface tournament insights: which teams outperform their expected goals, who the top scorers are, and whether home advantage matters.

In this step, get ready to:
  • Create a scatter plot comparing expected goals (xG) to actual goals scored.
  • Build a top 10 scorers bar chart by joining multiple DataFrames.
  • Analyze home advantage with a pie chart and average goals comparison.
Create the xG vs actual goals scatter plot

Expected goals (xG) measures the quality of chances a team creates. Comparing xG to actual goals reveals which teams are clinical finishers and which are wasteful.

  • Create a new cell in your notebook and add the following code:
fig, ax = plt.subplots(figsize=(8, 6))

ax.scatter(completed["home_xg"], completed["home_score"],
           alpha=0.6, label="Home Team", color="steelblue")
ax.scatter(completed["away_xg"], completed["away_score"],
           alpha=0.6, label="Away Team", color="coral")

# Add diagonal reference line where xG perfectly equals goals
max_val = max(completed["home_xg"].max(), completed["home_score"].max(),
              completed["away_xg"].max(), completed["away_score"].max()) + 0.5
ax.plot([0, max_val], [0, max_val], "k--", alpha=0.4, label="Perfect xG = Goals")

ax.set_xlabel("Expected Goals (xG)")
ax.set_ylabel("Actual Goals")
ax.set_title("xG vs Actual Goals - FIFA World Cup 2026")
ax.legend()
plt.tight_layout()
plt.show()

What does this code do?

This creates a scatter plot with two layers of data points. Home teams appear in steelblue, away teams in coral.

The dashed diagonal line represents perfect xG accuracy (where expected goals equals actual goals). Points above the line mean a team scored more than expected. Points below mean they underperformed their chances.

  • Run the cell by pressing Shift+Enter.
  • You should see a scatter plot with steelblue and coral dots clustered around the diagonal line, with some outliers above or below it.

Don't see a chart?

  • Make sure you ran the imports cell and the data loading cell from Step 1 first. Jupyter cells must run in order.
  • If you see a NameError: name 'completed' is not defined error, scroll up and re-run your Step 1 cells.
  • help me debug my xG scatter plot
Build a top 10 scorers bar chart

To find the top scorers, you need to join data across three DataFrames. The events DataFrame has goal events tied to player_id, but you need players for names and teams for team names.

  • Create a new cell and add the following code:
# Filter events to goals only and count per player
goals = events[events["event_type"] == "Goal"]
scorer_counts = goals.groupby("player_id").size().reset_index(name="goals_scored")
top_scorers = scorer_counts.nlargest(10, "goals_scored")

# Join with players and teams to get names
top_scorers = top_scorers.merge(players[["player_id", "player_name", "team_id"]], on="player_id")
top_scorers = top_scorers.merge(teams[["team_id", "team_name"]], on="team_id")

fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.barh(
    top_scorers["player_name"] + " (" + top_scorers["team_name"] + ")",
    top_scorers["goals_scored"],
    color="steelblue"
)
ax.set_xlabel("Goals Scored")
ax.set_title("Top 10 Scorers - FIFA World Cup 2026")
ax.invert_yaxis()
plt.tight_layout()
plt.show()

What does this code do?

  • First it filters events to rows where event_type == "Goal", then groups by player_id and counts goals per player.
  • The two .merge() calls are relational joins. The first attaches player_name and team_id from the players DataFrame. The second attaches team_name from teams.
  • The ax.invert_yaxis() call puts the highest scorer at the top of the bar chart.

Why join three DataFrames?

This is a common pattern in relational data. Events reference players by ID, not by name. To get human-readable labels, you join through the lookup tables. This is the same logic you would write as SQL JOINs in a database.

  • Run the cell with Shift+Enter.
  • You should see a horizontal bar chart with real player names and their team in parentheses. The highest scorer appears at the top.

Seeing an empty chart or KeyError?

  • A KeyError: 'event_type' means the events DataFrame did not load correctly. Re-run your Step 1 data loading cell.
  • If the chart is empty, check that the column name is exactly "Goal" with a capital G. Column values are case-sensitive.
  • help me debug my top scorers chart
Analyze home advantage with a pie chart

Does playing at home matter in the World Cup? You will categorize every completed match as a home win, away win, or draw, then visualize the distribution.

  • Create a new cell and add the following code:
# Classify each match outcome
completed["outcome"] = np.where(
    completed["home_score"] > completed["away_score"], "Home Win",
    np.where(completed["home_score"] < completed["away_score"], "Away Win", "Draw")
)

# Visualize outcome distribution as a pie chart
outcome_counts = completed["outcome"].value_counts()
fig, ax = plt.subplots(figsize=(6, 6))
ax.pie(outcome_counts, labels=outcome_counts.index, autopct="%1.1f%%",
       colors=["steelblue", "coral", "lightgray"])
ax.set_title("Match Outcomes - FIFA World Cup 2026")
plt.tight_layout()
plt.show()

print(f"Average home goals: {completed['home_score'].mean():.2f}")
print(f"Average away goals: {completed['away_score'].mean():.2f}")

What does this code do?

  • The nested np.where() calls act like an if/else chain. If home score is greater, it is a "Home Win". If less, "Away Win". Otherwise, "Draw".
  • The autopct="%1.1f%%" parameter displays percentage labels on each pie slice.
  • The final two print statements show average goals per match for home and away teams so you can compare numerically.
  • Run the cell with Shift+Enter.
  • You should see a pie chart with three colored slices showing the percentage split between Home Win, Away Win, and Draw. Below the chart, two lines print the average goals for home and away teams.

Pie chart looks wrong or shows an error?

  • If you see a SettingWithCopyWarning, that is just a warning and your code still works. The .copy() in Step 1 should prevent it, but if it appears, your results are still correct.
  • If slices do not add up to 100%, check that you are using the completed DataFrame (filtered to completed matches) and not the full matches DataFrame.
  • help me fix my home advantage pie chart
  • Save your notebook by pressing Ctrl+S (Windows) or Cmd+S (macOS).

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook and all three charts display correctly.

ⓧ I'd like to double check the full code

Here is the full notebook code up to this point. Compare it against your cells:

# %% [markdown]
# # FIFA World Cup 2026 Analysis
# Analyzing the ongoing 2026 FIFA World Cup using real match data.

# %% Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

sns.set_style("whitegrid")

# %% Step 1: Load Data
BASE_URL = "https://raw.githubusercontent.com/mominullptr/FIFA-World-Cup-2026-Dataset/main/"

matches = pd.read_csv(BASE_URL + "matches_detailed.csv")
match_stats = pd.read_csv(BASE_URL + "match_team_stats.csv")
players = pd.read_csv(BASE_URL + "squads_and_players.csv")
player_stats = pd.read_csv(BASE_URL + "player_stats.csv")
events = pd.read_csv(BASE_URL + "match_events.csv")
teams = pd.read_csv(BASE_URL + "teams.csv")

# Filter to completed matches only
completed = matches[matches["status"] == "Completed"].copy()
print(f"Completed matches: {len(completed)}")
print(f"Total players: {len(players)}")
print(f"Match events: {len(events)}")

# %% Step 2: EDA - xG vs Actual Goals
fig, ax = plt.subplots(figsize=(8, 6))

ax.scatter(completed["home_xg"], completed["home_score"],
           alpha=0.6, label="Home Team", color="steelblue")
ax.scatter(completed["away_xg"], completed["away_score"],
           alpha=0.6, label="Away Team", color="coral")

max_val = max(completed["home_xg"].max(), completed["home_score"].max(),
              completed["away_xg"].max(), completed["away_score"].max()) + 0.5
ax.plot([0, max_val], [0, max_val], "k--", alpha=0.4, label="Perfect xG = Goals")

ax.set_xlabel("Expected Goals (xG)")
ax.set_ylabel("Actual Goals")
ax.set_title("xG vs Actual Goals - FIFA World Cup 2026")
ax.legend()
plt.tight_layout()
plt.show()

# %% Step 2: EDA - Top 10 Scorers
goals = events[events["event_type"] == "Goal"]
scorer_counts = goals.groupby("player_id").size().reset_index(name="goals_scored")
top_scorers = scorer_counts.nlargest(10, "goals_scored")
top_scorers = top_scorers.merge(players[["player_id", "player_name", "team_id"]], on="player_id")
top_scorers = top_scorers.merge(teams[["team_id", "team_name"]], on="team_id")

fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.barh(
    top_scorers["player_name"] + " (" + top_scorers["team_name"] + ")",
    top_scorers["goals_scored"],
    color="steelblue"
)
ax.set_xlabel("Goals Scored")
ax.set_title("Top 10 Scorers - FIFA World Cup 2026")
ax.invert_yaxis()
plt.tight_layout()
plt.show()

# %% Step 2: EDA - Home Advantage
completed["outcome"] = np.where(
    completed["home_score"] > completed["away_score"], "Home Win",
    np.where(completed["home_score"] < completed["away_score"], "Away Win", "Draw")
)

outcome_counts = completed["outcome"].value_counts()
fig, ax = plt.subplots(figsize=(6, 6))
ax.pie(outcome_counts, labels=outcome_counts.index, autopct="%1.1f%%",
       colors=["steelblue", "coral", "lightgray"])
ax.set_title("Match Outcomes - FIFA World Cup 2026")
plt.tight_layout()
plt.show()

print(f"Average home goals: {completed['home_score'].mean():.2f}")
print(f"Average away goals: {completed['away_score'].mean():.2f}")

You now have three polished visualizations revealing how the tournament is playing out. Next up, you will transform this raw match data into a structured feature matrix that a machine learning model can learn from.

Engineer Features for Modeling

Your EDA revealed some fascinating patterns in the tournament data. Now it is time to transform those raw statistics into something a machine learning model can learn from.

Machine learning models cannot learn from scattered tables and text labels. They need a single, clean matrix of numbers with a clear target column that says what you want to predict. In this step, you will merge your data, define a target variable, and prepare everything for training.

In this step, get ready to:
  • Merge match statistics with match outcomes to build a unified dataset.
  • Create a target variable labeling each team-match row as Win, Draw, or Loss.
  • Split the data into training and test sets for model evaluation.
Merge match stats with match outcomes

Your match statistics (possession, shots, corners) live in one DataFrame, while the actual scores live in another. To know whether a team won or lost with those stats, you need to join these tables together.

  • Create a new cell in your notebook and paste the following code to merge the DataFrames:
# %% Step 3: Feature Engineering
# Merge match stats with match outcomes
stats_with_matches = match_stats.merge(
    completed[["match_id", "home_team_name", "away_team_name",
               "home_score", "away_score", "home_fifa_code", "away_fifa_code"]],
    on="match_id"
)

# Merge teams to get team_name for each stats row
stats_with_matches = stats_with_matches.merge(
    teams[["team_id", "fifa_code"]], on="team_id"
)

What does this code do?

The first merge joins match_stats with your completed DataFrame on match_id. This attaches the final scores and FIFA codes to each row of team statistics.

The second merge brings in the fifa_code from the teams table. You need this code to figure out whether each stats row belongs to the home team or the away team in that match.

Now each row in stats_with_matches has a team's performance stats alongside the match score. But you still need a label that says whether that team won, drew, or lost.

  • In the same cell, paste the following function that determines each team's result:
# Determine result for each team
def get_result(row):
    if row["fifa_code"] == row["home_fifa_code"]:
        team_goals = row["home_score"]
        opp_goals = row["away_score"]
    else:
        team_goals = row["away_score"]
        opp_goals = row["home_score"]

    if team_goals > opp_goals:
        return "Win"
    elif team_goals == opp_goals:
        return "Draw"
    else:
        return "Loss"

stats_with_matches["result"] = stats_with_matches.apply(get_result, axis=1)

What does this function do?

Each row in the DataFrame has stats for one team in one match. The function checks if that team's fifa_code matches the home team's code. If so, it compares the home score to the away score.

The function returns "Win", "Draw", or "Loss" depending on whether that team scored more, the same, or fewer goals than their opponent. The .apply(get_result, axis=1) call runs this logic on every row.

  • Run the cell by pressing Shift+Enter.

Getting a KeyError?

Make sure you ran all the cells from Step 1 and Step 2 first. The variables match_stats, completed, and teams must already exist in memory.

Also check that the column names match exactly. Column names are case-sensitive, so match_id is not the same as Match_ID.

Help me debug my merge KeyError

Select features and encode the target

A feature matrix is the structured input your model trains on. You need to pick which columns are predictive and drop any rows with missing data.

  • Create a new cell and paste the following code to select your features and encode the target:
# Select features
feature_cols = ["possession_pct", "total_shots", "shots_on_target",
                "corners", "fouls", "offsides", "saves"]

model_df = stats_with_matches[feature_cols + ["result"]].dropna()
print(f"Modeling dataset shape: {model_df.shape}")
print(f"\nClass distribution:\n{model_df['result'].value_counts()}")

# Encode target
le = LabelEncoder()
X = model_df[feature_cols]
y = le.fit_transform(model_df["result"])

What does this code do?

  • feature_cols lists the seven match statistics your model will learn from: possession, shots, shots on target, corners, fouls, offsides, and saves.
  • .dropna() removes rows where stats are missing. Some matches may not have verified data yet.
  • LabelEncoder converts the text labels ("Win", "Draw", "Loss") into numbers (0, 1, 2) because scikit-learn models require numeric inputs.
  • Run the cell with Shift+Enter.
  • You should see two outputs printed: the shape of your modeling dataset and the count of Win, Draw, and Loss rows.

Why does class distribution matter?

If 90% of your rows were "Win" and only 5% were "Draw", a model could score 90% accuracy by always guessing Win. Checking the distribution tells you whether the classes are roughly balanced or if you need special handling.

Seeing an empty DataFrame?

If model_df.shape shows 0 rows, all your data had NaN values in at least one feature column. Run stats_with_matches[feature_cols].isnull().sum() to see which columns have missing data.

Help me figure out why my feature matrix is empty

Split into training and test sets

You need to hold out a portion of your data that the model never sees during training. This test set gives you an honest estimate of how well the model predicts on new matches.

  • Create a new cell and paste the following code to split your data:
# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

What do these parameters do?

  • test_size=0.2 reserves 20% of your data for testing. The remaining 80% is used for training.
  • random_state=42 locks the randomness so you get the same split every time you run the notebook.
  • stratify=y ensures the Win/Draw/Loss ratio stays the same in both the training and test sets. Without this, the test set might accidentally have all Wins and no Draws.
  • Run the cell with Shift+Enter.
  • Verify the split worked by adding a new cell and running print(X_train.shape, X_test.shape). You should see two tuples showing the training set is roughly 4 times larger than the test set.

Getting a ValueError about stratify?

The stratify parameter fails if any class has too few samples to split. Check your class distribution from the previous cell. If one class has fewer than 2 samples, you may need to remove stratify=y temporarily.

Help me fix my train_test_split error

✔️ 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 code for this step. Compare it with what you have in your notebook:

# %% Step 3: Feature Engineering
# Merge match stats with match outcomes
stats_with_matches = match_stats.merge(
    completed[["match_id", "home_team_name", "away_team_name",
               "home_score", "away_score", "home_fifa_code", "away_fifa_code"]],
    on="match_id"
)

# Merge teams to get team_name for each stats row
stats_with_matches = stats_with_matches.merge(
    teams[["team_id", "fifa_code"]], on="team_id"
)

# Determine result for each team
def get_result(row):
    if row["fifa_code"] == row["home_fifa_code"]:
        team_goals = row["home_score"]
        opp_goals = row["away_score"]
    else:
        team_goals = row["away_score"]
        opp_goals = row["home_score"]

    if team_goals > opp_goals:
        return "Win"
    elif team_goals == opp_goals:
        return "Draw"
    else:
        return "Loss"

stats_with_matches["result"] = stats_with_matches.apply(get_result, axis=1)

# Select features
feature_cols = ["possession_pct", "total_shots", "shots_on_target",
                "corners", "fouls", "offsides", "saves"]

model_df = stats_with_matches[feature_cols + ["result"]].dropna()
print(f"Modeling dataset shape: {model_df.shape}")
print(f"\nClass distribution:\n{model_df['result'].value_counts()}")

# Encode target
le = LabelEncoder()
X = model_df[feature_cols]
y = le.fit_transform(model_df["result"])

# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

Your feature matrix is clean, encoded, and split. Next up, you will train two classification models and see which one best predicts match outcomes.

Train and Evaluate a Match Outcome Classifier

You have a clean feature matrix with seven match statistics and a labeled target column. The question is: can these numbers actually predict whether a team wins, draws, or loses?

In this step, you will train two classifiers on your data and compare them. Logistic Regression gives you a simple baseline. A Random Forest captures more complex patterns. After comparing accuracy, you will visualize where the model gets confused and which statistics drive its predictions.

In this step, get ready to:
  • Train a Logistic Regression baseline and a Random Forest classifier.
  • Evaluate both models with classification reports and accuracy scores.
  • Visualize results with a confusion matrix and feature importance chart.
Train both classifiers and compare their accuracy

Logistic Regression is a linear model that draws straight decision boundaries between classes. Random Forest builds many decision trees and lets them vote on the answer. Comparing both tells you whether the relationships in your data are simple or complex.

  • Create a new code cell in your notebook and add the following code to train both models and print their evaluation metrics:
# %% Step 4: Train Models
# Logistic Regression
lr = LogisticRegression(max_iter=1000, random_state=42)
lr.fit(X_train, y_train)
lr_preds = lr.predict(X_test)

print("=== Logistic Regression ===")
print(f"Accuracy: {accuracy_score(y_test, lr_preds):.3f}")
print(classification_report(y_test, lr_preds, target_names=le.classes_))

# Random Forest
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
rf_preds = rf.predict(X_test)

print("=== Random Forest ===")
print(f"Accuracy: {accuracy_score(y_test, rf_preds):.3f}")
print(classification_report(y_test, rf_preds, target_names=le.classes_))

What does this code do?

This cell trains two different classifiers on the same training data and immediately evaluates them on the held-out test set.

  • LogisticRegression(max_iter=1000, random_state=42) trains a linear classifier. max_iter=1000 gives the solver enough iterations to converge.
  • RandomForestClassifier(n_estimators=100, random_state=42) trains 100 decision trees that each see a random subset of the data and features.
  • classification_report prints precision, recall, and f1-score for each class (Win, Draw, Loss). These tell you how well the model handles each outcome type individually.
  • random_state=42 ensures you get reproducible results every time you run the cell.
  • Run the cell.
  • You should see two classification reports printed. Each shows an accuracy score at the top, followed by a per-class breakdown of precision, recall, and f1-score for Draw, Loss, and Win.

Seeing all zeros or very low scores?

If one class shows 0.00 for all metrics, your dataset may have very few examples of that class (especially Draw). This is normal for a live tournament dataset with limited matches. The model simply has not seen enough examples to learn the pattern.

If the cell throws a NameError, make sure you ran all previous cells in order. The variables X_train, y_train, X_test, and y_test must exist from Step 3.

Help me debug my classifier training

Visualize prediction errors with a confusion matrix

Accuracy gives you one number, but it hides where the model struggles. A confusion matrix shows you exactly which classes get mixed up. For example, does the model confuse Draws with Losses, or Wins with Draws?

  • Create a new code cell and add this code to generate a confusion matrix heatmap for the Random Forest model:
# %% Step 4: Confusion Matrix
fig, ax = plt.subplots(figsize=(6, 5))
cm = confusion_matrix(y_test, rf_preds)
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues",
            xticklabels=le.classes_, yticklabels=le.classes_, ax=ax)
ax.set_xlabel("Predicted")
ax.set_ylabel("Actual")
ax.set_title("Confusion Matrix - Random Forest")
plt.tight_layout()
plt.show()

What does this code do?

confusion_matrix(y_test, rf_preds) builds a grid where each row is the actual class and each column is the predicted class. Numbers on the diagonal are correct predictions. Off-diagonal numbers are mistakes.

sns.heatmap() renders the grid as a color-coded chart. Darker cells mean higher counts. The annot=True parameter prints the actual numbers inside each cell so you can read the exact counts.

  • Run the cell.
  • You should see a blue heatmap with class labels (Draw, Loss, Win) on both axes. The diagonal cells should have the highest numbers, indicating correct predictions.

Heatmap not displaying?

Make sure you have import seaborn as sns at the top of your notebook. If labels are missing or garbled, confirm that le.classes_ returns an array of your class names by running print(le.classes_) in a quick test cell.

Help me fix my confusion matrix visualization

Plot feature importances to find the stats that matter

Random Forest can tell you how much each feature contributed to its predictions. This answers the core question of the project: which match statistics actually predict winning?

  • Create a new code cell and add this code to extract and plot the feature importances:
# %% Step 4: Feature Importance
importances = rf.feature_importances_
feat_imp = pd.Series(importances, index=feature_cols).sort_values()

fig, ax = plt.subplots(figsize=(8, 5))
feat_imp.plot(kind="barh", color="steelblue", ax=ax)
ax.set_xlabel("Feature Importance")
ax.set_title("What Stats Predict Winning? - Random Forest Feature Importance")
plt.tight_layout()
plt.show()

What does this code do?

rf.feature_importances_ returns an array of scores (summing to 1.0) measuring how much each feature reduced prediction errors across all 100 trees.

Wrapping the scores in a pd.Series with feature_cols as the index lets you sort and plot them with readable labels. The ascending sort puts the most important feature at the bottom of the horizontal bar chart, making it visually prominent.

  • Run the cell.
  • You should see a horizontal bar chart with all seven features ranked by importance. Shots on target and possession should appear among the top predictors.

Feature names missing from the chart?

If the y-axis shows numbers instead of feature names, make sure you passed index=feature_cols when creating the pandas Series. The feature_cols list must still be defined from your Step 3 cell.

Help me debug my feature importance chart

Why compare two models?

Logistic Regression draws straight lines between classes. If it performs nearly as well as Random Forest, the relationships in your data are mostly linear. If Random Forest significantly outperforms it, nonlinear interactions between features (like high possession combined with high shots on target) are doing the heavy lifting.

This comparison is a standard practice in data science. You always want a simple baseline to put your more complex model's performance in context.

  • Save your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook.

ⓧ I'd like to double check the full code

Here is the full notebook code up to this point:

# %% [markdown]
# # FIFA World Cup 2026 Analysis
# Analyzing the ongoing 2026 FIFA World Cup using real match data.

# %% Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

sns.set_style("whitegrid")

# %% Step 1: Load Data
BASE_URL = "https://raw.githubusercontent.com/mominullptr/FIFA-World-Cup-2026-Dataset/main/"

matches = pd.read_csv(BASE_URL + "matches_detailed.csv")
match_stats = pd.read_csv(BASE_URL + "match_team_stats.csv")
players = pd.read_csv(BASE_URL + "squads_and_players.csv")
player_stats = pd.read_csv(BASE_URL + "player_stats.csv")
events = pd.read_csv(BASE_URL + "match_events.csv")
teams = pd.read_csv(BASE_URL + "teams.csv")

# Filter to completed matches only
completed = matches[matches["status"] == "Completed"].copy()
print(f"Completed matches: {len(completed)}")
print(f"Total players: {len(players)}")
print(f"Match events: {len(events)}")

# %% Step 2: EDA - xG vs Actual Goals
fig, ax = plt.subplots(figsize=(8, 6))

ax.scatter(completed["home_xg"], completed["home_score"],
           alpha=0.6, label="Home Team", color="steelblue")
ax.scatter(completed["away_xg"], completed["away_score"],
           alpha=0.6, label="Away Team", color="coral")

max_val = max(completed["home_xg"].max(), completed["home_score"].max(),
              completed["away_xg"].max(), completed["away_score"].max()) + 0.5
ax.plot([0, max_val], [0, max_val], "k--", alpha=0.4, label="Perfect xG = Goals")

ax.set_xlabel("Expected Goals (xG)")
ax.set_ylabel("Actual Goals")
ax.set_title("xG vs Actual Goals - FIFA World Cup 2026")
ax.legend()
plt.tight_layout()
plt.show()

# %% Step 2: EDA - Top 10 Scorers
goals = events[events["event_type"] == "Goal"]
scorer_counts = goals.groupby("player_id").size().reset_index(name="goals_scored")
top_scorers = scorer_counts.nlargest(10, "goals_scored")
top_scorers = top_scorers.merge(players[["player_id", "player_name", "team_id"]], on="player_id")
top_scorers = top_scorers.merge(teams[["team_id", "team_name"]], on="team_id")

fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.barh(
    top_scorers["player_name"] + " (" + top_scorers["team_name"] + ")",
    top_scorers["goals_scored"],
    color="steelblue"
)
ax.set_xlabel("Goals Scored")
ax.set_title("Top 10 Scorers - FIFA World Cup 2026")
ax.invert_yaxis()
plt.tight_layout()
plt.show()

# %% Step 2: EDA - Home Advantage
completed["outcome"] = np.where(
    completed["home_score"] > completed["away_score"], "Home Win",
    np.where(completed["home_score"] < completed["away_score"], "Away Win", "Draw")
)

outcome_counts = completed["outcome"].value_counts()
fig, ax = plt.subplots(figsize=(6, 6))
ax.pie(outcome_counts, labels=outcome_counts.index, autopct="%1.1f%%",
       colors=["steelblue", "coral", "lightgray"])
ax.set_title("Match Outcomes - FIFA World Cup 2026")
plt.tight_layout()
plt.show()

print(f"Average home goals: {completed['home_score'].mean():.2f}")
print(f"Average away goals: {completed['away_score'].mean():.2f}")

# %% Step 3: Feature Engineering
# Merge match stats with match outcomes
stats_with_matches = match_stats.merge(
    completed[["match_id", "home_team_name", "away_team_name",
               "home_score", "away_score", "home_fifa_code", "away_fifa_code"]],
    on="match_id"
)

# Merge teams to get team_name for each stats row
stats_with_matches = stats_with_matches.merge(
    teams[["team_id", "fifa_code"]], on="team_id"
)

# Determine result for each team
def get_result(row):
    if row["fifa_code"] == row["home_fifa_code"]:
        team_goals = row["home_score"]
        opp_goals = row["away_score"]
    else:
        team_goals = row["away_score"]
        opp_goals = row["home_score"]

    if team_goals > opp_goals:
        return "Win"
    elif team_goals == opp_goals:
        return "Draw"
    else:
        return "Loss"

stats_with_matches["result"] = stats_with_matches.apply(get_result, axis=1)

# Select features
feature_cols = ["possession_pct", "total_shots", "shots_on_target",
                "corners", "fouls", "offsides", "saves"]

model_df = stats_with_matches[feature_cols + ["result"]].dropna()
print(f"Modeling dataset shape: {model_df.shape}")
print(f"\nClass distribution:\n{model_df['result'].value_counts()}")

# Encode target
le = LabelEncoder()
X = model_df[feature_cols]
y = le.fit_transform(model_df["result"])

# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# %% Step 4: Train Models
# Logistic Regression
lr = LogisticRegression(max_iter=1000, random_state=42)
lr.fit(X_train, y_train)
lr_preds = lr.predict(X_test)

print("=== Logistic Regression ===")
print(f"Accuracy: {accuracy_score(y_test, lr_preds):.3f}")
print(classification_report(y_test, lr_preds, target_names=le.classes_))

# Random Forest
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
rf_preds = rf.predict(X_test)

print("=== Random Forest ===")
print(f"Accuracy: {accuracy_score(y_test, rf_preds):.3f}")
print(classification_report(y_test, rf_preds, target_names=le.classes_))

# %% Step 4: Confusion Matrix
fig, ax = plt.subplots(figsize=(6, 5))
cm = confusion_matrix(y_test, rf_preds)
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues",
            xticklabels=le.classes_, yticklabels=le.classes_, ax=ax)
ax.set_xlabel("Predicted")
ax.set_ylabel("Actual")
ax.set_title("Confusion Matrix - Random Forest")
plt.tight_layout()
plt.show()

# %% Step 4: Feature Importance
importances = rf.feature_importances_
feat_imp = pd.Series(importances, index=feature_cols).sort_values()

fig, ax = plt.subplots(figsize=(8, 5))
feat_imp.plot(kind="barh", color="steelblue", ax=ax)
ax.set_xlabel("Feature Importance")
ax.set_title("What Stats Predict Winning? - Random Forest Feature Importance")
plt.tight_layout()
plt.show()

Secret mission

Predict Upcoming Matches

Your model knows which stats predict winning. But can it predict the future? Use your trained Random Forest to forecast the outcomes of upcoming World Cup matches that haven't been played yet.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Anaconda and Jupyter Notebook, so there are no ongoing costs. Decide whether to keep your notebook as-is, shut down the Jupyter server, or delete the notebook file entirely.

Resources you used:

  • Jupyter Notebook file: world_cup_2026_analysis.ipynb.
  • Dataset loaded from GitHub (not stored locally, fetched on each run).

Keep everything running

No action needed. Choose this if you want to keep exploring the data or re-running cells.

  • Your Jupyter Notebook server stays running in the terminal where you launched it.
  • Your notebook file is saved locally and you can reopen it anytime from Anaconda Navigator.

Pause - I'll come back to this later

Shut down the Jupyter Notebook server to free up memory. Your notebook file stays on disk for next time.

  • In your browser, click the Jupyter tab showing the file list.
  • Select the checkbox next to world_cup_2026_analysis.ipynb and click Shutdown to stop the kernel.
  • Close the Jupyter browser tabs.
  • In the terminal or Anaconda Navigator window running Jupyter, press Ctrl+C to stop the server.

Delete - I don't want to use this again

Remove the notebook file completely. You can always recreate it by following this project again.

  • Shut down the Jupyter server by pressing Ctrl+C in the terminal or closing Anaconda Navigator.
  • Delete the notebook file by running this command in your terminal:

macOS

rm ~/world_cup_2026_analysis.ipynb

Windows

del %USERPROFILE%\world_cup_2026_analysis.ipynb

Check your save location

Jupyter Notebook saves files in the directory where the server was launched. If you started Jupyter from a different folder, navigate there instead to find your notebook.

Nice Work!

Nice Work!

Nice work! You just built a complete data science notebook that analyzes the ongoing 2026 FIFA World Cup, from raw CSV files all the way to a trained classifier that predicts match outcomes.

You've learned how to:

  • Load a multi-table relational dataset directly from GitHub and create polished EDA visualizations revealing tournament patterns like xG accuracy, top scorers, and home advantage.
  • Engineer a feature matrix from raw match statistics and train two classification models (Logistic Regression and Random Forest) using scikit-learn.
  • Evaluate model performance with classification reports, confusion matrices, and feature importance plots to interpret which match statistics most strongly predict winning.
  • Secret Mission: Generated predictions for upcoming tournament matches using team average statistics from completed games.

Ready to quiz yourself?