Skip to content

Releases: UnravelSports/unravelsports

v1.1.0

04 Jul 12:28
020a2e8

Choose a tag to compare

What's Changed

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

09 Jun 08:09
16c9b65

Choose a tag to compare

What's Changed

Full Changelog: v1.0.0...v1.0.1

v1.0.0

06 Jun 14:10
0f4fd2e

Choose a tag to compare

What's Changed

These changes are compared to v0.3.0!


⛓️‍💥 Breaking Changes:

  • Renamed CustomSpektralDataset to GraphDataset
  • Removed SoccerGraphConverter replaced with contents of SoccerGraphConverterPolars and renamed back to SoccerGraphConverter

⭐ Features:

  • Add frame_id to every Graph to make it easier to join back predictions to DataFrames.
  • Add custom function support.
  • Add converter.plot() functionality to SoccerGraphConverter
  • Add support for global features through global_feature_cols in SoccerGraphConverter and AmericanFootballGraphConverter

🐛 Bug Fixes

  • Revert back to old way of computing Graphs using Polars (v0.3.0 in SoccerGraphConverterPolars introduces a major bug)

Minor

  • Add epsilon for nans to avoid RunTimeWarnings
  • Fix sorting / randomization in SoccerGraphConverter
  • Fix ball padding
  • Add default engine="gpu" to Polars collect()
  • 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

27 May 21:33
60f934e

Choose a tag to compare

What's Changed

  • Rework DefaultGraphConverter and introduced breaking change removing original SoccerGraphConverter and renaming SoccerGraphConverterPolars to SoccerGraphConverter.
  • Add epsilon for nans
  • Add frame_id to every Graph

v0.4.0

22 May 06:39
247aa0d

Choose a tag to compare

TL;DR

  1. Fix sorting / randomization in SoccerGraphConverterPolars
  2. Revert back to old way of computing Graphs using Polars (v0.3.0 in SoccerGraphConverterPolars introduces a major bug)
  3. Add custom function support
  4. Add plotting functionality to SoccerGraphConverterPolars with converter.plot() (image and video)
  5. Fixed ball padding
  6. Add GPU to polars collect
  7. Fix sample rate
  8. 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

17 Feb 09:34
bafa98d

Choose a tag to compare

TL;DR

  • Deprecated SoccerGraphConverter in favor of SoccerGraphConverterPolars. This will be removed in the future.
    • note: This currently means the Graph features are slightly different and thus SoccerGraphConverter models are not compatible with SoccerGraphConverterPolars
    • 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.
  • 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 (BigDataBowlDataset and KloppyPolarsDataset).
  • For now we keep SoccerGraphConverter as separate functionality, with a deprecation warning to move to SoccerGraphConverterPolars
  • Additionally aligned AmericanFootballGraphConverter (which already had a Polars backend) with syntax used in SoccerGraphConverterPolars

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_acceleration and max_player_acceleration have been moved to KloppyPolarsDataset
  • KloppyPolarsDataset sets the orientation to Orientation.BALL_OWNING_TEAM (ball owning team plays left to right) when orient_ball_owning=True. If our dataset does not have the ball owning team we infer the ball owning team automatically using the ball_carrier_threshold and 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_TEAM messing 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_correction has been removed as a parameter
  • infer_ball_ownership has been removed as a parameter, this is now always handled automatically when necessary
  • infer_goalkeepers has 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_ids have been removed as parameters. We can now use label_col and graph_id_col as 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 settings dataclass into Polars datasets (BigDataBowlDataset and KloppyPolarsDataset). Available through dataset.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.0

Backend

  • Introduced Constant, Column and Group dataclasses 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

18 Oct 17:07
663a024

Choose a tag to compare

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

22 Aug 13:52

Choose a tag to compare

Full Changelog: v0.1.0...v0.1.2

v0.1.0

22 Aug 13:01

Choose a tag to compare