World Cup 2026 Player Stats Predictor
Build an ML web app that predicts player stats for 2026 World Cup matches.
Introduction
30 Second Summary
The 2026 World Cup is here, and everyone has opinions about which players will dominate. But what if you could back up those predictions with real data and machine learning?
In this project, you will build an interactive web app that predicts how many shots, goals, and passes a player will have in a 2026 World Cup match. You will combine real football data with a trained ML model and serve predictions through a dashboard anyone can use.
What You'll Build
A Python-based machine learning pipeline and interactive Streamlit dashboard that uses historical World Cup data to predict player-level match stats for 2026.
By the end of this project, you'll have:
- A data pipeline that merges StatsBomb World Cup event data (2018 and 2022) with current 2026 squad and team strength data from Hugging Face.
- A trained Random Forest regression model saved to disk that predicts shots, goals, and passes attempted.
- An interactive Streamlit dashboard where users select a 2026 World Cup team and player, then see predicted match stats with feature importance explanations.
- Secret Mission: Deploy the app live to the internet for free using Streamlit Community Cloud.
Are there any prerequisites?
None. This guide walks you through setting up every tool you need, including Python, Cursor, and all required packages. No prior machine learning experience is necessary.
Before We Start
Before We Start...
Set Up Your Environment
To predict World Cup player stats, you need a workspace with the right Python packages installed. This includes libraries for data loading, machine learning, and building an interactive web app.
In this step, you will create a dedicated project folder, set up an isolated Python environment, and install everything your predictor needs to run.
In this step, get ready to:
- Create a project folder and Python virtual environment.
- Install all required ML and web app packages.
- Verify that every package imports correctly.
Create a project folder and virtual environment
A virtual environment is an isolated Python installation that keeps your project's packages separate from everything else on your machine. This prevents version conflicts between projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir wc-predictor
cd wc-predictor
- Confirm the folder was created by running:
ls
The folder will be empty for now. That is expected.
- Create and activate your virtual environment by running the commands for your operating system:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
.\venv\Scripts\Activate.ps1
Seeing a script execution error?
PowerShell may block the activation script by default. Run this command first to allow it:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Then try the activation command again.
What do these commands do?
The first command creates a venv folder containing a fresh copy of Python and its package manager pip. The second command activates it so any packages you install go into this isolated environment.
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activation command (not just the creation command). The python3 -m venv venv command creates the environment. The source or Activate.ps1 command turns it on.
help me activate my Python virtual environment
Install all required packages
Your predictor needs six packages. Streamlit builds the web dashboard. scikit-learn trains the ML model. pandas handles data manipulation.
- Install all the packages by running this command:
pip install streamlit scikit-learn pandas statsbombpy joblib huggingface-hub
What are all these packages?
- streamlit turns Python scripts into interactive web apps with no HTML or JavaScript.
- scikit-learn provides machine learning algorithms including the Random Forest model you will train.
- pandas gives you DataFrames for loading, filtering, and transforming data.
- statsbombpy fetches free football event data from StatsBomb (every shot, pass, and goal from past World Cups).
- joblib saves and loads your trained model to disk.
- huggingface-hub downloads 2026 World Cup squad data from Hugging Face.
The installation may take a minute or two. Wait until you see the terminal return to the (venv) prompt with no errors.
Seeing installation errors?
Make sure you see (venv) in your prompt before running pip install. If you don't, the virtual environment isn't active and packages will install globally (or fail due to permissions).
help me fix pip install errors in my virtual environment
Verify the installation
A quick import check confirms that Python can find every package you just installed.
- Verify your installation by running this command:
macOS
python3 -c "import streamlit; import sklearn; import statsbombpy; print('All good!')"
Windows
python -c "import streamlit; import sklearn; import statsbombpy; print('All good!')"
You should see All good! printed in your terminal. This confirms all three critical packages are importable.
Seeing a ModuleNotFoundError?
This means the package didn't install correctly. Check which module the error names, then re-run the pip install command for that specific package.
Also confirm you are running python3 (macOS) or python (Windows) from inside the (venv) environment, not your system Python.
help me fix ModuleNotFoundError after pip install
Your environment is ready. Next up, you will load real World Cup match data and start exploring what players actually did on the pitch.
Load and Explore World Cup Data
Your environment is set up and ready to go. Now you need data to train your prediction model.
To predict player stats, you need two types of data: historical match events showing what players actually did in past World Cups, and current 2026 squad information showing who is playing. In this step, you will build a data pipeline that pulls from both sources and combines them into clean CSV files.
In this step, get ready to:
- Pull player event data from the 2018 and 2022 World Cups using StatsBomb.
- Download current 2026 squad data from Hugging Face.
- Run the data pipeline and inspect the combined dataset.
Create the data loader script
Your model needs granular player-level data from real World Cup matches. StatsBomb provides free open event data for every match in the 2018 and 2022 FIFA World Cups, recording every pass, shot, and goal at the individual event level.
- In Cursor, create a new file called data_loader.py inside your wc-predictor folder.
- Add the imports and warning suppression by pasting the following code:
import pandas as pd
from statsbombpy import sb
from huggingface_hub import hf_hub_download
import warnings
warnings.filterwarnings("ignore")
What do these imports do?
- pandas is the data manipulation library you will use to structure player stats into DataFrames.
- statsbombpy provides a Python interface to StatsBomb's free open event data.
- hf_hub_download lets you download individual files from Hugging Face datasets.
- warnings.filterwarnings("ignore") suppresses verbose deprecation warnings from the data libraries so your output stays clean.
- Save data_loader.py.
- Confirm the file appears in Cursor's Explorer sidebar.
File not showing up?
- Make sure you created data_loader.py inside the wc-predictor folder, not a level above it.
- Check that you saved the file with Cmd+S (macOS) or Ctrl+S (Windows).
Why isn't my data_loader.py showing in the Explorer sidebar?
Now add the function that pulls event-level data from the StatsBomb open data API. This function loops through every match in both World Cups and extracts shots, goals, and passes for each player.
- Below the warnings.filterwarnings("ignore") line, add the following function:
def load_statsbomb_events():
"""Load shot, pass, and goal events from 2018 and 2022 World Cups."""
world_cups = [
{"competition_id": 43, "season_id": 3, "year": 2018},
{"competition_id": 43, "season_id": 106, "year": 2022},
]
all_player_stats = []
for wc in world_cups:
matches = sb.matches(
competition_id=wc["competition_id"], season_id=wc["season_id"]
)
for _, match in matches.iterrows():
match_id = match["match_id"]
events = sb.events(match_id=match_id)
players_in_match = events[events["player"].notna()]["player"].unique()
for player_name in players_in_match:
player_events = events[events["player"] == player_name]
team = player_events["team"].iloc[0]
shots = len(player_events[player_events["type"] == "Shot"])
goals = len(
player_events[
(player_events["type"] == "Shot")
& (player_events["shot_outcome"] == "Goal")
]
)
passes = len(player_events[player_events["type"] == "Pass"])
home_team = match["home_team"]
away_team = match["away_team"]
opponent = away_team if team == home_team else home_team
stage = match["competition_stage"]
all_player_stats.append(
{
"player": player_name,
"team": team,
"opponent": opponent,
"match_id": match_id,
"year": wc["year"],
"stage": stage,
"shots": shots,
"goals": goals,
"passes": passes,
}
)
return pd.DataFrame(all_player_stats)
What does this function do?
- It defines two World Cup tournaments using StatsBomb's IDs: competition_id 43 (FIFA World Cup), season_id 3 (2018), and season_id 106 (2022).
- For each match, it calls sb.events(match_id=...) to get a DataFrame of every on-ball event (passes, shots, dribbles, etc.).
- It then groups events by player and counts their shots, goals, and passes for that match.
- Each player-match row also records the opponent and tournament stage, which will become features for the model later.
- Save data_loader.py.
You will not see a change yet. The next chunk adds the Hugging Face downloader, and then you will run everything together.
Add the Hugging Face data loader
Historical World Cup stats tell you what players did in the past. To make predictions for 2026, you also need to know which teams qualified and who is on their squads.
- Below the load_statsbomb_events() function, add the following function:
def load_hf_data():
"""Load 2026 World Cup team and player data from Hugging Face."""
teams_path = hf_hub_download(
repo_id="adibmed/football-predictions-dataset",
filename="output/04_wc2026_teams.csv",
repo_type="dataset",
)
players_path = hf_hub_download(
repo_id="adibmed/football-predictions-dataset",
filename="data/worldcup/players.csv",
repo_type="dataset",
)
teams_df = pd.read_csv(teams_path)
players_df = pd.read_csv(players_path)
return teams_df, players_df
What does this function do?
- hf_hub_download downloads individual CSV files from the adibmed/football-predictions-dataset on Hugging Face and caches them locally.
- 04_wc2026_teams.csv contains the 48 teams that qualified for the 2026 World Cup.
- players.csv contains player-level data with ratings for current squad members.
- Save data_loader.py.
You will not see a visible change yet. The next chunk ties everything together and runs the full pipeline.
Build the combined dataset and run the script
Now you need an orchestrator function that calls both loaders, prints summary statistics, and saves everything to CSV files on disk.
- Below the load_hf_data() function, add the following code to the bottom of data_loader.py:
def build_dataset():
"""Combine StatsBomb events with Hugging Face team data."""
print("Loading StatsBomb World Cup events (this may take a few minutes)...")
events_df = load_statsbomb_events()
print(f"Loaded {len(events_df)} player-match observations")
print(f"Unique players: {events_df['player'].nunique()}")
print(f"Average shots per player-match: {events_df['shots'].mean():.2f}")
print(f"Average passes per player-match: {events_df['passes'].mean():.2f}")
print("\nLoading 2026 World Cup data from Hugging Face...")
teams_df, players_df = load_hf_data()
print(f"2026 qualified teams: {len(teams_df)}")
events_df.to_csv("statsbomb_events.csv", index=False)
teams_df.to_csv("wc2026_teams.csv", index=False)
players_df.to_csv("wc2026_players.csv", index=False)
print("\nData saved to CSV files!")
return events_df, teams_df, players_df
if __name__ == "__main__":
events_df, teams_df, players_df = build_dataset()
What does this code do?
- build_dataset() calls both loader functions in sequence and prints summary stats so you can see how much data you collected.
- It saves three CSV files: statsbomb_events.csv (player-match observations), wc2026_teams.csv (qualified teams), and wc2026_players.csv (player ratings).
- The if __name__ == "__main__": block ensures the pipeline runs when you execute the script directly.
- Save data_loader.py.
- Run the script by entering this command in your terminal:
python3 data_loader.py
This will take a few minutes
The script fetches event data for every match in both World Cups over HTTP. Each match has thousands of events, and there are 128 total matches across 2018 and 2022. Let it run until you see the summary statistics printed.
When the script finishes, you should see output showing the number of player-match observations, unique players, average shots and passes, and the number of 2026 qualified teams.
- Confirm the CSV files were created by running:
ls *.csv
You should see statsbomb_events.csv, wc2026_teams.csv, and wc2026_players.csv listed.
Script failing or taking too long?
- If you see a ModuleNotFoundError, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- If the script seems stuck, it is likely still downloading match events. The StatsBomb API processes one match at a time, so 128 matches takes a while. Wait for at least 5 minutes before assuming something went wrong.
- If you see a network error, check your internet connection and try running the script again. The library caches previously downloaded matches.
Help me debug why my data_loader.py script is failing when I try to run it.
✔️ Awesome, I've got everything!
Great. Double check you have saved data_loader.py and that your three CSV files are present.
ⓧ I'd like to double check the full code
Here is the complete data_loader.py file. Compare it against yours to spot any differences:
import pandas as pd
from statsbombpy import sb
from huggingface_hub import hf_hub_download
import warnings
warnings.filterwarnings("ignore")
def load_statsbomb_events():
"""Load shot, pass, and goal events from 2018 and 2022 World Cups."""
world_cups = [
{"competition_id": 43, "season_id": 3, "year": 2018},
{"competition_id": 43, "season_id": 106, "year": 2022},
]
all_player_stats = []
for wc in world_cups:
matches = sb.matches(
competition_id=wc["competition_id"], season_id=wc["season_id"]
)
for _, match in matches.iterrows():
match_id = match["match_id"]
events = sb.events(match_id=match_id)
players_in_match = events[events["player"].notna()]["player"].unique()
for player_name in players_in_match:
player_events = events[events["player"] == player_name]
team = player_events["team"].iloc[0]
shots = len(player_events[player_events["type"] == "Shot"])
goals = len(
player_events[
(player_events["type"] == "Shot")
& (player_events["shot_outcome"] == "Goal")
]
)
passes = len(player_events[player_events["type"] == "Pass"])
home_team = match["home_team"]
away_team = match["away_team"]
opponent = away_team if team == home_team else home_team
stage = match["competition_stage"]
all_player_stats.append(
{
"player": player_name,
"team": team,
"opponent": opponent,
"match_id": match_id,
"year": wc["year"],
"stage": stage,
"shots": shots,
"goals": goals,
"passes": passes,
}
)
return pd.DataFrame(all_player_stats)
def load_hf_data():
"""Load 2026 World Cup team and player data from Hugging Face."""
teams_path = hf_hub_download(
repo_id="adibmed/football-predictions-dataset",
filename="output/04_wc2026_teams.csv",
repo_type="dataset",
)
players_path = hf_hub_download(
repo_id="adibmed/football-predictions-dataset",
filename="data/worldcup/players.csv",
repo_type="dataset",
)
teams_df = pd.read_csv(teams_path)
players_df = pd.read_csv(players_path)
return teams_df, players_df
def build_dataset():
"""Combine StatsBomb events with Hugging Face team data."""
print("Loading StatsBomb World Cup events (this may take a few minutes)...")
events_df = load_statsbomb_events()
print(f"Loaded {len(events_df)} player-match observations")
print(f"Unique players: {events_df['player'].nunique()}")
print(f"Average shots per player-match: {events_df['shots'].mean():.2f}")
print(f"Average passes per player-match: {events_df['passes'].mean():.2f}")
print("\nLoading 2026 World Cup data from Hugging Face...")
teams_df, players_df = load_hf_data()
print(f"2026 qualified teams: {len(teams_df)}")
events_df.to_csv("statsbomb_events.csv", index=False)
teams_df.to_csv("wc2026_teams.csv", index=False)
players_df.to_csv("wc2026_players.csv", index=False)
print("\nData saved to CSV files!")
return events_df, teams_df, players_df
if __name__ == "__main__":
events_df, teams_df, players_df = build_dataset()
Your raw data is ready. Next up, you will engineer features from these observations and train a Random Forest model to predict player stats.
Engineer Features and Train the Model
Your raw data is loaded and ready. Now you need to transform those raw event counts into features that a machine learning model can learn from.
In this step, you'll calculate per-player historical averages, encode match context as numbers, and train a Random Forest model that predicts how many shots, goals, and passes a player will have in a given match.
In this step, get ready to:
- Build a feature engineering pipeline that turns raw events into model-ready numbers.
- Train a Random Forest Regressor on 80% of the data and evaluate it on the remaining 20%.
- Save the trained model and supporting artifacts to disk for the Streamlit app to load.
Create the feature engineering function
Raw event counts (shots, goals, passes per match) aren't useful on their own. The model needs to understand a player's typical behaviour across all matches, not just one.
Feature engineering is the process of transforming raw data into informative inputs for a model. You'll calculate per-player averages and encode the tournament stage as a number.
- Create a new file called train_model.py in your wc-predictor folder.
- Add the imports and feature engineering function by pasting the following code:
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score
import joblib
def engineer_features(events_df, teams_df):
"""Create ML features from raw event data."""
# Calculate each player's average stats across all their World Cup matches
player_averages = (
events_df.groupby("player")
.agg(
avg_shots=("shots", "mean"),
avg_goals=("goals", "mean"),
avg_passes=("passes", "mean"),
matches_played=("match_id", "count"),
total_goals=("goals", "sum"),
)
.reset_index()
)
# Convert tournament stage names to numeric values (higher = later stage)
stage_map = {
"Group Stage": 0,
"Round of 16": 1,
"Quarter-finals": 2,
"Semi-finals": 3,
"Final": 4,
"3rd Place Final": 3,
}
features_list = []
# Build a feature row for each player-match observation
for _, row in events_df.iterrows():
player_stats = player_averages[player_averages["player"] == row["player"]]
if player_stats.empty:
continue
stage_encoded = stage_map.get(row["stage"], 0)
features_list.append(
{
"player": row["player"],
"team": row["team"],
"opponent": row["opponent"],
"avg_shots": player_stats["avg_shots"].values[0],
"avg_goals": player_stats["avg_goals"].values[0],
"avg_passes": player_stats["avg_passes"].values[0],
"matches_played": player_stats["matches_played"].values[0],
"total_goals": player_stats["total_goals"].values[0],
"stage_encoded": stage_encoded,
"shots": row["shots"],
"goals": row["goals"],
"passes": row["passes"],
}
)
features_df = pd.DataFrame(features_list)
return features_df, player_averages
What does this code do?
The engineer_features function transforms raw per-match event counts into meaningful ML features.
- player_averages groups all matches by player and calculates their average shots, goals, and passes per match. It also counts total matches played and career goals.
- stage_map converts text stage names ("Group Stage", "Final") into numbers so the model can understand that later rounds carry more pressure.
- The loop builds one feature row per player-match, combining the player's historical averages with the match context. The actual match outcomes (shots, goals, passes) stay attached as the targets the model will learn to predict.
Why Random Forest instead of a neural network?
Random Forest works well out-of-the-box without hyperparameter tuning. It also produces interpretable feature importances, which means you can show users WHY the model made its prediction.
More complex models like neural networks might squeeze out slightly better accuracy, but for a dataset of this size the improvement is marginal. The interpretability tradeoff is not worth it.
- Save train_model.py.
Add the training and saving function
With features engineered, the next piece orchestrates the full training pipeline. It loads data, calls your feature function, splits into train/test sets, fits the model, evaluates performance, and saves everything to disk.
- In train_model.py, add the following code below the engineer_features function:
def train_and_save():
"""Train the model and save it to disk."""
print("Loading data...")
events_df = pd.read_csv("statsbomb_events.csv")
teams_df = pd.read_csv("wc2026_teams.csv")
print("Engineering features...")
features_df, player_averages = engineer_features(events_df, teams_df)
# Define which columns the model uses as inputs and outputs
feature_columns = [
"avg_shots",
"avg_goals",
"avg_passes",
"matches_played",
"total_goals",
"stage_encoded",
]
target_columns = ["shots", "goals", "passes"]
X = features_df[feature_columns]
y = features_df[target_columns]
# Hold out 20% of data to test how well the model generalizes
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# Train a Random Forest with 100 decision trees
print("Training Random Forest model...")
model = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
# Evaluate on the held-out test set
y_pred = model.predict(X_test)
print("\nModel Performance:")
print("-" * 40)
for i, target in enumerate(target_columns):
mae = mean_absolute_error(y_test.iloc[:, i], y_pred[:, i])
r2 = r2_score(y_test.iloc[:, i], y_pred[:, i])
print(f" {target}: MAE = {mae:.3f}, R2 = {r2:.3f}")
print("\nFeature Importance:")
print("-" * 40)
for name, importance in zip(feature_columns, model.feature_importances_):
print(f" {name}: {importance:.3f}")
# Save model and artifacts so the Streamlit app can load them
joblib.dump(model, "model.pkl")
joblib.dump(feature_columns, "feature_columns.pkl")
player_averages.to_csv("player_averages.csv", index=False)
print("\nModel and artifacts saved!")
print(" - model.pkl")
print(" - feature_columns.pkl")
print(" - player_averages.csv")
if __name__ == "__main__":
train_and_save()
What does this code do?
- train_test_split holds back 20% of player-match observations as a test set. The model never sees these during training, so you can measure real-world accuracy.
- RandomForestRegressor(n_estimators=100) builds 100 decision trees that each learn slightly different patterns. Their combined vote produces the final prediction.
- MAE (Mean Absolute Error) tells you how far off predictions are on average. R2 tells you what fraction of the variance the model explains (1.0 is perfect, 0.0 is random guessing).
- joblib.dump serializes the trained model and feature column list to disk so the Streamlit app can reload them without retraining.
- Save train_model.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score
import joblib
def engineer_features(events_df, teams_df):
"""Create ML features from raw event data."""
player_averages = (
events_df.groupby("player")
.agg(
avg_shots=("shots", "mean"),
avg_goals=("goals", "mean"),
avg_passes=("passes", "mean"),
matches_played=("match_id", "count"),
total_goals=("goals", "sum"),
)
.reset_index()
)
stage_map = {
"Group Stage": 0,
"Round of 16": 1,
"Quarter-finals": 2,
"Semi-finals": 3,
"Final": 4,
"3rd Place Final": 3,
}
features_list = []
for _, row in events_df.iterrows():
player_stats = player_averages[player_averages["player"] == row["player"]]
if player_stats.empty:
continue
stage_encoded = stage_map.get(row["stage"], 0)
features_list.append(
{
"player": row["player"],
"team": row["team"],
"opponent": row["opponent"],
"avg_shots": player_stats["avg_shots"].values[0],
"avg_goals": player_stats["avg_goals"].values[0],
"avg_passes": player_stats["avg_passes"].values[0],
"matches_played": player_stats["matches_played"].values[0],
"total_goals": player_stats["total_goals"].values[0],
"stage_encoded": stage_encoded,
"shots": row["shots"],
"goals": row["goals"],
"passes": row["passes"],
}
)
features_df = pd.DataFrame(features_list)
return features_df, player_averages
def train_and_save():
"""Train the model and save it to disk."""
print("Loading data...")
events_df = pd.read_csv("statsbomb_events.csv")
teams_df = pd.read_csv("wc2026_teams.csv")
print("Engineering features...")
features_df, player_averages = engineer_features(events_df, teams_df)
feature_columns = [
"avg_shots",
"avg_goals",
"avg_passes",
"matches_played",
"total_goals",
"stage_encoded",
]
target_columns = ["shots", "goals", "passes"]
X = features_df[feature_columns]
y = features_df[target_columns]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
print("Training Random Forest model...")
model = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print("\nModel Performance:")
print("-" * 40)
for i, target in enumerate(target_columns):
mae = mean_absolute_error(y_test.iloc[:, i], y_pred[:, i])
r2 = r2_score(y_test.iloc[:, i], y_pred[:, i])
print(f" {target}: MAE = {mae:.3f}, R2 = {r2:.3f}")
print("\nFeature Importance:")
print("-" * 40)
for name, importance in zip(feature_columns, model.feature_importances_):
print(f" {name}: {importance:.3f}")
joblib.dump(model, "model.pkl")
joblib.dump(feature_columns, "feature_columns.pkl")
player_averages.to_csv("player_averages.csv", index=False)
print("\nModel and artifacts saved!")
print(" - model.pkl")
print(" - feature_columns.pkl")
print(" - player_averages.csv")
if __name__ == "__main__":
train_and_save()
Run the training script
Time to see the model in action. Running the script will engineer features from your CSV data, train the Random Forest, print evaluation metrics, and save the model artifacts.
- Run the training script by executing this command in your terminal:
python3 train_model.py
You should see output showing the model's MAE and R2 scores for each target (shots, goals, passes), followed by feature importance values and a confirmation that artifacts were saved.
- Confirm the saved artifacts exist by running:
ls
You should see model.pkl, feature_columns.pkl, and player_averages.csv listed alongside your other project files.
What should I see in the output?
The R2 scores will vary by target. Passes will likely have the highest R2 because passing volume is more consistent across matches. Goals will have a lower R2 because goals are rare, high-variance events.
Feature importance will show which inputs the model relied on most. You'll likely see avg_passes and avg_shots ranked highly, which makes intuitive sense.
Seeing a FileNotFoundError?
- Make sure you ran data_loader.py in the previous step. The training script reads statsbomb_events.csv and wc2026_teams.csv from the current directory.
- Confirm your terminal is inside the wc-predictor folder where those CSV files live.
Why can't my training script find the CSV files?
Your model is trained and saved. Next up, you'll build a Streamlit dashboard that loads this model and lets users explore predictions for any 2026 World Cup player.
Build the Streamlit Dashboard
You have a trained model that knows the patterns behind World Cup player performance. Now it needs a face.
In this step, you will build an interactive web app using Streamlit. Users will pick a team, player, and opponent from dropdown menus, then see predicted stats appear instantly as metric cards.
In this step, get ready to:
- Create the Streamlit app file with cached model and data loading.
- Build the prediction logic that assembles features and displays metric cards.
- Add feature importance charts and test the app locally.
Create the app file and load data
Streamlit apps are plain Python scripts that use special functions to render UI elements. The framework handles all the HTML, CSS, and JavaScript for you.
- In Cursor, create a new file called app.py inside your wc-predictor folder.
- Add the imports, page configuration, and cached loading functions by pasting this code:
import streamlit as st
import pandas as pd
import numpy as np
import joblib
st.set_page_config(page_title="WC 2026 Player Predictor", page_icon="\u26bd", layout="wide")
st.title("World Cup 2026 Player Stats Predictor")
st.markdown("Predict shots, goals, and passes for any player in the 2026 World Cup")
@st.cache_resource
def load_model():
# Load the trained Random Forest model and feature column names from disk
model = joblib.load("model.pkl")
feature_columns = joblib.load("feature_columns.pkl")
return model, feature_columns
@st.cache_data
def load_data():
# Load the CSV files created by our data pipeline and training scripts
player_averages = pd.read_csv("player_averages.csv")
teams_df = pd.read_csv("wc2026_teams.csv")
events_df = pd.read_csv("statsbomb_events.csv")
return player_averages, teams_df, events_df
What does this code do?
The @st.cache_resource decorator tells Streamlit to load the model only once, even when users interact with the app. Without caching, every click would reload the entire model from disk.
The @st.cache_data decorator does the same for DataFrames. It keeps CSV data in memory so repeated interactions stay fast.
- Below the loading functions, add the sidebar UI elements that let users select a match scenario:
model, feature_columns = load_model()
player_averages, teams_df, events_df = load_data()
# Get list of teams that have player event data
teams_with_players = events_df["team"].unique().tolist()
st.sidebar.header("Match Setup")
# Team selector filters available players dynamically
selected_team = st.sidebar.selectbox("Select Team", sorted(teams_with_players))
team_players = events_df[events_df["team"] == selected_team]["player"].unique()
team_player_stats = player_averages[player_averages["player"].isin(team_players)]
selected_player = st.sidebar.selectbox(
"Select Player", sorted(team_player_stats["player"].tolist())
)
# Remove the selected team from opponent options
opponents = [t for t in sorted(teams_with_players) if t != selected_team]
selected_opponent = st.sidebar.selectbox("Select Opponent", opponents)
# Map stage names to numeric values the model expects
stage_options = {
"Group Stage": 0,
"Round of 16": 1,
"Quarter-final": 2,
"Semi-final": 3,
"Final": 4,
}
selected_stage = st.sidebar.radio("Match Stage", list(stage_options.keys()))
What's happening here?
The sidebar dropdowns are dynamic. When a user picks a team, the player list updates to show only players from that team who have historical World Cup data.
The stage_options dictionary maps human-readable stage names to the numeric encoding your model was trained on.
- Save app.py.
Seeing import errors?
Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt. If not, run source venv/bin/activate again.
help me debug import errors in my Streamlit app
Add the prediction logic and metric cards
When the user clicks the Predict button, the app needs to assemble a feature vector matching exactly what the model was trained on, run the prediction, and display the results.
- Below the selected_stage line in app.py, add the prediction button and results display:
if st.sidebar.button("Predict", type="primary"):
player_stats = player_averages[player_averages["player"] == selected_player]
if not player_stats.empty:
# Build the feature vector using the same columns the model was trained on
features = pd.DataFrame(
[
{
"avg_shots": player_stats["avg_shots"].values[0],
"avg_goals": player_stats["avg_goals"].values[0],
"avg_passes": player_stats["avg_passes"].values[0],
"matches_played": player_stats["matches_played"].values[0],
"total_goals": player_stats["total_goals"].values[0],
"stage_encoded": stage_options[selected_stage],
}
]
)
# Run the model prediction and clamp negative values to zero
prediction = model.predict(features)[0]
pred_shots = max(0, round(prediction[0], 1))
pred_goals = max(0, round(prediction[1], 2))
pred_passes = max(0, round(prediction[2], 1))
st.subheader(f"Predictions for {selected_player}")
st.caption(
f"{selected_team} vs {selected_opponent} | {selected_stage}"
)
# Display predictions as large metric cards in three columns
col1, col2, col3 = st.columns(3)
col1.metric("Predicted Shots", pred_shots)
col2.metric("Predicted Goals", pred_goals)
col3.metric("Predicted Passes", pred_passes)
What does this code do?
The feature DataFrame mirrors the exact column order the model was trained on: avg_shots, avg_goals, avg_passes, matches_played, total_goals, and stage_encoded.
The max(0, ...) call prevents negative predictions. A regression model can technically predict negative values, but you cannot have negative goals in a real match.
- Save app.py.
Seeing a KeyError?
Double-check that the keys in your features dictionary match the column names in player_averages.csv exactly. Even a trailing space will cause a mismatch.
help me fix a KeyError in my prediction feature vector
Add feature importance and test the app
Predictions alone are not enough. Users want to know WHY the model predicts what it does. A feature importance chart shows which inputs matter most.
- Still inside the if not player_stats.empty: block, directly below the metric cards code, add the feature importance chart and historical averages table:
st.subheader("Feature Importance")
importances = model.feature_importances_
importance_df = pd.DataFrame(
{"Feature": feature_columns, "Importance": importances}
).sort_values("Importance", ascending=True)
# Bar chart shows which features the model relies on most
st.bar_chart(importance_df.set_index("Feature"))
st.subheader("Player Historical Averages (from World Cup data)")
st.dataframe(
player_stats[
["player", "avg_shots", "avg_goals", "avg_passes", "matches_played"]
].rename(
columns={
"avg_shots": "Avg Shots/Match",
"avg_goals": "Avg Goals/Match",
"avg_passes": "Avg Passes/Match",
"matches_played": "WC Matches",
}
),
hide_index=True,
)
else:
st.error(f"No historical data found for {selected_player}")
else:
st.info("Select a team, player, opponent, and match stage, then click Predict.")
What does this code do?
The model.feature_importances_ attribute comes from the trained Random Forest. It shows what percentage of the model's decision-making each feature accounts for.
The st.dataframe() call displays the player's historical averages so users can see what raw data drove the prediction. The else clauses handle edge cases: no data for the player, or no prediction requested yet.
- Save app.py.
- Launch your Streamlit app by running this command in the terminal:
streamlit run app.py
- Your browser should open automatically to http://localhost:8501. You should see the app title, a sidebar with Team, Player, Opponent dropdowns, and a Match Stage radio button.
- Select any team and player from the sidebar, choose an opponent and stage, then click the Predict button.
- You should see three metric cards (Predicted Shots, Predicted Goals, Predicted Passes), a feature importance bar chart, and a historical averages table below.
App not loading in the browser?
Make sure you are running the command from inside your wc-predictor folder. Streamlit needs to find model.pkl and the CSV files in the same directory.
If port 8501 is already in use, stop the other process with Ctrl+C and try again.
help me debug my Streamlit app that is not loading
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and the app is running in your browser.
ⓧ I'd like to double check the full code
Here is the complete app.py file for comparison:
import streamlit as st
import pandas as pd
import numpy as np
import joblib
st.set_page_config(page_title="WC 2026 Player Predictor", page_icon="\u26bd", layout="wide")
st.title("World Cup 2026 Player Stats Predictor")
st.markdown("Predict shots, goals, and passes for any player in the 2026 World Cup")
@st.cache_resource
def load_model():
model = joblib.load("model.pkl")
feature_columns = joblib.load("feature_columns.pkl")
return model, feature_columns
@st.cache_data
def load_data():
player_averages = pd.read_csv("player_averages.csv")
teams_df = pd.read_csv("wc2026_teams.csv")
events_df = pd.read_csv("statsbomb_events.csv")
return player_averages, teams_df, events_df
model, feature_columns = load_model()
player_averages, teams_df, events_df = load_data()
teams_with_players = events_df["team"].unique().tolist()
st.sidebar.header("Match Setup")
selected_team = st.sidebar.selectbox("Select Team", sorted(teams_with_players))
team_players = events_df[events_df["team"] == selected_team]["player"].unique()
team_player_stats = player_averages[player_averages["player"].isin(team_players)]
selected_player = st.sidebar.selectbox(
"Select Player", sorted(team_player_stats["player"].tolist())
)
opponents = [t for t in sorted(teams_with_players) if t != selected_team]
selected_opponent = st.sidebar.selectbox("Select Opponent", opponents)
stage_options = {
"Group Stage": 0,
"Round of 16": 1,
"Quarter-final": 2,
"Semi-final": 3,
"Final": 4,
}
selected_stage = st.sidebar.radio("Match Stage", list(stage_options.keys()))
if st.sidebar.button("Predict", type="primary"):
player_stats = player_averages[player_averages["player"] == selected_player]
if not player_stats.empty:
features = pd.DataFrame(
[
{
"avg_shots": player_stats["avg_shots"].values[0],
"avg_goals": player_stats["avg_goals"].values[0],
"avg_passes": player_stats["avg_passes"].values[0],
"matches_played": player_stats["matches_played"].values[0],
"total_goals": player_stats["total_goals"].values[0],
"stage_encoded": stage_options[selected_stage],
}
]
)
prediction = model.predict(features)[0]
pred_shots = max(0, round(prediction[0], 1))
pred_goals = max(0, round(prediction[1], 2))
pred_passes = max(0, round(prediction[2], 1))
st.subheader(f"Predictions for {selected_player}")
st.caption(
f"{selected_team} vs {selected_opponent} | {selected_stage}"
)
col1, col2, col3 = st.columns(3)
col1.metric("Predicted Shots", pred_shots)
col2.metric("Predicted Goals", pred_goals)
col3.metric("Predicted Passes", pred_passes)
st.subheader("Feature Importance")
importances = model.feature_importances_
importance_df = pd.DataFrame(
{"Feature": feature_columns, "Importance": importances}
).sort_values("Importance", ascending=True)
st.bar_chart(importance_df.set_index("Feature"))
st.subheader("Player Historical Averages (from World Cup data)")
st.dataframe(
player_stats[
["player", "avg_shots", "avg_goals", "avg_passes", "matches_played"]
].rename(
columns={
"avg_shots": "Avg Shots/Match",
"avg_goals": "Avg Goals/Match",
"avg_passes": "Avg Passes/Match",
"matches_played": "WC Matches",
}
),
hide_index=True,
)
else:
st.error(f"No historical data found for {selected_player}")
else:
st.info("Select a team, player, opponent, and match stage, then click Predict.")
Your prediction dashboard is live. You have a complete end-to-end ML pipeline: from raw event data, through feature engineering and model training, to an interactive web app. Next up, you will deploy this app to the internet for free so anyone can use it.
Secret mission
Deploy to Streamlit Community Cloud
Your predictor works locally, but what if you could share it with anyone? In this mission, you'll deploy your app to the internet for free using Streamlit Community Cloud. Anyone with the link can predict World Cup stats.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free services. Your Streamlit Community Cloud app costs nothing and sleeps automatically after 12 hours of inactivity. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Streamlit Community Cloud app (free, sleeps when unused).
- GitHub repository containing your project code and model files.
- Local wc-predictor folder with virtual environment, data files, and trained model.
Keep everything running
No action needed. Choose this if you plan to keep sharing your predictor or want to revisit the project later.
- Your Streamlit app stays live and wakes automatically when someone visits the URL.
- Your GitHub repository remains accessible for future updates.
- Your local files stay on your machine for further experimentation.
Pause - I'll come back to this later
Free up local disk space while keeping your cloud resources intact. You can always re-clone and rebuild later.
- Your Streamlit app stays deployed. It sleeps after 12 hours of inactivity and costs nothing.
- Your GitHub repository keeps your code safe in the cloud.
- Deactivate your virtual environment by running:
deactivate
- Optionally, delete the local folder to free disk space. You can re-clone from GitHub anytime.
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
Delete the Streamlit Community Cloud app
- Go to share.streamlit.io and find your app in the workspace.
- Click the overflow icon (three dots) next to your app.
- Click Delete.
- Enter the required confirmation string and click Delete again to confirm.
Delete the GitHub repository
- Go to your repository page on GitHub.
- Click Settings (under the repository name).
- Scroll down to the Danger Zone section.
- Click Delete this repository.
- Type the repository name to confirm, then click Delete this repository.
Delete the local project folder
- Deactivate your virtual environment first, then remove the folder by running:
macOS
deactivate
cd ~/Desktop
rm -rf wc-predictor
Windows
deactivate
cd ~\Desktop
rmdir /s /q wc-predictor
That's a Wrap!
That's a Wrap!
Nice work! You just built a full machine learning pipeline that predicts how World Cup players will perform in 2026 matches.
You've learned how to:
- Built a data pipeline combining StatsBomb World Cup event data with current 2026 team and player data from Hugging Face.
- Trained a Random Forest model to predict player-level match stats (shots, goals, passes) using feature engineering and model serialization.
- Created an interactive Streamlit dashboard for exploring predictions across all 2026 World Cup teams, complete with feature importance charts.
- Secret Mission: Deployed the app live to the internet for free on Streamlit Community Cloud.
Ready to quiz yourself?