Soccer Graph Neural Networks
This tutorial covers how to convert soccer tracking data into graphs and train Graph Neural Networks for various prediction tasks.
The workflow consists of:
Loading tracking data with Kloppy
Converting to Polars DataFrame
Adding labels and graph IDs
Converting to graph structures
Training a GNN model
Making predictions
Interactive Notebooks
We provide comprehensive Jupyter notebooks that walk through the entire process:
Quick Start Guides
PyTorch Geometric (Recommended)
-
Basic 5-step workflow
Model training and prediction
Saving and loading models
Spektral (Deprecated - Python 3.11 only)
-
Legacy TensorFlow/Keras approach
Not recommended for new projects
In-Depth Tutorials
PyTorch Geometric
Comprehensive GNN Training (PyTorch)
Multiple match processing
Custom graph features
Label engineering
Pickle file storage for large datasets
Advanced training patterns
Spektral
Comprehensive GNN Training (Spektral)
Legacy approach for Python 3.11
Graph Configuration FAQ
-
What is a graph?
Complete list of graph settings
All available node and edge features
Adjacency matrix representations
Custom feature functions
Key Concepts
Data Preparation
Loading Data
Use Kloppy to load tracking data from various providers:
from kloppy import sportec, skillcorner, tracab
from unravel.soccer import KloppyPolarsDataset
# Sportec (DFL Open Data)
kloppy_dataset = sportec.load_open_tracking_data(
only_alive=True, # Only frames with ball in play
limit=500 # Limit to 500 frames for testing
)
# SkillCorner
kloppy_dataset = skillcorner.load_open_data(
only_alive=True,
include_empty_frames=False
)
# Convert to Polars
polars_dataset = KloppyPolarsDataset(kloppy_dataset=kloppy_dataset)
Adding Labels
For supervised learning, add a label column:
from unravel.utils import add_dummy_label_column
# Option 1: Dummy labels (for testing)
polars_dataset.add_dummy_labels()
# Option 2: Join real labels from your data
import polars as pl
# Your labels should have matching keys to join on
labels = pl.DataFrame({
"frame_id": [10000, 10001, 10002, ...],
"label": [0, 1, 0, ...]
})
polars_dataset.dataset = polars_dataset.dataset.join(
labels,
on="frame_id",
how="left"
)
Adding Graph IDs
Group frames into graph samples along which polars_dataset.split_train_test() is split.
Graph Conversion
Basic Configuration
from unravel.soccer import SoccerGraphConverter
converter = SoccerGraphConverter(
dataset=polars_dataset,
# Ball connections
self_loop_ball=True, # Add self-loop to ball node
adjacency_matrix_connect_type="ball", # How to connect ball
# Graph structure
adjacency_matrix_type="split_by_team", # Team-based connections
# Labels
label_type="binary", # Only supported option
# Node values for specific roles
defending_team_node_value=0.1,
non_potential_receiver_node_value=0.1,
# Training options
random_seed=False, # Permutation invariance during training
pad=False, # Padding for fixed-size graphs
verbose=False,
chunk_size=2000, # Number of graphs to process at a time
# Add custom features or a subset of built in features
# If none are passed, defaults to Sahasrabudhe & Bekkers (2023)
node_feature_funcs=[is_gk, etc..],
edge_feature_funcs=[distances_between_players_normed, etc..], #
# Specifiy column you have added to polars_dataset.data
# These global features should be the _same_ value for every player
# for a given frame.
global_feature_cols=['goal_diff'],
# Do we attach the global features to all players+ball or only ball
global_feature_type="all", # or ball
# Extra columns from dataset to
# make available to custom feature functions (e.g., player height, position).
# Defaults to empty list.
additional_feature_cols=None
)
Built-in Node Features & Edge Features
[Available node features that are built-in.](https://github.com/UnravelSports/unravelsports/blob/main/unravel/utils/features/builtin.py)
Custom Features
Add custom node or edge features.
Kwargs contains all built-in polars_dataset.data columns (e.g. vx, x etc.) + additional_feature_cols that you’ve added.
from unravel.utils.features import graph_feature
@graph_feature(is_custom=True, feature_type="node")
def distances_between_players_normed(**kwargs):
distances_between_players = np.linalg.norm(
kwargs["position"][:, None, :] - kwargs["position"][None, :, :], axis=-1
)
return normalize_distance(
distances_between_players, max_distance=kwargs["settings"].max_distance
)
# Use in converter
converter = SoccerGraphConverter(
dataset=polars_dataset,
node_feature_cols=[distances_between_players_normed],
...
)
Adjacency Matrix Types
split_by_team: Separate graphs for each team, connected via balldelaunay: Spatial proximity based on Delaunay triangulationdense: Fully connected graphdense_ap: Dense for attacking team, proximity for defendingdense_dp: Dense for defending team, proximity for attacking
Converting to Graph Format
# PyTorch Geometric (recommended)
graphs = converter.to_pytorch_graphs()
# Spektral (deprecated)
graphs = converter.to_spektral_graphs()
# Save to pickle for reuse
import pickle
import gzip
with gzip.open("graphs.pickle.gz", "wb") as f:
pickle.dump(graphs, f)
# Load from pickle
with gzip.open("graphs.pickle.gz", "rb") as f:
graphs = pickle.load(f)
Model Training
PyTorch Geometric Approach
from unravel.utils import GraphDataset
from unravel.classifiers import PyGLightningCrystalGraphClassifier
import pytorch_lightning as pyl
from torch_geometric.loader import DataLoader
# Create dataset
dataset = GraphDataset(graphs=graphs, format="pyg")
# Split data (4:1:1 ratio for train:test:val)
train_graphs, test_graphs, val_graphs = dataset.split_test_train_validation(4, 1, 1)
# Create data loaders
train_loader = DataLoader(train_graphs, batch_size=32, shuffle=True)
val_loader = DataLoader(val_graphs, batch_size=32, shuffle=False)
test_loader = DataLoader(test_graphs, batch_size=32, shuffle=False)
# Initialize model
model = PyGLightningCrystalGraphClassifier(
)
# Train
trainer = pyl.Trainer(
max_epochs=10,
accelerator="auto", # Use GPU if available
devices=1,
log_every_n_steps=10,
)
trainer.fit(model, train_loader, val_loader)
# Test
test_results = trainer.test(model, test_loader)
# Save model
trainer.save_checkpoint("model.ckpt")
# Load model
model = PyGLightningCrystalGraphClassifier.load_from_checkpoint("model.ckpt")
Making Predictions
# Note, this is the same data as we trained on, it's just an example
kloppy_dataset = sportec.load_open_tracking_data(
only_alive=True, limit=500, coordinates="sportec")
new_data = KloppyPolarsDataset(kloppy_dataset=kloppy_dataset)
new_data.add_graph_ids(by=["frame_id"])
# Set converter to prediction mode (no labels needed)
converter_pred = SoccerGraphConverter(
dataset=new_data,
prediction=True, # Important!
# **same_settings_as_training
)
# Convert new data
new_graphs = converter_pred.to_pytorch_graphs()
new_dataset = GraphDataset(graphs=new_graphs, format="pyg")
pred_loader = DataLoader(new_dataset, batch_size=32)
# Predict
predictions = trainer.predict(model, pred_loader)