Releases: UnravelSports/unravelsports
v1.1.0
What's Changed
- Added EFPI (Elastic Formation and Position Identification) as discussed in EFPI: Elastic Formation and Position Identification in Football (Soccer) using Template Matching and Linear Assignment (Bekkers, 2025)
Basic Functionality:
from unravel.soccer import EFPI
model = EFPI(dataset=kloppy_polars_dataset)
model.fit(
# Default 65 formations , or specify a subset (e.g. ["442" , "433"])
formations=None,
# specific time intervals (e.g. 1m, 1m14s, 2m30s etc.), or specify "possession", "period" or "frame".
every="5m",
substitutions="drop",
change_threshold=0.1,
change_after_possession=True,
)v1.0.1
What's Changed
- kloppy > 3.17.0 by @UnravelSports in #50
- v1.0.1 by @UnravelSports in #51
Full Changelog: v1.0.0...v1.0.1
v1.0.0
What's Changed
These changes are compared to v0.3.0!
⛓️💥 Breaking Changes:
- Renamed
CustomSpektralDatasettoGraphDataset - Removed
SoccerGraphConverterreplaced with contents ofSoccerGraphConverterPolarsand renamed back toSoccerGraphConverter
⭐ Features:
- Add
frame_idto every Graph to make it easier to join back predictions to DataFrames. - Add custom function support.
- Add
converter.plot()functionality toSoccerGraphConverter - Add support for global features through
global_feature_colsinSoccerGraphConverterandAmericanFootballGraphConverter
🐛 Bug Fixes
- Revert back to old way of computing Graphs using Polars (v0.3.0 in
SoccerGraphConverterPolarsintroduces a major bug)
Minor
- Add epsilon for nans to avoid
RunTimeWarnings - Fix sorting / randomization in
SoccerGraphConverter - Fix ball padding
- Add default
engine="gpu"to Polarscollect() - Fix sample rate
Overview
1. Fix sorting / randomization in SoccerGraphConverter
Setting random_seed=False or random_seed=None now makes sure the underlying data object is sorted by frame, team in possession, team out of possession and finally ball.
SoccerGraphConverter(
dataset=kloppy_pl_ds,
label_col=label_column,
random_seed=False,
)2. Revert back to old way of computing Graphs using Polars
The new implementation introduced a major bug that didn't correctly parse out the Graphs after using Polars to compute them. This resulted in every Graph being equal to the first graph.
3. Add Custom Function Support
Refactor Feature Calculations
A refactor for compute_edge_features_pl, compute_node_features_pl and compute_adjacency_matrix_pl. They are renamed to compute_edge_features, compute_node_features and compute_adjacency_matrix.
They now look like something like this under the hood:
node_features = compute_node_features(
funcs=self.node_feature_funcs,
opts=self.feature_opts,
settings=self.settings,
**d,
)Edge / Node Feature Functions
self.node_feature_funcs and self.edge_feature_funcs are now both a list of functions that should take in **kwargs. For example:
@graph_feature(is_custom=False, feature_type="node")
def x_normed(**kwargs):
return normalize_between(
value=kwargs["x"],
max_value=kwargs["settings"].pitch_dimensions.x_dim.max,
min_value=kwargs["settings"].pitch_dimensions.x_dim.min,
)Passing this x_normed function in SoccerGraphConverter(..., node_feature_funcs=[x_normed]) now computes the normalized x value as defined in the above function.
The default functions that were initially the edge and node features in this package have now been declared as the default features properties (see below).
@property
def default_node_feature_funcs(self) -> list:
return [
x_normed,
y_normed,
speeds_normed,
velocity_components_2d_normed,
distance_to_goal_normed,
distance_to_ball_normed,
is_possession_team,
is_gk,
is_ball,
angle_to_goal_components_2d_normed,
angle_to_ball_components_2d_normed,
is_ball_carrier,
]
@property
def default_edge_feature_funcs(self) -> list:
return [
distances_between_players_normed,
speed_difference_normed,
angle_between_players_normed,
velocity_difference_normed,
]Custom Functions
We can also pass custom edge and node feature functions. Any of these functions needs to be decorated with @graph_feature decorator. This decorator takes 2 parameters is_custom and feature_type ("node" or "edge").
This will simply make sure the user consciously adding the correct functions to the correct list (node or edge features).
Additionally we introduce additional_feature_cols to SoccerGraphConverter which allows us to pass columns we've added to our KloppyPolarsData.data dataframe which we can subsequently access through kwargs in our custom functions.
For example:
kloppy_polars_dataset.data = (
kloppy_polars_dataset.data
# note, normally you'd join these columns on a frame level
.with_columns(
[
pl.lit(0.45).alias("fake_additional_feature_a"),
pl.lit(1).alias("fake_graph_feature_a"),
pl.lit(0.12).alias("fake_graph_feature_b"),
]
)
)
@graph_feature(is_custom=True, feature_type="edge")
def custom_edge_feature(**kwargs):
return (
kwargs["fake_additional_feature_a"][None, :]
+ kwargs["fake_additional_feature_a"][:, None]
)
SoccerGraphConverter(
dataset=kloppy_polars_dataset,
additional_feature_cols=["fake_additional_feature_a"],
edge_feature_funcs=[
distances_between_players_normed,
speed_difference_normed,
angle_between_players_normed,
velocity_difference_normed,
custom_edge_feature,
],
chunk_size=2_0000,
non_potential_receiver_node_value=0.1,
self_loop_ball=True,
adjacency_matrix_connect_type="ball",
adjacency_matrix_type="split_by_team",
label_type="binary",
defending_team_node_value=0.0,
random_seed=False,
pad=False,
verbose=False,
)This will now add an extra edge feature to our edge feature matrix with value "0.90" for all players.
Custom node feature functions need to return a (N, ) or a (N, k) numpy array. Custom edge features need to return a (N, N) or tuple of multiple (N, N) numpy arrays.
Gobal Features
We also show above how to add global_feature_cols which. These values are joined to the node_features to the "ball" or to "all" by setting global_feature_type (defaults to "ball").
Feature Options
We have also added feature_opts to DefaultGraphConverter. This is a dictionary with additional information we might want to use inside our custom functions. For example, if we want to normalize height in centimeters between 0 and 1, we can add feature_opts={"max_height": 200} and pass it to the SoccerGraphConverter. Subsequently we can access this maximum height in our custom node feature function via kwargs:
@graph_feature(is_custom=True, feature_type="node")
def normalized_height(**kwargs):
return kwargs['height_cm'] / kwargs['max_height']For this to work we also need to pass additional_feature_cols=["height_cm"] to SoccerGraphConverter and we would need to join a "height_cm" column to our KloppyPolarsDataset.data otherwise "height_cm" would not be in kwargs.
4. Add plotting functionality to SoccerGraphConverter with converter.plot() (image and video)
5. Fixed ball padding
Frames without a ball incorrectly didn't get a row for the ball when pad=True in the SoccerGraphConverter
6. Add GPU to polars collect
Computing the Graphs inside SoccerGraphConverter is now automatically set to collect(engine="gpu"). This will auto revert back to not use gpu if it's unsupported on your machine, as per Polars docs.
7. Fix sample rate
SoccerGraphConverter and GraphDataset now both support sample_rate as a parameter.
8. Add global_feature support
We can now add global features. These are columns in the kloppy_polars_dataset.data Polars DataFrame that have the same value for every row of a frame (we check this for every Graph and throw an error if it's not the case).
These global features are joined to the node_features to either "all" rows or the "ball" row (see global_feature_type)
converter = SoccerGraphConverter(
dataset=kloppy_pl_ds,
label_col=label_column,
global_feature_cols=[
"period_normed",
"timestamp_normed",
],
pad=True,
random_seed=False,
sample_rate=sample_rate,
global_feature_type="all",
)v0.4.1
What's Changed
- Rework DefaultGraphConverter and introduced breaking change removing original
SoccerGraphConverterand renamingSoccerGraphConverterPolarstoSoccerGraphConverter. - Add epsilon for nans
- Add frame_id to every Graph
v0.4.0
TL;DR
- Fix sorting / randomization in
SoccerGraphConverterPolars - Revert back to old way of computing Graphs using Polars (v0.3.0 in
SoccerGraphConverterPolarsintroduces a major bug) - Add custom function support
- Add plotting functionality to
SoccerGraphConverterPolarswithconverter.plot()(image and video) - Fixed ball padding
- Add GPU to polars collect
- Fix sample rate
- Add global_feature support
Change Log
1. Fix sorting / randomization in SoccerGraphConverterPolars
Setting random_seed=False or random_seed=None now makes sure the underlying data object is sorted by frame, team in possession, team out of possession and finally ball.
SoccerGraphConverterPolars(
dataset=kloppy_pl_ds,
label_col=label_column,
random_seed=False,
)2. Revert back to old way of computing Graphs using Polars
The new implementation introduced a major bug that didn't correctly parse out the Graphs after using Polars to compute them. This resulted in every Graph being equal to the first graph.
3. Add Custom Function Support
Refactor Feature Calculations
A refactor for compute_edge_features_pl, compute_node_features_pl and compute_adjacency_matrix_pl. They are renamed to compute_edge_features, compute_node_features and compute_adjacency_matrix.
They now look like something like this under the hood:
node_features = compute_node_features(
funcs=self.node_feature_funcs,
opts=self.feature_opts,
settings=self.settings,
**d,
)Edge / Node Feature Functions
self.node_feature_funcs and self.edge_feature_funcs are now both a list of functions that should take in **kwargs. For example:
@graph_feature(is_custom=False, feature_type="node")
def x_normed(**kwargs):
return normalize_between(
value=kwargs["x"],
max_value=kwargs["settings"].pitch_dimensions.x_dim.max,
min_value=kwargs["settings"].pitch_dimensions.x_dim.min,
)Passing this x_normed function in SoccerGraphConverterPolars(..., node_feature_funcs=[x_normed]) now computes the normalized x value as defined in the above function.
The default functions that were initially the edge and node features in this package have now been declared as the default features properties (see below).
@property
def default_node_feature_funcs(self) -> list:
return [
x_normed,
y_normed,
speeds_normed,
velocity_components_2d_normed,
distance_to_goal_normed,
distance_to_ball_normed,
is_possession_team,
is_gk,
is_ball,
angle_to_goal_components_2d_normed,
angle_to_ball_components_2d_normed,
is_ball_carrier,
]
@property
def default_edge_feature_funcs(self) -> list:
return [
distances_between_players_normed,
speed_difference_normed,
angle_between_players_normed,
velocity_difference_normed,
]Custom Functions
We can also pass custom edge and node feature functions. Any of these functions needs to be decorated with @graph_feature decorator. This decorator takes 2 parameters is_custom and feature_type ("node" or "edge").
This will simply make sure the user consciously adding the correct functions to the correct list (node or edge features).
Additionally we introduce additional_feature_cols to SoccerGraphConverterPolars which allows us to pass columns we've added to our KloppyPolarsData.data dataframe which we can subsequently access through kwargs in our custom functions.
For example:
kloppy_polars_dataset.data = (
kloppy_polars_dataset.data
# note, normally you'd join these columns on a frame level
.with_columns(
[
pl.lit(0.45).alias("fake_additional_feature_a"),
pl.lit(1).alias("fake_graph_feature_a"),
pl.lit(0.12).alias("fake_graph_feature_b"),
]
)
)
@graph_feature(is_custom=True, feature_type="edge")
def custom_edge_feature(**kwargs):
return (
kwargs["fake_additional_feature_a"][None, :]
+ kwargs["fake_additional_feature_a"][:, None]
)
SoccerGraphConverterPolars(
dataset=kloppy_polars_dataset,
additional_feature_cols=["fake_additional_feature_a"],
edge_feature_funcs=[
distances_between_players_normed,
speed_difference_normed,
angle_between_players_normed,
velocity_difference_normed,
custom_edge_feature,
],
chunk_size=2_0000,
non_potential_receiver_node_value=0.1,
self_loop_ball=True,
adjacency_matrix_connect_type="ball",
adjacency_matrix_type="split_by_team",
label_type="binary",
defending_team_node_value=0.0,
random_seed=False,
pad=False,
verbose=False,
)This will now add an extra edge feature to our edge feature matrix with value "0.90" for all players.
Custom node feature functions need to return a (N, ) or a (N, k) numpy array. Custom edge features need to return a (N, N) or tuple of multiple (N, N) numpy arrays.
Gobal Features
We also show above how to add global_feature_cols which. These values are joined to the node_features to the "ball" or to "all" by setting global_feature_type (defaults to "ball").
Feature Options
We have also added feature_opts to DefaultGraphConverter. This is a dictionary with additional information we might want to use inside our custom functions. For example, if we want to normalize height in centimeters between 0 and 1, we can add feature_opts={"max_height": 200} and pass it to the SoccerGraphConverterPolars. Subsequently we can access this maximum height in our custom node feature function via kwargs:
@graph_feature(is_custom=True, feature_type="node")
def normalized_height(**kwargs):
return kwargs['height_cm'] / kwargs['max_height']For this to work we also need to pass additional_feature_cols=["height_cm"] to SoccerGraphConverterPolars and we would need to join a "height_cm" column to our KloppyPolarsDataset.data otherwise "height_cm" would not be in kwargs.
4. Add plotting functionality to SoccerGraphConverterPolars with converter.plot() (image and video)
5. Fixed ball padding
Frames without a ball incorrectly didn't get a row for the ball when pad=True in the SoccerGraphConverterPolars
6. Add GPU to polars collect
Computing the Graphs inside SoccerGraphConverterPolars is now automatically set to collect(engine="gpu"). This will auto revert back to not use gpu if it's unsupported on your machine, as per Polars docs.
7. Fix sample rate
SoccerGraphConverterPolars and CustomSpektralDataset now both support sample_rate as a parameter.
8. Add global_feature support
We can now add global features. These are columns in the kloppy_polars_dataset.data Polars DataFrame that have the same value for every row of a frame (we check this for every Graph and throw an error if it's not the case).
These global features are joined to the node_features to either "all" rows or the "ball" row (see global_feature_type)
converter = SoccerGraphConverterPolars(
dataset=kloppy_pl_ds,
label_col=label_column,
global_feature_cols=[
"period_normed",
"timestamp_normed",
],
pad=True,
random_seed=False,
sample_rate=sample_rate,
global_feature_type="all",
)v0.3.0
TL;DR
- Deprecated
SoccerGraphConverterin favor ofSoccerGraphConverterPolars. This will be removed in the future.- note: This currently means the Graph features are slightly different and thus
SoccerGraphConvertermodels are not compatible withSoccerGraphConverterPolars - note: This means, for the time being, we have node_features_pl.py, edge_features_pl.py and adjacency_matrix_pl.py alongside the non-polars ones.
- note: This currently means the Graph features are slightly different and thus
- Introduced
unravel.soccer.PressingIntensity, see Pressing Intensity Jupyter Notebook for an example! - Removed necessity for Polars datasets to
.load(). This is now called within__init__. - Introduced settings dataclass into Polars datasets (
BigDataBowlDatasetandKloppyPolarsDataset). - For now we keep
SoccerGraphConverteras separate functionality, with a deprecation warning to move toSoccerGraphConverterPolars - Additionally aligned
AmericanFootballGraphConverter(which already had a Polars backend) with syntax used inSoccerGraphConverterPolars
Change Log
KloppyPolarsDataset
We introduce a KloppyPolarsDataset that converts the kloppy_dataset to a Polars dataframe and does a number of conversions and checks. Use it as follows:
kloppy_dataset = sportec.load_open_tracking_data(
match_id=match_id,
coordinates="secondspectrum",
only_alive=True,
limit=500, # limit to 500 frames in this example
)
kloppy_polars_dataset = KloppyPolarsDataset(
kloppy_dataset=kloppy_dataset,
ball_carrier_threshold=25.0,
max_ball_speed=28.0,
max_player_speed=12.0,
max_player_acceleration=6.0,
max_ball_acceleration=13.5,
orient_ball_owning=True
)
# This is where you add your own training labels
kloppy_polars_dataset.add_dummy_labels()
kloppy_polars_dataset.add_graph_ids()ball_carrier_threshold,max_ball_speed,max_player_speed,max_ball_accelerationandmax_player_accelerationhave been moved toKloppyPolarsDatasetKloppyPolarsDatasetsets the orientation toOrientation.BALL_OWNING_TEAM(ball owning team plays left to right) whenorient_ball_owning=True. If our dataset does not have the ball owning team we infer the ball owning team automatically using theball_carrier_thresholdand subsequently change the orientation automatically to be left to right for the ball owning team too. Additionally, we automatically identify the ball carrying player as the player on the ball owning team closest to the ball.- Fixed
Orientation.BALL_OWNING_TEAMmessing up our velocity and acceleration calculations.
Note: In SoccerGraphConverter if the ball owning team was not available we set the orientation to STATIC_HOME_AWAY meaning attacking could happen in two directions. I felt this was undesirable.
SoccerGraphConverterPolars
boundary_correctionhas been removed as a parameterinfer_ball_ownershiphas been removed as a parameter, this is now always handled automatically when necessaryinfer_goalkeepershas been removed as a parameter, this is now always handled automatically when necessary. If we have position labels (e.g. "GK") we use that.labels,graph_id,graph_idshave been removed as parameters. We can now uselabel_colandgraph_id_colas parameters, they default to "label" and "graph_id".
from unravel.soccer import SoccerGraphConverterPolars, KloppyPolarsDataset
from unravel.utils import CustomSpektralDataset
from kloppy import sportec
# Load Kloppy dataset
kloppy_dataset = sportec.load_open_tracking_data(only_alive=True, limit=500)
kloppy_polars_dataset = KloppyPolarsDataset(
kloppy_dataset=kloppy_dataset,
)
kloppy_polars_dataset.add_dummy_labels()
kloppy_polars_dataset.add_graph_ids(by=["frame_id"])
# Initialize the Graph Converter with dataset
# Here we use the default settings
converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset)
# Compute the graphs and add them to the CustomSpektralDataset
dataset = CustomSpektralDataset(graphs=converter.to_spektral_graphs())Pressing Intensity
See Pressing Intensity Jupyter Notebook for an example!
from unravel.soccer import PressingIntensity
import polars as pl
model = PressingIntensity(dataset=kloppy_polars_dataset)
model.fit(
start_time=pl.duration(minutes=1, seconds=53),
end_time=pl.duration(minutes=2, seconds=32),
period_id=1,
method="teams",
ball_method="max",
orient="home_away",
speed_threshold=2.0,
)
model.output.head()Settings
- Introduced
settingsdataclass into Polars datasets (BigDataBowlDatasetandKloppyPolarsDataset). Available throughdataset.settings.
class DefaultSettings:
home_team_id: Union[str, int]
away_team_id: Union[str, int]
provider: Union[Provider, str]
pitch_dimensions: Union[MetricPitchDimensions, AmericanFootballPitchDimensions]
orientation: Orientation
max_player_speed: float = 12.0
max_ball_speed: float = 28.0
max_player_acceleration: float = 6.0
max_ball_acceleration: float = 13.5
ball_carrier_threshold: float = 25.0Backend
- Introduced
Constant,ColumnandGroupdataclasses to more easily track which columns we are using under the hood.
class Constant:
BALL = "ball"
class Column:
BALL_OWNING_TEAM_ID = "ball_owning_team_id"
....
class Group:
BY_FRAME = [Column.GAME_ID, Column.PERIOD_ID, Column.FRAME_ID]
....- Updated examples
- Updated tests
v0.2.0
I’ve added support for BigDataBowl data. The implementation of the AmericanFootballGraphConverter converter uses a different approach than SoccerGraphConverter (previously GraphConverter). Since it relies on Polars instead of Kloppy.
The API works in a very similar manner, but some parameters are different between AmericanFootballGraphConverter and SoccerGraphConverter both because the sports are fundamentally different and because the data comes from different sources.
These differences can be seen in Graphs FAQ, Section B
All computed node features and edge features can be found in Graphs FAQ, Section C. Compared to the soccer implementation we now also include, for example:
- Height and weight
- Is Quarterback
- Acceleration
- Body Orientation
Additionally we provide a BigDataBowlDataset class to easily load all relevant data sources into the AmericanFootballGraphConverter via the dataset parameter, and to add labels and dummy graph ids. The BigDataBowlDataset requires 3 parameters, namely:
- tracking_file_path (ie. week1.csv)
- players_file_path (ie. players.csv)
- plays_file_path (ie. plays.csv)
This PR also includes tests for the American Football implementation and some other minor fixes for the Soccer implementation. As well as an abstraction from GraphConverter to DefaultGraphConverter which is now inherited by both AmericanFootballGraphConverter and SoccerGraphConverter.
Under the hood the same logic follow for DefaultGraphSettings.
The minor fixes include:
- Renaming AdjacencyMatrixType.DENSE_ATTACKING_PLAYERS to AdjacencyMatrixType.DENSE_AP
- Renaming AdjacencyMatrixType.DENSE_DEFENSIVE_PLAYERS to AdjacencyMatrixType.DENSE_DP
v0.1.2
Full Changelog: v0.1.0...v0.1.2
v0.1.0
Full Changelog: https://github.com/UnravelSports/unravelsports/commits/v0.1.0