From 33fdd4f084cbd25f808a03fdbe4b49eb7346ac5b Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 19 Feb 2025 12:15:27 -0500 Subject: [PATCH 01/46] Add feature specs definition to graph converter --- unravel/utils/objects/default_graph_converter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/unravel/utils/objects/default_graph_converter.py b/unravel/utils/objects/default_graph_converter.py index 24da2258..24772afb 100644 --- a/unravel/utils/objects/default_graph_converter.py +++ b/unravel/utils/objects/default_graph_converter.py @@ -89,6 +89,8 @@ class DefaultGraphConverter: settings: DefaultGraphSettings = field( init=False, repr=False, default_factory=DefaultGraphSettings ) + + feature_specs: dict = field(init=False, repr=False, default=None) def __post_init__(self): if hasattr( From 4598ef5a9decf4705f265a5125dd58b9847e8314 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 19 Feb 2025 12:35:59 -0500 Subject: [PATCH 02/46] Add error handling for feature_specs --- unravel/utils/objects/default_graph_converter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/unravel/utils/objects/default_graph_converter.py b/unravel/utils/objects/default_graph_converter.py index 24772afb..c334a77f 100644 --- a/unravel/utils/objects/default_graph_converter.py +++ b/unravel/utils/objects/default_graph_converter.py @@ -139,6 +139,9 @@ def __post_init__(self): if not isinstance(self.verbose, bool): raise Exception("'verbose' should be of type boolean (bool)") + + if not isinstance(self.feature_specs, dict): + raise ValueError("feature_specs must be a dictionary") def _shuffle(self): raise NotImplementedError() From 60e5255354149275d4154826a61b60268cf6d9bb Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 19 Feb 2025 12:51:55 -0500 Subject: [PATCH 03/46] Bug fix --- unravel/utils/objects/default_graph_converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unravel/utils/objects/default_graph_converter.py b/unravel/utils/objects/default_graph_converter.py index c334a77f..f866c05e 100644 --- a/unravel/utils/objects/default_graph_converter.py +++ b/unravel/utils/objects/default_graph_converter.py @@ -90,7 +90,7 @@ class DefaultGraphConverter: init=False, repr=False, default_factory=DefaultGraphSettings ) - feature_specs: dict = field(init=False, repr=False, default=None) + feature_specs: dict = field(default_factory=dict, repr=False) def __post_init__(self): if hasattr( From 82c87ebaf3374f399a7117ccfaff26bc038aa480 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 19 Feb 2025 12:52:21 -0500 Subject: [PATCH 04/46] Add default structure --- unravel/soccer/graphs/graph_converter_pl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 819e4d17..ab5bd26d 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -75,6 +75,7 @@ def __post_init__(self): else: self.dataset = self._remove_incomplete_frames() + self.feature_specs = {'node_features': {}, 'edge_features': {}} self._shuffle() def _shuffle(self): @@ -363,6 +364,7 @@ def __compute(self, args: List[pl.Series]) -> dict: velocity=velocity, team=d[Column.TEAM_ID], settings=self.settings, + feature_dict=self.feature_specs['edge_features'] ) node_features = compute_node_features_pl( @@ -376,6 +378,7 @@ def __compute(self, args: List[pl.Series]) -> dict: ball_carrier=d[Column.IS_BALL_CARRIER], graph_features=graph_features, settings=self.settings, + feature_dict=self.feature_specs['node_features'] ) return { From 71b53d022d32814fc72340535cde28c716859fdc Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 19 Feb 2025 23:40:23 -0500 Subject: [PATCH 05/46] add normalized node features --- .../graphs/features/node_features_pl.py | 144 ++++++++++-------- unravel/soccer/graphs/graph_converter_pl.py | 4 +- 2 files changed, 82 insertions(+), 66 deletions(-) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index f773142a..db16b9f5 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -32,6 +32,7 @@ def compute_node_features_pl( ball_carrier, graph_features, settings, + feature_dict ): ball_id = Constant.BALL @@ -61,74 +62,89 @@ def compute_node_features_pl( x=x, y=y, team=team, ball_id=ball_id ) - x_normed = normalize_between( - value=x, - max_value=settings.pitch_dimensions.x_dim.max, - min_value=settings.pitch_dimensions.x_dim.min, - ) - y_normed = normalize_between( - value=y, - max_value=settings.pitch_dimensions.y_dim.max, - min_value=settings.pitch_dimensions.y_dim.min, - ) - s_normed = normalize_speeds_nfl(s, team, ball_id=ball_id, settings=settings) - uv_velocity = unit_vectors(velocity) - - angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) - v_sin_normed = normalize_sincos(np.sin(angles)) - v_cos_normed = normalize_sincos(np.cos(angles)) - - dist_to_goal = np.linalg.norm(position - goal_mouth_position, axis=1) - normed_dist_to_goal = normalize_distance( - value=dist_to_goal, max_distance=max_dist_to_goal - ) - - normed_dist_to_ball = normalize_distance( - value=dist_to_ball, max_distance=max_dist_to_player - ) - - vec_to_goal = goal_mouth_position - position - angle_to_goal = np.arctan2(vec_to_goal[:, 1], vec_to_goal[:, 0]) - goal_sin_normed = normalize_sincos(np.sin(angle_to_goal)) - goal_cos_normed = normalize_sincos(np.cos(angle_to_goal)) - - vec_to_ball = ball_position - position - angle_to_ball = np.arctan2(vec_to_ball[:, 1], vec_to_ball[:, 0]) - ball_sin_normed = normalize_sincos(np.sin(angle_to_ball)) - ball_cos_normed = normalize_sincos(np.cos(angle_to_ball)) - - is_possession_team = np.where( - team == possession_team, 1, settings.defending_team_node_value - ) - - is_ball = np.where(team == ball_id, 1, 0) - - X = np.nan_to_num( - np.stack( - ( - x_normed, - y_normed, - s_normed, - v_sin_normed, - v_cos_normed, - normed_dist_to_goal, - normed_dist_to_ball, - is_possession_team, - is_gk, - is_ball, - goal_sin_normed, - goal_cos_normed, - ball_sin_normed, - ball_cos_normed, - ball_carrier, - ), - axis=-1, - ) - ) + # x_normed = normalize_between( + # value=x, + # max_value=settings.pitch_dimensions.x_dim.max, + # min_value=settings.pitch_dimensions.x_dim.min, + # ) + # y_normed = normalize_between( + # value=y, + # max_value=settings.pitch_dimensions.y_dim.max, + # min_value=settings.pitch_dimensions.y_dim.min, + # ) + # s_normed = normalize_speeds_nfl(s, team, ball_id=ball_id, settings=settings) + # uv_velocity = unit_vectors(velocity) + + # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) + # v_sin_normed = normalize_sincos(np.sin(angles)) + # v_cos_normed = normalize_sincos(np.cos(angles)) + + # dist_to_goal = np.linalg.norm(position - goal_mouth_position, axis=1) + # normed_dist_to_goal = normalize_distance( + # value=dist_to_goal, max_distance=max_dist_to_goal + # ) + + # normed_dist_to_ball = normalize_distance( + # value=dist_to_ball, max_distance=max_dist_to_player + # ) + + # vec_to_goal = goal_mouth_position - position + # angle_to_goal = np.arctan2(vec_to_goal[:, 1], vec_to_goal[:, 0]) + # goal_sin_normed = normalize_sincos(np.sin(angle_to_goal)) + # goal_cos_normed = normalize_sincos(np.cos(angle_to_goal)) + + # vec_to_ball = ball_position - position + # angle_to_ball = np.arctan2(vec_to_ball[:, 1], vec_to_ball[:, 0]) + # ball_sin_normed = normalize_sincos(np.sin(angle_to_ball)) + # ball_cos_normed = normalize_sincos(np.cos(angle_to_ball)) + + # is_possession_team = np.where( + # team == possession_team, 1, settings.defending_team_node_value + # ) + + # is_ball = np.where(team == ball_id, 1, 0) + feature_func_map = { + 'x': {'func': lambda value: value, 'defaults': {'value': x}}, + 'x_normed': {'func': normalize_between, 'defaults': {'value': x, 'max_value': settings.pitch_dimensions.x_dim.max, 'min_value': settings.pitch_dimensions.x_dim.min}}, + 'y': {'func': lambda value: value, 'defaults': {'value': y}}, + 'y_normed': {'func': normalize_between, 'defaults': {'value': y, 'max_value': settings.pitch_dimensions.y_dim.max, 'min_value': settings.pitch_dimensions.y_dim.min}}, + 's': {'func': lambda value: value, 'defaults': {'value': s}}, + 's_normed': {'func': normalize_speeds_nfl, 'defaults': {'s': s, 'team': team, 'ball_id': ball_id, 'settings': settings}} + } + computed_features = [] + for feature, custom_params in feature_dict.items(): + if feature in feature_func_map: + params = feature_func_map[feature]['defaults'].copy() + params.update(custom_params) + computed_features.append(feature_func_map[feature]['func'](**params)) + X = np.nan_to_num(np.stack(computed_features, axis=-1)) + # X = np.nan_to_num( + # np.stack( + # ( + # x_normed, + # y_normed, + # s_normed, + # v_sin_normed, + # v_cos_normed, + # normed_dist_to_goal, + # normed_dist_to_ball, + # is_possession_team, + # is_gk, + # is_ball, + # goal_sin_normed, + # goal_cos_normed, + # ball_sin_normed, + # ball_cos_normed, + # ball_carrier, + # ), + # axis=-1, + # ) + # ) if graph_features is not None: eg = np.ones((X.shape[0], graph_features.shape[0])) * 0.0 eg[ball_index] = graph_features X = np.hstack((X, eg)) + print(X) return X diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index ab5bd26d..bc8979ae 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -75,7 +75,7 @@ def __post_init__(self): else: self.dataset = self._remove_incomplete_frames() - self.feature_specs = {'node_features': {}, 'edge_features': {}} + #self.feature_specs = {'node_features': {}, 'edge_features': {}} self._shuffle() def _shuffle(self): @@ -380,7 +380,7 @@ def __compute(self, args: List[pl.Series]) -> dict: settings=self.settings, feature_dict=self.feature_specs['node_features'] ) - + #print(edge_features.tolist()) return { "e": edge_features.tolist(), "x": node_features.tolist(), From 838687b8729911f6e7003f74367c564d68a17f42 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 20 Feb 2025 00:17:54 -0500 Subject: [PATCH 06/46] Complete node features implementation --- .../graphs/features/node_features_pl.py | 88 +++++-------------- 1 file changed, 23 insertions(+), 65 deletions(-) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index db16b9f5..dc688dd8 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -62,85 +62,43 @@ def compute_node_features_pl( x=x, y=y, team=team, ball_id=ball_id ) - # x_normed = normalize_between( - # value=x, - # max_value=settings.pitch_dimensions.x_dim.max, - # min_value=settings.pitch_dimensions.x_dim.min, - # ) - # y_normed = normalize_between( - # value=y, - # max_value=settings.pitch_dimensions.y_dim.max, - # min_value=settings.pitch_dimensions.y_dim.min, - # ) - # s_normed = normalize_speeds_nfl(s, team, ball_id=ball_id, settings=settings) - # uv_velocity = unit_vectors(velocity) - - # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) - # v_sin_normed = normalize_sincos(np.sin(angles)) - # v_cos_normed = normalize_sincos(np.cos(angles)) - - # dist_to_goal = np.linalg.norm(position - goal_mouth_position, axis=1) - # normed_dist_to_goal = normalize_distance( - # value=dist_to_goal, max_distance=max_dist_to_goal - # ) - - # normed_dist_to_ball = normalize_distance( - # value=dist_to_ball, max_distance=max_dist_to_player - # ) - - # vec_to_goal = goal_mouth_position - position - # angle_to_goal = np.arctan2(vec_to_goal[:, 1], vec_to_goal[:, 0]) - # goal_sin_normed = normalize_sincos(np.sin(angle_to_goal)) - # goal_cos_normed = normalize_sincos(np.cos(angle_to_goal)) - - # vec_to_ball = ball_position - position - # angle_to_ball = np.arctan2(vec_to_ball[:, 1], vec_to_ball[:, 0]) - # ball_sin_normed = normalize_sincos(np.sin(angle_to_ball)) - # ball_cos_normed = normalize_sincos(np.cos(angle_to_ball)) - - # is_possession_team = np.where( - # team == possession_team, 1, settings.defending_team_node_value - # ) - - # is_ball = np.where(team == ball_id, 1, 0) feature_func_map = { 'x': {'func': lambda value: value, 'defaults': {'value': x}}, 'x_normed': {'func': normalize_between, 'defaults': {'value': x, 'max_value': settings.pitch_dimensions.x_dim.max, 'min_value': settings.pitch_dimensions.x_dim.min}}, 'y': {'func': lambda value: value, 'defaults': {'value': y}}, 'y_normed': {'func': normalize_between, 'defaults': {'value': y, 'max_value': settings.pitch_dimensions.y_dim.max, 'min_value': settings.pitch_dimensions.y_dim.min}}, 's': {'func': lambda value: value, 'defaults': {'value': s}}, - 's_normed': {'func': normalize_speeds_nfl, 'defaults': {'s': s, 'team': team, 'ball_id': ball_id, 'settings': settings}} + 's_normed': {'func': normalize_speeds_nfl, 'defaults': {'s': s, 'team': team, 'ball_id': ball_id, 'settings': settings}}, + 'velocity': {'func': lambda value: value, 'defaults': {'value': velocity}}, + 'v_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(normalize_angles(np.arctan2(unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0])))}}, + 'v_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(normalize_angles(np.arctan2(unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0])))}}, + 'dist_to_goal': {'func': lambda value: value, 'defaults': {'value': np.linalg.norm(position - goal_mouth_position, axis=1)}}, + 'normed_dist_to_goal': {'func': normalize_distance, 'defaults': {'value': np.linalg.norm(position - goal_mouth_position, axis=1), 'max_distance': max_dist_to_goal}}, + 'normed_dist_to_ball': {'func': normalize_distance, 'defaults': {'value': dist_to_ball, 'max_distance': max_dist_to_player}}, + 'vec_to_goal': {'func': lambda value: value, 'defaults': {'value': goal_mouth_position - position}}, + 'angle_to_goal': {'func': lambda value: value, 'defaults': {'value': np.arctan2((goal_mouth_position - position)[:, 1], (goal_mouth_position - position)[:, 0])}}, + 'goal_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(np.arctan2((goal_mouth_position - position)[:, 1], (goal_mouth_position - position)[:, 0]))}}, + 'goal_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(np.arctan2((goal_mouth_position - position)[:, 1], (goal_mouth_position - position)[:, 0]))}}, + 'vec_to_ball': {'func': lambda value: value, 'defaults': {'value': ball_position - position}}, + 'angle_to_ball': {'func': lambda value: value, 'defaults': {'value': np.arctan2((ball_position - position)[:, 1], (ball_position - position)[:, 0])}}, + 'ball_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(np.arctan2((ball_position - position)[:, 1], (ball_position - position)[:, 0]))}}, + 'ball_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(np.arctan2((ball_position - position)[:, 1], (ball_position - position)[:, 0]))}}, + 'ball_carrier': {'func': lambda value: value, 'defaults': {'value': ball_carrier}}, + 'is_possession_team': {'func': lambda value: value, 'defaults': {'value': np.where(team == possession_team, 1, settings.defending_team_node_value)}}, + 'is_ball': {'func': lambda value: value, 'defaults': {'value': np.where(team == ball_id, 1, 0)}}, + 'is_gk': {'func': lambda value: value, 'defaults': {'value': is_gk}}, } + computed_features = [] + for feature, custom_params in feature_dict.items(): if feature in feature_func_map: params = feature_func_map[feature]['defaults'].copy() params.update(custom_params) computed_features.append(feature_func_map[feature]['func'](**params)) + X = np.nan_to_num(np.stack(computed_features, axis=-1)) - # X = np.nan_to_num( - # np.stack( - # ( - # x_normed, - # y_normed, - # s_normed, - # v_sin_normed, - # v_cos_normed, - # normed_dist_to_goal, - # normed_dist_to_ball, - # is_possession_team, - # is_gk, - # is_ball, - # goal_sin_normed, - # goal_cos_normed, - # ball_sin_normed, - # ball_cos_normed, - # ball_carrier, - # ), - # axis=-1, - # ) - # ) - + if graph_features is not None: eg = np.ones((X.shape[0], graph_features.shape[0])) * 0.0 eg[ball_index] = graph_features From 79d96e783997ab7376dedbe94ca7d923cbf4a83a Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 20 Feb 2025 00:19:12 -0500 Subject: [PATCH 07/46] Remove redundant code --- unravel/soccer/graphs/features/edge_features_pl.py | 2 +- unravel/soccer/graphs/graph_converter_pl.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index b7ea54e5..3198268d 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -23,7 +23,7 @@ from ...dataset.kloppy_polars import Constant -def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, settings): +def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, settings, feature_dict): # Compute pairwise distances using broadcasting max_dist_to_player = np.sqrt( settings.pitch_dimensions.pitch_length**2 diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index bc8979ae..6b85064a 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -380,7 +380,7 @@ def __compute(self, args: List[pl.Series]) -> dict: settings=self.settings, feature_dict=self.feature_specs['node_features'] ) - #print(edge_features.tolist()) + return { "e": edge_features.tolist(), "x": node_features.tolist(), From 9a27cb1c9c506513db0be6f8556e8df619a1896d Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Fri, 21 Feb 2025 09:20:04 -0500 Subject: [PATCH 08/46] Add flexible edge features --- .../graphs/features/edge_features_pl.py | 142 +++++++++++++----- .../graphs/features/node_features_pl.py | 2 +- 2 files changed, 107 insertions(+), 37 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index 3198268d..185d7e80 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -33,19 +33,19 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett distances_between_players = np.linalg.norm( p3d[:, None, :] - p3d[None, :, :], axis=-1 ) - dist_matrix_normed = normalize_distance( - distances_between_players, max_distance=max_dist_to_player - ) # 11x11 - - speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 - speed_diff_matrix_normed = normalize_speed_differences_nfl( - s=speed_diff_matrix, - team=team, - ball_id=Constant.BALL, - settings=settings, - ) + # dist_matrix_normed = normalize_distance( + # distances_between_players, max_distance=max_dist_to_player + # ) # 11x11 + + #speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 + # speed_diff_matrix_normed = normalize_speed_differences_nfl( + # s=speed_diff_matrix, + # team=team, + # ball_id=Constant.BALL, + # settings=settings, + # ) - vect_to_player_matrix = p2d[:, None, :] - p2d[None, :, :] # NxNx2 + #vect_to_player_matrix = p2d[:, None, :] - p2d[None, :, :] # NxNx2 v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 @@ -54,38 +54,108 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett ) # 11x11x2 the vector between two players # Angles between players in sin and cos - angle_pos_matrix = np.nan_to_num( - np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) - ) - pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) - pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + # angle_pos_matrix = np.nan_to_num( + # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) + # ) + #pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + #pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) - vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) - vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) + #vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) + #vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) nan_mask = np.isnan(distances_between_players) non_zero_idxs, len_a = non_zeros(A=adjacency_matrix) - dist_matrix_normed[nan_mask] = 0 - speed_diff_matrix_normed[nan_mask] = 0 - - pos_cos_matrix[nan_mask] = 0 - pos_sin_matrix[nan_mask] = 0 - - e_tuple = list( - [ - reindex(dist_matrix_normed, non_zero_idxs, len_a), - reindex(speed_diff_matrix_normed, non_zero_idxs, len_a), - reindex(pos_cos_matrix, non_zero_idxs, len_a), - reindex(pos_sin_matrix, non_zero_idxs, len_a), - reindex(vel_cos_matrix, non_zero_idxs, len_a), - reindex(vel_sin_matrix, non_zero_idxs, len_a), - ] - ) - + #dist_matrix_normed[nan_mask] = 0 + #speed_diff_matrix_normed[nan_mask] = 0 + + #pos_cos_matrix[nan_mask] = 0 + #pos_sin_matrix[nan_mask] = 0 + feature_func_map = { + ''' + distances_between_players = np.linalg.norm( + p3d[:, None, :] - p3d[None, :, :], axis=-1 + ) + + dist_matrix_normed = normalize_distance( + distances_between_players, max_distance=max_dist_to_player + ) # 11x11 + + dist_matrix_normed[nan_mask] = 0 + ''' + + 'dist_matrix': {'func': lambda value: value, 'defaults': {'value': np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1)}}, + 'dist_matrix_normed': {'func': lambda value, max_distance : np.where(nan_mask, 0, normalize_distance(value, max_distance)), 'defaults': {'value': np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1), 'max_distance': max_dist_to_player}}, + + ''' + speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 + + speed_diff_matrix_normed = normalize_speed_differences_nfl( + s=speed_diff_matrix, + team=team, + ball_id=Constant.BALL, + settings=settings, + ) + + speed_diff_matrix_normed[nan_mask] = 0 + ''' + + 'speed_diff_matrix': {'func': lambda value: value, 'defaults': {'value': np.nan_to_num(s[None, :] - s[:, None])}}, + 'speed_diff_matrix_normed': {'func': lambda s, team, ball_id, settings: np.where(nan_mask, 0, normalize_speed_differences_nfl(s, team, ball_id, settings)), 'defaults': {'s': np.nan_to_num(s[None, :] - s[:, None]), 'team': team, 'ball_id': Constant.BALL, 'settings': settings}}, + + ''' + vect_to_player_matrix = ( + p2d[:, None, :] - p2d[None, :, :] + ) # 11x11x2 the vector between two players + + # Angles between players in sin and cos + angle_pos_matrix = np.nan_to_num( + np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) + ) + + pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + + pos_cos_matrix[nan_mask] = 0 + pos_sin_matrix[nan_mask] = 0 + ''' + + 'angle_pos_matrix': {'func': lambda vect_to_player_matrix: np.where(nan_mask, 0, np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0])), 'defaults': {'vect_to_player_matrix': vect_to_player_matrix}}, + 'pos_cos_matrix': {'func': lambda angle_pos_matrix: np.where(nan_mask, 0, normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix)))), 'defaults': {'angle_pos_matrix': np.nan_to_num(np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]))}}, + 'pos_sin_matrix': {'func': lambda angle_pos_matrix: np.where(nan_mask, 0, normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix)))), 'defaults': {'angle_pos_matrix': np.nan_to_num(np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]))}}, + + ''' + v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 + vect_to_player_matrix = ( + p2d[:, None, :] - p2d[None, :, :] + ) # 11x11x2 the vector between two players + + combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) + angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) + + vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) + vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) + ''' + + 'angle_vel_matrix': {'func': lambda angle_between, combined_matrix: np.where(nan_mask, 0, np.apply_along_axis(angle_between, 2, combined_matrix)), 'defaults': {'angle_between': angle_between, 'combined_matrix': combined_matrix}}, + 'vel_cos_matrix': {'func': lambda angle_vel_matrix: normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))), 'defaults': {'angle_vel_matrix': angle_vel_matrix}}, + 'vel_sin_matrix': {'func': lambda angle_vel_matrix: normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))), 'defaults': {'angle_vel_matrix': angle_vel_matrix}}, + } + + computed_features = [] + + for feature, custom_params in feature_dict.items(): + if feature in feature_func_map: + params = feature_func_map[feature]['defaults'].copy() + params.update(custom_params) + computed_value = feature_func_map[feature]['func'](**params) + computed_features.append(reindex(computed_value,non_zero_idxs, len_a)) + + e_tuple = list(computed_features) e = np.concatenate(e_tuple, axis=1) + #print(np.nan_to_num(e)) return np.nan_to_num(e) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index dc688dd8..73ab8fc5 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -104,5 +104,5 @@ def compute_node_features_pl( eg[ball_index] = graph_features X = np.hstack((X, eg)) - print(X) + #print(X) return X From e2f306e4900d108a0a72c132403fcde1020d55e1 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Fri, 21 Feb 2025 09:21:47 -0500 Subject: [PATCH 09/46] Add comments --- unravel/soccer/graphs/features/node_features_pl.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index 73ab8fc5..bd16ac34 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -70,6 +70,15 @@ def compute_node_features_pl( 's': {'func': lambda value: value, 'defaults': {'value': s}}, 's_normed': {'func': normalize_speeds_nfl, 'defaults': {'s': s, 'team': team, 'ball_id': ball_id, 'settings': settings}}, 'velocity': {'func': lambda value: value, 'defaults': {'value': velocity}}, + + ''' + uv_velocity = unit_vectors(velocity) + angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) + + v_sin_normed = normalize_sincos(np.sin(angles)) + v_cos_normed = normalize_sincos(np.cos(angles)) + ''' + 'v_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(normalize_angles(np.arctan2(unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0])))}}, 'v_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(normalize_angles(np.arctan2(unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0])))}}, 'dist_to_goal': {'func': lambda value: value, 'defaults': {'value': np.linalg.norm(position - goal_mouth_position, axis=1)}}, From 04eea9b907e4f21b757821c4a6732d3bc9d70ab9 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 25 Feb 2025 15:37:53 -0500 Subject: [PATCH 10/46] Reformatted with black --- .../graphs/features/edge_features_pl.py | 154 +++++++++---- .../graphs/features/node_features_pl.py | 202 ++++++++++++++---- unravel/soccer/graphs/graph_converter_pl.py | 33 ++- unravel/utils/display/colors.py | 2 +- .../utils/objects/default_graph_converter.py | 6 +- 5 files changed, 312 insertions(+), 85 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index 185d7e80..bb69b1e2 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -23,7 +23,9 @@ from ...dataset.kloppy_polars import Constant -def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, settings, feature_dict): +def compute_edge_features_pl( + adjacency_matrix, p3d, p2d, s, velocity, team, settings, feature_dict +): # Compute pairwise distances using broadcasting max_dist_to_player = np.sqrt( settings.pitch_dimensions.pitch_length**2 @@ -37,7 +39,7 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett # distances_between_players, max_distance=max_dist_to_player # ) # 11x11 - #speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 + # speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 # speed_diff_matrix_normed = normalize_speed_differences_nfl( # s=speed_diff_matrix, # team=team, @@ -45,7 +47,7 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett # settings=settings, # ) - #vect_to_player_matrix = p2d[:, None, :] - p2d[None, :, :] # NxNx2 + # vect_to_player_matrix = p2d[:, None, :] - p2d[None, :, :] # NxNx2 v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 @@ -57,24 +59,24 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett # angle_pos_matrix = np.nan_to_num( # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) # ) - #pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) - #pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + # pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + # pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) - #vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) - #vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) + # vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) + # vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) nan_mask = np.isnan(distances_between_players) non_zero_idxs, len_a = non_zeros(A=adjacency_matrix) - #dist_matrix_normed[nan_mask] = 0 - #speed_diff_matrix_normed[nan_mask] = 0 + # dist_matrix_normed[nan_mask] = 0 + # speed_diff_matrix_normed[nan_mask] = 0 - #pos_cos_matrix[nan_mask] = 0 - #pos_sin_matrix[nan_mask] = 0 + # pos_cos_matrix[nan_mask] = 0 + # pos_sin_matrix[nan_mask] = 0 feature_func_map = { - ''' + """ distances_between_players = np.linalg.norm( p3d[:, None, :] - p3d[None, :, :], axis=-1 ) @@ -84,12 +86,23 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett ) # 11x11 dist_matrix_normed[nan_mask] = 0 - ''' - - 'dist_matrix': {'func': lambda value: value, 'defaults': {'value': np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1)}}, - 'dist_matrix_normed': {'func': lambda value, max_distance : np.where(nan_mask, 0, normalize_distance(value, max_distance)), 'defaults': {'value': np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1), 'max_distance': max_dist_to_player}}, - - ''' + """ + "dist_matrix": { + "func": lambda value: value, + "defaults": { + "value": np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1) + }, + }, + "dist_matrix_normed": { + "func": lambda value, max_distance: np.where( + nan_mask, 0, normalize_distance(value, max_distance) + ), + "defaults": { + "value": np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1), + "max_distance": max_dist_to_player, + }, + }, + """ speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 speed_diff_matrix_normed = normalize_speed_differences_nfl( @@ -100,12 +113,23 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett ) speed_diff_matrix_normed[nan_mask] = 0 - ''' - - 'speed_diff_matrix': {'func': lambda value: value, 'defaults': {'value': np.nan_to_num(s[None, :] - s[:, None])}}, - 'speed_diff_matrix_normed': {'func': lambda s, team, ball_id, settings: np.where(nan_mask, 0, normalize_speed_differences_nfl(s, team, ball_id, settings)), 'defaults': {'s': np.nan_to_num(s[None, :] - s[:, None]), 'team': team, 'ball_id': Constant.BALL, 'settings': settings}}, - - ''' + """ + "speed_diff_matrix": { + "func": lambda value: value, + "defaults": {"value": np.nan_to_num(s[None, :] - s[:, None])}, + }, + "speed_diff_matrix_normed": { + "func": lambda s, team, ball_id, settings: np.where( + nan_mask, 0, normalize_speed_differences_nfl(s, team, ball_id, settings) + ), + "defaults": { + "s": np.nan_to_num(s[None, :] - s[:, None]), + "team": team, + "ball_id": Constant.BALL, + "settings": settings, + }, + }, + """ vect_to_player_matrix = ( p2d[:, None, :] - p2d[None, :, :] ) # 11x11x2 the vector between two players @@ -120,13 +144,42 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett pos_cos_matrix[nan_mask] = 0 pos_sin_matrix[nan_mask] = 0 - ''' - - 'angle_pos_matrix': {'func': lambda vect_to_player_matrix: np.where(nan_mask, 0, np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0])), 'defaults': {'vect_to_player_matrix': vect_to_player_matrix}}, - 'pos_cos_matrix': {'func': lambda angle_pos_matrix: np.where(nan_mask, 0, normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix)))), 'defaults': {'angle_pos_matrix': np.nan_to_num(np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]))}}, - 'pos_sin_matrix': {'func': lambda angle_pos_matrix: np.where(nan_mask, 0, normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix)))), 'defaults': {'angle_pos_matrix': np.nan_to_num(np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]))}}, - - ''' + """ + "angle_pos_matrix": { + "func": lambda vect_to_player_matrix: np.where( + nan_mask, + 0, + np.arctan2( + vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] + ), + ), + "defaults": {"vect_to_player_matrix": vect_to_player_matrix}, + }, + "pos_cos_matrix": { + "func": lambda angle_pos_matrix: np.where( + nan_mask, 0, normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + ), + "defaults": { + "angle_pos_matrix": np.nan_to_num( + np.arctan2( + vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] + ) + ) + }, + }, + "pos_sin_matrix": { + "func": lambda angle_pos_matrix: np.where( + nan_mask, 0, normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + ), + "defaults": { + "angle_pos_matrix": np.nan_to_num( + np.arctan2( + vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] + ) + ) + }, + }, + """ v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 vect_to_player_matrix = ( p2d[:, None, :] - p2d[None, :, :] @@ -137,25 +190,42 @@ def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, sett vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) - ''' - - 'angle_vel_matrix': {'func': lambda angle_between, combined_matrix: np.where(nan_mask, 0, np.apply_along_axis(angle_between, 2, combined_matrix)), 'defaults': {'angle_between': angle_between, 'combined_matrix': combined_matrix}}, - 'vel_cos_matrix': {'func': lambda angle_vel_matrix: normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))), 'defaults': {'angle_vel_matrix': angle_vel_matrix}}, - 'vel_sin_matrix': {'func': lambda angle_vel_matrix: normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))), 'defaults': {'angle_vel_matrix': angle_vel_matrix}}, + """ + "angle_vel_matrix": { + "func": lambda angle_between, combined_matrix: np.where( + nan_mask, 0, np.apply_along_axis(angle_between, 2, combined_matrix) + ), + "defaults": { + "angle_between": angle_between, + "combined_matrix": combined_matrix, + }, + }, + "vel_cos_matrix": { + "func": lambda angle_vel_matrix: normalize_sincos( + np.nan_to_num(np.cos(angle_vel_matrix)) + ), + "defaults": {"angle_vel_matrix": angle_vel_matrix}, + }, + "vel_sin_matrix": { + "func": lambda angle_vel_matrix: normalize_sincos( + np.nan_to_num(np.sin(angle_vel_matrix)) + ), + "defaults": {"angle_vel_matrix": angle_vel_matrix}, + }, } - + computed_features = [] - + for feature, custom_params in feature_dict.items(): if feature in feature_func_map: - params = feature_func_map[feature]['defaults'].copy() + params = feature_func_map[feature]["defaults"].copy() params.update(custom_params) - computed_value = feature_func_map[feature]['func'](**params) - computed_features.append(reindex(computed_value,non_zero_idxs, len_a)) + computed_value = feature_func_map[feature]["func"](**params) + computed_features.append(reindex(computed_value, non_zero_idxs, len_a)) e_tuple = list(computed_features) e = np.concatenate(e_tuple, axis=1) - #print(np.nan_to_num(e)) + # print(np.nan_to_num(e)) return np.nan_to_num(e) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index bd16ac34..7292ff72 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -32,7 +32,7 @@ def compute_node_features_pl( ball_carrier, graph_features, settings, - feature_dict + feature_dict, ): ball_id = Constant.BALL @@ -63,55 +63,185 @@ def compute_node_features_pl( ) feature_func_map = { - 'x': {'func': lambda value: value, 'defaults': {'value': x}}, - 'x_normed': {'func': normalize_between, 'defaults': {'value': x, 'max_value': settings.pitch_dimensions.x_dim.max, 'min_value': settings.pitch_dimensions.x_dim.min}}, - 'y': {'func': lambda value: value, 'defaults': {'value': y}}, - 'y_normed': {'func': normalize_between, 'defaults': {'value': y, 'max_value': settings.pitch_dimensions.y_dim.max, 'min_value': settings.pitch_dimensions.y_dim.min}}, - 's': {'func': lambda value: value, 'defaults': {'value': s}}, - 's_normed': {'func': normalize_speeds_nfl, 'defaults': {'s': s, 'team': team, 'ball_id': ball_id, 'settings': settings}}, - 'velocity': {'func': lambda value: value, 'defaults': {'value': velocity}}, - - ''' + "x": {"func": lambda value: value, "defaults": {"value": x}}, + "x_normed": { + "func": normalize_between, + "defaults": { + "value": x, + "max_value": settings.pitch_dimensions.x_dim.max, + "min_value": settings.pitch_dimensions.x_dim.min, + }, + }, + "y": {"func": lambda value: value, "defaults": {"value": y}}, + "y_normed": { + "func": normalize_between, + "defaults": { + "value": y, + "max_value": settings.pitch_dimensions.y_dim.max, + "min_value": settings.pitch_dimensions.y_dim.min, + }, + }, + "s": {"func": lambda value: value, "defaults": {"value": s}}, + "s_normed": { + "func": normalize_speeds_nfl, + "defaults": { + "s": s, + "team": team, + "ball_id": ball_id, + "settings": settings, + }, + }, + "velocity": {"func": lambda value: value, "defaults": {"value": velocity}}, + """ uv_velocity = unit_vectors(velocity) angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) v_sin_normed = normalize_sincos(np.sin(angles)) v_cos_normed = normalize_sincos(np.cos(angles)) - ''' - - 'v_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(normalize_angles(np.arctan2(unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0])))}}, - 'v_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(normalize_angles(np.arctan2(unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0])))}}, - 'dist_to_goal': {'func': lambda value: value, 'defaults': {'value': np.linalg.norm(position - goal_mouth_position, axis=1)}}, - 'normed_dist_to_goal': {'func': normalize_distance, 'defaults': {'value': np.linalg.norm(position - goal_mouth_position, axis=1), 'max_distance': max_dist_to_goal}}, - 'normed_dist_to_ball': {'func': normalize_distance, 'defaults': {'value': dist_to_ball, 'max_distance': max_dist_to_player}}, - 'vec_to_goal': {'func': lambda value: value, 'defaults': {'value': goal_mouth_position - position}}, - 'angle_to_goal': {'func': lambda value: value, 'defaults': {'value': np.arctan2((goal_mouth_position - position)[:, 1], (goal_mouth_position - position)[:, 0])}}, - 'goal_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(np.arctan2((goal_mouth_position - position)[:, 1], (goal_mouth_position - position)[:, 0]))}}, - 'goal_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(np.arctan2((goal_mouth_position - position)[:, 1], (goal_mouth_position - position)[:, 0]))}}, - 'vec_to_ball': {'func': lambda value: value, 'defaults': {'value': ball_position - position}}, - 'angle_to_ball': {'func': lambda value: value, 'defaults': {'value': np.arctan2((ball_position - position)[:, 1], (ball_position - position)[:, 0])}}, - 'ball_sin_normed': {'func': normalize_sincos, 'defaults': {'value': np.sin(np.arctan2((ball_position - position)[:, 1], (ball_position - position)[:, 0]))}}, - 'ball_cos_normed': {'func': normalize_sincos, 'defaults': {'value': np.cos(np.arctan2((ball_position - position)[:, 1], (ball_position - position)[:, 0]))}}, - 'ball_carrier': {'func': lambda value: value, 'defaults': {'value': ball_carrier}}, - 'is_possession_team': {'func': lambda value: value, 'defaults': {'value': np.where(team == possession_team, 1, settings.defending_team_node_value)}}, - 'is_ball': {'func': lambda value: value, 'defaults': {'value': np.where(team == ball_id, 1, 0)}}, - 'is_gk': {'func': lambda value: value, 'defaults': {'value': is_gk}}, + """ + "v_sin_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.sin( + normalize_angles( + np.arctan2( + unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] + ) + ) + ) + }, + }, + "v_cos_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.cos( + normalize_angles( + np.arctan2( + unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] + ) + ) + ) + }, + }, + "dist_to_goal": { + "func": lambda value: value, + "defaults": { + "value": np.linalg.norm(position - goal_mouth_position, axis=1) + }, + }, + "normed_dist_to_goal": { + "func": normalize_distance, + "defaults": { + "value": np.linalg.norm(position - goal_mouth_position, axis=1), + "max_distance": max_dist_to_goal, + }, + }, + "normed_dist_to_ball": { + "func": normalize_distance, + "defaults": {"value": dist_to_ball, "max_distance": max_dist_to_player}, + }, + "vec_to_goal": { + "func": lambda value: value, + "defaults": {"value": goal_mouth_position - position}, + }, + "angle_to_goal": { + "func": lambda value: value, + "defaults": { + "value": np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) + }, + }, + "goal_sin_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.sin( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) + ) + }, + }, + "goal_cos_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.cos( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) + ) + }, + }, + "vec_to_ball": { + "func": lambda value: value, + "defaults": {"value": ball_position - position}, + }, + "angle_to_ball": { + "func": lambda value: value, + "defaults": { + "value": np.arctan2( + (ball_position - position)[:, 1], (ball_position - position)[:, 0] + ) + }, + }, + "ball_sin_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.sin( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) + ) + }, + }, + "ball_cos_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.cos( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) + ) + }, + }, + "ball_carrier": { + "func": lambda value: value, + "defaults": {"value": ball_carrier}, + }, + "is_possession_team": { + "func": lambda value: value, + "defaults": { + "value": np.where( + team == possession_team, 1, settings.defending_team_node_value + ) + }, + }, + "is_ball": { + "func": lambda value: value, + "defaults": {"value": np.where(team == ball_id, 1, 0)}, + }, + "is_gk": {"func": lambda value: value, "defaults": {"value": is_gk}}, } - + computed_features = [] - + for feature, custom_params in feature_dict.items(): if feature in feature_func_map: - params = feature_func_map[feature]['defaults'].copy() + params = feature_func_map[feature]["defaults"].copy() params.update(custom_params) - computed_features.append(feature_func_map[feature]['func'](**params)) - + computed_features.append(feature_func_map[feature]["func"](**params)) + X = np.nan_to_num(np.stack(computed_features, axis=-1)) - + if graph_features is not None: eg = np.ones((X.shape[0], graph_features.shape[0])) * 0.0 eg[ball_index] = graph_features X = np.hstack((X, eg)) - #print(X) + # print(X) return X diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 6b85064a..251c2fc2 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -75,7 +75,34 @@ def __post_init__(self): else: self.dataset = self._remove_incomplete_frames() - #self.feature_specs = {'node_features': {}, 'edge_features': {}} + if self.feature_specs == None: + self.feature_specs = { + "node_features": { + "x_normed": {}, + "y_normed": {}, + "s_normed": {}, + "v_sin_normed": {}, + "v_cos_normed": {}, + "normed_dist_to_goal": {}, + "normed_dist_to_ball": {}, + "is_possession_team": {}, + "is_gk": {}, + "is_ball": {}, + "goal_sin_normed": {}, + "goal_cos_normed": {}, + "ball_sin_normed": {}, + "ball_cos_normed": {}, + "ball_carrier": {}, + }, + "edge_features": { + "dist_matrix_normed": {}, + "speed_diff_matrix_normed": {}, + "pos_cos_matrix": {}, + "pos_sin_matrix": {}, + "vel_cos_matrix": {}, + "vel_sin_matrix": {}, + }, + } self._shuffle() def _shuffle(self): @@ -364,7 +391,7 @@ def __compute(self, args: List[pl.Series]) -> dict: velocity=velocity, team=d[Column.TEAM_ID], settings=self.settings, - feature_dict=self.feature_specs['edge_features'] + feature_dict=self.feature_specs["edge_features"], ) node_features = compute_node_features_pl( @@ -378,7 +405,7 @@ def __compute(self, args: List[pl.Series]) -> dict: ball_carrier=d[Column.IS_BALL_CARRIER], graph_features=graph_features, settings=self.settings, - feature_dict=self.feature_specs['node_features'] + feature_dict=self.feature_specs["node_features"], ) return { diff --git a/unravel/utils/display/colors.py b/unravel/utils/display/colors.py index cc1cdb59..3a2a3b91 100644 --- a/unravel/utils/display/colors.py +++ b/unravel/utils/display/colors.py @@ -14,7 +14,7 @@ def __post_init__(self): @staticmethod def to_hex( - color: Union[str, Tuple[int, int, int], Tuple[int, int, int, float]] + color: Union[str, Tuple[int, int, int], Tuple[int, int, int, float]], ) -> str: if isinstance(color, str): try: diff --git a/unravel/utils/objects/default_graph_converter.py b/unravel/utils/objects/default_graph_converter.py index f866c05e..b2f31992 100644 --- a/unravel/utils/objects/default_graph_converter.py +++ b/unravel/utils/objects/default_graph_converter.py @@ -89,8 +89,8 @@ class DefaultGraphConverter: settings: DefaultGraphSettings = field( init=False, repr=False, default_factory=DefaultGraphSettings ) - - feature_specs: dict = field(default_factory=dict, repr=False) + + feature_specs: dict = field(repr=False, default=None) def __post_init__(self): if hasattr( @@ -139,7 +139,7 @@ def __post_init__(self): if not isinstance(self.verbose, bool): raise Exception("'verbose' should be of type boolean (bool)") - + if not isinstance(self.feature_specs, dict): raise ValueError("feature_specs must be a dictionary") From 264c04e1108f0da17e51a3f4de7bcac166fbee30 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 25 Feb 2025 16:36:23 -0500 Subject: [PATCH 11/46] Fix comments --- .../graphs/features/edge_features_pl.py | 86 +++++++++---------- .../graphs/features/node_features_pl.py | 17 ++-- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index bb69b1e2..8bf6213b 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -76,17 +76,17 @@ def compute_edge_features_pl( # pos_cos_matrix[nan_mask] = 0 # pos_sin_matrix[nan_mask] = 0 feature_func_map = { - """ - distances_between_players = np.linalg.norm( - p3d[:, None, :] - p3d[None, :, :], axis=-1 - ) + # """ + # distances_between_players = np.linalg.norm( + # p3d[:, None, :] - p3d[None, :, :], axis=-1 + # ) - dist_matrix_normed = normalize_distance( - distances_between_players, max_distance=max_dist_to_player - ) # 11x11 + # dist_matrix_normed = normalize_distance( + # distances_between_players, max_distance=max_dist_to_player + # ) # 11x11 - dist_matrix_normed[nan_mask] = 0 - """ + # dist_matrix_normed[nan_mask] = 0 + # """ "dist_matrix": { "func": lambda value: value, "defaults": { @@ -102,18 +102,18 @@ def compute_edge_features_pl( "max_distance": max_dist_to_player, }, }, - """ - speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 + # """ + # speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 - speed_diff_matrix_normed = normalize_speed_differences_nfl( - s=speed_diff_matrix, - team=team, - ball_id=Constant.BALL, - settings=settings, - ) + # speed_diff_matrix_normed = normalize_speed_differences_nfl( + # s=speed_diff_matrix, + # team=team, + # ball_id=Constant.BALL, + # settings=settings, + # ) - speed_diff_matrix_normed[nan_mask] = 0 - """ + # speed_diff_matrix_normed[nan_mask] = 0 + # """ "speed_diff_matrix": { "func": lambda value: value, "defaults": {"value": np.nan_to_num(s[None, :] - s[:, None])}, @@ -129,22 +129,22 @@ def compute_edge_features_pl( "settings": settings, }, }, - """ - vect_to_player_matrix = ( - p2d[:, None, :] - p2d[None, :, :] - ) # 11x11x2 the vector between two players + # """ + # vect_to_player_matrix = ( + # p2d[:, None, :] - p2d[None, :, :] + # ) # 11x11x2 the vector between two players - # Angles between players in sin and cos - angle_pos_matrix = np.nan_to_num( - np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) - ) + # # Angles between players in sin and cos + # angle_pos_matrix = np.nan_to_num( + # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) + # ) - pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) - pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + # pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + # pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) - pos_cos_matrix[nan_mask] = 0 - pos_sin_matrix[nan_mask] = 0 - """ + # pos_cos_matrix[nan_mask] = 0 + # pos_sin_matrix[nan_mask] = 0 + # """ "angle_pos_matrix": { "func": lambda vect_to_player_matrix: np.where( nan_mask, @@ -179,18 +179,18 @@ def compute_edge_features_pl( ) }, }, - """ - v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 - vect_to_player_matrix = ( - p2d[:, None, :] - p2d[None, :, :] - ) # 11x11x2 the vector between two players - - combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) - angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) + # """ + # v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 + # vect_to_player_matrix = ( + # p2d[:, None, :] - p2d[None, :, :] + # ) # 11x11x2 the vector between two players + + # combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) + # angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) - vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) - vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) - """ + # vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) + # vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) + # """ "angle_vel_matrix": { "func": lambda angle_between, combined_matrix: np.where( nan_mask, 0, np.apply_along_axis(angle_between, 2, combined_matrix) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index 7292ff72..40836046 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -92,13 +92,13 @@ def compute_node_features_pl( }, }, "velocity": {"func": lambda value: value, "defaults": {"value": velocity}}, - """ - uv_velocity = unit_vectors(velocity) - angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) + # """ + # uv_velocity = unit_vectors(velocity) + # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) - v_sin_normed = normalize_sincos(np.sin(angles)) - v_cos_normed = normalize_sincos(np.cos(angles)) - """ + # v_sin_normed = normalize_sincos(np.sin(angles)) + # v_cos_normed = normalize_sincos(np.cos(angles)) + # """ "v_sin_normed": { "func": normalize_sincos, "defaults": { @@ -235,7 +235,8 @@ def compute_node_features_pl( params = feature_func_map[feature]["defaults"].copy() params.update(custom_params) computed_features.append(feature_func_map[feature]["func"](**params)) - + else: + print("Error in feature", feature) X = np.nan_to_num(np.stack(computed_features, axis=-1)) if graph_features is not None: @@ -243,5 +244,5 @@ def compute_node_features_pl( eg[ball_index] = graph_features X = np.hstack((X, eg)) - # print(X) + #print(X.shape) return X From c22d15143be2efe8963d207f99f89abefbff8548 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 25 Feb 2025 16:37:23 -0500 Subject: [PATCH 12/46] black reformatting --- .../graphs/features/edge_features_pl.py | 112 +----------------- .../graphs/features/node_features_pl.py | 3 +- 2 files changed, 3 insertions(+), 112 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index 8bf6213b..18d43ae5 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -80,11 +80,9 @@ def compute_edge_features_pl( # distances_between_players = np.linalg.norm( # p3d[:, None, :] - p3d[None, :, :], axis=-1 # ) - # dist_matrix_normed = normalize_distance( # distances_between_players, max_distance=max_dist_to_player # ) # 11x11 - # dist_matrix_normed[nan_mask] = 0 # """ "dist_matrix": { @@ -104,14 +102,12 @@ def compute_edge_features_pl( }, # """ # speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 - # speed_diff_matrix_normed = normalize_speed_differences_nfl( # s=speed_diff_matrix, # team=team, # ball_id=Constant.BALL, # settings=settings, # ) - # speed_diff_matrix_normed[nan_mask] = 0 # """ "speed_diff_matrix": { @@ -133,15 +129,12 @@ def compute_edge_features_pl( # vect_to_player_matrix = ( # p2d[:, None, :] - p2d[None, :, :] # ) # 11x11x2 the vector between two players - # # Angles between players in sin and cos # angle_pos_matrix = np.nan_to_num( # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) # ) - # pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) # pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) - # pos_cos_matrix[nan_mask] = 0 # pos_sin_matrix[nan_mask] = 0 # """ @@ -184,10 +177,8 @@ def compute_edge_features_pl( # vect_to_player_matrix = ( # p2d[:, None, :] - p2d[None, :, :] # ) # 11x11x2 the vector between two players - # combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) # angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) - # vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) # vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) # """ @@ -225,104 +216,5 @@ def compute_edge_features_pl( e_tuple = list(computed_features) e = np.concatenate(e_tuple, axis=1) - # print(np.nan_to_num(e)) - return np.nan_to_num(e) - - -# def edge_features( -# attacking_players, -# defending_players, -# ball, -# max_player_speed, -# max_ball_speed, -# pitch_dimensions, -# adjacency_matrix, -# delaunay_adjacency_matrix, -# ): -# """ -# # edge features matrix is (np.non_zero(a), n_edge_features) (nz, n_edge_features) -# # so for every connected edge in the adjacency matrix (a) we have 1 row of features describing that edge -# # to do this we compute all values for a single feature in a <=23x23 square matrix -# # reshape it to a (<=23**2, ) matrix and then mask all values that are 0 in `a` (nz) -# # then we concat all the features into a single (nz, n_edge_features) matrix -# """ - -# max_dist_to_player = np.sqrt( -# pitch_dimensions.pitch_length**2 + pitch_dimensions.pitch_width**2 -# ) - -# players1 = players2 = attacking_players + defending_players + [ball] - -# h_pos = np.asarray([p.position for p in players1]) -# a_pos = np.asarray([p.position for p in players2]) - -# h_vel = np.asarray([p.velocity for p in players1]) -# a_vel = np.asarray([p.velocity for p in players2]) - -# h_spe = np.asarray([p.speed for p in players1]) -# a_spe = np.asarray([p.speed for p in players2]) - -# distances_between_players = np.linalg.norm( -# h_pos[:, None, :] - a_pos[None, :, :], axis=-1 -# ) -# nan_mask = np.isnan(distances_between_players) - -# dist_matrix = normalize_distance( -# distances_between_players, max_distance=max_dist_to_player -# ) # 11x11 - -# speed_diff_matrix = np.nan_to_num( -# normalize_speed(a_spe[None, :], max_speed=max(max_player_speed, max_ball_speed)) -# - normalize_speed( -# h_spe[:, None], max_speed=max(max_player_speed, max_ball_speed) -# ) -# ) # 11x11x1 - -# vect_to_player_matrix = ( -# h_pos[:, None, :] - a_pos[None, :, :] -# ) # 11x11x2 the vector between two players -# v_normed_matrix = a_vel[None, :, :] - h_vel[:, None, :] # 11x11x2 - -# angle_pos_matrix = np.nan_to_num( -# np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) -# ) -# pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) -# pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) - -# combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) -# angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) -# vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) -# vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) - -# non_zero_idxs, len_a = non_zeros(A=adjacency_matrix) -# # create a matrix where 1 if edge is same team else 0 - -# # if we have nan values we mask them to 0. -# # this only happens when we pad additional players -# dist_matrix[nan_mask] = 0 -# speed_diff_matrix[nan_mask] = 0 -# pos_cos_matrix[nan_mask] = 0 -# pos_sin_matrix[nan_mask] = 0 -# vel_cos_matrix[nan_mask] = 0 -# vel_sin_matrix[nan_mask] = 0 - -# e_tuple = list( -# [ -# # same_team_matrix[non_zero_idxs].reshape(len_a, 1), -# reindex(dist_matrix, non_zero_idxs, len_a), -# reindex(speed_diff_matrix, non_zero_idxs, len_a), -# reindex(pos_cos_matrix, non_zero_idxs, len_a), -# reindex(pos_sin_matrix, non_zero_idxs, len_a), -# reindex(vel_cos_matrix, non_zero_idxs, len_a), -# reindex(vel_sin_matrix, non_zero_idxs, len_a), -# ] -# ) - -# if delaunay_adjacency_matrix is not None: -# # if we are not using Delaunay as adjacency matrix, -# # use it as edge features to indicate "clear passing lines" -# extra_tuple = list([reindex(delaunay_adjacency_matrix, non_zero_idxs, len_a)]) -# e_tuple.extend(extra_tuple) - -# e = np.concatenate(e_tuple, axis=1) -# return np.nan_to_num(e) + + return np.nan_to_num(e) \ No newline at end of file diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index 40836046..acf6c293 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -95,7 +95,6 @@ def compute_node_features_pl( # """ # uv_velocity = unit_vectors(velocity) # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) - # v_sin_normed = normalize_sincos(np.sin(angles)) # v_cos_normed = normalize_sincos(np.cos(angles)) # """ @@ -244,5 +243,5 @@ def compute_node_features_pl( eg[ball_index] = graph_features X = np.hstack((X, eg)) - #print(X.shape) + # print(X.shape) return X From c387d545765e0703f2a5c36ea7379e715b71a5a0 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 25 Feb 2025 16:39:59 -0500 Subject: [PATCH 13/46] Modify initialisation of feature spec to take None --- unravel/utils/objects/default_graph_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unravel/utils/objects/default_graph_converter.py b/unravel/utils/objects/default_graph_converter.py index b2f31992..2e68dddb 100644 --- a/unravel/utils/objects/default_graph_converter.py +++ b/unravel/utils/objects/default_graph_converter.py @@ -140,8 +140,8 @@ def __post_init__(self): if not isinstance(self.verbose, bool): raise Exception("'verbose' should be of type boolean (bool)") - if not isinstance(self.feature_specs, dict): - raise ValueError("feature_specs must be a dictionary") + if self.feature_specs is not None and not isinstance(self.feature_specs, dict): + raise ValueError("feature_specs must be a dictionary or None") def _shuffle(self): raise NotImplementedError() From e0bf15c3ac74ff8bbe8a0d922c734dec3eb5ebcc Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 11 Mar 2025 17:05:00 -0400 Subject: [PATCH 14/46] Add error handling at initialisation for node features --- unravel/soccer/graphs/features/__init__.py | 1 + .../graphs/features/node_feature_func_map.py | 246 ++++++++++++++++++ unravel/soccer/graphs/graph_converter_pl.py | 25 ++ 3 files changed, 272 insertions(+) create mode 100644 unravel/soccer/graphs/features/node_feature_func_map.py diff --git a/unravel/soccer/graphs/features/__init__.py b/unravel/soccer/graphs/features/__init__.py index 4135270b..1b565828 100644 --- a/unravel/soccer/graphs/features/__init__.py +++ b/unravel/soccer/graphs/features/__init__.py @@ -5,3 +5,4 @@ from .adjacency_matrix_pl import compute_adjacency_matrix_pl from .edge_features_pl import compute_edge_features_pl from .node_features_pl import compute_node_features_pl +from .node_feature_func_map import get_node_feature_func_map, NodeFeatureDefaults diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py new file mode 100644 index 00000000..23c0429c --- /dev/null +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -0,0 +1,246 @@ +import math +import numpy as np + +from ....utils import ( + normalize_coords, + normalize_speeds_nfl, + normalize_sincos, + normalize_distance, + unit_vector_from_angle, + normalize_speeds_nfl, + normalize_accelerations_nfl, + normalize_between, + unit_vector, + unit_vectors, + normalize_angles, + normalize_distance, + normalize_coords, + normalize_speed, + distance_to_ball, +) + +from ...dataset.kloppy_polars import Constant +from typing import TypedDict, Dict, Optional, Union + +class NodeFeatureDefaults(TypedDict): + #value: Optional[Union[float, np.ndarray]] + value: float + max_value: Optional[float] + min_value: Optional[float] + team: Optional[int] + ball_id: Optional[int] + s: Optional[Union[float, np.ndarray]] + goal_mouth_position: Optional[np.ndarray] + ball_position: Optional[np.ndarray] + is_gk: Optional[bool] + +class FeatureFuncMap(TypedDict): + defaults: NodeFeatureDefaults + +def get_node_feature_func_map(x=None, + y=None, + s=None, + velocity=None, + team=None, + possession_team=None, + is_gk=None, + ball_carrier=None, + graph_features=None, + settings=None): + + ball_id = Constant.BALL + + position = np.stack((x, y), axis=-1) if x and y else None + + if team and len(np.where(team == ball_id)) >= 1: + ball_index = np.where(team == ball_id)[0] + ball_position = position[ball_index][0] + else: + ball_position = np.asarray([np.nan, np.nan]) + ball_index = 0 + + goal_mouth_position = ( + settings.pitch_dimensions.x_dim.max, + (settings.pitch_dimensions.y_dim.max + settings.pitch_dimensions.y_dim.min) / 2, + ) if settings else None + + max_dist_to_player = np.sqrt( + settings.pitch_dimensions.pitch_length**2 + + settings.pitch_dimensions.pitch_width**2 + ) if settings else None + + max_dist_to_goal = np.sqrt( + settings.pitch_dimensions.pitch_length**2 + + settings.pitch_dimensions.pitch_width**2 + ) if settings else None + + position, ball_position, dist_to_ball = distance_to_ball( + x=x, y=y, team=team, ball_id=ball_id + ) if all([x,y,team]) else (None,None,None) + + feature_func_map: Dict[str, FeatureFuncMap] = { + "x": {"func": lambda value: value, "defaults": {"value": x}}, + "x_normed": { + "func": normalize_between, + "defaults": { + "value": x if x else None, + "max_value": settings.pitch_dimensions.x_dim.max if settings else None, + "min_value": settings.pitch_dimensions.x_dim.min if settings else None, + }, + }, + "y": {"func": lambda value: value, "defaults": {"value": y}}, + "y_normed": { + "func": normalize_between, + "defaults": { + "value": y if y else None, + "max_value": settings.pitch_dimensions.y_dim.max if settings else None, + "min_value": settings.pitch_dimensions.y_dim.min if settings else None, + }, + }, + "s": {"func": lambda value: value, "defaults": {"value": s}}, + "s_normed": { + "func": normalize_speeds_nfl, + "defaults": { + "s": s if s else None, + "team": team if team else None, + "ball_id": ball_id, + "settings": settings if settings else None, + }, + }, + "velocity": {"func": lambda value: value, "defaults": {"value": velocity if velocity else None}}, + # """ + # uv_velocity = unit_vectors(velocity) + # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) + # v_sin_normed = normalize_sincos(np.sin(angles)) + # v_cos_normed = normalize_sincos(np.cos(angles)) + # """ + "v_sin_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.sin( + normalize_angles( + np.arctan2( + unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] + ) + ) + ) if velocity else None + }, + }, + "v_cos_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.cos( + normalize_angles( + np.arctan2( + unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] + ) + ) + ) if velocity else None + }, + }, + "dist_to_goal": { + "func": lambda value: value, + "defaults": { + "value": np.linalg.norm(position - goal_mouth_position, axis=1) if position and goal_mouth_position else None + }, + }, + "normed_dist_to_goal": { + "func": normalize_distance, + "defaults": { + "value": np.linalg.norm(position - goal_mouth_position, axis=1) if position and goal_mouth_position else None, + "max_distance": max_dist_to_goal, + }, + }, + "normed_dist_to_ball": { + "func": normalize_distance, + "defaults": {"value": dist_to_ball, "max_distance": max_dist_to_player}, + }, + "vec_to_goal": { + "func": lambda value: value, + "defaults": {"value": goal_mouth_position - position if goal_mouth_position and position else None}, + }, + "angle_to_goal": { + "func": lambda value: value, + "defaults": { + "value": np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) if goal_mouth_position and position else None + }, + }, + "goal_sin_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.sin( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) + ) if goal_mouth_position and position else None + }, + }, + "goal_cos_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.cos( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) + ) if goal_mouth_position and position else None + }, + }, + "vec_to_ball": { + "func": lambda value: value, + "defaults": {"value": ball_position - position if ball_position and position else None}, + }, + "angle_to_ball": { + "func": lambda value: value, + "defaults": { + "value": np.arctan2( + (ball_position - position)[:, 1], (ball_position - position)[:, 0] + ) if ball_position and position else None + }, + }, + "ball_sin_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.sin( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) + ) if ball_position and position else None + }, + }, + "ball_cos_normed": { + "func": normalize_sincos, + "defaults": { + "value": np.cos( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) + ) if ball_position and position else None + }, + }, + "ball_carrier": { + "func": lambda value: value, + "defaults": {"value": ball_carrier}, + }, + "is_possession_team": { + "func": lambda value: value, + "defaults": { + "value": np.where( + team == possession_team, 1, settings.defending_team_node_value + ) if all([team, possession_team, settings]) else None + }, + }, + "is_ball": { + "func": lambda value: value, + "defaults": {"value": np.where(team == ball_id, 1, 0)} if team else None, + }, + "is_gk": {"func": lambda value: value, "defaults": {"value": is_gk}}, + } + + return feature_func_map \ No newline at end of file diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 251c2fc2..3500d7fb 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -17,6 +17,8 @@ compute_node_features_pl, compute_adjacency_matrix_pl, compute_edge_features_pl, + get_node_feature_func_map, + NodeFeatureDefaults ) from ...utils import * @@ -103,8 +105,31 @@ def __post_init__(self): "vel_sin_matrix": {}, }, } + + self._validate_feature_specs(self.feature_specs) self._shuffle() + def _validate_feature_specs(self, feature_specs: dict): + #errors = [] + node_feature_map = get_node_feature_func_map() + for feature in feature_specs['node_features']: + if feature not in node_feature_map: + raise ValueError( + f"Feature {feature} is not a valid node feature. Valid features are {list(node_feature_map.keys())}" + ) + for key, value in feature_specs['node_features'][feature].items(): + if key not in node_feature_map[feature]['defaults']: + raise ValueError( + f"Feature {feature} does not have a key '{key}'. Valid keys are {list(node_feature_map[feature]['defaults'].keys())}" + ) + + #expected_type = type(node_feature_map[feature]['defaults'][key]) + expected_type = NodeFeatureDefaults.__annotations__.get(key) + if not isinstance(value, expected_type): + raise TypeError( + f"Feature {feature} key '{key}' should be of type {expected_type}" + ) + def _shuffle(self): if isinstance(self.settings.random_seed, int): self.dataset = self.dataset.sample( From 08e9c2a2a378066d64552225d8ca841a2f2794ab Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 13:19:01 -0400 Subject: [PATCH 15/46] Modified node features to use default feature function map --- .../graphs/features/edge_features_pl.py | 4 +- .../graphs/features/node_feature_func_map.py | 274 ++++++++++++------ .../graphs/features/node_features_pl.py | 198 +------------ unravel/soccer/graphs/graph_converter_pl.py | 20 +- 4 files changed, 214 insertions(+), 282 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index 18d43ae5..6c88da47 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -216,5 +216,5 @@ def compute_edge_features_pl( e_tuple = list(computed_features) e = np.concatenate(e_tuple, axis=1) - - return np.nan_to_num(e) \ No newline at end of file + + return np.nan_to_num(e) diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py index 23c0429c..f3a5ec0a 100644 --- a/unravel/soccer/graphs/features/node_feature_func_map.py +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -22,22 +22,26 @@ from ...dataset.kloppy_polars import Constant from typing import TypedDict, Dict, Optional, Union + class NodeFeatureDefaults(TypedDict): - #value: Optional[Union[float, np.ndarray]] + # value: Optional[Union[float, np.ndarray]] value: float - max_value: Optional[float] - min_value: Optional[float] - team: Optional[int] - ball_id: Optional[int] + max_value: Optional[float] + min_value: Optional[float] + team: Optional[int] + ball_id: Optional[int] s: Optional[Union[float, np.ndarray]] goal_mouth_position: Optional[np.ndarray] ball_position: Optional[np.ndarray] is_gk: Optional[bool] + class FeatureFuncMap(TypedDict): defaults: NodeFeatureDefaults -def get_node_feature_func_map(x=None, + +def get_node_feature_func_map( + x=None, y=None, s=None, velocity=None, @@ -46,13 +50,18 @@ def get_node_feature_func_map(x=None, is_gk=None, ball_carrier=None, graph_features=None, - settings=None): - + settings=None, +): + ball_id = Constant.BALL - position = np.stack((x, y), axis=-1) if x and y else None + position = np.stack((x, y), axis=-1) if x is not None and y is not None else None - if team and len(np.where(team == ball_id)) >= 1: + if ( + team is not None + and position is not None + and len(np.where(team == ball_id)) >= 1 + ): ball_index = np.where(team == ball_id)[0] ball_position = position[ball_index][0] else: @@ -60,54 +69,88 @@ def get_node_feature_func_map(x=None, ball_index = 0 goal_mouth_position = ( - settings.pitch_dimensions.x_dim.max, - (settings.pitch_dimensions.y_dim.max + settings.pitch_dimensions.y_dim.min) / 2, - ) if settings else None - - max_dist_to_player = np.sqrt( - settings.pitch_dimensions.pitch_length**2 - + settings.pitch_dimensions.pitch_width**2 - ) if settings else None - - max_dist_to_goal = np.sqrt( - settings.pitch_dimensions.pitch_length**2 - + settings.pitch_dimensions.pitch_width**2 - ) if settings else None - - position, ball_position, dist_to_ball = distance_to_ball( - x=x, y=y, team=team, ball_id=ball_id - ) if all([x,y,team]) else (None,None,None) - + ( + settings.pitch_dimensions.x_dim.max, + (settings.pitch_dimensions.y_dim.max + settings.pitch_dimensions.y_dim.min) + / 2, + ) + if settings is not None + else None + ) + + max_dist_to_player = ( + np.sqrt( + settings.pitch_dimensions.pitch_length**2 + + settings.pitch_dimensions.pitch_width**2 + ) + if settings is not None + else None + ) + + max_dist_to_goal = ( + np.sqrt( + settings.pitch_dimensions.pitch_length**2 + + settings.pitch_dimensions.pitch_width**2 + ) + if settings is not None + else None + ) + + position, ball_position, dist_to_ball = ( + distance_to_ball(x=x, y=y, team=team, ball_id=ball_id) + if x is not None and y is not None and team is not None + else (None, None, None) + ) + feature_func_map: Dict[str, FeatureFuncMap] = { "x": {"func": lambda value: value, "defaults": {"value": x}}, "x_normed": { "func": normalize_between, "defaults": { - "value": x if x else None, - "max_value": settings.pitch_dimensions.x_dim.max if settings else None, - "min_value": settings.pitch_dimensions.x_dim.min if settings else None, + "value": x if x is not None else None, + "max_value": ( + settings.pitch_dimensions.x_dim.max + if settings is not None + else None + ), + "min_value": ( + settings.pitch_dimensions.x_dim.min + if settings is not None + else None + ), }, }, "y": {"func": lambda value: value, "defaults": {"value": y}}, "y_normed": { "func": normalize_between, "defaults": { - "value": y if y else None, - "max_value": settings.pitch_dimensions.y_dim.max if settings else None, - "min_value": settings.pitch_dimensions.y_dim.min if settings else None, + "value": y if y is not None else None, + "max_value": ( + settings.pitch_dimensions.y_dim.max + if settings is not None + else None + ), + "min_value": ( + settings.pitch_dimensions.y_dim.min + if settings is not None + else None + ), }, }, "s": {"func": lambda value: value, "defaults": {"value": s}}, "s_normed": { "func": normalize_speeds_nfl, "defaults": { - "s": s if s else None, - "team": team if team else None, + "s": s if s is not None else None, + "team": team if team is not None else None, "ball_id": ball_id, - "settings": settings if settings else None, + "settings": settings if settings is not None else None, }, }, - "velocity": {"func": lambda value: value, "defaults": {"value": velocity if velocity else None}}, + "velocity": { + "func": lambda value: value, + "defaults": {"value": velocity if velocity is not None else None}, + }, # """ # uv_velocity = unit_vectors(velocity) # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) @@ -117,37 +160,55 @@ def get_node_feature_func_map(x=None, "v_sin_normed": { "func": normalize_sincos, "defaults": { - "value": np.sin( - normalize_angles( - np.arctan2( - unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] + "value": ( + np.sin( + normalize_angles( + np.arctan2( + unit_vectors(velocity)[:, 1], + unit_vectors(velocity)[:, 0], + ) ) ) - ) if velocity else None + if velocity is not None + else None + ) }, }, "v_cos_normed": { "func": normalize_sincos, "defaults": { - "value": np.cos( - normalize_angles( - np.arctan2( - unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] + "value": ( + np.cos( + normalize_angles( + np.arctan2( + unit_vectors(velocity)[:, 1], + unit_vectors(velocity)[:, 0], + ) ) ) - ) if velocity else None + if velocity is not None + else None + ) }, }, "dist_to_goal": { "func": lambda value: value, "defaults": { - "value": np.linalg.norm(position - goal_mouth_position, axis=1) if position and goal_mouth_position else None + "value": ( + np.linalg.norm(position - goal_mouth_position, axis=1) + if position is not None and goal_mouth_position is not None + else None + ) }, }, "normed_dist_to_goal": { "func": normalize_distance, "defaults": { - "value": np.linalg.norm(position - goal_mouth_position, axis=1) if position and goal_mouth_position else None, + "value": ( + np.linalg.norm(position - goal_mouth_position, axis=1) + if position is not None and goal_mouth_position is not None + else None + ), "max_distance": max_dist_to_goal, }, }, @@ -157,71 +218,108 @@ def get_node_feature_func_map(x=None, }, "vec_to_goal": { "func": lambda value: value, - "defaults": {"value": goal_mouth_position - position if goal_mouth_position and position else None}, + "defaults": { + "value": ( + goal_mouth_position - position + if goal_mouth_position is not None and position is not None + else None + ) + }, }, "angle_to_goal": { "func": lambda value: value, "defaults": { - "value": np.arctan2( - (goal_mouth_position - position)[:, 1], - (goal_mouth_position - position)[:, 0], - ) if goal_mouth_position and position else None + "value": ( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) + if goal_mouth_position is not None and position is not None + else None + ) }, }, "goal_sin_normed": { "func": normalize_sincos, "defaults": { - "value": np.sin( - np.arctan2( - (goal_mouth_position - position)[:, 1], - (goal_mouth_position - position)[:, 0], + "value": ( + np.sin( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) ) - ) if goal_mouth_position and position else None + if goal_mouth_position is not None and position is not None + else None + ) }, }, "goal_cos_normed": { "func": normalize_sincos, "defaults": { - "value": np.cos( - np.arctan2( - (goal_mouth_position - position)[:, 1], - (goal_mouth_position - position)[:, 0], + "value": ( + np.cos( + np.arctan2( + (goal_mouth_position - position)[:, 1], + (goal_mouth_position - position)[:, 0], + ) ) - ) if goal_mouth_position and position else None + if goal_mouth_position is not None and position is not None + else None + ) }, }, "vec_to_ball": { "func": lambda value: value, - "defaults": {"value": ball_position - position if ball_position and position else None}, + "defaults": { + "value": ( + ball_position - position + if ball_position is not None and position is not None + else None + ) + }, }, "angle_to_ball": { "func": lambda value: value, "defaults": { - "value": np.arctan2( - (ball_position - position)[:, 1], (ball_position - position)[:, 0] - ) if ball_position and position else None + "value": ( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) + if ball_position is not None and position is not None + else None + ) }, }, "ball_sin_normed": { "func": normalize_sincos, "defaults": { - "value": np.sin( - np.arctan2( - (ball_position - position)[:, 1], - (ball_position - position)[:, 0], + "value": ( + np.sin( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) ) - ) if ball_position and position else None + if ball_position is not None and position is not None + else None + ) }, }, "ball_cos_normed": { "func": normalize_sincos, "defaults": { - "value": np.cos( - np.arctan2( - (ball_position - position)[:, 1], - (ball_position - position)[:, 0], + "value": ( + np.cos( + np.arctan2( + (ball_position - position)[:, 1], + (ball_position - position)[:, 0], + ) ) - ) if ball_position and position else None + if ball_position is not None and position is not None + else None + ) }, }, "ball_carrier": { @@ -231,16 +329,24 @@ def get_node_feature_func_map(x=None, "is_possession_team": { "func": lambda value: value, "defaults": { - "value": np.where( - team == possession_team, 1, settings.defending_team_node_value - ) if all([team, possession_team, settings]) else None + "value": ( + np.where( + team == possession_team, 1, settings.defending_team_node_value + ) + if team is not None + and possession_team is not None + and settings is not None + else None + ) }, }, "is_ball": { "func": lambda value: value, - "defaults": {"value": np.where(team == ball_id, 1, 0)} if team else None, + "defaults": ( + {"value": np.where(team == ball_id, 1, 0)} if team is not None else None + ), }, "is_gk": {"func": lambda value: value, "defaults": {"value": is_gk}}, } - return feature_func_map \ No newline at end of file + return feature_func_map diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index acf6c293..3f4694b4 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -19,6 +19,7 @@ distance_to_ball, ) from ...dataset.kloppy_polars import Constant +from .node_feature_func_map import get_node_feature_func_map def compute_node_features_pl( @@ -36,197 +37,23 @@ def compute_node_features_pl( ): ball_id = Constant.BALL - position = np.stack((x, y), axis=-1) - if len(np.where(team == ball_id)) >= 1: ball_index = np.where(team == ball_id)[0] - ball_position = position[ball_index][0] else: - ball_position = np.asarray([np.nan, np.nan]) ball_index = 0 - goal_mouth_position = ( - settings.pitch_dimensions.x_dim.max, - (settings.pitch_dimensions.y_dim.max + settings.pitch_dimensions.y_dim.min) / 2, - ) - max_dist_to_player = np.sqrt( - settings.pitch_dimensions.pitch_length**2 - + settings.pitch_dimensions.pitch_width**2 + feature_func_map = get_node_feature_func_map( + x, + y, + s, + velocity, + team, + possession_team, + is_gk, + ball_carrier, + graph_features, + settings, ) - max_dist_to_goal = np.sqrt( - settings.pitch_dimensions.pitch_length**2 - + settings.pitch_dimensions.pitch_width**2 - ) - - position, ball_position, dist_to_ball = distance_to_ball( - x=x, y=y, team=team, ball_id=ball_id - ) - - feature_func_map = { - "x": {"func": lambda value: value, "defaults": {"value": x}}, - "x_normed": { - "func": normalize_between, - "defaults": { - "value": x, - "max_value": settings.pitch_dimensions.x_dim.max, - "min_value": settings.pitch_dimensions.x_dim.min, - }, - }, - "y": {"func": lambda value: value, "defaults": {"value": y}}, - "y_normed": { - "func": normalize_between, - "defaults": { - "value": y, - "max_value": settings.pitch_dimensions.y_dim.max, - "min_value": settings.pitch_dimensions.y_dim.min, - }, - }, - "s": {"func": lambda value: value, "defaults": {"value": s}}, - "s_normed": { - "func": normalize_speeds_nfl, - "defaults": { - "s": s, - "team": team, - "ball_id": ball_id, - "settings": settings, - }, - }, - "velocity": {"func": lambda value: value, "defaults": {"value": velocity}}, - # """ - # uv_velocity = unit_vectors(velocity) - # angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) - # v_sin_normed = normalize_sincos(np.sin(angles)) - # v_cos_normed = normalize_sincos(np.cos(angles)) - # """ - "v_sin_normed": { - "func": normalize_sincos, - "defaults": { - "value": np.sin( - normalize_angles( - np.arctan2( - unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] - ) - ) - ) - }, - }, - "v_cos_normed": { - "func": normalize_sincos, - "defaults": { - "value": np.cos( - normalize_angles( - np.arctan2( - unit_vectors(velocity)[:, 1], unit_vectors(velocity)[:, 0] - ) - ) - ) - }, - }, - "dist_to_goal": { - "func": lambda value: value, - "defaults": { - "value": np.linalg.norm(position - goal_mouth_position, axis=1) - }, - }, - "normed_dist_to_goal": { - "func": normalize_distance, - "defaults": { - "value": np.linalg.norm(position - goal_mouth_position, axis=1), - "max_distance": max_dist_to_goal, - }, - }, - "normed_dist_to_ball": { - "func": normalize_distance, - "defaults": {"value": dist_to_ball, "max_distance": max_dist_to_player}, - }, - "vec_to_goal": { - "func": lambda value: value, - "defaults": {"value": goal_mouth_position - position}, - }, - "angle_to_goal": { - "func": lambda value: value, - "defaults": { - "value": np.arctan2( - (goal_mouth_position - position)[:, 1], - (goal_mouth_position - position)[:, 0], - ) - }, - }, - "goal_sin_normed": { - "func": normalize_sincos, - "defaults": { - "value": np.sin( - np.arctan2( - (goal_mouth_position - position)[:, 1], - (goal_mouth_position - position)[:, 0], - ) - ) - }, - }, - "goal_cos_normed": { - "func": normalize_sincos, - "defaults": { - "value": np.cos( - np.arctan2( - (goal_mouth_position - position)[:, 1], - (goal_mouth_position - position)[:, 0], - ) - ) - }, - }, - "vec_to_ball": { - "func": lambda value: value, - "defaults": {"value": ball_position - position}, - }, - "angle_to_ball": { - "func": lambda value: value, - "defaults": { - "value": np.arctan2( - (ball_position - position)[:, 1], (ball_position - position)[:, 0] - ) - }, - }, - "ball_sin_normed": { - "func": normalize_sincos, - "defaults": { - "value": np.sin( - np.arctan2( - (ball_position - position)[:, 1], - (ball_position - position)[:, 0], - ) - ) - }, - }, - "ball_cos_normed": { - "func": normalize_sincos, - "defaults": { - "value": np.cos( - np.arctan2( - (ball_position - position)[:, 1], - (ball_position - position)[:, 0], - ) - ) - }, - }, - "ball_carrier": { - "func": lambda value: value, - "defaults": {"value": ball_carrier}, - }, - "is_possession_team": { - "func": lambda value: value, - "defaults": { - "value": np.where( - team == possession_team, 1, settings.defending_team_node_value - ) - }, - }, - "is_ball": { - "func": lambda value: value, - "defaults": {"value": np.where(team == ball_id, 1, 0)}, - }, - "is_gk": {"func": lambda value: value, "defaults": {"value": is_gk}}, - } - computed_features = [] for feature, custom_params in feature_dict.items(): @@ -243,5 +70,4 @@ def compute_node_features_pl( eg[ball_index] = graph_features X = np.hstack((X, eg)) - # print(X.shape) return X diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 3500d7fb..de241a92 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -18,7 +18,7 @@ compute_adjacency_matrix_pl, compute_edge_features_pl, get_node_feature_func_map, - NodeFeatureDefaults + NodeFeatureDefaults, ) from ...utils import * @@ -105,31 +105,31 @@ def __post_init__(self): "vel_sin_matrix": {}, }, } - + self._validate_feature_specs(self.feature_specs) self._shuffle() def _validate_feature_specs(self, feature_specs: dict): - #errors = [] - node_feature_map = get_node_feature_func_map() - for feature in feature_specs['node_features']: + # errors = [] + node_feature_map = get_node_feature_func_map(settings=self.settings) + for feature in feature_specs["node_features"]: if feature not in node_feature_map: raise ValueError( f"Feature {feature} is not a valid node feature. Valid features are {list(node_feature_map.keys())}" ) - for key, value in feature_specs['node_features'][feature].items(): - if key not in node_feature_map[feature]['defaults']: + for key, value in feature_specs["node_features"][feature].items(): + if key not in node_feature_map[feature]["defaults"]: raise ValueError( f"Feature {feature} does not have a key '{key}'. Valid keys are {list(node_feature_map[feature]['defaults'].keys())}" ) - - #expected_type = type(node_feature_map[feature]['defaults'][key]) + + # expected_type = type(node_feature_map[feature]['defaults'][key]) expected_type = NodeFeatureDefaults.__annotations__.get(key) if not isinstance(value, expected_type): raise TypeError( f"Feature {feature} key '{key}' should be of type {expected_type}" ) - + def _shuffle(self): if isinstance(self.settings.random_seed, int): self.dataset = self.dataset.sample( From 9ac758c29c518f38e5217a0c9ae8c0d4766c5238 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 18:20:24 -0400 Subject: [PATCH 16/46] Add edge feature error handling --- unravel/soccer/graphs/features/__init__.py | 1 + .../graphs/features/edge_feature_func_map.py | 246 ++++++++++++++++++ .../graphs/features/edge_features_pl.py | 190 +------------- .../graphs/features/node_features_pl.py | 17 -- unravel/soccer/graphs/graph_converter_pl.py | 37 ++- 5 files changed, 276 insertions(+), 215 deletions(-) create mode 100644 unravel/soccer/graphs/features/edge_feature_func_map.py diff --git a/unravel/soccer/graphs/features/__init__.py b/unravel/soccer/graphs/features/__init__.py index 1b565828..3df950d3 100644 --- a/unravel/soccer/graphs/features/__init__.py +++ b/unravel/soccer/graphs/features/__init__.py @@ -6,3 +6,4 @@ from .edge_features_pl import compute_edge_features_pl from .node_features_pl import compute_node_features_pl from .node_feature_func_map import get_node_feature_func_map, NodeFeatureDefaults +from .edge_feature_func_map import get_edge_feature_func_map, EdgeFeatureDefaults diff --git a/unravel/soccer/graphs/features/edge_feature_func_map.py b/unravel/soccer/graphs/features/edge_feature_func_map.py new file mode 100644 index 00000000..4777ac07 --- /dev/null +++ b/unravel/soccer/graphs/features/edge_feature_func_map.py @@ -0,0 +1,246 @@ +import math +import numpy as np + +from ....utils import ( + normalize_distance, + normalize_speed, + normalize_sincos, + angle_between, + non_zeros, + reindex, + normalize_distance, + normalize_sincos, + non_zeros, + reindex, + normalize_speed_differences_nfl, + normalize_accelerations_nfl, +) + +from ...dataset.kloppy_polars import Constant +from typing import TypedDict, Dict, Optional, Union + + +class EdgeFeatureDefaults(TypedDict): + value: float + max_distance: float + s: Optional[Union[float, np.ndarray]] + team: Optional[int] + ball_id: Optional[int] + + +class FeatureFuncMap(TypedDict): + defaults: EdgeFeatureDefaults + + +def get_edge_feature_func_map( + p3d=None, p2d=None, s=None, velocity=None, team=None, settings=None +): + + # Compute pairwise distances using broadcasting + max_dist_to_player = ( + np.sqrt( + settings.pitch_dimensions.pitch_length**2 + + settings.pitch_dimensions.pitch_width**2 + ) + if settings is not None + else None + ) + + distances_between_players = ( + np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1) + if p3d is not None + else None + ) + + v_normed_matrix = ( + velocity[None, :, :] - velocity[:, None, :] if velocity is not None else None + ) # 11x11x2 + + vect_to_player_matrix = ( + (p2d[:, None, :] - p2d[None, :, :]) if p2d is not None else None + ) # 11x11x2 the vector between two players + + if vect_to_player_matrix is not None and v_normed_matrix is not None: + combined_matrix = np.concatenate( + (vect_to_player_matrix, v_normed_matrix), axis=2 + ) + angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) + else: + combined_matrix = None + angle_vel_matrix = None + + nan_mask = ( + np.isnan(distances_between_players) + if distances_between_players is not None + else None + ) + + feature_func_map: Dict[str, FeatureFuncMap] = { + # """ + # distances_between_players = np.linalg.norm( + # p3d[:, None, :] - p3d[None, :, :], axis=-1 + # ) + # dist_matrix_normed = normalize_distance( + # distances_between_players, max_distance=max_dist_to_player + # ) # 11x11 + # dist_matrix_normed[nan_mask] = 0 + # """ + "dist_matrix": { + "func": lambda value: value, + "defaults": { + "value": ( + np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1) + if p3d is not None + else None + ) + }, + }, + "dist_matrix_normed": { + "func": lambda value, max_distance: np.where( + nan_mask, 0, normalize_distance(value, max_distance) + ), + "defaults": { + "value": ( + np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1) + if p3d is not None + else None + ), + "max_distance": ( + max_dist_to_player if max_dist_to_player is not None else None + ), + }, + }, + # """ + # speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 + # speed_diff_matrix_normed = normalize_speed_differences_nfl( + # s=speed_diff_matrix, + # team=team, + # ball_id=Constant.BALL, + # settings=settings, + # ) + # speed_diff_matrix_normed[nan_mask] = 0 + # """ + "speed_diff_matrix": { + "func": lambda value: value, + "defaults": { + "value": ( + np.nan_to_num(s[None, :] - s[:, None]) if s is not None else None + ) + }, + }, + "speed_diff_matrix_normed": { + "func": lambda s, team, ball_id, settings: np.where( + nan_mask, 0, normalize_speed_differences_nfl(s, team, ball_id, settings) + ), + "defaults": { + "s": np.nan_to_num(s[None, :] - s[:, None]) if s is not None else None, + "team": team if team is not None else None, + "ball_id": Constant.BALL, + "settings": settings if settings is not None else None, + }, + }, + # """ + # vect_to_player_matrix = ( + # p2d[:, None, :] - p2d[None, :, :] + # ) # 11x11x2 the vector between two players + # # Angles between players in sin and cos + # angle_pos_matrix = np.nan_to_num( + # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) + # ) + # pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + # pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + # pos_cos_matrix[nan_mask] = 0 + # pos_sin_matrix[nan_mask] = 0 + # """ + "angle_pos_matrix": { + "func": lambda vect_to_player_matrix: np.where( + nan_mask, + 0, + np.arctan2( + vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] + ), + ), + "defaults": { + "vect_to_player_matrix": ( + vect_to_player_matrix if vect_to_player_matrix is not None else None + ) + }, + }, + "pos_cos_matrix": { + "func": lambda angle_pos_matrix: np.where( + nan_mask, 0, normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) + ), + "defaults": { + "angle_pos_matrix": ( + np.nan_to_num( + np.arctan2( + vect_to_player_matrix[:, :, 1], + vect_to_player_matrix[:, :, 0], + ) + ) + if vect_to_player_matrix is not None + else None + ), + }, + }, + "pos_sin_matrix": { + "func": lambda angle_pos_matrix: np.where( + nan_mask, 0, normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) + ), + "defaults": { + "angle_pos_matrix": ( + np.nan_to_num( + np.arctan2( + vect_to_player_matrix[:, :, 1], + vect_to_player_matrix[:, :, 0], + ) + ) + if vect_to_player_matrix is not None + else None + ), + }, + }, + # """ + # v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 + # vect_to_player_matrix = ( + # p2d[:, None, :] - p2d[None, :, :] + # ) # 11x11x2 the vector between two players + # combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) + # angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) + # vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) + # vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) + # """ + "angle_vel_matrix": { + "func": lambda angle_between, combined_matrix: np.where( + nan_mask, 0, np.apply_along_axis(angle_between, 2, combined_matrix) + ), + "defaults": { + "angle_between": angle_between, + "combined_matrix": ( + combined_matrix if combined_matrix is not None else None + ), + }, + }, + "vel_cos_matrix": { + "func": lambda angle_vel_matrix: normalize_sincos( + np.nan_to_num(np.cos(angle_vel_matrix)) + ), + "defaults": { + "angle_vel_matrix": ( + angle_vel_matrix if angle_vel_matrix is not None else None + ) + }, + }, + "vel_sin_matrix": { + "func": lambda angle_vel_matrix: normalize_sincos( + np.nan_to_num(np.sin(angle_vel_matrix)) + ), + "defaults": { + "angle_vel_matrix": ( + angle_vel_matrix if angle_vel_matrix is not None else None + ) + }, + }, + } + + return feature_func_map diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index 6c88da47..562f6588 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -9,202 +9,18 @@ reindex, ) -import numpy as np - -from ....utils import ( - normalize_distance, - normalize_sincos, - non_zeros, - reindex, - normalize_speed_differences_nfl, - normalize_accelerations_nfl, -) - from ...dataset.kloppy_polars import Constant +from .edge_feature_func_map import get_edge_feature_func_map + def compute_edge_features_pl( adjacency_matrix, p3d, p2d, s, velocity, team, settings, feature_dict ): - # Compute pairwise distances using broadcasting - max_dist_to_player = np.sqrt( - settings.pitch_dimensions.pitch_length**2 - + settings.pitch_dimensions.pitch_width**2 - ) - distances_between_players = np.linalg.norm( - p3d[:, None, :] - p3d[None, :, :], axis=-1 - ) - # dist_matrix_normed = normalize_distance( - # distances_between_players, max_distance=max_dist_to_player - # ) # 11x11 - - # speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 - # speed_diff_matrix_normed = normalize_speed_differences_nfl( - # s=speed_diff_matrix, - # team=team, - # ball_id=Constant.BALL, - # settings=settings, - # ) - - # vect_to_player_matrix = p2d[:, None, :] - p2d[None, :, :] # NxNx2 - - v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 - - vect_to_player_matrix = ( - p2d[:, None, :] - p2d[None, :, :] - ) # 11x11x2 the vector between two players - - # Angles between players in sin and cos - # angle_pos_matrix = np.nan_to_num( - # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) - # ) - # pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) - # pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) - - combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) - angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) - # vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) - # vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) - - nan_mask = np.isnan(distances_between_players) non_zero_idxs, len_a = non_zeros(A=adjacency_matrix) - # dist_matrix_normed[nan_mask] = 0 - # speed_diff_matrix_normed[nan_mask] = 0 - - # pos_cos_matrix[nan_mask] = 0 - # pos_sin_matrix[nan_mask] = 0 - feature_func_map = { - # """ - # distances_between_players = np.linalg.norm( - # p3d[:, None, :] - p3d[None, :, :], axis=-1 - # ) - # dist_matrix_normed = normalize_distance( - # distances_between_players, max_distance=max_dist_to_player - # ) # 11x11 - # dist_matrix_normed[nan_mask] = 0 - # """ - "dist_matrix": { - "func": lambda value: value, - "defaults": { - "value": np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1) - }, - }, - "dist_matrix_normed": { - "func": lambda value, max_distance: np.where( - nan_mask, 0, normalize_distance(value, max_distance) - ), - "defaults": { - "value": np.linalg.norm(p3d[:, None, :] - p3d[None, :, :], axis=-1), - "max_distance": max_dist_to_player, - }, - }, - # """ - # speed_diff_matrix = np.nan_to_num(s[None, :] - s[:, None]) # NxNx1 - # speed_diff_matrix_normed = normalize_speed_differences_nfl( - # s=speed_diff_matrix, - # team=team, - # ball_id=Constant.BALL, - # settings=settings, - # ) - # speed_diff_matrix_normed[nan_mask] = 0 - # """ - "speed_diff_matrix": { - "func": lambda value: value, - "defaults": {"value": np.nan_to_num(s[None, :] - s[:, None])}, - }, - "speed_diff_matrix_normed": { - "func": lambda s, team, ball_id, settings: np.where( - nan_mask, 0, normalize_speed_differences_nfl(s, team, ball_id, settings) - ), - "defaults": { - "s": np.nan_to_num(s[None, :] - s[:, None]), - "team": team, - "ball_id": Constant.BALL, - "settings": settings, - }, - }, - # """ - # vect_to_player_matrix = ( - # p2d[:, None, :] - p2d[None, :, :] - # ) # 11x11x2 the vector between two players - # # Angles between players in sin and cos - # angle_pos_matrix = np.nan_to_num( - # np.arctan2(vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0]) - # ) - # pos_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) - # pos_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) - # pos_cos_matrix[nan_mask] = 0 - # pos_sin_matrix[nan_mask] = 0 - # """ - "angle_pos_matrix": { - "func": lambda vect_to_player_matrix: np.where( - nan_mask, - 0, - np.arctan2( - vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] - ), - ), - "defaults": {"vect_to_player_matrix": vect_to_player_matrix}, - }, - "pos_cos_matrix": { - "func": lambda angle_pos_matrix: np.where( - nan_mask, 0, normalize_sincos(np.nan_to_num(np.cos(angle_pos_matrix))) - ), - "defaults": { - "angle_pos_matrix": np.nan_to_num( - np.arctan2( - vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] - ) - ) - }, - }, - "pos_sin_matrix": { - "func": lambda angle_pos_matrix: np.where( - nan_mask, 0, normalize_sincos(np.nan_to_num(np.sin(angle_pos_matrix))) - ), - "defaults": { - "angle_pos_matrix": np.nan_to_num( - np.arctan2( - vect_to_player_matrix[:, :, 1], vect_to_player_matrix[:, :, 0] - ) - ) - }, - }, - # """ - # v_normed_matrix = velocity[None, :, :] - velocity[:, None, :] # 11x11x2 - # vect_to_player_matrix = ( - # p2d[:, None, :] - p2d[None, :, :] - # ) # 11x11x2 the vector between two players - # combined_matrix = np.concatenate((vect_to_player_matrix, v_normed_matrix), axis=2) - # angle_vel_matrix = np.apply_along_axis(angle_between, 2, combined_matrix) - # vel_cos_matrix = normalize_sincos(np.nan_to_num(np.cos(angle_vel_matrix))) - # vel_sin_matrix = normalize_sincos(np.nan_to_num(np.sin(angle_vel_matrix))) - # """ - "angle_vel_matrix": { - "func": lambda angle_between, combined_matrix: np.where( - nan_mask, 0, np.apply_along_axis(angle_between, 2, combined_matrix) - ), - "defaults": { - "angle_between": angle_between, - "combined_matrix": combined_matrix, - }, - }, - "vel_cos_matrix": { - "func": lambda angle_vel_matrix: normalize_sincos( - np.nan_to_num(np.cos(angle_vel_matrix)) - ), - "defaults": {"angle_vel_matrix": angle_vel_matrix}, - }, - "vel_sin_matrix": { - "func": lambda angle_vel_matrix: normalize_sincos( - np.nan_to_num(np.sin(angle_vel_matrix)) - ), - "defaults": {"angle_vel_matrix": angle_vel_matrix}, - }, - } - + feature_func_map = get_edge_feature_func_map(p3d, p2d, s, velocity, team, settings) computed_features = [] for feature, custom_params in feature_dict.items(): diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index 3f4694b4..93fcc9c6 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -1,23 +1,6 @@ import math import numpy as np -from ....utils import ( - normalize_coords, - normalize_speeds_nfl, - normalize_sincos, - normalize_distance, - unit_vector_from_angle, - normalize_speeds_nfl, - normalize_accelerations_nfl, - normalize_between, - unit_vector, - unit_vectors, - normalize_angles, - normalize_distance, - normalize_coords, - normalize_speed, - distance_to_ball, -) from ...dataset.kloppy_polars import Constant from .node_feature_func_map import get_node_feature_func_map diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index de241a92..e2e1a883 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -18,7 +18,9 @@ compute_adjacency_matrix_pl, compute_edge_features_pl, get_node_feature_func_map, + get_edge_feature_func_map, NodeFeatureDefaults, + EdgeFeatureDefaults, ) from ...utils import * @@ -106,25 +108,38 @@ def __post_init__(self): }, } - self._validate_feature_specs(self.feature_specs) + self._validate_feature_specs( + self.feature_specs, + get_node_feature_func_map, + NodeFeatureDefaults, + "node_features", + ) + self._validate_feature_specs( + self.feature_specs, + get_edge_feature_func_map, + EdgeFeatureDefaults, + "edge_features", + ) self._shuffle() - def _validate_feature_specs(self, feature_specs: dict): - # errors = [] - node_feature_map = get_node_feature_func_map(settings=self.settings) - for feature in feature_specs["node_features"]: - if feature not in node_feature_map: + def _validate_feature_specs( + self, feature_specs: dict, feature_func, feature_defaults, feature_tag + ): + # validate features + feature_map = feature_func(settings=self.settings) + for feature in feature_specs[feature_tag]: + if feature not in feature_map: raise ValueError( - f"Feature {feature} is not a valid node feature. Valid features are {list(node_feature_map.keys())}" + f"feature {feature} is not a valid {feature_tag[:4]} feature. Valid features are {list(feature_map.keys())}" ) - for key, value in feature_specs["node_features"][feature].items(): - if key not in node_feature_map[feature]["defaults"]: + for key, value in feature_specs[feature_tag][feature].items(): + if key not in feature_map[feature]["defaults"]: raise ValueError( - f"Feature {feature} does not have a key '{key}'. Valid keys are {list(node_feature_map[feature]['defaults'].keys())}" + f"{feature_tag[:4]} feature {feature} does not have a key '{key}'. Valid keys are {list(feature_map[feature]['defaults'].keys())}" ) # expected_type = type(node_feature_map[feature]['defaults'][key]) - expected_type = NodeFeatureDefaults.__annotations__.get(key) + expected_type = feature_defaults.__annotations__.get(key) if not isinstance(value, expected_type): raise TypeError( f"Feature {feature} key '{key}' should be of type {expected_type}" From 2266156badf84434e520c4a8749624d8d3fffa85 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 21:19:55 -0400 Subject: [PATCH 17/46] Handle edge cases --- .../graphs/features/node_feature_func_map.py | 1 + unravel/soccer/graphs/graph_converter_pl.py | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py index f3a5ec0a..021b5f19 100644 --- a/unravel/soccer/graphs/features/node_feature_func_map.py +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -28,6 +28,7 @@ class NodeFeatureDefaults(TypedDict): value: float max_value: Optional[float] min_value: Optional[float] + max_distance: Optional[float] team: Optional[int] ball_id: Optional[int] s: Optional[Union[float, np.ndarray]] diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index e2e1a883..c48484cf 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -79,7 +79,7 @@ def __post_init__(self): else: self.dataset = self._remove_incomplete_frames() - if self.feature_specs == None: + if self.feature_specs == None or self.feature_specs == {}: self.feature_specs = { "node_features": { "x_normed": {}, @@ -108,6 +108,22 @@ def __post_init__(self): }, } + for key in self.feature_specs.keys(): + if key not in ["node_features", "edge_features"]: + raise ValueError( + f"feature_specs should only contain 'node_features' or 'edge_features' as keys. You provided {key}" + ) + + if 'node_features' not in self.feature_specs: + self.feature_specs['node_features'] = {} + if 'edge_features' not in self.feature_specs: + self.feature_specs['edge_features'] = {} + + if self.feature_specs["node_features"] == {} and self.feature_specs["edge_features"] == {}: + raise ValueError( + "Please provide feature_specs for either 'node_features' or 'edge_features' or both..." + ) + self._validate_feature_specs( self.feature_specs, get_node_feature_func_map, @@ -126,6 +142,8 @@ def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ): # validate features + if feature_tag not in feature_specs: + return feature_map = feature_func(settings=self.settings) for feature in feature_specs[feature_tag]: if feature not in feature_map: From 8115e38847bba9b7055f9865b1a0496188327e4c Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 21:20:16 -0400 Subject: [PATCH 18/46] Add tests for flexible implementation --- tests/test_polar_flex.py | 321 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 tests/test_polar_flex.py diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py new file mode 100644 index 00000000..c8ba9060 --- /dev/null +++ b/tests/test_polar_flex.py @@ -0,0 +1,321 @@ +import pytest +from pathlib import Path +from kloppy import skillcorner, sportec +from kloppy.domain import Ground, TrackingDataset, Orientation +from unravel.soccer import ( + SoccerGraphConverterPolars, + KloppyPolarsDataset, + PressingIntensity, + Constant, + Column, + Group, +) +from spektral.data import Graph + +class TestPolarFlex: + @pytest.fixture + def match_data(self, base_dir: Path) -> str: + return base_dir / "files" / "skillcorner_match_data.json" + + @pytest.fixture + def structured_data(self, base_dir: Path) -> str: + return base_dir / "files" / "skillcorner_structured_data.json.gz" + + @pytest.fixture() + def kloppy_dataset(self, match_data: str, structured_data: str) -> TrackingDataset: + return skillcorner.load( + raw_data=structured_data, + meta_data=match_data, + coordinates="tracab", + include_empty_frames=False, + limit=500, + ) + + @pytest.fixture() + def kloppy_polars_dataset( + self, kloppy_dataset: TrackingDataset + ) -> KloppyPolarsDataset: + dataset = KloppyPolarsDataset( + kloppy_dataset=kloppy_dataset, + ball_carrier_threshold=25.0, + max_player_speed=12.0, + max_player_acceleration=12.0, + max_ball_speed=13.5, + max_ball_acceleration=100, + ) + dataset.add_dummy_labels(by=["game_id", "frame_id"]) + dataset.add_graph_ids(by=["game_id", "frame_id"]) + return dataset + + @pytest.fixture() + def default_converter( + self, kloppy_polars_dataset: KloppyPolarsDataset + ) -> SoccerGraphConverterPolars: + + return SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + ) + + @pytest.fixture() + def default_overriden_converter( + self, kloppy_polars_dataset: KloppyPolarsDataset + ) -> SoccerGraphConverterPolars: + my_feature_specs = { + 'node_features':{ + 'x_normed': {}, + 'y_normed': {}, + 's_normed': {}, + 'v_sin_normed': {}, + 'v_cos_normed': {}, + 'normed_dist_to_goal': {}, + 'normed_dist_to_ball': {}, + 'is_possession_team': {}, + 'is_gk': {}, + 'is_ball': {}, + 'goal_sin_normed': {}, + 'goal_cos_normed': {}, + 'ball_sin_normed': {}, + 'ball_cos_normed': {}, + 'ball_carrier': {} + }, + 'edge_features':{ + 'dist_matrix_normed': {'max_distance': 100.0}, + 'speed_diff_matrix_normed': {}, + 'pos_cos_matrix': {}, + 'pos_sin_matrix': {}, + 'vel_cos_matrix': {}, + 'vel_sin_matrix': {} + } + } + + return SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs=my_feature_specs + ) + + @pytest.fixture() + def valid_feature_converter( + self, kloppy_polars_dataset: KloppyPolarsDataset + ) -> SoccerGraphConverterPolars: + + return SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs={ + 'node_features':{ + 'x_normed': {}, + 'y_normed': {'max_value': 100.0}, + 'v_cos_normed': {}, + 'normed_dist_to_goal': {'max_distance': 50.0} + }, + 'edge_features':{ + 'dist_matrix_normed': {'max_distance': 100.0}, + 'speed_diff_matrix_normed': {} + } + } + ) + + def test_default_features(self, default_converter: SoccerGraphConverterPolars): + spektral_graphs = default_converter.to_spektral_graphs() + + data = spektral_graphs + assert data[0].id == "2417-1529" + assert len(data) == 384 + assert isinstance(data[0], Graph) + + x = data[0].x + n_players = x.shape[0] + assert x.shape == (n_players, 15) + print(">>>", x[0, 0]) + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) + assert 0.8997899683121747 == pytest.approx(x[0, 4], abs=1e-5) + assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) + + e = data[0].e + print(e) + assert e.shape == (129, 6) + assert 0.0 == pytest.approx(e[0, 0], abs=1e-5) + assert 0.5 == pytest.approx(e[0, 4], abs=1e-5) + assert 0.28591171233629764 == pytest.approx(e[8, 2], abs=1e-5) + + a = data[0].a + assert a.shape == (n_players, n_players) + assert 1.0 == pytest.approx(a[0, 0], abs=1e-5) + assert 1.0 == pytest.approx(a[0, 4], abs=1e-5) + assert 0.0 == pytest.approx(a[8, 2], abs=1e-5) + + def test_default_overriden_features(self, default_overriden_converter: SoccerGraphConverterPolars): + spektral_graphs = default_overriden_converter.to_spektral_graphs() + + data = spektral_graphs + assert data[0].id == "2417-1529" + assert len(data) == 384 + assert isinstance(data[0], Graph) + + x = data[0].x + n_players = x.shape[0] + assert x.shape == (n_players, 15) + print(">>>", x[0, 0]) + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) + assert 0.8997899683121747 == pytest.approx(x[0, 4], abs=1e-5) + assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) + + e = data[0].e + assert e.shape == (129, 6) + assert 0.0 == pytest.approx(e[0, 0], abs=1e-5) + assert 0.5 == pytest.approx(e[0, 4], abs=1e-5) + assert 0.28591171233629764 == pytest.approx(e[8, 2], abs=1e-5) + + a = data[0].a + assert a.shape == (n_players, n_players) + assert 1.0 == pytest.approx(a[0, 0], abs=1e-5) + assert 1.0 == pytest.approx(a[0, 4], abs=1e-5) + assert 0.0 == pytest.approx(a[8, 2], abs=1e-5) + + def test_valid_features(self, valid_feature_converter: SoccerGraphConverterPolars): + spektral_graphs = valid_feature_converter.to_spektral_graphs() + data = spektral_graphs + assert data[0].id == "2417-1529" + assert len(data) == 384 + assert isinstance(data[0], Graph) + + x = data[0].x + assert x.shape[1] == 4 + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) + assert 0.2280424804491045 == pytest.approx(x[0, 1], abs=1e-5) + assert 0.8997899683121747 == pytest.approx(x[0, 2], abs=1e-5) + + + def test_empty_feature_specs(self, kloppy_polars_dataset: KloppyPolarsDataset): + with pytest.raises(ValueError): + SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs={ + 'node_features':{}, + 'edge_features':{} + } + ) + + def test_incorrect_feature_tag(self, kloppy_polars_dataset: KloppyPolarsDataset): + with pytest.raises(ValueError): + SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs={ + 'player_features':{} + } + ) + + def test_invalid_features(self, kloppy_polars_dataset: KloppyPolarsDataset): + with pytest.raises(ValueError): + SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs={ + 'node_features':{ + 'x_velocity': {}, + } + } + ) + + def test_invalid_params(self, kloppy_polars_dataset: KloppyPolarsDataset): + with pytest.raises(ValueError): + SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs={ + 'edge_features':{ + 'dist_matrix_normed': {'max_value': 100.0}, + } + } + ) + + def test_invalid_param_type(self, kloppy_polars_dataset: KloppyPolarsDataset): + with pytest.raises(TypeError): + SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + feature_specs={ + 'edge_features':{ + 'dist_matrix_normed': {'max_distance': False}, + } + } + ) + + + From 40f330d9003a19e78c143dd05edea43839f78607 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 21:22:55 -0400 Subject: [PATCH 19/46] Reformatting --- tests/test_polar_flex.py | 144 ++++++++++---------- unravel/soccer/graphs/graph_converter_pl.py | 19 +-- 2 files changed, 80 insertions(+), 83 deletions(-) diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index c8ba9060..2dad2bc6 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -12,11 +12,12 @@ ) from spektral.data import Graph -class TestPolarFlex: + +class TestPolarFlex: @pytest.fixture def match_data(self, base_dir: Path) -> str: return base_dir / "files" / "skillcorner_match_data.json" - + @pytest.fixture def structured_data(self, base_dir: Path) -> str: return base_dir / "files" / "skillcorner_structured_data.json.gz" @@ -30,7 +31,7 @@ def kloppy_dataset(self, match_data: str, structured_data: str) -> TrackingDatas include_empty_frames=False, limit=500, ) - + @pytest.fixture() def kloppy_polars_dataset( self, kloppy_dataset: TrackingDataset @@ -46,7 +47,7 @@ def kloppy_polars_dataset( dataset.add_dummy_labels(by=["game_id", "frame_id"]) dataset.add_graph_ids(by=["game_id", "frame_id"]) return dataset - + @pytest.fixture() def default_converter( self, kloppy_polars_dataset: KloppyPolarsDataset @@ -65,39 +66,39 @@ def default_converter( pad=False, verbose=False, ) - + @pytest.fixture() def default_overriden_converter( self, kloppy_polars_dataset: KloppyPolarsDataset ) -> SoccerGraphConverterPolars: my_feature_specs = { - 'node_features':{ - 'x_normed': {}, - 'y_normed': {}, - 's_normed': {}, - 'v_sin_normed': {}, - 'v_cos_normed': {}, - 'normed_dist_to_goal': {}, - 'normed_dist_to_ball': {}, - 'is_possession_team': {}, - 'is_gk': {}, - 'is_ball': {}, - 'goal_sin_normed': {}, - 'goal_cos_normed': {}, - 'ball_sin_normed': {}, - 'ball_cos_normed': {}, - 'ball_carrier': {} + "node_features": { + "x_normed": {}, + "y_normed": {}, + "s_normed": {}, + "v_sin_normed": {}, + "v_cos_normed": {}, + "normed_dist_to_goal": {}, + "normed_dist_to_ball": {}, + "is_possession_team": {}, + "is_gk": {}, + "is_ball": {}, + "goal_sin_normed": {}, + "goal_cos_normed": {}, + "ball_sin_normed": {}, + "ball_cos_normed": {}, + "ball_carrier": {}, + }, + "edge_features": { + "dist_matrix_normed": {"max_distance": 100.0}, + "speed_diff_matrix_normed": {}, + "pos_cos_matrix": {}, + "pos_sin_matrix": {}, + "vel_cos_matrix": {}, + "vel_sin_matrix": {}, }, - 'edge_features':{ - 'dist_matrix_normed': {'max_distance': 100.0}, - 'speed_diff_matrix_normed': {}, - 'pos_cos_matrix': {}, - 'pos_sin_matrix': {}, - 'vel_cos_matrix': {}, - 'vel_sin_matrix': {} - } } - + return SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, chunk_size=2_0000, @@ -110,14 +111,14 @@ def default_overriden_converter( random_seed=False, pad=False, verbose=False, - feature_specs=my_feature_specs + feature_specs=my_feature_specs, ) - + @pytest.fixture() def valid_feature_converter( self, kloppy_polars_dataset: KloppyPolarsDataset ) -> SoccerGraphConverterPolars: - + return SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, chunk_size=2_0000, @@ -131,22 +132,22 @@ def valid_feature_converter( pad=False, verbose=False, feature_specs={ - 'node_features':{ - 'x_normed': {}, - 'y_normed': {'max_value': 100.0}, - 'v_cos_normed': {}, - 'normed_dist_to_goal': {'max_distance': 50.0} + "node_features": { + "x_normed": {}, + "y_normed": {"max_value": 100.0}, + "v_cos_normed": {}, + "normed_dist_to_goal": {"max_distance": 50.0}, + }, + "edge_features": { + "dist_matrix_normed": {"max_distance": 100.0}, + "speed_diff_matrix_normed": {}, }, - 'edge_features':{ - 'dist_matrix_normed': {'max_distance': 100.0}, - 'speed_diff_matrix_normed': {} - } - } - ) - + }, + ) + def test_default_features(self, default_converter: SoccerGraphConverterPolars): spektral_graphs = default_converter.to_spektral_graphs() - + data = spektral_graphs assert data[0].id == "2417-1529" assert len(data) == 384 @@ -159,7 +160,7 @@ def test_default_features(self, default_converter: SoccerGraphConverterPolars): assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) assert 0.8997899683121747 == pytest.approx(x[0, 4], abs=1e-5) assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) - + e = data[0].e print(e) assert e.shape == (129, 6) @@ -172,10 +173,12 @@ def test_default_features(self, default_converter: SoccerGraphConverterPolars): assert 1.0 == pytest.approx(a[0, 0], abs=1e-5) assert 1.0 == pytest.approx(a[0, 4], abs=1e-5) assert 0.0 == pytest.approx(a[8, 2], abs=1e-5) - - def test_default_overriden_features(self, default_overriden_converter: SoccerGraphConverterPolars): + + def test_default_overriden_features( + self, default_overriden_converter: SoccerGraphConverterPolars + ): spektral_graphs = default_overriden_converter.to_spektral_graphs() - + data = spektral_graphs assert data[0].id == "2417-1529" assert len(data) == 384 @@ -188,7 +191,7 @@ def test_default_overriden_features(self, default_overriden_converter: SoccerGra assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) assert 0.8997899683121747 == pytest.approx(x[0, 4], abs=1e-5) assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) - + e = data[0].e assert e.shape == (129, 6) assert 0.0 == pytest.approx(e[0, 0], abs=1e-5) @@ -214,7 +217,6 @@ def test_valid_features(self, valid_feature_converter: SoccerGraphConverterPolar assert 0.2280424804491045 == pytest.approx(x[0, 1], abs=1e-5) assert 0.8997899683121747 == pytest.approx(x[0, 2], abs=1e-5) - def test_empty_feature_specs(self, kloppy_polars_dataset: KloppyPolarsDataset): with pytest.raises(ValueError): SoccerGraphConverterPolars( @@ -229,12 +231,9 @@ def test_empty_feature_specs(self, kloppy_polars_dataset: KloppyPolarsDataset): random_seed=False, pad=False, verbose=False, - feature_specs={ - 'node_features':{}, - 'edge_features':{} - } + feature_specs={"node_features": {}, "edge_features": {}}, ) - + def test_incorrect_feature_tag(self, kloppy_polars_dataset: KloppyPolarsDataset): with pytest.raises(ValueError): SoccerGraphConverterPolars( @@ -249,11 +248,9 @@ def test_incorrect_feature_tag(self, kloppy_polars_dataset: KloppyPolarsDataset) random_seed=False, pad=False, verbose=False, - feature_specs={ - 'player_features':{} - } + feature_specs={"player_features": {}}, ) - + def test_invalid_features(self, kloppy_polars_dataset: KloppyPolarsDataset): with pytest.raises(ValueError): SoccerGraphConverterPolars( @@ -269,12 +266,12 @@ def test_invalid_features(self, kloppy_polars_dataset: KloppyPolarsDataset): pad=False, verbose=False, feature_specs={ - 'node_features':{ - 'x_velocity': {}, + "node_features": { + "x_velocity": {}, } - } + }, ) - + def test_invalid_params(self, kloppy_polars_dataset: KloppyPolarsDataset): with pytest.raises(ValueError): SoccerGraphConverterPolars( @@ -290,12 +287,12 @@ def test_invalid_params(self, kloppy_polars_dataset: KloppyPolarsDataset): pad=False, verbose=False, feature_specs={ - 'edge_features':{ - 'dist_matrix_normed': {'max_value': 100.0}, + "edge_features": { + "dist_matrix_normed": {"max_value": 100.0}, } - } + }, ) - + def test_invalid_param_type(self, kloppy_polars_dataset: KloppyPolarsDataset): with pytest.raises(TypeError): SoccerGraphConverterPolars( @@ -311,11 +308,8 @@ def test_invalid_param_type(self, kloppy_polars_dataset: KloppyPolarsDataset): pad=False, verbose=False, feature_specs={ - 'edge_features':{ - 'dist_matrix_normed': {'max_distance': False}, + "edge_features": { + "dist_matrix_normed": {"max_distance": False}, } - } + }, ) - - - diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index c48484cf..6ce6cee1 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -113,17 +113,20 @@ def __post_init__(self): raise ValueError( f"feature_specs should only contain 'node_features' or 'edge_features' as keys. You provided {key}" ) - - if 'node_features' not in self.feature_specs: - self.feature_specs['node_features'] = {} - if 'edge_features' not in self.feature_specs: - self.feature_specs['edge_features'] = {} - - if self.feature_specs["node_features"] == {} and self.feature_specs["edge_features"] == {}: + + if "node_features" not in self.feature_specs: + self.feature_specs["node_features"] = {} + if "edge_features" not in self.feature_specs: + self.feature_specs["edge_features"] = {} + + if ( + self.feature_specs["node_features"] == {} + and self.feature_specs["edge_features"] == {} + ): raise ValueError( "Please provide feature_specs for either 'node_features' or 'edge_features' or both..." ) - + self._validate_feature_specs( self.feature_specs, get_node_feature_func_map, From 203a4b97e8d6232d80fe0dc3ac3f4961f3486aab Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 22:42:16 -0400 Subject: [PATCH 20/46] Add function to save configuration --- .../graphs/features/edge_feature_func_map.py | 2 +- .../graphs/features/node_feature_func_map.py | 4 +- unravel/soccer/graphs/graph_converter_pl.py | 47 ++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_feature_func_map.py b/unravel/soccer/graphs/features/edge_feature_func_map.py index 4777ac07..b3fcbee8 100644 --- a/unravel/soccer/graphs/features/edge_feature_func_map.py +++ b/unravel/soccer/graphs/features/edge_feature_func_map.py @@ -136,7 +136,7 @@ def get_edge_feature_func_map( "s": np.nan_to_num(s[None, :] - s[:, None]) if s is not None else None, "team": team if team is not None else None, "ball_id": Constant.BALL, - "settings": settings if settings is not None else None, + "settings": settings if s is not None and team is not None else None, }, }, # """ diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py index 021b5f19..087faccb 100644 --- a/unravel/soccer/graphs/features/node_feature_func_map.py +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -145,7 +145,7 @@ def get_node_feature_func_map( "s": s if s is not None else None, "team": team if team is not None else None, "ball_id": ball_id, - "settings": settings if settings is not None else None, + "settings": settings if s is not None and team is not None else None, }, }, "velocity": { @@ -344,7 +344,7 @@ def get_node_feature_func_map( "is_ball": { "func": lambda value: value, "defaults": ( - {"value": np.where(team == ball_id, 1, 0)} if team is not None else None + {"value": np.where(team == ball_id, 1, 0) if team is not None else None} ), }, "is_gk": {"func": lambda value: value, "defaults": {"value": is_gk}}, diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 6ce6cee1..bc73288e 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -1,5 +1,8 @@ import logging import sys +import os +import json +from dataclasses import asdict from dataclasses import dataclass @@ -139,8 +142,18 @@ def __post_init__(self): EdgeFeatureDefaults, "edge_features", ) + self.populate_feature_specs(get_node_feature_func_map, "node_features") + self.populate_feature_specs(get_edge_feature_func_map, "edge_features") self._shuffle() - + + def populate_feature_specs(self, feature_func, feature_tag): + feature_map = feature_func(settings=self.settings) + for feature, custom_params in self.feature_specs[feature_tag].items(): + params = feature_map[feature]["defaults"].copy() + params.update(custom_params) + params = {k: v for k, v in params.items() if v is not None} + self.feature_specs[feature_tag][feature] = params + def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ): @@ -566,3 +579,35 @@ def to_pickle(self, file_path: str, verbose: bool = False) -> None: with gzip.open(file_path, "wb") as file: pickle.dump(self.graph_frames, file) + + def save(self, file_path: str) -> None: + package_version = self._get_package_version() + print(self.feature_specs) + data_to_save = { + "package_version": package_version, + "feature_specs": self.feature_specs, + # "node_feature_map": get_node_feature_func_map(settings=self.settings), + # "edge_feature_map": get_edge_feature_func_map(settings=self.settings), + } + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w') as f: + json.dump(data_to_save, f, indent=4) + + print(f"Configuration saved to {file_path}") + + def _get_package_version(self): + version_file_path = os.path.join(os.path.dirname(__file__), "../../__init__.py") + + if not os.path.exists(version_file_path): + raise FileNotFoundError(f"__init__.py not found at {version_file_path}") + + with open(version_file_path, 'r') as f: + lines = f.readlines() + + for line in lines: + if line.startswith('__version__'): + # Extract the version value + version = line.split('=')[-1].strip().strip('"') + return version + + raise ValueError("Version not found in __init__.py") From 471c58d1aebb9c675d9f839a6371396fd51d9e0f Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sun, 16 Mar 2025 22:43:07 -0400 Subject: [PATCH 21/46] Reformatted --- unravel/soccer/graphs/graph_converter_pl.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index bc73288e..da0d8102 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -145,7 +145,7 @@ def __post_init__(self): self.populate_feature_specs(get_node_feature_func_map, "node_features") self.populate_feature_specs(get_edge_feature_func_map, "edge_features") self._shuffle() - + def populate_feature_specs(self, feature_func, feature_tag): feature_map = feature_func(settings=self.settings) for feature, custom_params in self.feature_specs[feature_tag].items(): @@ -153,7 +153,7 @@ def populate_feature_specs(self, feature_func, feature_tag): params.update(custom_params) params = {k: v for k, v in params.items() if v is not None} self.feature_specs[feature_tag][feature] = params - + def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ): @@ -579,35 +579,35 @@ def to_pickle(self, file_path: str, verbose: bool = False) -> None: with gzip.open(file_path, "wb") as file: pickle.dump(self.graph_frames, file) - + def save(self, file_path: str) -> None: package_version = self._get_package_version() print(self.feature_specs) data_to_save = { "package_version": package_version, "feature_specs": self.feature_specs, - # "node_feature_map": get_node_feature_func_map(settings=self.settings), + # "node_feature_map": get_node_feature_func_map(settings=self.settings), # "edge_feature_map": get_edge_feature_func_map(settings=self.settings), } os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, 'w') as f: + with open(file_path, "w") as f: json.dump(data_to_save, f, indent=4) print(f"Configuration saved to {file_path}") - + def _get_package_version(self): version_file_path = os.path.join(os.path.dirname(__file__), "../../__init__.py") if not os.path.exists(version_file_path): raise FileNotFoundError(f"__init__.py not found at {version_file_path}") - with open(version_file_path, 'r') as f: + with open(version_file_path, "r") as f: lines = f.readlines() for line in lines: - if line.startswith('__version__'): + if line.startswith("__version__"): # Extract the version value - version = line.split('=')[-1].strip().strip('"') + version = line.split("=")[-1].strip().strip('"') return version - + raise ValueError("Version not found in __init__.py") From 66588624928abd9f25e5ef8427ed126382ad95ed Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 29 Mar 2025 10:37:16 -0400 Subject: [PATCH 22/46] Add graph settings and dataset features to save functionality --- unravel/soccer/dataset/kloppy_polars.py | 15 +++++++++++ unravel/soccer/graphs/graph_converter_pl.py | 3 +++ unravel/soccer/graphs/graph_settings_pl.py | 30 ++++++++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/unravel/soccer/dataset/kloppy_polars.py b/unravel/soccer/dataset/kloppy_polars.py index b976b748..ec5a3ecf 100644 --- a/unravel/soccer/dataset/kloppy_polars.py +++ b/unravel/soccer/dataset/kloppy_polars.py @@ -78,6 +78,21 @@ def __init__( self.load() + def get_features(self) -> Dict[str, float]: + """ + Returns the features of the dataset. + """ + return { + "ball_carrier_threshold": self._ball_carrier_threshold, + "max_player_speed": self._max_player_speed, + "max_ball_speed": self._max_ball_speed, + "max_player_acceleration": self._max_player_acceleration, + "max_ball_acceleration": self._max_ball_acceleration, + "orient_ball_owning": self._orient_ball_owning, + # "pitch_dimensions": self.kloppy_dataset.metadata.pitch_dimensions, + # "orientation": self.kloppy_dataset.metadata.orientation, + } + def __repr__(self) -> str: n_frames = ( self.data[Column.FRAME_ID].n_unique() if hasattr(self, "data") else None diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index da0d8102..f996d316 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -72,6 +72,7 @@ def __post_init__(self): else self.dataset._graph_id_column ) + self.dataset_checkpoint = self.dataset self.dataset = self.dataset.data self._sport_specific_checks() @@ -586,6 +587,8 @@ def save(self, file_path: str) -> None: data_to_save = { "package_version": package_version, "feature_specs": self.feature_specs, + "dataset_features": self.dataset_checkpoint.get_features(), + "graph_settings": self.settings.to_dict() # "node_feature_map": get_node_feature_func_map(settings=self.settings), # "edge_feature_map": get_edge_feature_func_map(settings=self.settings), } diff --git a/unravel/soccer/graphs/graph_settings_pl.py b/unravel/soccer/graphs/graph_settings_pl.py index 10165be4..4b17fbb2 100644 --- a/unravel/soccer/graphs/graph_settings_pl.py +++ b/unravel/soccer/graphs/graph_settings_pl.py @@ -1,6 +1,7 @@ -from dataclasses import dataclass +from dataclasses import dataclass, asdict from ...utils import DefaultGraphSettings +from enum import Enum from dataclasses import dataclass, field from kloppy.domain import MetricPitchDimensions @@ -35,3 +36,30 @@ def _sport_specific_checks(self): self.non_potential_receiver_node_value = 1 elif self.non_potential_receiver_node_value < 0: self.non_potential_receiver_node_value = 0 + + def to_dict(self): + """Custom serialization method that skips Enum fields (like 'unit') and serializes others.""" + + def make_serializable(obj): + if isinstance(obj, Enum): + return None + elif isinstance(obj, (int, float, str, bool, type(None), list, dict)): + return obj + elif isinstance(obj, MetricPitchDimensions): + return { + key: make_serializable(value) + for key, value in obj.__dict__.items() + if not isinstance(value, Enum) + } + elif hasattr(obj, "__dict__"): + return {key: make_serializable(value) for key, value in obj.__dict__.items()} + return None + + return {key: make_serializable(value) for key, value in self.__dict__.items()} + + @classmethod + def from_dict(cls, data): + """Custom deserialization method""" + if "pitch_dimensions" in data: + data["pitch_dimensions"] = MetricPitchDimensions(**data["pitch_dimensions"]) + return cls(**data) From 4b0b5359d944d9085691670c45add05b13336426 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Mon, 31 Mar 2025 11:17:39 -0400 Subject: [PATCH 23/46] Complete save functionality --- unravel/soccer/graphs/graph_converter_pl.py | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index f996d316..7072a981 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -581,16 +581,28 @@ def to_pickle(self, file_path: str, verbose: bool = False) -> None: with gzip.open(file_path, "wb") as file: pickle.dump(self.graph_frames, file) + def to_dict(self): + def _transform_empty_dicts(d): + if isinstance(d, dict): + return {k: _transform_empty_dicts(v) if v != {} else None for k, v in d.items()} + return d + result = {} + for attr, value in self.__dict__.items(): + try: + json.dumps(value) # Check if value is JSON serializable + result[attr] = value + except (TypeError, OverflowError): + pass # Skip non-serializable attributes + return _transform_empty_dicts(result) + def save(self, file_path: str) -> None: package_version = self._get_package_version() - print(self.feature_specs) data_to_save = { "package_version": package_version, - "feature_specs": self.feature_specs, + "graph_converter_attributes": self.to_dict(), + "graph_settings": self.settings.to_dict(), + "graph_feature_cols": self.dataset_checkpoint.data.columns + (self.graph_feature_cols or []), "dataset_features": self.dataset_checkpoint.get_features(), - "graph_settings": self.settings.to_dict() - # "node_feature_map": get_node_feature_func_map(settings=self.settings), - # "edge_feature_map": get_edge_feature_func_map(settings=self.settings), } os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, "w") as f: From a1cdc2aecc4d9c136b2dc2a519e9fdadc2360713 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 1 Apr 2025 10:14:51 -0400 Subject: [PATCH 24/46] Fix dataset feature save --- unravel/soccer/dataset/kloppy_polars.py | 4 ++- unravel/soccer/graphs/graph_settings_pl.py | 27 ----------------- .../utils/objects/default_graph_settings.py | 30 +++++++++++++++++++ unravel/utils/objects/default_settings.py | 28 +++++++++++++++++ 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/unravel/soccer/dataset/kloppy_polars.py b/unravel/soccer/dataset/kloppy_polars.py index ec5a3ecf..8ee9745a 100644 --- a/unravel/soccer/dataset/kloppy_polars.py +++ b/unravel/soccer/dataset/kloppy_polars.py @@ -82,6 +82,7 @@ def get_features(self) -> Dict[str, float]: """ Returns the features of the dataset. """ + return { "ball_carrier_threshold": self._ball_carrier_threshold, "max_player_speed": self._max_player_speed, @@ -89,10 +90,11 @@ def get_features(self) -> Dict[str, float]: "max_player_acceleration": self._max_player_acceleration, "max_ball_acceleration": self._max_ball_acceleration, "orient_ball_owning": self._orient_ball_owning, + "settings": self.settings.to_dict(), # "pitch_dimensions": self.kloppy_dataset.metadata.pitch_dimensions, # "orientation": self.kloppy_dataset.metadata.orientation, } - + def __repr__(self) -> str: n_frames = ( self.data[Column.FRAME_ID].n_unique() if hasattr(self, "data") else None diff --git a/unravel/soccer/graphs/graph_settings_pl.py b/unravel/soccer/graphs/graph_settings_pl.py index 4b17fbb2..493f0a4c 100644 --- a/unravel/soccer/graphs/graph_settings_pl.py +++ b/unravel/soccer/graphs/graph_settings_pl.py @@ -36,30 +36,3 @@ def _sport_specific_checks(self): self.non_potential_receiver_node_value = 1 elif self.non_potential_receiver_node_value < 0: self.non_potential_receiver_node_value = 0 - - def to_dict(self): - """Custom serialization method that skips Enum fields (like 'unit') and serializes others.""" - - def make_serializable(obj): - if isinstance(obj, Enum): - return None - elif isinstance(obj, (int, float, str, bool, type(None), list, dict)): - return obj - elif isinstance(obj, MetricPitchDimensions): - return { - key: make_serializable(value) - for key, value in obj.__dict__.items() - if not isinstance(value, Enum) - } - elif hasattr(obj, "__dict__"): - return {key: make_serializable(value) for key, value in obj.__dict__.items()} - return None - - return {key: make_serializable(value) for key, value in self.__dict__.items()} - - @classmethod - def from_dict(cls, data): - """Custom deserialization method""" - if "pitch_dimensions" in data: - data["pitch_dimensions"] = MetricPitchDimensions(**data["pitch_dimensions"]) - return cls(**data) diff --git a/unravel/utils/objects/default_graph_settings.py b/unravel/utils/objects/default_graph_settings.py index d77b5c1c..80180920 100644 --- a/unravel/utils/objects/default_graph_settings.py +++ b/unravel/utils/objects/default_graph_settings.py @@ -1,6 +1,8 @@ import numpy as np from dataclasses import dataclass, field from typing import Union +from enum import Enum +from kloppy.domain import MetricPitchDimensions from ..features import ( AdjacencyMatrixType, @@ -115,3 +117,31 @@ def __pad_settings(self): def _sport_specific_checks(self): raise NotImplementedError() + + def to_dict(self): + """Custom serialization method that skips Enum fields (like 'unit') and serializes others.""" + + def make_serializable(obj): + if isinstance(obj, Enum): + return Enum.value + elif isinstance(obj, (int, float, str, bool, type(None), list, dict)): + return obj + elif isinstance(obj, MetricPitchDimensions): + return { + key: make_serializable(value) + for key, value in obj.__dict__.items() + if not isinstance(value, Enum) + } + elif hasattr(obj, "__dict__"): + return {key: make_serializable(value) for key, value in obj.__dict__.items()} + return None + + return {key: make_serializable(value) for key, value in self.__dict__.items()} + + @classmethod + def from_dict(cls, data): + """Custom deserialization method""" + if "pitch_dimensions" in data: + data["pitch_dimensions"] = MetricPitchDimensions(**data["pitch_dimensions"]) + return cls(**data) + diff --git a/unravel/utils/objects/default_settings.py b/unravel/utils/objects/default_settings.py index ef160534..dc35e58c 100644 --- a/unravel/utils/objects/default_settings.py +++ b/unravel/utils/objects/default_settings.py @@ -1,6 +1,7 @@ import numpy as np from dataclasses import dataclass, field from typing import Union +from enum import Enum from kloppy.domain import Dimension, Unit, MetricPitchDimensions, Provider, Orientation @@ -42,3 +43,30 @@ class DefaultSettings: max_player_acceleration: float = 6.0 max_ball_acceleration: float = 13.5 ball_carrier_threshold: float = 25.0 + + def to_dict(self): + """Custom serialization method that skips Enum fields (like 'unit') and serializes others.""" + + def make_serializable(obj): + if isinstance(obj, Enum): + return obj.value + elif isinstance(obj, (int, float, str, bool, type(None), list, dict)): + return obj + elif isinstance(obj, MetricPitchDimensions): + return { + key: make_serializable(value) + for key, value in obj.__dict__.items() + if not isinstance(value, Enum) + } + elif hasattr(obj, "__dict__"): + return {key: make_serializable(value) for key, value in obj.__dict__.items()} + return None + + return {key: make_serializable(value) for key, value in self.__dict__.items()} + + @classmethod + def from_dict(cls, data): + """Custom deserialization method""" + if "pitch_dimensions" in data: + data["pitch_dimensions"] = MetricPitchDimensions(**data["pitch_dimensions"]) + return cls(**data) \ No newline at end of file From 07a7978c5112d8fdcd5f043f8b2353d04aa1229a Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 1 Apr 2025 10:15:23 -0400 Subject: [PATCH 25/46] Add version check code for load --- .../soccer/graphs/exceptions/exceptions.py | 49 +++++++++++++++++++ unravel/soccer/graphs/graph_converter_pl.py | 39 +++++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/unravel/soccer/graphs/exceptions/exceptions.py b/unravel/soccer/graphs/exceptions/exceptions.py index 1fcf5d9c..c384e4bc 100644 --- a/unravel/soccer/graphs/exceptions/exceptions.py +++ b/unravel/soccer/graphs/exceptions/exceptions.py @@ -1,3 +1,52 @@ +import warnings +from packaging import version +from unravel import __version__ as installed_version + +class VersionMismatchError(Exception): + """Exception raised for major or minor version mismatches.""" + pass + +class VersionChecker: + """Class to check and warn about version mismatches.""" + + @classmethod + def check_versioning(cls, config_version): + """ + Check if the installed version matches the configuration version. + + Args: + config_version (str): Version string from the configuration + + Raises: + VersionMismatchError: If the major or minor version differs. + Warns: + VersionMismatchWarning: If only the patch version differs. + """ + installed_ver = version.parse(installed_version) + config_ver = version.parse(config_version) + + # Extract major, minor, and patch + installed_major, installed_minor, installed_patch = installed_ver.release + config_major, config_minor, config_patch = config_ver.release + + release_notes_url = "https://github.com/UnravelSports/unravelsports/releases" + + if (installed_major, installed_minor) != (config_major, config_minor): + raise VersionMismatchError( + f"Version mismatch detected: You are using unravelsports v{installed_version}, " + f"but your configuration was created with v{config_version}.\n" + f"This may cause unexpected behavior or incompatibilities.\n" + f"Please check the release notes: {release_notes_url}" + ) + + if installed_patch != config_patch: + warnings.warn( + f"Patch version mismatch detected: Installed v{installed_version}, " + f"but config was created with v{config_version}.\n" + f"While this is usually safe, please check the release notes: {release_notes_url}", + UserWarning + ) + class MissingDatasetError(Exception): pass diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 7072a981..1b0a2d8d 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -25,6 +25,7 @@ NodeFeatureDefaults, EdgeFeatureDefaults, ) +from .exceptions import VersionChecker from ...utils import * @@ -143,11 +144,11 @@ def __post_init__(self): EdgeFeatureDefaults, "edge_features", ) - self.populate_feature_specs(get_node_feature_func_map, "node_features") - self.populate_feature_specs(get_edge_feature_func_map, "edge_features") + self._populate_feature_specs(get_node_feature_func_map, "node_features") + self._populate_feature_specs(get_edge_feature_func_map, "edge_features") self._shuffle() - def populate_feature_specs(self, feature_func, feature_tag): + def _populate_feature_specs(self, feature_func, feature_tag): feature_map = feature_func(settings=self.settings) for feature, custom_params in self.feature_specs[feature_tag].items(): params = feature_map[feature]["defaults"].copy() @@ -155,6 +156,38 @@ def populate_feature_specs(self, feature_func, feature_tag): params = {k: v for k, v in params.items() if v is not None} self.feature_specs[feature_tag][feature] = params + def load_from_json(self, file_path: str) -> None: + """ + Load the configuration from a JSON file. + Args: + file_path (str): Path to the JSON file. + """ + + # Read configuration file + configuration = None + with open(file_path, "r") as f: + configuration = json.load(f) + if configuration is None: + raise ValueError("Configuration file is empty or invalid.") + + # Validate version + config_version = configuration.get("package_version") + if not config_version: + raise ValueError("Configuration file does not specify a version.") + + VersionChecker.check_versioning(config_version) + + #Set converter attributes + for key, value in configuration["graph_converter_attributes"].items(): + if hasattr(self, key): + setattr(self, key, value) + else: + raise ValueError(f"Invalid attribute '{key}' in configuration file.") + + if configuration.get("graph_settings"): + self.settings = configuration.get("graph_settings") + self.__post_init__() + def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ): From 241a7b771c5af9253700a376127bdd25ef219d1e Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 1 Apr 2025 12:28:58 -0400 Subject: [PATCH 26/46] Ball ID Type Bug Fix --- unravel/soccer/graphs/features/edge_feature_func_map.py | 2 +- unravel/soccer/graphs/features/node_feature_func_map.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_feature_func_map.py b/unravel/soccer/graphs/features/edge_feature_func_map.py index b3fcbee8..01681c4c 100644 --- a/unravel/soccer/graphs/features/edge_feature_func_map.py +++ b/unravel/soccer/graphs/features/edge_feature_func_map.py @@ -25,7 +25,7 @@ class EdgeFeatureDefaults(TypedDict): max_distance: float s: Optional[Union[float, np.ndarray]] team: Optional[int] - ball_id: Optional[int] + ball_id: Optional[str] class FeatureFuncMap(TypedDict): diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py index 087faccb..1b79df98 100644 --- a/unravel/soccer/graphs/features/node_feature_func_map.py +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -30,7 +30,7 @@ class NodeFeatureDefaults(TypedDict): min_value: Optional[float] max_distance: Optional[float] team: Optional[int] - ball_id: Optional[int] + ball_id: Optional[str] s: Optional[Union[float, np.ndarray]] goal_mouth_position: Optional[np.ndarray] ball_position: Optional[np.ndarray] From 50fdadc0a2de6db1c67d5e2e198c5a2fa865606f Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 1 Apr 2025 12:29:59 -0400 Subject: [PATCH 27/46] Reformat --- unravel/soccer/dataset/kloppy_polars.py | 2 +- unravel/soccer/graphs/exceptions/exceptions.py | 14 +++++++++----- unravel/utils/objects/default_graph_settings.py | 13 +++++++------ unravel/utils/objects/default_settings.py | 14 ++++++++------ 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/unravel/soccer/dataset/kloppy_polars.py b/unravel/soccer/dataset/kloppy_polars.py index 8ee9745a..6002513f 100644 --- a/unravel/soccer/dataset/kloppy_polars.py +++ b/unravel/soccer/dataset/kloppy_polars.py @@ -82,7 +82,7 @@ def get_features(self) -> Dict[str, float]: """ Returns the features of the dataset. """ - + return { "ball_carrier_threshold": self._ball_carrier_threshold, "max_player_speed": self._max_player_speed, diff --git a/unravel/soccer/graphs/exceptions/exceptions.py b/unravel/soccer/graphs/exceptions/exceptions.py index c384e4bc..fd647987 100644 --- a/unravel/soccer/graphs/exceptions/exceptions.py +++ b/unravel/soccer/graphs/exceptions/exceptions.py @@ -2,10 +2,13 @@ from packaging import version from unravel import __version__ as installed_version + class VersionMismatchError(Exception): """Exception raised for major or minor version mismatches.""" + pass + class VersionChecker: """Class to check and warn about version mismatches.""" @@ -13,10 +16,10 @@ class VersionChecker: def check_versioning(cls, config_version): """ Check if the installed version matches the configuration version. - + Args: config_version (str): Version string from the configuration - + Raises: VersionMismatchError: If the major or minor version differs. Warns: @@ -30,7 +33,7 @@ def check_versioning(cls, config_version): config_major, config_minor, config_patch = config_ver.release release_notes_url = "https://github.com/UnravelSports/unravelsports/releases" - + if (installed_major, installed_minor) != (config_major, config_minor): raise VersionMismatchError( f"Version mismatch detected: You are using unravelsports v{installed_version}, " @@ -38,15 +41,16 @@ def check_versioning(cls, config_version): f"This may cause unexpected behavior or incompatibilities.\n" f"Please check the release notes: {release_notes_url}" ) - + if installed_patch != config_patch: warnings.warn( f"Patch version mismatch detected: Installed v{installed_version}, " f"but config was created with v{config_version}.\n" f"While this is usually safe, please check the release notes: {release_notes_url}", - UserWarning + UserWarning, ) + class MissingDatasetError(Exception): pass diff --git a/unravel/utils/objects/default_graph_settings.py b/unravel/utils/objects/default_graph_settings.py index 80180920..a3527078 100644 --- a/unravel/utils/objects/default_graph_settings.py +++ b/unravel/utils/objects/default_graph_settings.py @@ -117,23 +117,25 @@ def __pad_settings(self): def _sport_specific_checks(self): raise NotImplementedError() - + def to_dict(self): """Custom serialization method that skips Enum fields (like 'unit') and serializes others.""" - + def make_serializable(obj): if isinstance(obj, Enum): return Enum.value elif isinstance(obj, (int, float, str, bool, type(None), list, dict)): return obj - elif isinstance(obj, MetricPitchDimensions): + elif isinstance(obj, MetricPitchDimensions): return { key: make_serializable(value) for key, value in obj.__dict__.items() if not isinstance(value, Enum) } - elif hasattr(obj, "__dict__"): - return {key: make_serializable(value) for key, value in obj.__dict__.items()} + elif hasattr(obj, "__dict__"): + return { + key: make_serializable(value) for key, value in obj.__dict__.items() + } return None return {key: make_serializable(value) for key, value in self.__dict__.items()} @@ -144,4 +146,3 @@ def from_dict(cls, data): if "pitch_dimensions" in data: data["pitch_dimensions"] = MetricPitchDimensions(**data["pitch_dimensions"]) return cls(**data) - diff --git a/unravel/utils/objects/default_settings.py b/unravel/utils/objects/default_settings.py index dc35e58c..affb50c4 100644 --- a/unravel/utils/objects/default_settings.py +++ b/unravel/utils/objects/default_settings.py @@ -43,23 +43,25 @@ class DefaultSettings: max_player_acceleration: float = 6.0 max_ball_acceleration: float = 13.5 ball_carrier_threshold: float = 25.0 - + def to_dict(self): """Custom serialization method that skips Enum fields (like 'unit') and serializes others.""" - + def make_serializable(obj): if isinstance(obj, Enum): return obj.value elif isinstance(obj, (int, float, str, bool, type(None), list, dict)): return obj - elif isinstance(obj, MetricPitchDimensions): + elif isinstance(obj, MetricPitchDimensions): return { key: make_serializable(value) for key, value in obj.__dict__.items() if not isinstance(value, Enum) } - elif hasattr(obj, "__dict__"): - return {key: make_serializable(value) for key, value in obj.__dict__.items()} + elif hasattr(obj, "__dict__"): + return { + key: make_serializable(value) for key, value in obj.__dict__.items() + } return None return {key: make_serializable(value) for key, value in self.__dict__.items()} @@ -69,4 +71,4 @@ def from_dict(cls, data): """Custom deserialization method""" if "pitch_dimensions" in data: data["pitch_dimensions"] = MetricPitchDimensions(**data["pitch_dimensions"]) - return cls(**data) \ No newline at end of file + return cls(**data) From 842b10610da1b13a70250e2e3a2b7eadc383fef9 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Tue, 1 Apr 2025 12:30:22 -0400 Subject: [PATCH 28/46] Complete JSON load function --- unravel/soccer/graphs/graph_converter_pl.py | 58 ++++++++++++++++----- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 1b0a2d8d..0c27c957 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -3,6 +3,7 @@ import os import json from dataclasses import asdict +from inspect import signature from dataclasses import dataclass @@ -162,32 +163,60 @@ def load_from_json(self, file_path: str) -> None: Args: file_path (str): Path to the JSON file. """ - + + def transform_empty_dicts(d): + if isinstance(d, dict): + return { + k: transform_empty_dicts(v) if v is not None else {} + for k, v in d.items() + } + return d + # Read configuration file configuration = None with open(file_path, "r") as f: configuration = json.load(f) if configuration is None: raise ValueError("Configuration file is empty or invalid.") - + # Validate version config_version = configuration.get("package_version") if not config_version: raise ValueError("Configuration file does not specify a version.") VersionChecker.check_versioning(config_version) - - #Set converter attributes + + # Set converter attributes + if "graph_converter_attributes" in configuration: + if "feature_specs" in configuration["graph_converter_attributes"]: + configuration["graph_converter_attributes"]["feature_specs"] = ( + transform_empty_dicts( + configuration["graph_converter_attributes"]["feature_specs"] + ) + ) + + configuration["graph_converter_attributes"].pop("label_column", None) + configuration["graph_converter_attributes"].pop("graph_id_column", None) + for key, value in configuration["graph_converter_attributes"].items(): + if key == "dataset": + print("Dataset is not settable from JSON file.") if hasattr(self, key): setattr(self, key, value) else: raise ValueError(f"Invalid attribute '{key}' in configuration file.") - - if configuration.get("graph_settings"): - self.settings = configuration.get("graph_settings") + + if "graph_settings" in configuration: + graph_settings_dict = configuration["graph_settings"] + valid_keys = signature(DefaultGraphSettings).parameters.keys() + filtered_settings = { + k: v for k, v in graph_settings_dict.items() if k in valid_keys + } + self.settings = DefaultGraphSettings(**filtered_settings) + + self.dataset = self.dataset_checkpoint self.__post_init__() - + def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ): @@ -210,7 +239,7 @@ def _validate_feature_specs( expected_type = feature_defaults.__annotations__.get(key) if not isinstance(value, expected_type): raise TypeError( - f"Feature {feature} key '{key}' should be of type {expected_type}" + f"Feature {feature} key '{key}' should be of type {expected_type}. Instead got {type(value)}" ) def _shuffle(self): @@ -617,8 +646,12 @@ def to_pickle(self, file_path: str, verbose: bool = False) -> None: def to_dict(self): def _transform_empty_dicts(d): if isinstance(d, dict): - return {k: _transform_empty_dicts(v) if v != {} else None for k, v in d.items()} + return { + k: _transform_empty_dicts(v) if v != {} else None + for k, v in d.items() + } return d + result = {} for attr, value in self.__dict__.items(): try: @@ -627,14 +660,15 @@ def _transform_empty_dicts(d): except (TypeError, OverflowError): pass # Skip non-serializable attributes return _transform_empty_dicts(result) - + def save(self, file_path: str) -> None: package_version = self._get_package_version() data_to_save = { "package_version": package_version, "graph_converter_attributes": self.to_dict(), "graph_settings": self.settings.to_dict(), - "graph_feature_cols": self.dataset_checkpoint.data.columns + (self.graph_feature_cols or []), + "graph_feature_cols": self.dataset_checkpoint.data.columns + + (self.graph_feature_cols or []), "dataset_features": self.dataset_checkpoint.get_features(), } os.makedirs(os.path.dirname(file_path), exist_ok=True) From 48a5c33d1df49038629a63f775407a9c013b9c60 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 2 Apr 2025 11:18:33 -0400 Subject: [PATCH 29/46] Add test for save and load functionality --- tests/files/default_feature_specs.json | 174 +++++++++++++++++++++++++ tests/files/new_feature_specs.json | 174 +++++++++++++++++++++++++ tests/test_polar_flex.py | 89 +++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 tests/files/default_feature_specs.json create mode 100644 tests/files/new_feature_specs.json diff --git a/tests/files/default_feature_specs.json b/tests/files/default_feature_specs.json new file mode 100644 index 00000000..ebcb7be3 --- /dev/null +++ b/tests/files/default_feature_specs.json @@ -0,0 +1,174 @@ +{ + "package_version": "0.3.0", + "graph_converter_attributes": { + "prediction": false, + "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, + "label_col": null, + "graph_id_col": null, + "feature_specs": { + "node_features": { + "x_normed": { + "max_value": 52.5, + "min_value": -52.5 + }, + "y_normed": { + "max_value": 34.0, + "min_value": -34.0 + }, + "s_normed": { + "ball_id": "ball" + }, + "v_sin_normed": null, + "v_cos_normed": null, + "normed_dist_to_goal": { + "max_distance": 125.0959631642844 + }, + "normed_dist_to_ball": { + "max_distance": 125.0959631642844 + }, + "is_possession_team": null, + "is_gk": null, + "is_ball": null, + "goal_sin_normed": null, + "goal_cos_normed": null, + "ball_sin_normed": null, + "ball_cos_normed": null, + "ball_carrier": null + }, + "edge_features": { + "dist_matrix_normed": { + "max_distance": 100.0 + }, + "speed_diff_matrix_normed": { + "ball_id": "ball" + }, + "pos_cos_matrix": null, + "pos_sin_matrix": null, + "vel_cos_matrix": null, + "vel_sin_matrix": null + } + }, + "chunk_size": 20000, + "non_potential_receiver_node_value": 0.1, + "graph_feature_cols": null, + "label_column": "label", + "graph_id_column": "graph_id" + }, + "graph_settings": { + "infer_ball_ownership": true, + "max_player_speed": 12.0, + "max_ball_speed": 28.0, + "max_player_acceleration": null, + "max_ball_acceleration": null, + "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, + "ball_id": "ball", + "goalkeeper_id": "GK", + "boundary_correction": null, + "non_potential_receiver_node_value": 0.1, + "ball_carrier_treshold": 25.0, + "_pitch_dimensions": { + "x_dim": { + "min": -52.5, + "max": 52.5 + }, + "y_dim": { + "min": -34.0, + "max": 34.0 + }, + "standardized": false, + "goal_width": 7.32, + "goal_height": 2.44, + "six_yard_width": 18.32, + "six_yard_length": 5.5, + "penalty_area_width": 40.32, + "penalty_area_length": 16.5, + "circle_radius": 9.15, + "corner_radius": 1, + "penalty_spot_distance": 11, + "penalty_arc_radius": 9.15, + "pitch_length": 105, + "pitch_width": 68 + } + }, + "graph_feature_cols": [ + "period_id", + "timestamp", + "frame_id", + "ball_state", + "id", + "x", + "y", + "z", + "team_id", + "position_name", + "game_id", + "vx", + "vy", + "vz", + "v", + "ax", + "ay", + "az", + "a", + "ball_owning_team_id", + "is_ball_carrier", + "label", + "graph_id" + ], + "dataset_features": { + "ball_carrier_threshold": 25.0, + "max_player_speed": 12.0, + "max_ball_speed": 13.5, + "max_player_acceleration": 12.0, + "max_ball_acceleration": 100, + "orient_ball_owning": true, + "settings": { + "home_team_id": 100, + "away_team_id": 103, + "provider": "secondspectrum", + "pitch_dimensions": { + "x_dim": { + "min": -52.5, + "max": 52.5 + }, + "y_dim": { + "min": -34.0, + "max": 34.0 + }, + "standardized": false, + "goal_width": 7.32, + "goal_height": 2.44, + "six_yard_width": 18.32, + "six_yard_length": 5.5, + "penalty_area_width": 40.32, + "penalty_area_length": 16.5, + "circle_radius": 9.15, + "corner_radius": 1, + "penalty_spot_distance": 11, + "penalty_arc_radius": 9.15, + "pitch_length": 105, + "pitch_width": 68 + }, + "orientation": "ball-owning-team", + "max_player_speed": 12.0, + "max_ball_speed": 13.5, + "max_player_acceleration": 12.0, + "max_ball_acceleration": 100, + "ball_carrier_threshold": 25.0 + } + } +} \ No newline at end of file diff --git a/tests/files/new_feature_specs.json b/tests/files/new_feature_specs.json new file mode 100644 index 00000000..ebcb7be3 --- /dev/null +++ b/tests/files/new_feature_specs.json @@ -0,0 +1,174 @@ +{ + "package_version": "0.3.0", + "graph_converter_attributes": { + "prediction": false, + "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, + "label_col": null, + "graph_id_col": null, + "feature_specs": { + "node_features": { + "x_normed": { + "max_value": 52.5, + "min_value": -52.5 + }, + "y_normed": { + "max_value": 34.0, + "min_value": -34.0 + }, + "s_normed": { + "ball_id": "ball" + }, + "v_sin_normed": null, + "v_cos_normed": null, + "normed_dist_to_goal": { + "max_distance": 125.0959631642844 + }, + "normed_dist_to_ball": { + "max_distance": 125.0959631642844 + }, + "is_possession_team": null, + "is_gk": null, + "is_ball": null, + "goal_sin_normed": null, + "goal_cos_normed": null, + "ball_sin_normed": null, + "ball_cos_normed": null, + "ball_carrier": null + }, + "edge_features": { + "dist_matrix_normed": { + "max_distance": 100.0 + }, + "speed_diff_matrix_normed": { + "ball_id": "ball" + }, + "pos_cos_matrix": null, + "pos_sin_matrix": null, + "vel_cos_matrix": null, + "vel_sin_matrix": null + } + }, + "chunk_size": 20000, + "non_potential_receiver_node_value": 0.1, + "graph_feature_cols": null, + "label_column": "label", + "graph_id_column": "graph_id" + }, + "graph_settings": { + "infer_ball_ownership": true, + "max_player_speed": 12.0, + "max_ball_speed": 28.0, + "max_player_acceleration": null, + "max_ball_acceleration": null, + "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, + "ball_id": "ball", + "goalkeeper_id": "GK", + "boundary_correction": null, + "non_potential_receiver_node_value": 0.1, + "ball_carrier_treshold": 25.0, + "_pitch_dimensions": { + "x_dim": { + "min": -52.5, + "max": 52.5 + }, + "y_dim": { + "min": -34.0, + "max": 34.0 + }, + "standardized": false, + "goal_width": 7.32, + "goal_height": 2.44, + "six_yard_width": 18.32, + "six_yard_length": 5.5, + "penalty_area_width": 40.32, + "penalty_area_length": 16.5, + "circle_radius": 9.15, + "corner_radius": 1, + "penalty_spot_distance": 11, + "penalty_arc_radius": 9.15, + "pitch_length": 105, + "pitch_width": 68 + } + }, + "graph_feature_cols": [ + "period_id", + "timestamp", + "frame_id", + "ball_state", + "id", + "x", + "y", + "z", + "team_id", + "position_name", + "game_id", + "vx", + "vy", + "vz", + "v", + "ax", + "ay", + "az", + "a", + "ball_owning_team_id", + "is_ball_carrier", + "label", + "graph_id" + ], + "dataset_features": { + "ball_carrier_threshold": 25.0, + "max_player_speed": 12.0, + "max_ball_speed": 13.5, + "max_player_acceleration": 12.0, + "max_ball_acceleration": 100, + "orient_ball_owning": true, + "settings": { + "home_team_id": 100, + "away_team_id": 103, + "provider": "secondspectrum", + "pitch_dimensions": { + "x_dim": { + "min": -52.5, + "max": 52.5 + }, + "y_dim": { + "min": -34.0, + "max": 34.0 + }, + "standardized": false, + "goal_width": 7.32, + "goal_height": 2.44, + "six_yard_width": 18.32, + "six_yard_length": 5.5, + "penalty_area_width": 40.32, + "penalty_area_length": 16.5, + "circle_radius": 9.15, + "corner_radius": 1, + "penalty_spot_distance": 11, + "penalty_arc_radius": 9.15, + "pitch_length": 105, + "pitch_width": 68 + }, + "orientation": "ball-owning-team", + "max_player_speed": 12.0, + "max_ball_speed": 13.5, + "max_player_acceleration": 12.0, + "max_ball_acceleration": 100, + "ball_carrier_threshold": 25.0 + } + } +} \ No newline at end of file diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index 2dad2bc6..402a4947 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -1,5 +1,6 @@ import pytest from pathlib import Path +import json from kloppy import skillcorner, sportec from kloppy.domain import Ground, TrackingDataset, Orientation from unravel.soccer import ( @@ -22,6 +23,14 @@ def match_data(self, base_dir: Path) -> str: def structured_data(self, base_dir: Path) -> str: return base_dir / "files" / "skillcorner_structured_data.json.gz" + @pytest.fixture + def feature_specs_file(self, base_dir: Path) -> str: + return base_dir / "files" / "default_feature_specs.json" + + @pytest.fixture() + def new_feature_specs_file(self, base_dir: Path) -> str: + return base_dir / "files" / "new_feature_specs.json" + @pytest.fixture() def kloppy_dataset(self, match_data: str, structured_data: str) -> TrackingDataset: return skillcorner.load( @@ -145,6 +154,26 @@ def valid_feature_converter( }, ) + @pytest.fixture() + def default_loaded_converter( + self, kloppy_polars_dataset: KloppyPolarsDataset, feature_specs_file: str + ) -> SoccerGraphConverterPolars: + converter = SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + 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, + ) + converter.load_from_json(feature_specs_file) + return converter + def test_default_features(self, default_converter: SoccerGraphConverterPolars): spektral_graphs = default_converter.to_spektral_graphs() @@ -217,6 +246,49 @@ def test_valid_features(self, valid_feature_converter: SoccerGraphConverterPolar assert 0.2280424804491045 == pytest.approx(x[0, 1], abs=1e-5) assert 0.8997899683121747 == pytest.approx(x[0, 2], abs=1e-5) + def test_default_loaded_features( + self, default_loaded_converter: SoccerGraphConverterPolars + ): + spektral_graphs = default_loaded_converter.to_spektral_graphs() + + data = spektral_graphs + assert data[0].id == "2417-1529" + assert len(data) == 384 + assert isinstance(data[0], Graph) + + x = data[0].x + n_players = x.shape[0] + assert x.shape == (n_players, 15) + print(">>>", x[0, 0]) + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) + assert 0.8997899683121747 == pytest.approx(x[0, 4], abs=1e-5) + assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) + + e = data[0].e + assert e.shape == (129, 6) + assert 0.0 == pytest.approx(e[0, 0], abs=1e-5) + assert 0.5 == pytest.approx(e[0, 4], abs=1e-5) + assert 0.28591171233629764 == pytest.approx(e[8, 2], abs=1e-5) + + a = data[0].a + assert a.shape == (n_players, n_players) + assert 1.0 == pytest.approx(a[0, 0], abs=1e-5) + assert 1.0 == pytest.approx(a[0, 4], abs=1e-5) + assert 0.0 == pytest.approx(a[8, 2], abs=1e-5) + + def test_valid_features(self, valid_feature_converter: SoccerGraphConverterPolars): + spektral_graphs = valid_feature_converter.to_spektral_graphs() + data = spektral_graphs + assert data[0].id == "2417-1529" + assert len(data) == 384 + assert isinstance(data[0], Graph) + + x = data[0].x + assert x.shape[1] == 4 + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) + assert 0.2280424804491045 == pytest.approx(x[0, 1], abs=1e-5) + assert 0.8997899683121747 == pytest.approx(x[0, 2], abs=1e-5) + def test_empty_feature_specs(self, kloppy_polars_dataset: KloppyPolarsDataset): with pytest.raises(ValueError): SoccerGraphConverterPolars( @@ -313,3 +385,20 @@ def test_invalid_param_type(self, kloppy_polars_dataset: KloppyPolarsDataset): } }, ) + + def test_default_load_feature_specs( + self, + default_overriden_converter: SoccerGraphConverterPolars, + default_loaded_converter: SoccerGraphConverterPolars, + feature_specs_file: str, + new_feature_specs_file: str, + ): + default_overriden_converter.save(feature_specs_file) + default_loaded_converter.save(new_feature_specs_file) + + with open(feature_specs_file, "r") as f1, open( + new_feature_specs_file, "r" + ) as f2: + default_overriden_specs = json.load(f1) + new_specs = json.load(f2) + assert default_overriden_specs == new_specs From ca6f587bd84a1454ccf000f5e21e6791ae9142f5 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 2 Apr 2025 20:08:25 -0400 Subject: [PATCH 30/46] Complete save and load tests --- tests/files/default_feature_specs.json | 27 ++------- tests/files/new_feature_specs.json | 27 ++------- tests/test_polar_flex.py | 83 ++++++++++++++++---------- 3 files changed, 61 insertions(+), 76 deletions(-) diff --git a/tests/files/default_feature_specs.json b/tests/files/default_feature_specs.json index ebcb7be3..00bc834f 100644 --- a/tests/files/default_feature_specs.json +++ b/tests/files/default_feature_specs.json @@ -19,28 +19,13 @@ "min_value": -52.5 }, "y_normed": { - "max_value": 34.0, + "max_value": 100.0, "min_value": -34.0 }, - "s_normed": { - "ball_id": "ball" - }, - "v_sin_normed": null, "v_cos_normed": null, "normed_dist_to_goal": { - "max_distance": 125.0959631642844 - }, - "normed_dist_to_ball": { - "max_distance": 125.0959631642844 - }, - "is_possession_team": null, - "is_gk": null, - "is_ball": null, - "goal_sin_normed": null, - "goal_cos_normed": null, - "ball_sin_normed": null, - "ball_cos_normed": null, - "ball_carrier": null + "max_distance": 50.0 + } }, "edge_features": { "dist_matrix_normed": { @@ -48,11 +33,7 @@ }, "speed_diff_matrix_normed": { "ball_id": "ball" - }, - "pos_cos_matrix": null, - "pos_sin_matrix": null, - "vel_cos_matrix": null, - "vel_sin_matrix": null + } } }, "chunk_size": 20000, diff --git a/tests/files/new_feature_specs.json b/tests/files/new_feature_specs.json index ebcb7be3..00bc834f 100644 --- a/tests/files/new_feature_specs.json +++ b/tests/files/new_feature_specs.json @@ -19,28 +19,13 @@ "min_value": -52.5 }, "y_normed": { - "max_value": 34.0, + "max_value": 100.0, "min_value": -34.0 }, - "s_normed": { - "ball_id": "ball" - }, - "v_sin_normed": null, "v_cos_normed": null, "normed_dist_to_goal": { - "max_distance": 125.0959631642844 - }, - "normed_dist_to_ball": { - "max_distance": 125.0959631642844 - }, - "is_possession_team": null, - "is_gk": null, - "is_ball": null, - "goal_sin_normed": null, - "goal_cos_normed": null, - "ball_sin_normed": null, - "ball_cos_normed": null, - "ball_carrier": null + "max_distance": 50.0 + } }, "edge_features": { "dist_matrix_normed": { @@ -48,11 +33,7 @@ }, "speed_diff_matrix_normed": { "ball_id": "ball" - }, - "pos_cos_matrix": null, - "pos_sin_matrix": null, - "vel_cos_matrix": null, - "vel_sin_matrix": null + } } }, "chunk_size": 20000, diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index 402a4947..865e7473 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -3,14 +3,7 @@ import json from kloppy import skillcorner, sportec from kloppy.domain import Ground, TrackingDataset, Orientation -from unravel.soccer import ( - SoccerGraphConverterPolars, - KloppyPolarsDataset, - PressingIntensity, - Constant, - Column, - Group, -) +from unravel.soccer import SoccerGraphConverterPolars, KloppyPolarsDataset from spektral.data import Graph @@ -123,6 +116,18 @@ def default_overriden_converter( feature_specs=my_feature_specs, ) + @pytest.fixture() + def default_loaded_converter( + self, + kloppy_polars_dataset: KloppyPolarsDataset, + feature_specs_file: str, + default_converter: SoccerGraphConverterPolars, + ) -> SoccerGraphConverterPolars: + default_converter.save(feature_specs_file) + converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) + converter.load_from_json(feature_specs_file) + return converter + @pytest.fixture() def valid_feature_converter( self, kloppy_polars_dataset: KloppyPolarsDataset @@ -154,26 +159,6 @@ def valid_feature_converter( }, ) - @pytest.fixture() - def default_loaded_converter( - self, kloppy_polars_dataset: KloppyPolarsDataset, feature_specs_file: str - ) -> SoccerGraphConverterPolars: - converter = SoccerGraphConverterPolars( - dataset=kloppy_polars_dataset, - 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, - ) - converter.load_from_json(feature_specs_file) - return converter - def test_default_features(self, default_converter: SoccerGraphConverterPolars): spektral_graphs = default_converter.to_spektral_graphs() @@ -388,12 +373,12 @@ def test_invalid_param_type(self, kloppy_polars_dataset: KloppyPolarsDataset): def test_default_load_feature_specs( self, - default_overriden_converter: SoccerGraphConverterPolars, + default_converter: SoccerGraphConverterPolars, default_loaded_converter: SoccerGraphConverterPolars, feature_specs_file: str, new_feature_specs_file: str, ): - default_overriden_converter.save(feature_specs_file) + default_converter.save(feature_specs_file) default_loaded_converter.save(new_feature_specs_file) with open(feature_specs_file, "r") as f1, open( @@ -402,3 +387,41 @@ def test_default_load_feature_specs( default_overriden_specs = json.load(f1) new_specs = json.load(f2) assert default_overriden_specs == new_specs + + def test_overriden_load_feature_specs( + self, + kloppy_polars_dataset: KloppyPolarsDataset, + default_overriden_converter: SoccerGraphConverterPolars, + feature_specs_file: str, + new_feature_specs_file: str, + ): + default_overriden_converter.save(feature_specs_file) + converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) + converter.load_from_json(feature_specs_file) + converter.save(new_feature_specs_file) + + with open(feature_specs_file, "r") as f1, open( + new_feature_specs_file, "r" + ) as f2: + default_overriden_specs = json.load(f1) + new_specs = json.load(f2) + assert default_overriden_specs == new_specs + + def test_valid_load_feature_specs( + self, + kloppy_polars_dataset: KloppyPolarsDataset, + valid_feature_converter: SoccerGraphConverterPolars, + feature_specs_file: str, + new_feature_specs_file: str, + ): + valid_feature_converter.save(feature_specs_file) + converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) + converter.load_from_json(feature_specs_file) + converter.save(new_feature_specs_file) + + with open(feature_specs_file, "r") as f1, open( + new_feature_specs_file, "r" + ) as f2: + default_overriden_specs = json.load(f1) + new_specs = json.load(f2) + assert default_overriden_specs == new_specs From f4f508a3259bfeee9c19f8e621997d74e7e087f5 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Wed, 2 Apr 2025 20:30:00 -0400 Subject: [PATCH 31/46] Add function descriptions --- tests/test_polar_flex.py | 34 ++++++++++++++++++- unravel/soccer/dataset/kloppy_polars.py | 4 +-- .../graphs/features/edge_feature_func_map.py | 3 ++ .../graphs/features/node_feature_func_map.py | 3 ++ unravel/soccer/graphs/graph_converter_pl.py | 17 ++++++++-- 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index 865e7473..cbe6eba3 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -54,6 +54,9 @@ def kloppy_polars_dataset( def default_converter( self, kloppy_polars_dataset: KloppyPolarsDataset ) -> SoccerGraphConverterPolars: + """ + SoccerGraphConverter without any feature specs overriden. The default feature specs are used. + """ return SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, @@ -73,6 +76,9 @@ def default_converter( def default_overriden_converter( self, kloppy_polars_dataset: KloppyPolarsDataset ) -> SoccerGraphConverterPolars: + """ + SoccerGraphConverter with feature specs overriden. All the default feature specs are used except that max_distance of dist_matrix_normed is set to 100.0. + """ my_feature_specs = { "node_features": { "x_normed": {}, @@ -123,6 +129,9 @@ def default_loaded_converter( feature_specs_file: str, default_converter: SoccerGraphConverterPolars, ) -> SoccerGraphConverterPolars: + """ + SoccerGraphConverter with feature specs loaded from a json file. The default_converter is saved to a json file and then loaded to create a new converter. + """ default_converter.save(feature_specs_file) converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) converter.load_from_json(feature_specs_file) @@ -132,7 +141,9 @@ def default_loaded_converter( def valid_feature_converter( self, kloppy_polars_dataset: KloppyPolarsDataset ) -> SoccerGraphConverterPolars: - + """ + SoccerGraphConverter with a subset of valid feature specs overriden. + """ return SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, chunk_size=2_0000, @@ -292,6 +303,9 @@ def test_empty_feature_specs(self, kloppy_polars_dataset: KloppyPolarsDataset): ) def test_incorrect_feature_tag(self, kloppy_polars_dataset: KloppyPolarsDataset): + """ + Tests if the converter raises a Value error when the feature_specs contain an incorrect tag. Here player_features is not a valid tag. + """ with pytest.raises(ValueError): SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, @@ -309,6 +323,9 @@ def test_incorrect_feature_tag(self, kloppy_polars_dataset: KloppyPolarsDataset) ) def test_invalid_features(self, kloppy_polars_dataset: KloppyPolarsDataset): + """ + Tests if the converter raises a Value error when the feature_specs contain an invalid feature. Here x_velocity is not a valid feature. + """ with pytest.raises(ValueError): SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, @@ -330,6 +347,9 @@ def test_invalid_features(self, kloppy_polars_dataset: KloppyPolarsDataset): ) def test_invalid_params(self, kloppy_polars_dataset: KloppyPolarsDataset): + """ + Tests if the converter raises a Value error when the feature_specs contain an invalid parameter. Here max_value is an incorrect parameter for dist_matrix_normed. + """ with pytest.raises(ValueError): SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, @@ -351,6 +371,9 @@ def test_invalid_params(self, kloppy_polars_dataset: KloppyPolarsDataset): ) def test_invalid_param_type(self, kloppy_polars_dataset: KloppyPolarsDataset): + """ + Tests if the converter raises a TypeError when the feature_specs contain an invalid parameter type. Here max_distance should be a string instead of a float. + """ with pytest.raises(TypeError): SoccerGraphConverterPolars( dataset=kloppy_polars_dataset, @@ -378,6 +401,9 @@ def test_default_load_feature_specs( feature_specs_file: str, new_feature_specs_file: str, ): + """ + Tests if the default feature specs are saved correctly from a json file. + """ default_converter.save(feature_specs_file) default_loaded_converter.save(new_feature_specs_file) @@ -395,6 +421,9 @@ def test_overriden_load_feature_specs( feature_specs_file: str, new_feature_specs_file: str, ): + """ + Tests if the default overriden converter is saved and loaded correctly. + """ default_overriden_converter.save(feature_specs_file) converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) converter.load_from_json(feature_specs_file) @@ -414,6 +443,9 @@ def test_valid_load_feature_specs( feature_specs_file: str, new_feature_specs_file: str, ): + """ + Tests if the valid feature converter is saved and loaded correctly. + """ valid_feature_converter.save(feature_specs_file) converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) converter.load_from_json(feature_specs_file) diff --git a/unravel/soccer/dataset/kloppy_polars.py b/unravel/soccer/dataset/kloppy_polars.py index 6002513f..93aef79d 100644 --- a/unravel/soccer/dataset/kloppy_polars.py +++ b/unravel/soccer/dataset/kloppy_polars.py @@ -80,7 +80,7 @@ def __init__( def get_features(self) -> Dict[str, float]: """ - Returns the features of the dataset. + Returns the dataset features. """ return { @@ -91,8 +91,6 @@ def get_features(self) -> Dict[str, float]: "max_ball_acceleration": self._max_ball_acceleration, "orient_ball_owning": self._orient_ball_owning, "settings": self.settings.to_dict(), - # "pitch_dimensions": self.kloppy_dataset.metadata.pitch_dimensions, - # "orientation": self.kloppy_dataset.metadata.orientation, } def __repr__(self) -> str: diff --git a/unravel/soccer/graphs/features/edge_feature_func_map.py b/unravel/soccer/graphs/features/edge_feature_func_map.py index 01681c4c..2e66da0f 100644 --- a/unravel/soccer/graphs/features/edge_feature_func_map.py +++ b/unravel/soccer/graphs/features/edge_feature_func_map.py @@ -35,6 +35,9 @@ class FeatureFuncMap(TypedDict): def get_edge_feature_func_map( p3d=None, p2d=None, s=None, velocity=None, team=None, settings=None ): + """ + Returns a dictionary of feature functions for edge features. + """ # Compute pairwise distances using broadcasting max_dist_to_player = ( diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py index 1b79df98..b21ccb70 100644 --- a/unravel/soccer/graphs/features/node_feature_func_map.py +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -53,6 +53,9 @@ def get_node_feature_func_map( graph_features=None, settings=None, ): + """ + Returns a dictionary of feature functions for node features. + """ ball_id = Constant.BALL diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 0c27c957..0eee9ab6 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -2,7 +2,6 @@ import sys import os import json -from dataclasses import asdict from inspect import signature from dataclasses import dataclass @@ -85,6 +84,7 @@ def __post_init__(self): else: self.dataset = self._remove_incomplete_frames() + # Override the feature specs to the default version if they are not provided if self.feature_specs == None or self.feature_specs == {}: self.feature_specs = { "node_features": { @@ -150,6 +150,9 @@ def __post_init__(self): self._shuffle() def _populate_feature_specs(self, feature_func, feature_tag): + """ + Populates the feature specs with custom parameters. + """ feature_map = feature_func(settings=self.settings) for feature, custom_params in self.feature_specs[feature_tag].items(): params = feature_map[feature]["defaults"].copy() @@ -195,6 +198,7 @@ def transform_empty_dicts(d): ) ) + # Do not load label_column and graph_id_column from JSON file configuration["graph_converter_attributes"].pop("label_column", None) configuration["graph_converter_attributes"].pop("graph_id_column", None) @@ -220,7 +224,9 @@ def transform_empty_dicts(d): def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ): - # validate features + """ + Validate feature specs for correct feature names, parameter names and types + """ if feature_tag not in feature_specs: return feature_map = feature_func(settings=self.settings) @@ -645,6 +651,7 @@ def to_pickle(self, file_path: str, verbose: bool = False) -> None: def to_dict(self): def _transform_empty_dicts(d): + # Function to transform empty dicts to None if isinstance(d, dict): return { k: _transform_empty_dicts(v) if v != {} else None @@ -662,6 +669,12 @@ def _transform_empty_dicts(d): return _transform_empty_dicts(result) def save(self, file_path: str) -> None: + """ + Function to save the configuration of the graph converter to a JSON file. + Args: + file_path (str): Path to the JSON file. + """ + package_version = self._get_package_version() data_to_save = { "package_version": package_version, From 52924e1e596b8abbdf6508714df771d0775c9853 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 11:09:38 -0400 Subject: [PATCH 32/46] clean post_init --- unravel/soccer/graphs/graph_converter_pl.py | 61 +++++++++++---------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 0eee9ab6..6835032e 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -34,6 +34,34 @@ stdout_handler = logging.StreamHandler(sys.stdout) logger.addHandler(stdout_handler) +DEFAULT_SOCCER_FEATURE_SPECS = { + "node_features": { + "x_normed": {}, + "y_normed": {}, + "s_normed": {}, + "v_sin_normed": {}, + "v_cos_normed": {}, + "normed_dist_to_goal": {}, + "normed_dist_to_ball": {}, + "is_possession_team": {}, + "is_gk": {}, + "is_ball": {}, + "goal_sin_normed": {}, + "goal_cos_normed": {}, + "ball_sin_normed": {}, + "ball_cos_normed": {}, + "ball_carrier": {}, + }, + "edge_features": { + "dist_matrix_normed": {}, + "speed_diff_matrix_normed": {}, + "pos_cos_matrix": {}, + "pos_sin_matrix": {}, + "vel_cos_matrix": {}, + "vel_sin_matrix": {}, + }, +} + @dataclass(repr=True) class SoccerGraphConverterPolars(DefaultGraphConverter): @@ -84,35 +112,13 @@ def __post_init__(self): else: self.dataset = self._remove_incomplete_frames() + self._validate_feature_specs_general() + self._shuffle() + + def _validate_feature_specs_general(self): # Override the feature specs to the default version if they are not provided if self.feature_specs == None or self.feature_specs == {}: - self.feature_specs = { - "node_features": { - "x_normed": {}, - "y_normed": {}, - "s_normed": {}, - "v_sin_normed": {}, - "v_cos_normed": {}, - "normed_dist_to_goal": {}, - "normed_dist_to_ball": {}, - "is_possession_team": {}, - "is_gk": {}, - "is_ball": {}, - "goal_sin_normed": {}, - "goal_cos_normed": {}, - "ball_sin_normed": {}, - "ball_cos_normed": {}, - "ball_carrier": {}, - }, - "edge_features": { - "dist_matrix_normed": {}, - "speed_diff_matrix_normed": {}, - "pos_cos_matrix": {}, - "pos_sin_matrix": {}, - "vel_cos_matrix": {}, - "vel_sin_matrix": {}, - }, - } + self.feature_specs = DEFAULT_SOCCER_FEATURE_SPECS for key in self.feature_specs.keys(): if key not in ["node_features", "edge_features"]: @@ -147,7 +153,6 @@ def __post_init__(self): ) self._populate_feature_specs(get_node_feature_func_map, "node_features") self._populate_feature_specs(get_edge_feature_func_map, "edge_features") - self._shuffle() def _populate_feature_specs(self, feature_func, feature_tag): """ From dcaeaa5f35e2fa6a2d6f2ecb72e68bcfd554bf81 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 11:39:29 -0400 Subject: [PATCH 33/46] Fix default feature_specs --- unravel/soccer/graphs/graph_converter_pl.py | 102 +++++++++++++------- 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 6835032e..e8197eef 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -34,35 +34,61 @@ stdout_handler = logging.StreamHandler(sys.stdout) logger.addHandler(stdout_handler) +# DEFAULT_SOCCER_FEATURE_SPECS = { +# "node_features": { +# "x_normed": {}, +# "y_normed": {}, +# "s_normed": {}, +# "v_sin_normed": {}, +# "v_cos_normed": {}, +# "normed_dist_to_goal": {}, +# "normed_dist_to_ball": {}, +# "is_possession_team": {}, +# "is_gk": {}, +# "is_ball": {}, +# "goal_sin_normed": {}, +# "goal_cos_normed": {}, +# "ball_sin_normed": {}, +# "ball_cos_normed": {}, +# "ball_carrier": {}, +# }, +# "edge_features": { +# "dist_matrix_normed": {}, +# "speed_diff_matrix_normed": {}, +# "pos_cos_matrix": {}, +# "pos_sin_matrix": {}, +# "vel_cos_matrix": {}, +# "vel_sin_matrix": {}, +# }, +# } DEFAULT_SOCCER_FEATURE_SPECS = { "node_features": { - "x_normed": {}, - "y_normed": {}, - "s_normed": {}, - "v_sin_normed": {}, - "v_cos_normed": {}, - "normed_dist_to_goal": {}, - "normed_dist_to_ball": {}, - "is_possession_team": {}, - "is_gk": {}, - "is_ball": {}, - "goal_sin_normed": {}, - "goal_cos_normed": {}, - "ball_sin_normed": {}, - "ball_cos_normed": {}, - "ball_carrier": {}, + "x_normed": None, + "y_normed": None, + "s_normed": None, + "v_sin_normed": None, + "v_cos_normed": None, + "normed_dist_to_goal": None, + "normed_dist_to_ball": None, + "is_possession_team": None, + "is_gk": None, + "is_ball": None, + "goal_sin_normed": None, + "goal_cos_normed": None, + "ball_sin_normed":None, + "ball_cos_normed":None, + "ball_carrier": None, }, "edge_features": { - "dist_matrix_normed": {}, - "speed_diff_matrix_normed": {}, - "pos_cos_matrix": {}, - "pos_sin_matrix": {}, - "vel_cos_matrix": {}, - "vel_sin_matrix": {}, + "dist_matrix_normed": None, + "speed_diff_matrix_normed": None, + "pos_cos_matrix": None, + "pos_sin_matrix": None, + "vel_cos_matrix": None, + "vel_sin_matrix": None, }, } - @dataclass(repr=True) class SoccerGraphConverterPolars(DefaultGraphConverter): """ @@ -161,7 +187,8 @@ def _populate_feature_specs(self, feature_func, feature_tag): feature_map = feature_func(settings=self.settings) for feature, custom_params in self.feature_specs[feature_tag].items(): params = feature_map[feature]["defaults"].copy() - params.update(custom_params) + if custom_params is not None: + params.update(custom_params) params = {k: v for k, v in params.items() if v is not None} self.feature_specs[feature_tag][feature] = params @@ -240,18 +267,23 @@ def _validate_feature_specs( raise ValueError( f"feature {feature} is not a valid {feature_tag[:4]} feature. Valid features are {list(feature_map.keys())}" ) - for key, value in feature_specs[feature_tag][feature].items(): - if key not in feature_map[feature]["defaults"]: - raise ValueError( - f"{feature_tag[:4]} feature {feature} does not have a key '{key}'. Valid keys are {list(feature_map[feature]['defaults'].keys())}" - ) - - # expected_type = type(node_feature_map[feature]['defaults'][key]) - expected_type = feature_defaults.__annotations__.get(key) - if not isinstance(value, expected_type): - raise TypeError( - f"Feature {feature} key '{key}' should be of type {expected_type}. Instead got {type(value)}" - ) + #check if feature_specs[feature_tag][feature] is a dictionary + if isinstance(feature_specs[feature_tag][feature], dict): + # if feature_specs[feature_tag][feature] is not None: + # if type(feature_specs[feature_tag][feature]) == bool: + print(feature, "-->", feature_specs[feature_tag][feature]) + for key, value in feature_specs[feature_tag][feature].items(): + if key not in feature_map[feature]["defaults"]: + raise ValueError( + f"{feature_tag[:4]} feature {feature} does not have a key '{key}'. Valid keys are {list(feature_map[feature]['defaults'].keys())}" + ) + + # expected_type = type(node_feature_map[feature]['defaults'][key]) + expected_type = feature_defaults.__annotations__.get(key) + if not isinstance(value, expected_type): + raise TypeError( + f"Feature {feature} key '{key}' should be of type {expected_type}. Instead got {type(value)}" + ) def _shuffle(self): if isinstance(self.settings.random_seed, int): From e0779c6fe8391cc4d631aa3cc77a8985f9998dea Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 12:40:41 -0400 Subject: [PATCH 34/46] Modify to take None argument --- unravel/soccer/graphs/features/edge_features_pl.py | 1 + unravel/soccer/graphs/features/node_features_pl.py | 1 + 2 files changed, 2 insertions(+) diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index 562f6588..7cb84f69 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -26,6 +26,7 @@ def compute_edge_features_pl( for feature, custom_params in feature_dict.items(): if feature in feature_func_map: params = feature_func_map[feature]["defaults"].copy() + custom_params = {k: v for k, v in custom_params.items() if v is not None} params.update(custom_params) computed_value = feature_func_map[feature]["func"](**params) computed_features.append(reindex(computed_value, non_zero_idxs, len_a)) diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index 93fcc9c6..489c7b3a 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -42,6 +42,7 @@ def compute_node_features_pl( for feature, custom_params in feature_dict.items(): if feature in feature_func_map: params = feature_func_map[feature]["defaults"].copy() + custom_params = {k: v for k, v in custom_params.items() if v is not None} params.update(custom_params) computed_features.append(feature_func_map[feature]["func"](**params)) else: From b46047809974e66151717137c0f4446a5ad75e15 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 12:41:23 -0400 Subject: [PATCH 35/46] Remove value type check --- unravel/soccer/graphs/features/edge_feature_func_map.py | 4 ++-- unravel/soccer/graphs/features/node_feature_func_map.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/unravel/soccer/graphs/features/edge_feature_func_map.py b/unravel/soccer/graphs/features/edge_feature_func_map.py index 2e66da0f..d80d4ae3 100644 --- a/unravel/soccer/graphs/features/edge_feature_func_map.py +++ b/unravel/soccer/graphs/features/edge_feature_func_map.py @@ -21,8 +21,8 @@ class EdgeFeatureDefaults(TypedDict): - value: float - max_distance: float + # value: Optional[float] + max_distance: Optional[float] s: Optional[Union[float, np.ndarray]] team: Optional[int] ball_id: Optional[str] diff --git a/unravel/soccer/graphs/features/node_feature_func_map.py b/unravel/soccer/graphs/features/node_feature_func_map.py index b21ccb70..5b3badfa 100644 --- a/unravel/soccer/graphs/features/node_feature_func_map.py +++ b/unravel/soccer/graphs/features/node_feature_func_map.py @@ -25,7 +25,7 @@ class NodeFeatureDefaults(TypedDict): # value: Optional[Union[float, np.ndarray]] - value: float + # value: Optional[float] max_value: Optional[float] min_value: Optional[float] max_distance: Optional[float] From 6ef5351b74002ac738d8cd6ba0ea52bc0a0c3537 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 12:42:07 -0400 Subject: [PATCH 36/46] Change feature spec definition to take None --- tests/test_polar_flex.py | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index cbe6eba3..6d6728a2 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -81,29 +81,29 @@ def default_overriden_converter( """ my_feature_specs = { "node_features": { - "x_normed": {}, - "y_normed": {}, - "s_normed": {}, - "v_sin_normed": {}, - "v_cos_normed": {}, - "normed_dist_to_goal": {}, - "normed_dist_to_ball": {}, - "is_possession_team": {}, - "is_gk": {}, - "is_ball": {}, - "goal_sin_normed": {}, - "goal_cos_normed": {}, - "ball_sin_normed": {}, - "ball_cos_normed": {}, - "ball_carrier": {}, + "x_normed": None, + "y_normed": None, + "s_normed": None, + "v_sin_normed": None, + "v_cos_normed": None, + "normed_dist_to_goal": None, + "normed_dist_to_ball": None, + "is_possession_team": None, + "is_gk": None, + "is_ball": None, + "goal_sin_normed": None, + "goal_cos_normed": None, + "ball_sin_normed": None, + "ball_cos_normed": None, + "ball_carrier": None, }, "edge_features": { "dist_matrix_normed": {"max_distance": 100.0}, - "speed_diff_matrix_normed": {}, - "pos_cos_matrix": {}, - "pos_sin_matrix": {}, - "vel_cos_matrix": {}, - "vel_sin_matrix": {}, + "speed_diff_matrix_normed": None, + "pos_cos_matrix": None, + "pos_sin_matrix": None, + "vel_cos_matrix": None, + "vel_sin_matrix": None, }, } @@ -158,14 +158,14 @@ def valid_feature_converter( verbose=False, feature_specs={ "node_features": { - "x_normed": {}, + "x_normed": None, "y_normed": {"max_value": 100.0}, - "v_cos_normed": {}, + "v_cos_normed": None, "normed_dist_to_goal": {"max_distance": 50.0}, }, "edge_features": { "dist_matrix_normed": {"max_distance": 100.0}, - "speed_diff_matrix_normed": {}, + "speed_diff_matrix_normed": None, }, }, ) From 59a400e06adf2c9710d0fcf29ad5b00cfebd2659 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 12:50:20 -0400 Subject: [PATCH 37/46] Bug Fixes --- unravel/soccer/graphs/graph_converter_pl.py | 54 ++------------------- 1 file changed, 3 insertions(+), 51 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index e8197eef..dfdc4e62 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -34,33 +34,6 @@ stdout_handler = logging.StreamHandler(sys.stdout) logger.addHandler(stdout_handler) -# DEFAULT_SOCCER_FEATURE_SPECS = { -# "node_features": { -# "x_normed": {}, -# "y_normed": {}, -# "s_normed": {}, -# "v_sin_normed": {}, -# "v_cos_normed": {}, -# "normed_dist_to_goal": {}, -# "normed_dist_to_ball": {}, -# "is_possession_team": {}, -# "is_gk": {}, -# "is_ball": {}, -# "goal_sin_normed": {}, -# "goal_cos_normed": {}, -# "ball_sin_normed": {}, -# "ball_cos_normed": {}, -# "ball_carrier": {}, -# }, -# "edge_features": { -# "dist_matrix_normed": {}, -# "speed_diff_matrix_normed": {}, -# "pos_cos_matrix": {}, -# "pos_sin_matrix": {}, -# "vel_cos_matrix": {}, -# "vel_sin_matrix": {}, -# }, -# } DEFAULT_SOCCER_FEATURE_SPECS = { "node_features": { "x_normed": None, @@ -141,7 +114,7 @@ def __post_init__(self): self._validate_feature_specs_general() self._shuffle() - def _validate_feature_specs_general(self): + def _validate_feature_specs_general(self): # Override the feature specs to the default version if they are not provided if self.feature_specs == None or self.feature_specs == {}: self.feature_specs = DEFAULT_SOCCER_FEATURE_SPECS @@ -189,7 +162,7 @@ def _populate_feature_specs(self, feature_func, feature_tag): params = feature_map[feature]["defaults"].copy() if custom_params is not None: params.update(custom_params) - params = {k: v for k, v in params.items() if v is not None} + self.feature_specs[feature_tag][feature] = params def load_from_json(self, file_path: str) -> None: @@ -199,14 +172,6 @@ def load_from_json(self, file_path: str) -> None: file_path (str): Path to the JSON file. """ - def transform_empty_dicts(d): - if isinstance(d, dict): - return { - k: transform_empty_dicts(v) if v is not None else {} - for k, v in d.items() - } - return d - # Read configuration file configuration = None with open(file_path, "r") as f: @@ -221,15 +186,6 @@ def transform_empty_dicts(d): VersionChecker.check_versioning(config_version) - # Set converter attributes - if "graph_converter_attributes" in configuration: - if "feature_specs" in configuration["graph_converter_attributes"]: - configuration["graph_converter_attributes"]["feature_specs"] = ( - transform_empty_dicts( - configuration["graph_converter_attributes"]["feature_specs"] - ) - ) - # Do not load label_column and graph_id_column from JSON file configuration["graph_converter_attributes"].pop("label_column", None) configuration["graph_converter_attributes"].pop("graph_id_column", None) @@ -269,18 +225,14 @@ def _validate_feature_specs( ) #check if feature_specs[feature_tag][feature] is a dictionary if isinstance(feature_specs[feature_tag][feature], dict): - # if feature_specs[feature_tag][feature] is not None: - # if type(feature_specs[feature_tag][feature]) == bool: - print(feature, "-->", feature_specs[feature_tag][feature]) for key, value in feature_specs[feature_tag][feature].items(): if key not in feature_map[feature]["defaults"]: raise ValueError( f"{feature_tag[:4]} feature {feature} does not have a key '{key}'. Valid keys are {list(feature_map[feature]['defaults'].keys())}" ) - # expected_type = type(node_feature_map[feature]['defaults'][key]) expected_type = feature_defaults.__annotations__.get(key) - if not isinstance(value, expected_type): + if expected_type and not isinstance(value, expected_type): raise TypeError( f"Feature {feature} key '{key}' should be of type {expected_type}. Instead got {type(value)}" ) From d289228ebb98b4844338ba9facd2064f026d7af7 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 12:50:48 -0400 Subject: [PATCH 38/46] Changed to take null --- tests/files/default_feature_specs.json | 13 +++++++++++-- tests/files/new_feature_specs.json | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/files/default_feature_specs.json b/tests/files/default_feature_specs.json index 00bc834f..7f28b0b7 100644 --- a/tests/files/default_feature_specs.json +++ b/tests/files/default_feature_specs.json @@ -15,24 +15,33 @@ "feature_specs": { "node_features": { "x_normed": { + "value": null, "max_value": 52.5, "min_value": -52.5 }, "y_normed": { + "value": null, "max_value": 100.0, "min_value": -34.0 }, - "v_cos_normed": null, + "v_cos_normed": { + "value": null + }, "normed_dist_to_goal": { + "value": null, "max_distance": 50.0 } }, "edge_features": { "dist_matrix_normed": { + "value": null, "max_distance": 100.0 }, "speed_diff_matrix_normed": { - "ball_id": "ball" + "s": null, + "team": null, + "ball_id": "ball", + "settings": null } } }, diff --git a/tests/files/new_feature_specs.json b/tests/files/new_feature_specs.json index 00bc834f..7f28b0b7 100644 --- a/tests/files/new_feature_specs.json +++ b/tests/files/new_feature_specs.json @@ -15,24 +15,33 @@ "feature_specs": { "node_features": { "x_normed": { + "value": null, "max_value": 52.5, "min_value": -52.5 }, "y_normed": { + "value": null, "max_value": 100.0, "min_value": -34.0 }, - "v_cos_normed": null, + "v_cos_normed": { + "value": null + }, "normed_dist_to_goal": { + "value": null, "max_distance": 50.0 } }, "edge_features": { "dist_matrix_normed": { + "value": null, "max_distance": 100.0 }, "speed_diff_matrix_normed": { - "ball_id": "ball" + "s": null, + "team": null, + "ball_id": "ball", + "settings": null } } }, From a0ff8037e6a17fa77814ec7a7537116ddb2e6383 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Thu, 10 Apr 2025 13:28:58 -0400 Subject: [PATCH 39/46] Validate dataset features and graph columns --- unravel/soccer/graphs/graph_converter_pl.py | 38 +++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index dfdc4e62..6e1bbe92 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -190,9 +190,26 @@ def load_from_json(self, file_path: str) -> None: configuration["graph_converter_attributes"].pop("label_column", None) configuration["graph_converter_attributes"].pop("graph_id_column", None) + #validate data cols + if "dataset_cols" in configuration: + #check if all columns in the dataset specified in the JSON file are in the dataset + for col in self.dataset.columns: + if col not in configuration["dataset_cols"]: + raise ValueError( + f"Column '{col}' is missing in dataset_cols." + ) + + #validate graph converter attributes for key, value in configuration["graph_converter_attributes"].items(): if key == "dataset": print("Dataset is not settable from JSON file.") + if key == "graph_feature_cols" and configuration["graph_converter_attributes"]["graph_feature_cols"] is not None: + #check if graph feature columns exist in the dataset + for col in configuration["graph_converter_attributes"]["graph_feature_cols"]: + if col not in self.dataset.columns: + raise ValueError( + f"Graph feature column '{col}' not found in dataset columns." + ) if hasattr(self, key): setattr(self, key, value) else: @@ -204,10 +221,24 @@ def load_from_json(self, file_path: str) -> None: filtered_settings = { k: v for k, v in graph_settings_dict.items() if k in valid_keys } + print(valid_keys) + print(filtered_settings) self.settings = DefaultGraphSettings(**filtered_settings) self.dataset = self.dataset_checkpoint self.__post_init__() + if "dataset_features" in configuration: + for key, value in configuration["dataset_features"].items(): + dataset_features = self.dataset_checkpoint.get_features() + if key in dataset_features: + if value != dataset_features[key]: + raise ValueError( + f"Feature '{key}' in dataset does not match the value in the configuration file." + ) + else: + raise ValueError( + f"Feature '{key}' not found in dataset features." + ) def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag @@ -669,9 +700,12 @@ def save(self, file_path: str) -> None: "package_version": package_version, "graph_converter_attributes": self.to_dict(), "graph_settings": self.settings.to_dict(), - "graph_feature_cols": self.dataset_checkpoint.data.columns - + (self.graph_feature_cols or []), + # "graph_feature_cols": self.dataset_checkpoint.data.columns + # + (self.graph_feature_cols or []), "dataset_features": self.dataset_checkpoint.get_features(), + "dataset_cols": self.dataset_checkpoint.data.columns, + # "graph_feature_cols": self.graph_feature_cols or [], + } os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, "w") as f: From 41286c674d4050c24221f31b065301b6b4d436dd Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 12:32:52 -0400 Subject: [PATCH 40/46] Remove redundancy --- tests/files/default_feature_specs.json | 53 +++++++++++++------------- tests/files/new_feature_specs.json | 53 +++++++++++++------------- 2 files changed, 54 insertions(+), 52 deletions(-) diff --git a/tests/files/default_feature_specs.json b/tests/files/default_feature_specs.json index 7f28b0b7..54aa5fca 100644 --- a/tests/files/default_feature_specs.json +++ b/tests/files/default_feature_specs.json @@ -48,6 +48,7 @@ "chunk_size": 20000, "non_potential_receiver_node_value": 0.1, "graph_feature_cols": null, + "from_json": null, "label_column": "label", "graph_id_column": "graph_id" }, @@ -94,31 +95,6 @@ "pitch_width": 68 } }, - "graph_feature_cols": [ - "period_id", - "timestamp", - "frame_id", - "ball_state", - "id", - "x", - "y", - "z", - "team_id", - "position_name", - "game_id", - "vx", - "vy", - "vz", - "v", - "ax", - "ay", - "az", - "a", - "ball_owning_team_id", - "is_ball_carrier", - "label", - "graph_id" - ], "dataset_features": { "ball_carrier_threshold": 25.0, "max_player_speed": 12.0, @@ -160,5 +136,30 @@ "max_ball_acceleration": 100, "ball_carrier_threshold": 25.0 } - } + }, + "dataset_cols": [ + "period_id", + "timestamp", + "frame_id", + "ball_state", + "id", + "x", + "y", + "z", + "team_id", + "position_name", + "game_id", + "vx", + "vy", + "vz", + "v", + "ax", + "ay", + "az", + "a", + "ball_owning_team_id", + "is_ball_carrier", + "label", + "graph_id" + ] } \ No newline at end of file diff --git a/tests/files/new_feature_specs.json b/tests/files/new_feature_specs.json index 7f28b0b7..54aa5fca 100644 --- a/tests/files/new_feature_specs.json +++ b/tests/files/new_feature_specs.json @@ -48,6 +48,7 @@ "chunk_size": 20000, "non_potential_receiver_node_value": 0.1, "graph_feature_cols": null, + "from_json": null, "label_column": "label", "graph_id_column": "graph_id" }, @@ -94,31 +95,6 @@ "pitch_width": 68 } }, - "graph_feature_cols": [ - "period_id", - "timestamp", - "frame_id", - "ball_state", - "id", - "x", - "y", - "z", - "team_id", - "position_name", - "game_id", - "vx", - "vy", - "vz", - "v", - "ax", - "ay", - "az", - "a", - "ball_owning_team_id", - "is_ball_carrier", - "label", - "graph_id" - ], "dataset_features": { "ball_carrier_threshold": 25.0, "max_player_speed": 12.0, @@ -160,5 +136,30 @@ "max_ball_acceleration": 100, "ball_carrier_threshold": 25.0 } - } + }, + "dataset_cols": [ + "period_id", + "timestamp", + "frame_id", + "ball_state", + "id", + "x", + "y", + "z", + "team_id", + "position_name", + "game_id", + "vx", + "vy", + "vz", + "v", + "ax", + "ay", + "az", + "a", + "ball_owning_team_id", + "is_ball_carrier", + "label", + "graph_id" + ] } \ No newline at end of file From 5c4d87923b492a5b3ea98a56967de10e6d55e6e0 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 12:33:26 -0400 Subject: [PATCH 41/46] Add from_json --- tests/test_polar_flex.py | 15 +++--- unravel/soccer/graphs/graph_converter_pl.py | 53 +++++++++++---------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index 6d6728a2..2d7fc12b 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -133,8 +133,9 @@ def default_loaded_converter( SoccerGraphConverter with feature specs loaded from a json file. The default_converter is saved to a json file and then loaded to create a new converter. """ default_converter.save(feature_specs_file) - converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) - converter.load_from_json(feature_specs_file) + converter = SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, from_json=feature_specs_file + ) return converter @pytest.fixture() @@ -425,8 +426,9 @@ def test_overriden_load_feature_specs( Tests if the default overriden converter is saved and loaded correctly. """ default_overriden_converter.save(feature_specs_file) - converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) - converter.load_from_json(feature_specs_file) + converter = SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, from_json=feature_specs_file + ) converter.save(new_feature_specs_file) with open(feature_specs_file, "r") as f1, open( @@ -447,8 +449,9 @@ def test_valid_load_feature_specs( Tests if the valid feature converter is saved and loaded correctly. """ valid_feature_converter.save(feature_specs_file) - converter = SoccerGraphConverterPolars(dataset=kloppy_polars_dataset) - converter.load_from_json(feature_specs_file) + converter = SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, from_json=feature_specs_file + ) converter.save(new_feature_specs_file) with open(feature_specs_file, "r") as f1, open( diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 6e1bbe92..a3ad7fb6 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -48,8 +48,8 @@ "is_ball": None, "goal_sin_normed": None, "goal_cos_normed": None, - "ball_sin_normed":None, - "ball_cos_normed":None, + "ball_sin_normed": None, + "ball_cos_normed": None, "ball_carrier": None, }, "edge_features": { @@ -62,6 +62,7 @@ }, } + @dataclass(repr=True) class SoccerGraphConverterPolars(DefaultGraphConverter): """ @@ -83,6 +84,7 @@ class SoccerGraphConverterPolars(DefaultGraphConverter): chunk_size: int = 2_0000 non_potential_receiver_node_value: float = 0.1 graph_feature_cols: Optional[List[str]] = None + from_json: Optional[str] = None def __post_init__(self): if not isinstance(self.dataset, KloppyPolarsDataset): @@ -91,6 +93,10 @@ def __post_init__(self): self.pitch_dimensions: MetricPitchDimensions = ( self.dataset.settings.pitch_dimensions ) + + if self.from_json is not None: + self._load_from_json(self.from_json) + self.label_column: str = ( self.label_col if self.label_col is not None else self.dataset._label_column ) @@ -114,7 +120,7 @@ def __post_init__(self): self._validate_feature_specs_general() self._shuffle() - def _validate_feature_specs_general(self): + def _validate_feature_specs_general(self): # Override the feature specs to the default version if they are not provided if self.feature_specs == None or self.feature_specs == {}: self.feature_specs = DEFAULT_SOCCER_FEATURE_SPECS @@ -165,7 +171,7 @@ def _populate_feature_specs(self, feature_func, feature_tag): self.feature_specs[feature_tag][feature] = params - def load_from_json(self, file_path: str) -> None: + def _load_from_json(self, file_path: str) -> None: """ Load the configuration from a JSON file. Args: @@ -190,22 +196,26 @@ def load_from_json(self, file_path: str) -> None: configuration["graph_converter_attributes"].pop("label_column", None) configuration["graph_converter_attributes"].pop("graph_id_column", None) - #validate data cols + # validate data cols if "dataset_cols" in configuration: - #check if all columns in the dataset specified in the JSON file are in the dataset - for col in self.dataset.columns: + # check if all columns in the dataset specified in the JSON file are in the dataset + for col in self.dataset.data.columns: if col not in configuration["dataset_cols"]: - raise ValueError( - f"Column '{col}' is missing in dataset_cols." - ) - - #validate graph converter attributes + raise ValueError(f"Column '{col}' is missing in dataset_cols.") + + # validate graph converter attributes for key, value in configuration["graph_converter_attributes"].items(): if key == "dataset": print("Dataset is not settable from JSON file.") - if key == "graph_feature_cols" and configuration["graph_converter_attributes"]["graph_feature_cols"] is not None: - #check if graph feature columns exist in the dataset - for col in configuration["graph_converter_attributes"]["graph_feature_cols"]: + if ( + key == "graph_feature_cols" + and configuration["graph_converter_attributes"]["graph_feature_cols"] + is not None + ): + # check if graph feature columns exist in the dataset + for col in configuration["graph_converter_attributes"][ + "graph_feature_cols" + ]: if col not in self.dataset.columns: raise ValueError( f"Graph feature column '{col}' not found in dataset columns." @@ -221,24 +231,18 @@ def load_from_json(self, file_path: str) -> None: filtered_settings = { k: v for k, v in graph_settings_dict.items() if k in valid_keys } - print(valid_keys) - print(filtered_settings) self.settings = DefaultGraphSettings(**filtered_settings) - self.dataset = self.dataset_checkpoint - self.__post_init__() if "dataset_features" in configuration: for key, value in configuration["dataset_features"].items(): - dataset_features = self.dataset_checkpoint.get_features() + dataset_features = self.dataset.get_features() if key in dataset_features: if value != dataset_features[key]: raise ValueError( f"Feature '{key}' in dataset does not match the value in the configuration file." ) else: - raise ValueError( - f"Feature '{key}' not found in dataset features." - ) + raise ValueError(f"Feature '{key}' not found in dataset features.") def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag @@ -254,7 +258,7 @@ def _validate_feature_specs( raise ValueError( f"feature {feature} is not a valid {feature_tag[:4]} feature. Valid features are {list(feature_map.keys())}" ) - #check if feature_specs[feature_tag][feature] is a dictionary + # check if feature_specs[feature_tag][feature] is a dictionary if isinstance(feature_specs[feature_tag][feature], dict): for key, value in feature_specs[feature_tag][feature].items(): if key not in feature_map[feature]["defaults"]: @@ -705,7 +709,6 @@ def save(self, file_path: str) -> None: "dataset_features": self.dataset_checkpoint.get_features(), "dataset_cols": self.dataset_checkpoint.data.columns, # "graph_feature_cols": self.graph_feature_cols or [], - } os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, "w") as f: From 4b309129ec73ec17084bb7d18878cdab595df863 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 12:46:10 -0400 Subject: [PATCH 42/46] Use FileLike --- unravel/soccer/graphs/graph_converter_pl.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index a3ad7fb6..c4928c6f 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -11,6 +11,7 @@ from kloppy.domain import ( MetricPitchDimensions, ) +from kloppy.io import FileLike, open_as_file from spektral.data import Graph @@ -95,7 +96,10 @@ def __post_init__(self): ) if self.from_json is not None: - self._load_from_json(self.from_json) + configuration = None + with open_as_file(self.from_json) as f: + configuration = json.load(f) + self._load_from_json(configuration) self.label_column: str = ( self.label_col if self.label_col is not None else self.dataset._label_column @@ -171,7 +175,7 @@ def _populate_feature_specs(self, feature_func, feature_tag): self.feature_specs[feature_tag][feature] = params - def _load_from_json(self, file_path: str) -> None: + def _load_from_json(self, configuration: dict) -> None: """ Load the configuration from a JSON file. Args: @@ -179,11 +183,9 @@ def _load_from_json(self, file_path: str) -> None: """ # Read configuration file - configuration = None - with open(file_path, "r") as f: - configuration = json.load(f) - if configuration is None: - raise ValueError("Configuration file is empty or invalid.") + + # if configuration is None: + # raise ValueError("Configuration file is empty or invalid.") # Validate version config_version = configuration.get("package_version") From 4366c758f5aec0c03a8e27f1875e8bb481ee9483 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 13:18:42 -0400 Subject: [PATCH 43/46] Allow boolean parameters in feature specs --- tests/test_polar_flex.py | 4 ++-- unravel/soccer/graphs/graph_converter_pl.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index 2d7fc12b..fbd0233e 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -89,13 +89,13 @@ def default_overriden_converter( "normed_dist_to_goal": None, "normed_dist_to_ball": None, "is_possession_team": None, - "is_gk": None, + "is_gk": False, "is_ball": None, "goal_sin_normed": None, "goal_cos_normed": None, "ball_sin_normed": None, "ball_cos_normed": None, - "ball_carrier": None, + "ball_carrier": False, }, "edge_features": { "dist_matrix_normed": {"max_distance": 100.0}, diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index c4928c6f..f87db2e0 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -163,6 +163,8 @@ def _validate_feature_specs_general(self): self._populate_feature_specs(get_node_feature_func_map, "node_features") self._populate_feature_specs(get_edge_feature_func_map, "edge_features") + print(self.feature_specs) + def _populate_feature_specs(self, feature_func, feature_tag): """ Populates the feature specs with custom parameters. @@ -260,7 +262,10 @@ def _validate_feature_specs( raise ValueError( f"feature {feature} is not a valid {feature_tag[:4]} feature. Valid features are {list(feature_map.keys())}" ) - # check if feature_specs[feature_tag][feature] is a dictionary + # if feature_specs[feature_tag][feature] is a boolean, convert it to dictionary + if isinstance(feature_specs[feature_tag][feature], bool): + if feature_specs[feature_tag][feature] == False: + feature_specs[feature_tag][feature] = {"value": None} if isinstance(feature_specs[feature_tag][feature], dict): for key, value in feature_specs[feature_tag][feature].items(): if key not in feature_map[feature]["defaults"]: From a08c1af347faa6cc86e7799da0085d97b18d0380 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 13:43:20 -0400 Subject: [PATCH 44/46] Revert to previous test --- tests/test_polar_flex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_polar_flex.py b/tests/test_polar_flex.py index fbd0233e..2d7fc12b 100644 --- a/tests/test_polar_flex.py +++ b/tests/test_polar_flex.py @@ -89,13 +89,13 @@ def default_overriden_converter( "normed_dist_to_goal": None, "normed_dist_to_ball": None, "is_possession_team": None, - "is_gk": False, + "is_gk": None, "is_ball": None, "goal_sin_normed": None, "goal_cos_normed": None, "ball_sin_normed": None, "ball_cos_normed": None, - "ball_carrier": False, + "ball_carrier": None, }, "edge_features": { "dist_matrix_normed": {"max_distance": 100.0}, From e8b4a35624c8dd192b0d1c9d59de75529f824639 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 13:53:41 -0400 Subject: [PATCH 45/46] Remove print statement --- unravel/soccer/graphs/graph_converter_pl.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index f87db2e0..1901887b 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -163,8 +163,6 @@ def _validate_feature_specs_general(self): self._populate_feature_specs(get_node_feature_func_map, "node_features") self._populate_feature_specs(get_edge_feature_func_map, "edge_features") - print(self.feature_specs) - def _populate_feature_specs(self, feature_func, feature_tag): """ Populates the feature specs with custom parameters. From 521137e42cb5ce2a265d7536b9e4b83eef5a29e6 Mon Sep 17 00:00:00 2001 From: Mihir Thalanki Date: Sat, 12 Apr 2025 13:59:49 -0400 Subject: [PATCH 46/46] Add orientation check --- unravel/soccer/graphs/graph_converter_pl.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index 1901887b..e695b50b 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -235,6 +235,7 @@ def _load_from_json(self, configuration: dict) -> None: } self.settings = DefaultGraphSettings(**filtered_settings) + # validate dataset feature columns if "dataset_features" in configuration: for key, value in configuration["dataset_features"].items(): dataset_features = self.dataset.get_features() @@ -246,6 +247,21 @@ def _load_from_json(self, configuration: dict) -> None: else: raise ValueError(f"Feature '{key}' not found in dataset features.") + # validate orientation + if ( + "orientation" + in configuration["dataset_features"]["settings"]["pitch_dimensions"] + ): + if ( + configuration["dataset_features"]["settings"]["pitch_dimensions"][ + "orientation" + ] + != self.dataset.settings.pitch_dimensions.orientation + ): + raise ValueError( + f"Orientation in dataset does not match the value in the configuration file." + ) + def _validate_feature_specs( self, feature_specs: dict, feature_func, feature_defaults, feature_tag ):