rustat-python-api 0.5.8__tar.gz → 0.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rustat-python-api-0.5.8/rustat_python_api.egg-info → rustat-python-api-0.6.1}/PKG-INFO +1 -1
- rustat-python-api-0.6.1/rustat_python_api/pitch_control.py +719 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/processing.py +17 -10
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1/rustat_python_api.egg-info}/PKG-INFO +1 -1
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/requires.txt +1 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/setup.py +3 -2
- rustat-python-api-0.5.8/rustat_python_api/pitch_control.py +0 -330
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/LICENSE +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/README.md +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/pyproject.toml +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/__init__.py +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/config.py +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/models_api.py +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/parser.py +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/urls.py +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/SOURCES.txt +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/dependency_links.txt +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/top_level.txt +0 -0
- {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/setup.cfg +0 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.stats import multivariate_normal as mvn
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import matplotlib.animation as animation
|
|
6
|
+
import matplotsoccer as mpl
|
|
7
|
+
import torch
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PitchControl:
|
|
12
|
+
def __init__(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame = None):
|
|
13
|
+
self.team_ids = tracking['team_id'].unique()
|
|
14
|
+
sides = tracking.groupby('team_id')['side_1h'].unique()
|
|
15
|
+
side_by_team = dict(zip(self.team_ids, sides[self.team_ids].apply(lambda x: x[0])))
|
|
16
|
+
self.side_by_half = {
|
|
17
|
+
1: side_by_team,
|
|
18
|
+
2:
|
|
19
|
+
{
|
|
20
|
+
team: 'left' if side == 'right' else 'right'
|
|
21
|
+
for team, side in side_by_team.items()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
self._grid_cache: dict[tuple[int, str, torch.dtype], tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
|
|
26
|
+
|
|
27
|
+
self.locs_home, self.locs_away, self.locs_ball, self.t = self.get_locs(
|
|
28
|
+
tracking,
|
|
29
|
+
events,
|
|
30
|
+
ball_data
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def get_locs(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame | None) -> tuple:
|
|
34
|
+
events = events[[
|
|
35
|
+
'possession_number', 'team_id', 'possession_team_id',
|
|
36
|
+
'half', 'second', 'pos_x', 'pos_y'
|
|
37
|
+
]]
|
|
38
|
+
|
|
39
|
+
events = self.swap_coords_batch(events)
|
|
40
|
+
|
|
41
|
+
if ball_data is None:
|
|
42
|
+
ball_data = self.interpolate_ball_data(
|
|
43
|
+
events[['half', 'second', 'pos_x', 'pos_y']],
|
|
44
|
+
tracking
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
locs_home, locs_away = self.build_player_locs(tracking)
|
|
48
|
+
|
|
49
|
+
locs_ball = {
|
|
50
|
+
half: ball_data[ball_data['half'] == half][['pos_x', 'pos_y']].values
|
|
51
|
+
for half in tracking['half'].unique()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
t = {
|
|
55
|
+
half: ball_data[ball_data['half'] == half]['second'].values
|
|
56
|
+
for half in tracking['half'].unique()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return locs_home, locs_away, locs_ball, t
|
|
60
|
+
|
|
61
|
+
# def swap_coords(self, row, how: str = 'x'):
|
|
62
|
+
# half = row['half']
|
|
63
|
+
# team_id = row['team_id']
|
|
64
|
+
# possession_team_id = row['possession_team_id']
|
|
65
|
+
# x = row['pos_x']
|
|
66
|
+
# y = row['pos_y']
|
|
67
|
+
|
|
68
|
+
# if isinstance(possession_team_id, list):
|
|
69
|
+
# current_side = 'left' if team_id in possession_team_id else 'right'
|
|
70
|
+
# real_side = self.side_by_half[half][str(int(team_id))]
|
|
71
|
+
# else:
|
|
72
|
+
# current_side = 'left' if team_id == possession_team_id else 'right'
|
|
73
|
+
# real_side = self.side_by_half[half][str(int(team_id))]
|
|
74
|
+
|
|
75
|
+
# if current_side != real_side:
|
|
76
|
+
# if how == 'x':
|
|
77
|
+
# x = 105 - x
|
|
78
|
+
# else:
|
|
79
|
+
# y = 68 - y
|
|
80
|
+
|
|
81
|
+
# return x if how == 'x' else y
|
|
82
|
+
|
|
83
|
+
def swap_coords_batch(self, events: pd.DataFrame) -> pd.DataFrame:
|
|
84
|
+
"""Vectorised replacement for per-row `swap_coords`.
|
|
85
|
+
|
|
86
|
+
Modifies *events* in-place: flips coordinates for rows where the
|
|
87
|
+
current attacking direction (left/right) does not match the
|
|
88
|
+
canonical side stored in ``self.side_by_half``.
|
|
89
|
+
Returns the same DataFrame for chaining.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
side_by_half = self.side_by_half # local alias for speed
|
|
93
|
+
|
|
94
|
+
def needs_swap(row):
|
|
95
|
+
team_id = row['team_id']
|
|
96
|
+
poss = row['possession_team_id']
|
|
97
|
+
half = row['half']
|
|
98
|
+
|
|
99
|
+
current_left = team_id in poss if isinstance(poss, list) else team_id == poss
|
|
100
|
+
current_side = 'left' if current_left else 'right'
|
|
101
|
+
real_side = side_by_half[half][str(int(team_id))]
|
|
102
|
+
return current_side != real_side
|
|
103
|
+
|
|
104
|
+
mask = events.apply(needs_swap, axis=1)
|
|
105
|
+
|
|
106
|
+
# flip coords in bulk
|
|
107
|
+
events.loc[mask, 'pos_x'] = 105 - events.loc[mask, 'pos_x']
|
|
108
|
+
events.loc[mask, 'pos_y'] = 68 - events.loc[mask, 'pos_y']
|
|
109
|
+
return events
|
|
110
|
+
|
|
111
|
+
def build_player_locs(self, tracking: pd.DataFrame):
|
|
112
|
+
"""Vectorised construction of player location dictionaries.
|
|
113
|
+
|
|
114
|
+
Returns (locs_home, locs_away) where each is
|
|
115
|
+
{half: {player_id: np.ndarray(T,2)}}.
|
|
116
|
+
"""
|
|
117
|
+
locs_home = {1: {}, 2: {}}
|
|
118
|
+
locs_away = {1: {}, 2: {}}
|
|
119
|
+
|
|
120
|
+
# Work per half to keep order and avoid extra boolean checks.
|
|
121
|
+
for half in (1, 2):
|
|
122
|
+
half_df = tracking[tracking['half'] == half]
|
|
123
|
+
for side, locs_out in [('left', locs_home), ('right', locs_away)]:
|
|
124
|
+
side_df = half_df[half_df['side_1h'] == side]
|
|
125
|
+
for pid, grp in side_df.groupby('player_id'):
|
|
126
|
+
locs_out[half][pid] = grp[['pos_x', 'pos_y']].values
|
|
127
|
+
return locs_home, locs_away
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def interpolate_ball_data(
|
|
131
|
+
ball_data: pd.DataFrame,
|
|
132
|
+
player_data: pd.DataFrame
|
|
133
|
+
) -> pd.DataFrame:
|
|
134
|
+
ball_data = ball_data.drop_duplicates(subset=['second', 'half'])
|
|
135
|
+
|
|
136
|
+
interpolated_data = []
|
|
137
|
+
for half in ball_data['half'].unique():
|
|
138
|
+
ball_half = ball_data[ball_data['half'] == half]
|
|
139
|
+
player_half = player_data[player_data['half'] == half]
|
|
140
|
+
|
|
141
|
+
player_times = player_half['second'].unique()
|
|
142
|
+
|
|
143
|
+
ball_half = ball_half.sort_values(by='second')
|
|
144
|
+
interpolated_half = pd.DataFrame({'second': player_times})
|
|
145
|
+
interpolated_half['pos_x'] = np.interp(
|
|
146
|
+
interpolated_half['second'], ball_half['second'], ball_half['pos_x']
|
|
147
|
+
)
|
|
148
|
+
interpolated_half['pos_y'] = np.interp(
|
|
149
|
+
interpolated_half['second'], ball_half['second'], ball_half['pos_y']
|
|
150
|
+
)
|
|
151
|
+
interpolated_half['half'] = half
|
|
152
|
+
interpolated_data.append(interpolated_half)
|
|
153
|
+
|
|
154
|
+
interpolated_ball_data = pd.concat(interpolated_data, ignore_index=True)
|
|
155
|
+
return interpolated_ball_data
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def get_player_data(player_id, half, tracking):
|
|
159
|
+
timestamps = tracking[tracking['half'] == half]['second'].unique()
|
|
160
|
+
player_data = tracking[
|
|
161
|
+
(tracking['player_id'] == player_id)
|
|
162
|
+
& (tracking['half'] == half)
|
|
163
|
+
][['second', 'pos_x', 'pos_y']]
|
|
164
|
+
|
|
165
|
+
player_data_full = pd.DataFrame({'second': timestamps})
|
|
166
|
+
player_data_full = player_data_full.merge(player_data, on='second', how='left')
|
|
167
|
+
|
|
168
|
+
return player_data_full[['pos_x', 'pos_y']].values
|
|
169
|
+
|
|
170
|
+
def influence_np(
|
|
171
|
+
self,
|
|
172
|
+
player_index: str,
|
|
173
|
+
location: np.ndarray,
|
|
174
|
+
time_index: int,
|
|
175
|
+
home_or_away: str,
|
|
176
|
+
half: int,
|
|
177
|
+
verbose: bool = False
|
|
178
|
+
):
|
|
179
|
+
if home_or_away == 'h':
|
|
180
|
+
data = self.locs_home[half].copy()
|
|
181
|
+
elif home_or_away == 'a':
|
|
182
|
+
data = self.locs_away[half].copy()
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError("Enter either 'h' or 'a'.")
|
|
185
|
+
|
|
186
|
+
locs_ball = self.locs_ball[half].copy()
|
|
187
|
+
t = self.t[half].copy()
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
np.all(np.isfinite(data[player_index][[time_index, time_index + 1], :]))
|
|
191
|
+
& np.all(np.isfinite(locs_ball[time_index, :]))
|
|
192
|
+
):
|
|
193
|
+
jitter = 1e-10 ## to prevent identically zero covariance matrices when velocity is zero
|
|
194
|
+
## compute velocity by fwd difference
|
|
195
|
+
s = (
|
|
196
|
+
np.linalg.norm(
|
|
197
|
+
data[player_index][time_index + 1,:]
|
|
198
|
+
- data[player_index][time_index,:] + jitter
|
|
199
|
+
)
|
|
200
|
+
/ (t[time_index + 1] - t[time_index])
|
|
201
|
+
)
|
|
202
|
+
## velocities in x,y directions
|
|
203
|
+
sxy = (
|
|
204
|
+
(data[player_index][time_index + 1, :] - data[player_index][time_index, :] + jitter)
|
|
205
|
+
/ (t[time_index + 1] - t[time_index])
|
|
206
|
+
)
|
|
207
|
+
## angle between velocity vector & x-axis
|
|
208
|
+
theta = np.arccos(sxy[0] / np.linalg.norm(sxy))
|
|
209
|
+
## rotation matrix
|
|
210
|
+
R = np.array([[np.cos(theta), -np.sin(theta)],[np.sin(theta), np.cos(theta)]])
|
|
211
|
+
mu = data[player_index][time_index, :] + sxy * 0.5
|
|
212
|
+
Srat = (s / 13) ** 2
|
|
213
|
+
Ri = np.linalg.norm(locs_ball[time_index, :] - data[player_index][time_index, :])
|
|
214
|
+
## don't think this function is specified in the paper but looks close enough to fig 9
|
|
215
|
+
Ri = np.minimum(4 + Ri ** 3/ (18 ** 3 / 6), 10)
|
|
216
|
+
S = np.array([[(1 + Srat) * Ri / 2, 0], [0, (1 - Srat) * Ri / 2]])
|
|
217
|
+
Sigma = np.matmul(R, S)
|
|
218
|
+
Sigma = np.matmul(Sigma, S)
|
|
219
|
+
Sigma = np.matmul(Sigma, np.linalg.inv(R)) ## this is not efficient, forgive me.
|
|
220
|
+
out = mvn.pdf(location, mu, Sigma) / mvn.pdf(data[player_index][time_index, :], mu, Sigma)
|
|
221
|
+
else:
|
|
222
|
+
if verbose:
|
|
223
|
+
print("Data is not finite.")
|
|
224
|
+
out = np.zeros(location.shape[0])
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
def _batch_influence_pt(
|
|
228
|
+
self,
|
|
229
|
+
player_dict: dict,
|
|
230
|
+
locs: torch.Tensor,
|
|
231
|
+
time_index: int,
|
|
232
|
+
half: int,
|
|
233
|
+
device: str,
|
|
234
|
+
dtype: torch.dtype,
|
|
235
|
+
) -> torch.Tensor:
|
|
236
|
+
"""Compute cumulative influence of *many* players at once.
|
|
237
|
+
|
|
238
|
+
Parameters
|
|
239
|
+
----------
|
|
240
|
+
player_dict : dict[player_id -> np.ndarray(shape=(T,2))]
|
|
241
|
+
Pre-loaded trajectory arrays for one team.
|
|
242
|
+
locs : torch.Tensor shape (N,2)
|
|
243
|
+
Grid locations (already on correct device / dtype).
|
|
244
|
+
time_index : int
|
|
245
|
+
Frame index t.
|
|
246
|
+
half : int
|
|
247
|
+
Half number.
|
|
248
|
+
device, dtype : torch configuration.
|
|
249
|
+
|
|
250
|
+
Returns
|
|
251
|
+
-------
|
|
252
|
+
torch.Tensor shape (N,)
|
|
253
|
+
Sum of influences from all valid players in *player_dict*.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
pos_t_list, pos_tp1_list = [], []
|
|
257
|
+
for arr in player_dict.values():
|
|
258
|
+
# Ensure we have t and t+1 and no NaNs at those rows
|
|
259
|
+
if (
|
|
260
|
+
time_index + 1 < arr.shape[0]
|
|
261
|
+
and np.isfinite(arr[[time_index, time_index + 1], :]).all()
|
|
262
|
+
):
|
|
263
|
+
pos_t_list.append(arr[time_index])
|
|
264
|
+
pos_tp1_list.append(arr[time_index + 1])
|
|
265
|
+
|
|
266
|
+
if not pos_t_list:
|
|
267
|
+
return torch.zeros(locs.shape[0], device=device, dtype=dtype)
|
|
268
|
+
|
|
269
|
+
pos_t = torch.tensor(np.asarray(pos_t_list), device=device, dtype=dtype) # (P,2)
|
|
270
|
+
pos_tp1 = torch.tensor(np.asarray(pos_tp1_list), device=device, dtype=dtype) # (P,2)
|
|
271
|
+
|
|
272
|
+
# Velocity, speed, rotation -------------------------
|
|
273
|
+
dt_sec = float(self.t[half][time_index + 1] - self.t[half][time_index])
|
|
274
|
+
sxy = (pos_tp1 - pos_t) / dt_sec # (P,2)
|
|
275
|
+
|
|
276
|
+
speed = torch.linalg.norm(sxy, dim=1) # (P,)
|
|
277
|
+
norm_sxy = speed.clamp(min=1e-6)
|
|
278
|
+
|
|
279
|
+
theta = torch.acos(torch.clamp(sxy[:, 0] / norm_sxy, -1 + 1e-6, 1 - 1e-6)) # (P,)
|
|
280
|
+
cos_t, sin_t = torch.cos(theta), torch.sin(theta)
|
|
281
|
+
|
|
282
|
+
R = torch.stack(
|
|
283
|
+
[
|
|
284
|
+
torch.stack([cos_t, -sin_t], dim=1),
|
|
285
|
+
torch.stack([sin_t, cos_t], dim=1),
|
|
286
|
+
],
|
|
287
|
+
dim=1,
|
|
288
|
+
) # (P,2,2)
|
|
289
|
+
|
|
290
|
+
# Shape parameters ----------------------------------
|
|
291
|
+
Srat = (speed / 13) ** 2 # (P,)
|
|
292
|
+
|
|
293
|
+
ball_pos = torch.tensor(
|
|
294
|
+
self.locs_ball[half][time_index], device=device, dtype=dtype
|
|
295
|
+
) # (2,)
|
|
296
|
+
Ri = torch.linalg.norm(ball_pos - pos_t, dim=1) # (P,)
|
|
297
|
+
Ri = torch.minimum(4 + Ri ** 3 / (18 ** 3 / 6), torch.tensor(10.0, device=device, dtype=dtype))
|
|
298
|
+
|
|
299
|
+
S11 = (1 + Srat) * Ri / 2
|
|
300
|
+
S22 = (1 - Srat) * Ri / 2
|
|
301
|
+
|
|
302
|
+
S = torch.zeros((pos_t.shape[0], 2, 2), device=device, dtype=dtype)
|
|
303
|
+
S[:, 0, 0] = S11
|
|
304
|
+
S[:, 1, 1] = S22
|
|
305
|
+
|
|
306
|
+
Sigma = R @ S @ S @ R.transpose(1, 2) # (P,2,2)
|
|
307
|
+
|
|
308
|
+
eye = torch.eye(2, device=device, dtype=dtype) * 1e-6
|
|
309
|
+
eye = eye.expand(pos_t.shape[0], 2, 2) # broadcast to (P,2,2)
|
|
310
|
+
|
|
311
|
+
if dtype == torch.float16:
|
|
312
|
+
Sigma_inv = torch.linalg.inv((Sigma + eye).float()).to(dtype)
|
|
313
|
+
else:
|
|
314
|
+
Sigma_inv = torch.linalg.inv(Sigma + eye)
|
|
315
|
+
|
|
316
|
+
# Mean ----------------------------------------------
|
|
317
|
+
mu = pos_t + 0.5 * sxy # (P,2)
|
|
318
|
+
|
|
319
|
+
# Grid diff & Mahalanobis ----------------------------
|
|
320
|
+
diff = locs.view(1, -1, 2) # (1,N,2)
|
|
321
|
+
diff = diff - mu.unsqueeze(1) # (P,N,2)
|
|
322
|
+
|
|
323
|
+
maha = torch.einsum('pni,pij,pnj->pn', diff, Sigma_inv, diff) # (P,N)
|
|
324
|
+
|
|
325
|
+
# Replace NaNs that arise from invalid player positions with large value
|
|
326
|
+
# so their influence tends to zero after exponent, then eliminate residual NaNs.
|
|
327
|
+
maha = torch.nan_to_num(maha, nan=1e9, posinf=1e9, neginf=1e9)
|
|
328
|
+
|
|
329
|
+
out = torch.exp(-0.5 * maha) # (P,N)
|
|
330
|
+
|
|
331
|
+
return out.sum(dim=0) # sum over players
|
|
332
|
+
|
|
333
|
+
def _batch_team_influence_frames_pt(
|
|
334
|
+
self,
|
|
335
|
+
pos_t: torch.Tensor, # (F,P,2)
|
|
336
|
+
pos_tp1: torch.Tensor, # (F,P,2)
|
|
337
|
+
ball_pos: torch.Tensor, # (F,2)
|
|
338
|
+
dt_secs: torch.Tensor, # (F,)
|
|
339
|
+
locs: torch.Tensor, # (N,2)
|
|
340
|
+
dtype: torch.dtype,
|
|
341
|
+
) -> torch.Tensor: # returns (F,N)
|
|
342
|
+
"""Vectorised influence for many frames & players of ОДНОЙ команды."""
|
|
343
|
+
|
|
344
|
+
device = locs.device
|
|
345
|
+
|
|
346
|
+
sxy = (pos_tp1 - pos_t) / dt_secs[:, None, None] # (F,P,2)
|
|
347
|
+
speed = torch.linalg.norm(sxy, dim=-1) # (F,P)
|
|
348
|
+
norm_sxy = speed.clamp(min=1e-6)
|
|
349
|
+
|
|
350
|
+
theta = torch.acos(torch.clamp(sxy[..., 0] / norm_sxy, -1 + 1e-6, 1 - 1e-6))
|
|
351
|
+
cos_t, sin_t = torch.cos(theta), torch.sin(theta) # (F,P)
|
|
352
|
+
|
|
353
|
+
R = torch.stack(
|
|
354
|
+
[
|
|
355
|
+
torch.stack([cos_t, -sin_t], dim=-1),
|
|
356
|
+
torch.stack([sin_t, cos_t], dim=-1),
|
|
357
|
+
],
|
|
358
|
+
dim=-2,
|
|
359
|
+
) # (F,P,2,2)
|
|
360
|
+
|
|
361
|
+
Srat = (speed / 13) ** 2 # (F,P)
|
|
362
|
+
|
|
363
|
+
Ri = torch.linalg.norm(ball_pos[:, None, :] - pos_t, dim=-1) # (F,P)
|
|
364
|
+
Ri = torch.minimum(4 + Ri ** 3 / (18 ** 3 / 6), torch.tensor(10.0, device=device, dtype=dtype))
|
|
365
|
+
|
|
366
|
+
S11 = (1 + Srat) * Ri / 2
|
|
367
|
+
S22 = (1 - Srat) * Ri / 2
|
|
368
|
+
|
|
369
|
+
S = torch.zeros((*pos_t.shape[:-1], 2, 2), device=device, dtype=dtype) # (F,P,2,2)
|
|
370
|
+
S[..., 0, 0] = S11
|
|
371
|
+
S[..., 1, 1] = S22
|
|
372
|
+
|
|
373
|
+
Sigma = R @ S @ S @ R.transpose(-1, -2) # (F,P,2,2)
|
|
374
|
+
|
|
375
|
+
eye = torch.eye(2, device=device, dtype=dtype) * 1e-6
|
|
376
|
+
eye = eye.view(1, 1, 2, 2)
|
|
377
|
+
|
|
378
|
+
if dtype == torch.float16:
|
|
379
|
+
Sigma_inv = torch.linalg.inv((Sigma + eye).float()).to(dtype)
|
|
380
|
+
else:
|
|
381
|
+
Sigma_inv = torch.linalg.inv(Sigma + eye)
|
|
382
|
+
|
|
383
|
+
mu = pos_t + 0.5 * sxy # (F,P,2)
|
|
384
|
+
|
|
385
|
+
diff = locs.view(1, 1, -1, 2) # (1,1,N,2)
|
|
386
|
+
diff = diff - mu.unsqueeze(2) # (F,P,N,2)
|
|
387
|
+
|
|
388
|
+
maha = torch.einsum('fpni,fpij,fpnj->fpn', diff, Sigma_inv, diff) # (F,P,N)
|
|
389
|
+
|
|
390
|
+
# Replace NaNs that arise from invalid player positions with large value
|
|
391
|
+
# so their influence tends to zero after exponent, then eliminate residual NaNs.
|
|
392
|
+
maha = torch.nan_to_num(maha, nan=1e9, posinf=1e9, neginf=1e9)
|
|
393
|
+
|
|
394
|
+
out = torch.exp(-0.5 * maha) # (F,P,N)
|
|
395
|
+
|
|
396
|
+
return out.sum(dim=1) # sum over players
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _stack_team_frames(players: list[np.ndarray], frames: np.ndarray, device: str, dtype: torch.dtype):
|
|
400
|
+
"""Stack positions for given frames into torch tensors (pos_t, pos_tp1)."""
|
|
401
|
+
# Ensure every player's trajectory is long enough; if not, pad by repeating
|
|
402
|
+
# the last available coordinate so that indexing `frames` and `frames+1` is safe.
|
|
403
|
+
max_needed = frames[-1] + 1 # we access idx and idx+1
|
|
404
|
+
|
|
405
|
+
padded = []
|
|
406
|
+
for p in players:
|
|
407
|
+
if len(p) <= max_needed:
|
|
408
|
+
pad_len = max_needed + 1 - len(p)
|
|
409
|
+
if pad_len > 0:
|
|
410
|
+
last = p[-1][None, :]
|
|
411
|
+
p = np.vstack([p, np.repeat(last, pad_len, axis=0)])
|
|
412
|
+
padded.append(p)
|
|
413
|
+
|
|
414
|
+
pos_t = torch.tensor(
|
|
415
|
+
np.stack([p[frames] for p in padded], axis=1), device=device, dtype=dtype
|
|
416
|
+
) # (F,P,2)
|
|
417
|
+
pos_tp1 = torch.tensor(
|
|
418
|
+
np.stack([p[frames + 1] for p in padded], axis=1), device=device, dtype=dtype
|
|
419
|
+
)
|
|
420
|
+
return pos_t, pos_tp1
|
|
421
|
+
|
|
422
|
+
def _fit_full_pt(
|
|
423
|
+
self,
|
|
424
|
+
half: int,
|
|
425
|
+
dt: int,
|
|
426
|
+
device: str,
|
|
427
|
+
batch_size: int,
|
|
428
|
+
use_fp16: bool,
|
|
429
|
+
verbose: bool,
|
|
430
|
+
):
|
|
431
|
+
"""Internal helper with fully batched PyTorch implementation."""
|
|
432
|
+
|
|
433
|
+
dtype = torch.float16 if use_fp16 else torch.float32
|
|
434
|
+
xx_t, yy_t, locs_t = self._get_grid(dt, device, dtype)
|
|
435
|
+
xx, yy = xx_t.cpu().numpy(), yy_t.cpu().numpy()
|
|
436
|
+
|
|
437
|
+
T = len(self.t[half]) - 1
|
|
438
|
+
pc_all = np.empty((T, dt, dt), dtype=np.float32)
|
|
439
|
+
|
|
440
|
+
home_players = list(self.locs_home[half].values())
|
|
441
|
+
away_players = list(self.locs_away[half].values())
|
|
442
|
+
|
|
443
|
+
for start in tqdm(range(0, T, batch_size)):
|
|
444
|
+
end = min(start + batch_size, T)
|
|
445
|
+
frames = np.arange(start, end)
|
|
446
|
+
|
|
447
|
+
# deltas t
|
|
448
|
+
dt_secs = torch.tensor(
|
|
449
|
+
self.t[half][frames + 1] - self.t[half][frames],
|
|
450
|
+
device=device,
|
|
451
|
+
dtype=dtype,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
ball_pos = torch.tensor(
|
|
455
|
+
self.locs_ball[half][frames], device=device, dtype=dtype
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# stack teams
|
|
459
|
+
pos_t_h, pos_tp1_h = self._stack_team_frames(home_players, frames, device, dtype)
|
|
460
|
+
pos_t_a, pos_tp1_a = self._stack_team_frames(away_players, frames, device, dtype)
|
|
461
|
+
|
|
462
|
+
Zh = self._batch_team_influence_frames_pt(
|
|
463
|
+
pos_t_h, pos_tp1_h, ball_pos, dt_secs, locs_t, dtype
|
|
464
|
+
)
|
|
465
|
+
Za = self._batch_team_influence_frames_pt(
|
|
466
|
+
pos_t_a, pos_tp1_a, ball_pos, dt_secs, locs_t, dtype
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
res_batch = torch.sigmoid(Za - Zh).reshape(-1, dt, dt)
|
|
470
|
+
pc_all[start:end] = res_batch.cpu().numpy().astype(np.float32)
|
|
471
|
+
|
|
472
|
+
if verbose:
|
|
473
|
+
print(f"pt full: frames {start}-{end-1} done")
|
|
474
|
+
|
|
475
|
+
return pc_all, xx, yy
|
|
476
|
+
|
|
477
|
+
def _get_grid(self, dt: int, device: str, dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
478
|
+
"""Helper to create a grid of locations for torch-based pitch control."""
|
|
479
|
+
x = torch.linspace(0, 105, dt, device=device, dtype=dtype)
|
|
480
|
+
y = torch.linspace(0, 68, dt, device=device, dtype=dtype)
|
|
481
|
+
xx_t, yy_t = torch.meshgrid(x, y, indexing="xy") # (dt,dt)
|
|
482
|
+
locs_t = torch.stack([xx_t, yy_t], dim=-1).reshape(-1, 2) # (N,2)
|
|
483
|
+
return xx_t, yy_t, locs_t
|
|
484
|
+
|
|
485
|
+
def _fit_pt(
|
|
486
|
+
self,
|
|
487
|
+
half: int,
|
|
488
|
+
tp: int,
|
|
489
|
+
dt: int = 200,
|
|
490
|
+
device: str = "cpu",
|
|
491
|
+
verbose: bool = False,
|
|
492
|
+
use_fp16: bool = True,
|
|
493
|
+
):
|
|
494
|
+
"""Torch-accelerated pitch-control calculation.
|
|
495
|
+
|
|
496
|
+
Returns
|
|
497
|
+
-------
|
|
498
|
+
result : np.ndarray shape (dt,dt)
|
|
499
|
+
xx, yy : np.ndarray meshgrids (dt,dt)
|
|
500
|
+
"""
|
|
501
|
+
if torch is None:
|
|
502
|
+
raise ImportError("PyTorch is required for backend='torch'.")
|
|
503
|
+
|
|
504
|
+
dtype = torch.float16 if (use_fp16 and device != "cpu") else torch.float32
|
|
505
|
+
|
|
506
|
+
# ---- grid caching ----
|
|
507
|
+
key = (dt, device, dtype)
|
|
508
|
+
if key not in self._grid_cache:
|
|
509
|
+
xx_t, yy_t, locs_t = self._get_grid(dt, device, dtype)
|
|
510
|
+
# Store; keep cache small (max 3 grids)
|
|
511
|
+
if len(self._grid_cache) >= 3:
|
|
512
|
+
self._grid_cache.pop(next(iter(self._grid_cache)))
|
|
513
|
+
self._grid_cache[key] = (xx_t, yy_t, locs_t)
|
|
514
|
+
else:
|
|
515
|
+
xx_t, yy_t, locs_t = self._grid_cache[key]
|
|
516
|
+
|
|
517
|
+
# --- vectorised influence computation ---
|
|
518
|
+
Zh = self._batch_influence_pt(
|
|
519
|
+
self.locs_home[half], locs_t, tp, half, device, dtype
|
|
520
|
+
)
|
|
521
|
+
Za = self._batch_influence_pt(
|
|
522
|
+
self.locs_away[half], locs_t, tp, half, device, dtype
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
res_t = torch.sigmoid(Za - Zh).reshape(dt, dt)
|
|
526
|
+
|
|
527
|
+
# Convert to numpy for downstream plotting
|
|
528
|
+
return res_t.cpu().numpy(), xx_t.cpu().numpy(), yy_t.cpu().numpy()
|
|
529
|
+
|
|
530
|
+
def _fit_np(self, half: int, tp: int, dt: int, verbose: bool = False) -> tuple:
|
|
531
|
+
x = np.linspace(0, 105, dt)
|
|
532
|
+
y = np.linspace(0, 68, dt)
|
|
533
|
+
xx, yy = np.meshgrid(x, y)
|
|
534
|
+
|
|
535
|
+
Zh = np.zeros(dt*dt)
|
|
536
|
+
Za = np.zeros(dt*dt)
|
|
537
|
+
|
|
538
|
+
locations = np.c_[xx.flatten(),yy.flatten()]
|
|
539
|
+
|
|
540
|
+
for k in self.locs_home[half].keys():
|
|
541
|
+
# if len(self.locs_home[half][k]) >= tp:
|
|
542
|
+
Zh += self.influence_np(k, locations, tp, 'h', half, verbose)
|
|
543
|
+
for k in self.locs_away[half].keys():
|
|
544
|
+
# if len(self.locs_away[half][k]) >= tp:
|
|
545
|
+
Za += self.influence_np(k, locations, tp, 'a', half, verbose)
|
|
546
|
+
|
|
547
|
+
Zh = Zh.reshape((dt, dt))
|
|
548
|
+
Za = Za.reshape((dt, dt))
|
|
549
|
+
result = 1 / (1 + np.exp(-Za + Zh))
|
|
550
|
+
|
|
551
|
+
return result, xx, yy
|
|
552
|
+
|
|
553
|
+
def fit(
|
|
554
|
+
self,
|
|
555
|
+
half: int,
|
|
556
|
+
tp: int,
|
|
557
|
+
dt: int = 100,
|
|
558
|
+
backend: str = "np",
|
|
559
|
+
device: str = "cpu",
|
|
560
|
+
verbose: bool = False,
|
|
561
|
+
use_fp16: bool = True,
|
|
562
|
+
):
|
|
563
|
+
"""Selects NumPy or PyTorch backend depending on `backend`."""
|
|
564
|
+
match backend:
|
|
565
|
+
case "np" | "numpy":
|
|
566
|
+
return self._fit_np(half, tp, dt, verbose)
|
|
567
|
+
case "torch" | "pt":
|
|
568
|
+
return self._fit_pt(half, tp, dt, device=device, verbose=verbose, use_fp16=use_fp16)
|
|
569
|
+
case _:
|
|
570
|
+
raise ValueError(f"Unknown backend '{backend}'. Use 'np' or 'torch'.")
|
|
571
|
+
|
|
572
|
+
def fit_full(
|
|
573
|
+
self,
|
|
574
|
+
half: int,
|
|
575
|
+
dt: int = 100,
|
|
576
|
+
backend: str = "np",
|
|
577
|
+
device: str = "cpu",
|
|
578
|
+
batch_size: int = 30*60,
|
|
579
|
+
use_fp16: bool = True,
|
|
580
|
+
verbose: bool = False,
|
|
581
|
+
):
|
|
582
|
+
"""Compute pitch-control map for *каждый* кадр тайма.
|
|
583
|
+
|
|
584
|
+
Returns
|
|
585
|
+
-------
|
|
586
|
+
maps : np.ndarray, shape (T, dt, dt)
|
|
587
|
+
Pitch-control probability for home team at every frame.
|
|
588
|
+
xx, yy : np.ndarray, shape (dt, dt)
|
|
589
|
+
Coordinate grids (общие для всех кадров).
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
T = len(self.t[half]) - 1 # мы используем t и t+1, поэтому последний кадр T-1
|
|
593
|
+
|
|
594
|
+
match backend:
|
|
595
|
+
case "np" | "numpy":
|
|
596
|
+
pc_all = np.empty((T, dt, dt), dtype=np.float32)
|
|
597
|
+
for tp in tqdm(range(T)):
|
|
598
|
+
pc_map, xx, yy = self._fit_np(half, tp, dt, verbose=False)
|
|
599
|
+
pc_all[tp] = pc_map.astype(np.float32)
|
|
600
|
+
if verbose and tp % 500 == 0:
|
|
601
|
+
print(f"np full-match: done {tp}/{T}")
|
|
602
|
+
return pc_all, xx, yy
|
|
603
|
+
|
|
604
|
+
case "torch" | "pt":
|
|
605
|
+
return self._fit_full_pt(
|
|
606
|
+
half, dt, device, batch_size, use_fp16, verbose
|
|
607
|
+
)
|
|
608
|
+
case _:
|
|
609
|
+
raise ValueError("backend must be 'np' or 'pt'")
|
|
610
|
+
|
|
611
|
+
def draw_pitch_control(
|
|
612
|
+
self,
|
|
613
|
+
half: int,
|
|
614
|
+
tp: int,
|
|
615
|
+
pitch_control: tuple = None,
|
|
616
|
+
save: bool = False,
|
|
617
|
+
dt: int = 200,
|
|
618
|
+
filename: str = 'pitch_control'
|
|
619
|
+
):
|
|
620
|
+
if pitch_control is None:
|
|
621
|
+
pitch_control, xx, yy = self.fit(half, tp, dt)
|
|
622
|
+
else:
|
|
623
|
+
pitch_control, xx, yy = pitch_control
|
|
624
|
+
|
|
625
|
+
fig, ax = plt.subplots(figsize=(10.5, 6.8))
|
|
626
|
+
# mpl.field(fieldcolor="white", linecolor="black", alpha=1, show=False, ax=ax)
|
|
627
|
+
mpl.field("white", show=False, ax=ax)
|
|
628
|
+
|
|
629
|
+
plt.contourf(xx, yy, pitch_control)
|
|
630
|
+
|
|
631
|
+
for k in self.locs_home[half].keys():
|
|
632
|
+
# if len(self.locs_home[half][k]) >= tp:
|
|
633
|
+
if np.isfinite(self.locs_home[half][k][tp, :]).all():
|
|
634
|
+
plt.scatter(
|
|
635
|
+
self.locs_home[half][k][tp, 0],
|
|
636
|
+
self.locs_home[half][k][tp, 1],
|
|
637
|
+
color='darkgrey'
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
for k in self.locs_away[half].keys():
|
|
641
|
+
# if len(self.locs_away[half][k]) >= tp:
|
|
642
|
+
if np.isfinite(self.locs_away[half][k][tp, :]).all():
|
|
643
|
+
plt.scatter(
|
|
644
|
+
self.locs_away[half][k][tp, 0],
|
|
645
|
+
self.locs_away[half][k][tp, 1], color='black'
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
plt.scatter(
|
|
649
|
+
self.locs_ball[half][tp, 0],
|
|
650
|
+
self.locs_ball[half][tp, 1],
|
|
651
|
+
color='red'
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if save:
|
|
655
|
+
plt.savefig(f'{filename}.png', dpi=300)
|
|
656
|
+
else:
|
|
657
|
+
plt.show()
|
|
658
|
+
|
|
659
|
+
def animate_pitch_control(
|
|
660
|
+
self,
|
|
661
|
+
half: int,
|
|
662
|
+
tp: int,
|
|
663
|
+
filename: str = "pitch_control_animation",
|
|
664
|
+
dt: int = 200,
|
|
665
|
+
frames: int = 30,
|
|
666
|
+
interval: int = 1000
|
|
667
|
+
):
|
|
668
|
+
"""
|
|
669
|
+
ffmpeg should be installed on your machine.
|
|
670
|
+
"""
|
|
671
|
+
fig, ax = plt.subplots(figsize=(10.5, 6.8))
|
|
672
|
+
|
|
673
|
+
def animate(i):
|
|
674
|
+
fr = tp + i
|
|
675
|
+
pitch_control, xx, yy = self.fit(half, fr, dt)
|
|
676
|
+
|
|
677
|
+
mpl.field("white", show=False, ax=ax)
|
|
678
|
+
ax.axis('off')
|
|
679
|
+
|
|
680
|
+
plt.contourf(xx, yy, pitch_control)
|
|
681
|
+
|
|
682
|
+
for k in self.locs_home[half].keys():
|
|
683
|
+
# if len(self.locs_home[half][k]) >= fr:
|
|
684
|
+
if np.isfinite(self.locs_home[half][k][fr, :]).all():
|
|
685
|
+
plt.scatter(
|
|
686
|
+
self.locs_home[half][k][fr, 0],
|
|
687
|
+
self.locs_home[half][k][fr, 1],
|
|
688
|
+
color='darkgrey'
|
|
689
|
+
)
|
|
690
|
+
for k in self.locs_away[half].keys():
|
|
691
|
+
# if len(self.locs_away[half][k]) >= fr:
|
|
692
|
+
if np.isfinite(self.locs_away[half][k][fr, :]).all():
|
|
693
|
+
plt.scatter(
|
|
694
|
+
self.locs_away[half][k][fr, 0],
|
|
695
|
+
self.locs_away[half][k][fr, 1],
|
|
696
|
+
color='black'
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
plt.scatter(
|
|
700
|
+
self.locs_ball[half][fr, 0],
|
|
701
|
+
self.locs_ball[half][fr, 1],
|
|
702
|
+
color='red'
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
return ax
|
|
706
|
+
|
|
707
|
+
x = np.linspace(0, 105, dt)
|
|
708
|
+
y = np.linspace(0, 68, dt)
|
|
709
|
+
xx, yy = np.meshgrid(x, y)
|
|
710
|
+
|
|
711
|
+
ani = animation.FuncAnimation(
|
|
712
|
+
fig=fig,
|
|
713
|
+
func=animate,
|
|
714
|
+
frames=min(frames, len(self.locs_ball[half]) - tp),
|
|
715
|
+
interval=interval,
|
|
716
|
+
blit=False
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
ani.save(f'{filename}.mp4', writer='ffmpeg')
|
|
@@ -13,20 +13,27 @@ def process_list(x: pd.Series):
|
|
|
13
13
|
else:
|
|
14
14
|
return lst
|
|
15
15
|
|
|
16
|
+
def take_last(x: pd.Series):
|
|
17
|
+
lst = x.dropna().tolist()
|
|
18
|
+
if lst:
|
|
19
|
+
return lst[-1]
|
|
20
|
+
return np.nan
|
|
21
|
+
|
|
16
22
|
|
|
17
23
|
def gluing(df: pd.DataFrame) -> pd.DataFrame:
|
|
18
24
|
cols = ['player_id', 'half', 'second', 'pos_x', 'pos_y']
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
26
|
+
agg_rules = {}
|
|
27
|
+
|
|
28
|
+
for col_name in df.columns:
|
|
29
|
+
if col_name not in cols:
|
|
30
|
+
if col_name in ['action_name', 'action_id']:
|
|
31
|
+
agg_rules[col_name] = process_list
|
|
32
|
+
else:
|
|
33
|
+
agg_rules[col_name] = take_last
|
|
34
|
+
|
|
35
|
+
df_gb = df.groupby(cols).agg(agg_rules).reset_index()
|
|
36
|
+
|
|
30
37
|
df_gb['pos_dest_nan'] = (df_gb['pos_dest_x'].isna() & df_gb['pos_dest_y'].isna()).astype(int)
|
|
31
38
|
df_gb = df_gb.sort_values(by=['half', 'second', 'possession_number', 'pos_dest_nan']).reset_index(drop=True)
|
|
32
39
|
return df_gb
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name='rustat-python-api',
|
|
5
|
-
version='0.
|
|
5
|
+
version='0.6.1',
|
|
6
6
|
description='A Python wrapper for RuStat API',
|
|
7
7
|
long_description=open('README.md').read(),
|
|
8
8
|
long_description_content_type='text/markdown',
|
|
@@ -17,7 +17,8 @@ setup(
|
|
|
17
17
|
'tqdm==4.66.5',
|
|
18
18
|
'scipy==1.14.1',
|
|
19
19
|
'matplotlib',
|
|
20
|
-
'matplotsoccer'
|
|
20
|
+
'matplotsoccer',
|
|
21
|
+
'torch'
|
|
21
22
|
],
|
|
22
23
|
classifiers=[
|
|
23
24
|
'Programming Language :: Python :: 3',
|
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
import pandas as pd
|
|
2
|
-
import numpy as np
|
|
3
|
-
from scipy.stats import multivariate_normal as mvn
|
|
4
|
-
import matplotlib.pyplot as plt
|
|
5
|
-
import matplotlib.animation as animation
|
|
6
|
-
import matplotsoccer as mpl
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class PitchControl:
|
|
10
|
-
def __init__(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame = None):
|
|
11
|
-
self.team_ids = tracking['team_id'].unique()
|
|
12
|
-
sides = tracking.groupby('team_id')['side_1h'].unique()
|
|
13
|
-
side_by_team = dict(zip(self.team_ids, sides[self.team_ids].apply(lambda x: x[0])))
|
|
14
|
-
self.side_by_half = {
|
|
15
|
-
1: side_by_team,
|
|
16
|
-
2:
|
|
17
|
-
{
|
|
18
|
-
team: 'left' if side == 'right' else 'right'
|
|
19
|
-
for team, side in side_by_team.items()
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
self.locs_home, self.locs_away, self.locs_ball, self.t = self.get_locs(
|
|
24
|
-
tracking,
|
|
25
|
-
events,
|
|
26
|
-
ball_data
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
def get_locs(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame | None) -> tuple:
|
|
30
|
-
events = events[[
|
|
31
|
-
'possession_number', 'team_id', 'possession_team_id',
|
|
32
|
-
'half', 'second', 'pos_x', 'pos_y'
|
|
33
|
-
]]
|
|
34
|
-
|
|
35
|
-
events.loc[:, 'pos_x'] = events.apply(
|
|
36
|
-
lambda x: self.swap_coords(x, 'x'), axis=1
|
|
37
|
-
)
|
|
38
|
-
events.loc[:, 'pos_y'] = events.apply(
|
|
39
|
-
lambda x: self.swap_coords(x, 'y'), axis=1
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
if ball_data is None:
|
|
43
|
-
ball_data = self.interpolate_ball_data(
|
|
44
|
-
events[['half', 'second', 'pos_x', 'pos_y']],
|
|
45
|
-
tracking
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
locs_home = {
|
|
49
|
-
half: {
|
|
50
|
-
player_id: self.get_player_data(player_id, half, tracking)
|
|
51
|
-
for player_id in tracking[tracking['side_1h'] == 'left']['player_id'].unique()
|
|
52
|
-
}
|
|
53
|
-
for half in tracking['half'].unique()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
locs_away = {
|
|
57
|
-
half: {
|
|
58
|
-
player_id: self.get_player_data(player_id, half, tracking)
|
|
59
|
-
for player_id in tracking[tracking['side_1h'] == 'right']['player_id'].unique()
|
|
60
|
-
}
|
|
61
|
-
for half in tracking['half'].unique()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
locs_ball = {
|
|
65
|
-
half: ball_data[ball_data['half'] == half][['pos_x', 'pos_y']].values
|
|
66
|
-
for half in tracking['half'].unique()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
t = {
|
|
70
|
-
half: ball_data[ball_data['half'] == half]['second'].values
|
|
71
|
-
for half in tracking['half'].unique()
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return locs_home, locs_away, locs_ball, t
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def swap_coords(self, row, how: str = 'x'):
|
|
78
|
-
half = row['half']
|
|
79
|
-
team_id = row['team_id']
|
|
80
|
-
possession_team_id = row['possession_team_id']
|
|
81
|
-
x = row['pos_x']
|
|
82
|
-
y = row['pos_y']
|
|
83
|
-
|
|
84
|
-
if isinstance(possession_team_id, list):
|
|
85
|
-
current_side = 'left' if team_id in possession_team_id else 'right'
|
|
86
|
-
real_side = self.side_by_half[half][str(int(team_id))]
|
|
87
|
-
else:
|
|
88
|
-
current_side = 'left' if team_id == possession_team_id else 'right'
|
|
89
|
-
real_side = self.side_by_half[half][str(int(team_id))]
|
|
90
|
-
|
|
91
|
-
if current_side != real_side:
|
|
92
|
-
if how == 'x':
|
|
93
|
-
x = 105 - x
|
|
94
|
-
else:
|
|
95
|
-
y = 68 - y
|
|
96
|
-
|
|
97
|
-
return x if how == 'x' else y
|
|
98
|
-
|
|
99
|
-
@staticmethod
|
|
100
|
-
def interpolate_ball_data(
|
|
101
|
-
ball_data: pd.DataFrame,
|
|
102
|
-
player_data: pd.DataFrame
|
|
103
|
-
) -> pd.DataFrame:
|
|
104
|
-
ball_data = ball_data.drop_duplicates(subset=['second', 'half'])
|
|
105
|
-
|
|
106
|
-
interpolated_data = []
|
|
107
|
-
for half in ball_data['half'].unique():
|
|
108
|
-
ball_half = ball_data[ball_data['half'] == half]
|
|
109
|
-
player_half = player_data[player_data['half'] == half]
|
|
110
|
-
|
|
111
|
-
player_times = player_half['second'].unique()
|
|
112
|
-
|
|
113
|
-
ball_half = ball_half.sort_values(by='second')
|
|
114
|
-
interpolated_half = pd.DataFrame({'second': player_times})
|
|
115
|
-
interpolated_half['pos_x'] = np.interp(
|
|
116
|
-
interpolated_half['second'], ball_half['second'], ball_half['pos_x']
|
|
117
|
-
)
|
|
118
|
-
interpolated_half['pos_y'] = np.interp(
|
|
119
|
-
interpolated_half['second'], ball_half['second'], ball_half['pos_y']
|
|
120
|
-
)
|
|
121
|
-
interpolated_half['half'] = half
|
|
122
|
-
interpolated_data.append(interpolated_half)
|
|
123
|
-
|
|
124
|
-
interpolated_ball_data = pd.concat(interpolated_data, ignore_index=True)
|
|
125
|
-
return interpolated_ball_data
|
|
126
|
-
|
|
127
|
-
@staticmethod
|
|
128
|
-
def get_player_data(player_id, half, tracking):
|
|
129
|
-
timestamps = tracking[tracking['half'] == half]['second'].unique()
|
|
130
|
-
player_data = tracking[
|
|
131
|
-
(tracking['player_id'] == player_id)
|
|
132
|
-
& (tracking['half'] == half)
|
|
133
|
-
][['second', 'pos_x', 'pos_y']]
|
|
134
|
-
|
|
135
|
-
player_data_full = pd.DataFrame({'second': timestamps})
|
|
136
|
-
player_data_full = player_data_full.merge(player_data, on='second', how='left')
|
|
137
|
-
|
|
138
|
-
return player_data_full[['pos_x', 'pos_y']].values
|
|
139
|
-
|
|
140
|
-
# return tracking[
|
|
141
|
-
# (tracking['player_id'] == player_id)
|
|
142
|
-
# & (tracking['half'] == half)
|
|
143
|
-
# ][['pos_x', 'pos_y']].values
|
|
144
|
-
|
|
145
|
-
def influence_function(
|
|
146
|
-
self,
|
|
147
|
-
player_index: str,
|
|
148
|
-
location: np.ndarray,
|
|
149
|
-
time_index: int,
|
|
150
|
-
home_or_away: str,
|
|
151
|
-
half: int,
|
|
152
|
-
verbose: bool = False
|
|
153
|
-
):
|
|
154
|
-
if home_or_away == 'h':
|
|
155
|
-
data = self.locs_home[half].copy()
|
|
156
|
-
elif home_or_away == 'a':
|
|
157
|
-
data = self.locs_away[half].copy()
|
|
158
|
-
else:
|
|
159
|
-
raise ValueError("Enter either 'h' or 'a'.")
|
|
160
|
-
|
|
161
|
-
locs_ball = self.locs_ball[half].copy()
|
|
162
|
-
t = self.t[half].copy()
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
np.all(np.isfinite(data[player_index][[time_index, time_index + 1], :]))
|
|
166
|
-
& np.all(np.isfinite(locs_ball[time_index, :]))
|
|
167
|
-
):
|
|
168
|
-
jitter = 1e-10 ## to prevent identically zero covariance matrices when velocity is zero
|
|
169
|
-
## compute velocity by fwd difference
|
|
170
|
-
s = (
|
|
171
|
-
np.linalg.norm(
|
|
172
|
-
data[player_index][time_index + 1,:]
|
|
173
|
-
- data[player_index][time_index,:] + jitter
|
|
174
|
-
)
|
|
175
|
-
/ (t[time_index + 1] - t[time_index])
|
|
176
|
-
)
|
|
177
|
-
## velocities in x,y directions
|
|
178
|
-
sxy = (
|
|
179
|
-
(data[player_index][time_index + 1, :] - data[player_index][time_index, :] + jitter)
|
|
180
|
-
/ (t[time_index + 1] - t[time_index])
|
|
181
|
-
)
|
|
182
|
-
## angle between velocity vector & x-axis
|
|
183
|
-
theta = np.arccos(sxy[0] / np.linalg.norm(sxy))
|
|
184
|
-
## rotation matrix
|
|
185
|
-
R = np.array([[np.cos(theta), -np.sin(theta)],[np.sin(theta), np.cos(theta)]])
|
|
186
|
-
mu = data[player_index][time_index, :] + sxy * 0.5
|
|
187
|
-
Srat = (s / 13) ** 2
|
|
188
|
-
Ri = np.linalg.norm(locs_ball[time_index, :] - data[player_index][time_index, :])
|
|
189
|
-
## don't think this function is specified in the paper but looks close enough to fig 9
|
|
190
|
-
Ri = np.minimum(4 + Ri ** 3/ (18 ** 3 / 6), 10)
|
|
191
|
-
S = np.array([[(1 + Srat) * Ri / 2, 0], [0, (1 - Srat) * Ri / 2]])
|
|
192
|
-
Sigma = np.matmul(R, S)
|
|
193
|
-
Sigma = np.matmul(Sigma, S)
|
|
194
|
-
Sigma = np.matmul(Sigma, np.linalg.inv(R)) ## this is not efficient, forgive me.
|
|
195
|
-
out = mvn.pdf(location, mu, Sigma) / mvn.pdf(data[player_index][time_index, :], mu, Sigma)
|
|
196
|
-
else:
|
|
197
|
-
if verbose:
|
|
198
|
-
print("Data is not finite.")
|
|
199
|
-
out = np.zeros(location.shape[0])
|
|
200
|
-
return out
|
|
201
|
-
|
|
202
|
-
def fit(self, half: int, tp: int, dt: int, verbose: bool = False) -> tuple:
|
|
203
|
-
x = np.linspace(0, 105, dt)
|
|
204
|
-
y = np.linspace(0, 68, dt)
|
|
205
|
-
xx, yy = np.meshgrid(x, y)
|
|
206
|
-
|
|
207
|
-
Zh = np.zeros(dt*dt)
|
|
208
|
-
Za = np.zeros(dt*dt)
|
|
209
|
-
|
|
210
|
-
locations = np.c_[xx.flatten(),yy.flatten()]
|
|
211
|
-
|
|
212
|
-
for k in self.locs_home[half].keys():
|
|
213
|
-
# if len(self.locs_home[half][k]) >= tp:
|
|
214
|
-
Zh += self.influence_function(k, locations, tp, 'h', half, verbose)
|
|
215
|
-
for k in self.locs_away[half].keys():
|
|
216
|
-
# if len(self.locs_away[half][k]) >= tp:
|
|
217
|
-
Za += self.influence_function(k, locations, tp, 'a', half, verbose)
|
|
218
|
-
|
|
219
|
-
Zh = Zh.reshape((dt, dt))
|
|
220
|
-
Za = Za.reshape((dt, dt))
|
|
221
|
-
result = 1 / (1 + np.exp(-Za + Zh))
|
|
222
|
-
|
|
223
|
-
return result, xx, yy
|
|
224
|
-
|
|
225
|
-
def draw_pitch_control(
|
|
226
|
-
self,
|
|
227
|
-
half: int,
|
|
228
|
-
tp: int,
|
|
229
|
-
pitch_control: tuple = None,
|
|
230
|
-
save: bool = False,
|
|
231
|
-
dt: int = 200,
|
|
232
|
-
filename: str = 'pitch_control'
|
|
233
|
-
):
|
|
234
|
-
if pitch_control is None:
|
|
235
|
-
pitch_control, xx, yy = self.fit(half, tp, dt)
|
|
236
|
-
|
|
237
|
-
fig, ax = plt.subplots(figsize=(10.5, 6.8))
|
|
238
|
-
mpl.field("white", show=False, ax=ax)
|
|
239
|
-
|
|
240
|
-
plt.contourf(xx, yy, pitch_control)
|
|
241
|
-
|
|
242
|
-
for k in self.locs_home[half].keys():
|
|
243
|
-
# if len(self.locs_home[half][k]) >= tp:
|
|
244
|
-
if np.isfinite(self.locs_home[half][k][tp, :]).all():
|
|
245
|
-
plt.scatter(
|
|
246
|
-
self.locs_home[half][k][tp, 0],
|
|
247
|
-
self.locs_home[half][k][tp, 1],
|
|
248
|
-
color='darkgrey'
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
for k in self.locs_away[half].keys():
|
|
252
|
-
# if len(self.locs_away[half][k]) >= tp:
|
|
253
|
-
if np.isfinite(self.locs_away[half][k][tp, :]).all():
|
|
254
|
-
plt.scatter(
|
|
255
|
-
self.locs_away[half][k][tp, 0],
|
|
256
|
-
self.locs_away[half][k][tp, 1], color='black'
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
plt.scatter(
|
|
260
|
-
self.locs_ball[half][tp, 0],
|
|
261
|
-
self.locs_ball[half][tp, 1],
|
|
262
|
-
color='red'
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
if save:
|
|
266
|
-
plt.savefig(f'{filename}.png', dpi=300)
|
|
267
|
-
else:
|
|
268
|
-
plt.show()
|
|
269
|
-
|
|
270
|
-
def animate_pitch_control(
|
|
271
|
-
self,
|
|
272
|
-
half: int,
|
|
273
|
-
tp: int,
|
|
274
|
-
filename: str = "pitch_control_animation",
|
|
275
|
-
dt: int = 200,
|
|
276
|
-
frames: int = 30,
|
|
277
|
-
interval: int = 1000
|
|
278
|
-
):
|
|
279
|
-
"""
|
|
280
|
-
ffmpeg should be installed on your machine.
|
|
281
|
-
"""
|
|
282
|
-
fig, ax = plt.subplots(figsize=(10.5, 6.8))
|
|
283
|
-
|
|
284
|
-
def animate(i):
|
|
285
|
-
fr = tp + i
|
|
286
|
-
pitch_control, xx, yy = self.fit(half, fr, dt)
|
|
287
|
-
|
|
288
|
-
mpl.field("white", show=False, ax=ax)
|
|
289
|
-
ax.axis('off')
|
|
290
|
-
|
|
291
|
-
plt.contourf(xx, yy, pitch_control)
|
|
292
|
-
|
|
293
|
-
for k in self.locs_home[half].keys():
|
|
294
|
-
# if len(self.locs_home[half][k]) >= fr:
|
|
295
|
-
if np.isfinite(self.locs_home[half][k][fr, :]).all():
|
|
296
|
-
plt.scatter(
|
|
297
|
-
self.locs_home[half][k][fr, 0],
|
|
298
|
-
self.locs_home[half][k][fr, 1],
|
|
299
|
-
color='darkgrey'
|
|
300
|
-
)
|
|
301
|
-
for k in self.locs_away[half].keys():
|
|
302
|
-
# if len(self.locs_away[half][k]) >= fr:
|
|
303
|
-
if np.isfinite(self.locs_away[half][k][fr, :]).all():
|
|
304
|
-
plt.scatter(
|
|
305
|
-
self.locs_away[half][k][fr, 0],
|
|
306
|
-
self.locs_away[half][k][fr, 1],
|
|
307
|
-
color='black'
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
plt.scatter(
|
|
311
|
-
self.locs_ball[half][fr, 0],
|
|
312
|
-
self.locs_ball[half][fr, 1],
|
|
313
|
-
color='red'
|
|
314
|
-
)
|
|
315
|
-
|
|
316
|
-
return ax
|
|
317
|
-
|
|
318
|
-
x = np.linspace(0, 105, dt)
|
|
319
|
-
y = np.linspace(0, 68, dt)
|
|
320
|
-
xx, yy = np.meshgrid(x, y)
|
|
321
|
-
|
|
322
|
-
ani = animation.FuncAnimation(
|
|
323
|
-
fig=fig,
|
|
324
|
-
func=animate,
|
|
325
|
-
frames=min(frames, len(self.locs_ball[half]) - tp),
|
|
326
|
-
interval=interval,
|
|
327
|
-
blit=False
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
ani.save(f'{filename}.mp4', writer='ffmpeg')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|