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: 1. Loading tracking data with Kloppy 2. Converting to Polars DataFrame 3. Adding labels and graph IDs 4. Converting to graph structures 5. Training a GNN model 6. Making predictions Interactive Notebooks --------------------- We provide comprehensive Jupyter notebooks that walk through the entire process: Quick Start Guides ~~~~~~~~~~~~~~~~~~ **PyTorch Geometric (Recommended)** * `Quick Start Guide (PyTorch) `_ * Basic 5-step workflow * Model training and prediction * Saving and loading models **Spektral (Deprecated - Python 3.11 only)** * `Quick Start Guide (Spektral) `_ * 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 ~~~~~~~~~~~~~~~~~~~~~~~ * `Graphs 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: .. code-block:: python 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: .. code-block:: python 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. .. code-block:: python polars_dataset.add_graph_ids(by=["frame_id"]) Graph Conversion ~~~~~~~~~~~~~~~~ **Basic Configuration** .. code-block:: python 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. .. code-block:: python 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 ball * ``delaunay``: Spatial proximity based on Delaunay triangulation * ``dense``: Fully connected graph * ``dense_ap``: Dense for attacking team, proximity for defending * ``dense_dp``: Dense for defending team, proximity for attacking Converting to Graph Format ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python # 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** .. code-block:: python 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 ~~~~~~~~~~~~~~~~~~ .. code-block:: python # 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)