rustat-python-api 0.7.1__tar.gz → 0.7.2__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.7.1/rustat_python_api.egg-info → rustat-python-api-0.7.2}/PKG-INFO +1 -1
- rustat-python-api-0.7.2/rustat_python_api/matching/__init__.py +9 -0
- rustat-python-api-0.7.2/rustat_python_api/matching/dataloader.py +80 -0
- rustat-python-api-0.7.2/rustat_python_api/matching/pc_adder.py +219 -0
- rustat-python-api-0.7.2/rustat_python_api/matching/tr_adder.py +354 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2/rustat_python_api.egg-info}/PKG-INFO +1 -1
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api.egg-info/SOURCES.txt +5 -1
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/setup.py +1 -1
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/LICENSE +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/README.md +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/pyproject.toml +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/__init__.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/config.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/kernels/__init__.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/kernels/maha.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/models_api.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/parser.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/pitch_control.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/processing.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api/urls.py +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api.egg-info/dependency_links.txt +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api.egg-info/requires.txt +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api.egg-info/top_level.txt +0 -0
- {rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/setup.cfg +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from ast import literal_eval
|
|
4
|
+
|
|
5
|
+
from .pc_adder import PitchControlAdder
|
|
6
|
+
from .tr_adder import TrackingFeaturesAdder
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MatchInferLoader:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
events: pd.DataFrame, tracking: pd.DataFrame, ball: pd.DataFrame,
|
|
13
|
+
modes: list[str], rads: list[int],
|
|
14
|
+
radii: list[int], cone_degrees: list[int], k_list: list[int],
|
|
15
|
+
):
|
|
16
|
+
self.events = events
|
|
17
|
+
self.tracking = tracking
|
|
18
|
+
self.ball = ball
|
|
19
|
+
self.modes = modes
|
|
20
|
+
self.rads = rads
|
|
21
|
+
self.radii = radii
|
|
22
|
+
self.cone_degrees = cone_degrees
|
|
23
|
+
self.k_list = k_list
|
|
24
|
+
|
|
25
|
+
def _save_index(self):
|
|
26
|
+
self.events['orig_index'] = self.events.index
|
|
27
|
+
|
|
28
|
+
def _process_events(self):
|
|
29
|
+
self.events = process_events_after_loading(self.events)
|
|
30
|
+
|
|
31
|
+
def _add_pc_features(self):
|
|
32
|
+
pc_adder = PitchControlAdder(self.events, self.tracking, self.ball, device="mps", backend="pt")
|
|
33
|
+
pc_adder.run(modes=self.modes, rads=self.rads)
|
|
34
|
+
self.events = pc_adder.events
|
|
35
|
+
|
|
36
|
+
def _add_tr_features(self):
|
|
37
|
+
tr_adder = TrackingFeaturesAdder(self.events, self.tracking, self.ball)
|
|
38
|
+
tr_adder.run(
|
|
39
|
+
radii=self.radii,
|
|
40
|
+
cone_degrees=self.cone_degrees,
|
|
41
|
+
k_list=self.k_list
|
|
42
|
+
)
|
|
43
|
+
self.events = tr_adder.events
|
|
44
|
+
|
|
45
|
+
def fit(self, inplace: bool = False):
|
|
46
|
+
self._process_events()
|
|
47
|
+
self._add_pc_features()
|
|
48
|
+
self._add_tr_features()
|
|
49
|
+
|
|
50
|
+
if not inplace:
|
|
51
|
+
return self.events
|
|
52
|
+
|
|
53
|
+
def get_tracking_columns(self, events: pd.DataFrame = None) -> list[str]:
|
|
54
|
+
if events is None:
|
|
55
|
+
events = self.events
|
|
56
|
+
|
|
57
|
+
pc_columns = [column for column in events.columns if column.startswith("pc_")]
|
|
58
|
+
tr_columns = [column for column in events.columns if column.startswith("tf_")]
|
|
59
|
+
|
|
60
|
+
return pc_columns + tr_columns
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def process_events_after_loading(events: pd.DataFrame) -> pd.DataFrame:
|
|
64
|
+
events = events[events['half'].isin([1, 2])]
|
|
65
|
+
|
|
66
|
+
events[[
|
|
67
|
+
'pos_dest_x', 'pos_dest_y'
|
|
68
|
+
]] = events[[
|
|
69
|
+
'pos_dest_x', 'pos_dest_y'
|
|
70
|
+
]].apply(pd.to_numeric, errors='coerce')
|
|
71
|
+
|
|
72
|
+
events['sub_tags'] = events['sub_tags'].astype(str).apply(literal_eval)
|
|
73
|
+
events['tags'] = events['tags'].astype(str).apply(literal_eval)
|
|
74
|
+
events['possession_team_name'] = events['possession_team_name'].astype(str).apply(
|
|
75
|
+
lambda x: literal_eval(x) if isinstance(x, str) and '[' in x else x
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
events['team_id'] = events['team_id'].astype(np.int64)
|
|
79
|
+
|
|
80
|
+
return events
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from rustat_python_api import PitchControl
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PitchControlAdder:
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
events: pd.DataFrame, tracking: pd.DataFrame, ball: pd.DataFrame,
|
|
10
|
+
device: str = 'cpu', backend: str = 'pt'
|
|
11
|
+
):
|
|
12
|
+
self.events = events
|
|
13
|
+
self.tracking = tracking
|
|
14
|
+
self.ball = ball
|
|
15
|
+
|
|
16
|
+
self.device = device
|
|
17
|
+
self.backend = backend
|
|
18
|
+
|
|
19
|
+
self.pitch_control = PitchControl(tracking, events, ball)
|
|
20
|
+
self.sec2timestamp = self._get_sec2timestamp()
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def to_tracking_second(sec: float) -> float:
|
|
24
|
+
return float(np.round(np.rint(sec * 30.0) / 30.0, 2))
|
|
25
|
+
|
|
26
|
+
def _get_sec2timestamp(self):
|
|
27
|
+
return {
|
|
28
|
+
half: {val: i for i, val in enumerate(self.pitch_control.t[half].tolist())}
|
|
29
|
+
for half in self.pitch_control.t.keys()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def _get_pc(self, sec: float, half: int, dt: int = 100) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
33
|
+
timestamp = self.sec2timestamp[half][sec]
|
|
34
|
+
pc, xx, yy = self.pitch_control.fit(half=half, tp=timestamp, dt=dt, backend=self.backend, device=str(self.device))
|
|
35
|
+
|
|
36
|
+
return pc, xx, yy
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _get_pc_value_nearest(
|
|
40
|
+
pc, xx, yy,
|
|
41
|
+
x: float, y: float
|
|
42
|
+
) -> float:
|
|
43
|
+
d2 = (xx - x) ** 2 + (yy - y) ** 2
|
|
44
|
+
i, j = np.unravel_index(np.argmin(d2), pc.shape)
|
|
45
|
+
|
|
46
|
+
return float(pc[i, j])
|
|
47
|
+
|
|
48
|
+
def _get_pc_value_mean(
|
|
49
|
+
self,
|
|
50
|
+
pc, xx, yy,
|
|
51
|
+
x: float, y: float, rad: float
|
|
52
|
+
) -> float:
|
|
53
|
+
if rad is None or rad <= 0:
|
|
54
|
+
return self._get_pc_value_nearest(pc, xx, yy, x, y)
|
|
55
|
+
|
|
56
|
+
d2 = (xx - x) ** 2 + (yy - y) ** 2
|
|
57
|
+
mask = d2 <= (rad * rad)
|
|
58
|
+
|
|
59
|
+
if not np.any(mask):
|
|
60
|
+
return self._get_pc_value_nearest(pc, xx, yy, x, y)
|
|
61
|
+
|
|
62
|
+
return float(pc[mask].mean())
|
|
63
|
+
|
|
64
|
+
def _get_pc_value_gaussian(
|
|
65
|
+
self,
|
|
66
|
+
pc, xx, yy,
|
|
67
|
+
x: float, y: float, sigma: float
|
|
68
|
+
) -> float:
|
|
69
|
+
if sigma is None or sigma <= 0:
|
|
70
|
+
return self._get_pc_value_nearest(pc, xx, yy, x, y)
|
|
71
|
+
|
|
72
|
+
d2 = (xx - x) ** 2 + (yy - y) ** 2
|
|
73
|
+
w = np.exp(-d2 / (2.0 * sigma * sigma))
|
|
74
|
+
z = w.sum()
|
|
75
|
+
|
|
76
|
+
if z <= 0:
|
|
77
|
+
return self._get_pc_value_nearest(pc, xx, yy, x, y)
|
|
78
|
+
|
|
79
|
+
return float((pc * w).sum() / z)
|
|
80
|
+
|
|
81
|
+
def _get_pc_value(
|
|
82
|
+
self,
|
|
83
|
+
pc: np.ndarray, xx: np.ndarray, yy: np.ndarray,
|
|
84
|
+
x: float, y: float, rad: float, mode: str = 'mean'
|
|
85
|
+
) -> float:
|
|
86
|
+
"""
|
|
87
|
+
mode: one of 'mean', 'sum', 'mass', 'gaussian', 'nearest'
|
|
88
|
+
"""
|
|
89
|
+
match mode:
|
|
90
|
+
case 'mean':
|
|
91
|
+
return self._get_pc_value_mean(pc, xx, yy, x, y, rad)
|
|
92
|
+
# case 'sum':
|
|
93
|
+
# return self._get_pc_value_sum(pc, xx, yy, x, y, rad)
|
|
94
|
+
# case 'mass':
|
|
95
|
+
# return self._get_pc_value_mass(pc, xx, yy, x, y, rad)
|
|
96
|
+
case 'gaussian':
|
|
97
|
+
return self._get_pc_value_gaussian(pc, xx, yy, x, y, rad)
|
|
98
|
+
case _:
|
|
99
|
+
return self._get_pc_value_nearest(pc, xx, yy, x, y)
|
|
100
|
+
|
|
101
|
+
def run(
|
|
102
|
+
self,
|
|
103
|
+
modes: list[str], rads: list[float], dt: int = 100
|
|
104
|
+
):
|
|
105
|
+
assert len(modes) == len(rads)
|
|
106
|
+
|
|
107
|
+
self.events['sec_tracking'] = self.events['second'].map(self.to_tracking_second)
|
|
108
|
+
|
|
109
|
+
right_by_half = {
|
|
110
|
+
int(h): {
|
|
111
|
+
int(tid)
|
|
112
|
+
for tid, side in self.pitch_control.side_by_half.get(int(h), {}).items()
|
|
113
|
+
if side == 'right'
|
|
114
|
+
}
|
|
115
|
+
for h in self.pitch_control.side_by_half.keys()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Preallocate outputs: for each (mode, rad) keep src and dest arrays
|
|
119
|
+
n = self.events.index.size
|
|
120
|
+
pcs_src = [np.full(n, np.nan, dtype=float) for _ in modes]
|
|
121
|
+
pcs_dst = [np.full(n, np.nan, dtype=float) for _ in modes]
|
|
122
|
+
|
|
123
|
+
# Vectorized flip mask: team plays to the right in this half
|
|
124
|
+
flip_mask = np.zeros(n, dtype=bool)
|
|
125
|
+
halves_arr = self.events['half'].to_numpy()
|
|
126
|
+
team_series = self.events['team_id']
|
|
127
|
+
|
|
128
|
+
for h, right_set in right_by_half.items():
|
|
129
|
+
if not right_set:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
idx_h = (halves_arr == h)
|
|
133
|
+
idx_right = team_series.isin(right_set).to_numpy()
|
|
134
|
+
flip_mask |= (idx_h & idx_right)
|
|
135
|
+
|
|
136
|
+
# Coordinates (can contain NaN)
|
|
137
|
+
x = self.events['pos_x'].to_numpy()
|
|
138
|
+
y = self.events['pos_y'].to_numpy()
|
|
139
|
+
xd = self.events['pos_dest_x'].to_numpy()
|
|
140
|
+
yd = self.events['pos_dest_y'].to_numpy()
|
|
141
|
+
|
|
142
|
+
# Flip coordinates where necessary
|
|
143
|
+
x = np.where(flip_mask, 105.0 - x, x)
|
|
144
|
+
y = np.where(flip_mask, 68.0 - y, y)
|
|
145
|
+
xd = np.where(flip_mask, 105.0 - xd, xd)
|
|
146
|
+
yd = np.where(flip_mask, 68.0 - yd, yd)
|
|
147
|
+
|
|
148
|
+
# Masks
|
|
149
|
+
src_nan = np.isnan(x) | np.isnan(y)
|
|
150
|
+
dest_nan = np.isnan(xd) | np.isnan(yd)
|
|
151
|
+
eq_eps = 1e-6
|
|
152
|
+
eq_mask = (~dest_nan) & np.isclose(xd, x, atol=eq_eps) & np.isclose(yd, y, atol=eq_eps)
|
|
153
|
+
|
|
154
|
+
# Cache (half, sec) -> (pc, xx, yy)
|
|
155
|
+
cache: dict[tuple[int, float], tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
|
|
156
|
+
secs = self.events['sec_tracking'].to_numpy()
|
|
157
|
+
|
|
158
|
+
# for i in tqdm(range(n)):
|
|
159
|
+
for i in range(n):
|
|
160
|
+
sec = float(secs[i])
|
|
161
|
+
half = int(halves_arr[i])
|
|
162
|
+
|
|
163
|
+
# Ensure second exists in mapping for this half
|
|
164
|
+
sec_map = self.sec2timestamp.get(half)
|
|
165
|
+
if not sec_map or sec not in sec_map:
|
|
166
|
+
print(f"[WARNING] Second {sec} not found for half {half}")
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
key = (half, sec)
|
|
170
|
+
if key in cache:
|
|
171
|
+
pc, xx, yy = cache[key]
|
|
172
|
+
else:
|
|
173
|
+
try:
|
|
174
|
+
pc, xx, yy = self._get_pc(sec=sec, half=half, dt=dt)
|
|
175
|
+
except Exception:
|
|
176
|
+
print(f"[WARNING] Failed to get PC for half {half}, second {sec}")
|
|
177
|
+
pc, xx, yy = (None, None, None)
|
|
178
|
+
cache[key] = (pc, xx, yy)
|
|
179
|
+
|
|
180
|
+
if pc is None:
|
|
181
|
+
print(f"[WARNING] PC is None for half {half}, second {sec}")
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
team = team_series.iloc[i]
|
|
185
|
+
is_right_start = team in right_by_half.get(1, set())
|
|
186
|
+
# if not is_right_start:
|
|
187
|
+
# pc = 1 - pc
|
|
188
|
+
|
|
189
|
+
# Source
|
|
190
|
+
if not src_nan[i]:
|
|
191
|
+
xi = float(x[i])
|
|
192
|
+
yi = float(y[i])
|
|
193
|
+
for k, (mode, rad) in enumerate(zip(modes, rads)):
|
|
194
|
+
pc_value = self._get_pc_value(pc, xx, yy, xi, yi, float(rad), mode=mode)
|
|
195
|
+
|
|
196
|
+
if not is_right_start:
|
|
197
|
+
pc_value = 1 - pc_value
|
|
198
|
+
|
|
199
|
+
pcs_src[k][i] = pc_value
|
|
200
|
+
|
|
201
|
+
# Destination
|
|
202
|
+
if eq_mask[i]:
|
|
203
|
+
# reuse source value when dest == src
|
|
204
|
+
for k in range(len(modes)):
|
|
205
|
+
pcs_dst[k][i] = pcs_src[k][i]
|
|
206
|
+
elif not dest_nan[i]:
|
|
207
|
+
xdi = float(xd[i]); ydi = float(yd[i])
|
|
208
|
+
for k, (mode, rad) in enumerate(zip(modes, rads)):
|
|
209
|
+
pc_value = self._get_pc_value(pc, xx, yy, xdi, ydi, float(rad), mode=mode)
|
|
210
|
+
|
|
211
|
+
if not is_right_start:
|
|
212
|
+
pc_value = 1 - pc_value
|
|
213
|
+
|
|
214
|
+
pcs_dst[k][i] = pc_value
|
|
215
|
+
|
|
216
|
+
# Write columns
|
|
217
|
+
for k, (mode, rad) in enumerate(zip(modes, rads)):
|
|
218
|
+
self.events[f'pc_{mode}_src_r{rad:g}'] = pcs_src[k]
|
|
219
|
+
self.events[f'pc_{mode}_dest_r{rad:g}'] = pcs_dst[k]
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TrackingFeaturesAdder:
|
|
6
|
+
def __init__(self, events: pd.DataFrame, tracking: pd.DataFrame, ball: pd.DataFrame):
|
|
7
|
+
self.events = events
|
|
8
|
+
self.tracking = tracking
|
|
9
|
+
self.ball = ball
|
|
10
|
+
|
|
11
|
+
# Normalize dtypes
|
|
12
|
+
if 'team_id' in self.tracking.columns:
|
|
13
|
+
self.tracking['team_id'] = self.tracking['team_id'].astype(str)
|
|
14
|
+
if 'team_id' in self.events.columns:
|
|
15
|
+
# keep original, but store string view for joins/masks
|
|
16
|
+
self._events_team_str = self.events['team_id'].astype(str)
|
|
17
|
+
else:
|
|
18
|
+
self._events_team_str = pd.Series(index=self.events.index, dtype=str)
|
|
19
|
+
|
|
20
|
+
# Cache team ids for side mapping
|
|
21
|
+
self.team_ids = list(self.tracking['team_id'].dropna().astype(str).unique())
|
|
22
|
+
|
|
23
|
+
# Build side_by_half using tracking side_1h
|
|
24
|
+
# sides: Series index team_id -> np.ndarray like ["left"] or ["right"]
|
|
25
|
+
sides = self.tracking.groupby('team_id')['side_1h'].unique()
|
|
26
|
+
# Align to our team_ids and take the first value
|
|
27
|
+
sides = sides.reindex(self.team_ids)
|
|
28
|
+
side_by_team = {
|
|
29
|
+
tid: (arr[0] if isinstance(arr, (list, np.ndarray)) and len(arr) > 0 else np.nan)
|
|
30
|
+
for tid, arr in sides.items()
|
|
31
|
+
}
|
|
32
|
+
# Half 2 sides are swapped
|
|
33
|
+
self.side_by_half = {
|
|
34
|
+
1: side_by_team,
|
|
35
|
+
2: {
|
|
36
|
+
tid: ('left' if side == 'right' else 'right') if isinstance(side, str) else np.nan
|
|
37
|
+
for tid, side in side_by_team.items()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def to_tracking_second(sec: float) -> float:
|
|
43
|
+
return float(np.round(np.rint(sec * 30.0) / 30.0, 2))
|
|
44
|
+
|
|
45
|
+
def _build_frames(self) -> dict:
|
|
46
|
+
"""
|
|
47
|
+
Build dictionary: (half, sec_tracking) -> dict with arrays: x, y, team_id(str), player_id
|
|
48
|
+
"""
|
|
49
|
+
tr = self.tracking.copy()
|
|
50
|
+
# Ensure numeric positions
|
|
51
|
+
tr['pos_x'] = pd.to_numeric(tr['pos_x'], errors='coerce')
|
|
52
|
+
tr['pos_y'] = pd.to_numeric(tr['pos_y'], errors='coerce')
|
|
53
|
+
tr['sec_tracking'] = tr['second'].apply(self.to_tracking_second)
|
|
54
|
+
|
|
55
|
+
frames = {}
|
|
56
|
+
cols_needed = ['team_id', 'pos_x', 'pos_y']
|
|
57
|
+
if 'player_id' in tr.columns:
|
|
58
|
+
cols_needed.append('player_id')
|
|
59
|
+
else:
|
|
60
|
+
tr['player_id'] = -1
|
|
61
|
+
cols_needed.append('player_id')
|
|
62
|
+
|
|
63
|
+
for (half, sec), df in tr.groupby(['half', 'sec_tracking'], sort=False):
|
|
64
|
+
frames[(int(half), float(sec))] = {
|
|
65
|
+
'team': df['team_id'].astype(str).to_numpy(),
|
|
66
|
+
'x': df['pos_x'].to_numpy(dtype=float),
|
|
67
|
+
'y': df['pos_y'].to_numpy(dtype=float),
|
|
68
|
+
'pid': df['player_id'].to_numpy(),
|
|
69
|
+
}
|
|
70
|
+
return frames
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _flip_coords(x: np.ndarray, y: np.ndarray, do_flip: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
74
|
+
# Flip in TV orientation box 105x68
|
|
75
|
+
xf = np.where(do_flip, 105.0 - x, x)
|
|
76
|
+
yf = np.where(do_flip, 68.0 - y, y)
|
|
77
|
+
return xf, yf
|
|
78
|
+
|
|
79
|
+
def run(self, radii: list[float], cone_degrees: list[float] | None = None, k_list: list[int] | None = None) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Compute tracking-based features for each event and write them into self.events.
|
|
82
|
+
|
|
83
|
+
Features (for pos and pos_dest, unless noted otherwise):
|
|
84
|
+
- counts of teammates/opponents with x <, x > current point (attack-aligned)
|
|
85
|
+
- counts of teammates/opponents within given radii
|
|
86
|
+
- pos only: x of second-back (closest to own goal excluding GK via 2nd) and farthest x
|
|
87
|
+
"""
|
|
88
|
+
# Prepare events times aligned to tracking
|
|
89
|
+
self.events['sec_tracking'] = self.events['second'].map(self.to_tracking_second)
|
|
90
|
+
|
|
91
|
+
# Build right-side sets per half for flipping event coordinates
|
|
92
|
+
right_by_half = {
|
|
93
|
+
int(h): {tid for tid, side in sides.items() if side == 'right'}
|
|
94
|
+
for h, sides in self.side_by_half.items()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Event coordinate arrays
|
|
98
|
+
n = self.events.index.size
|
|
99
|
+
halves_arr = self.events['half'].to_numpy()
|
|
100
|
+
teams_ev_str = self._events_team_str.to_numpy()
|
|
101
|
+
x = self.events['pos_x'].to_numpy()
|
|
102
|
+
y = self.events['pos_y'].to_numpy()
|
|
103
|
+
xd = self.events['pos_dest_x'].to_numpy()
|
|
104
|
+
yd = self.events['pos_dest_y'].to_numpy()
|
|
105
|
+
|
|
106
|
+
# Flip mask for events where the event team plays RIGHT in this half (align to TV orientation)
|
|
107
|
+
flip_mask = np.zeros(n, dtype=bool)
|
|
108
|
+
for h, right_set in right_by_half.items():
|
|
109
|
+
if not right_set:
|
|
110
|
+
continue
|
|
111
|
+
idx_h = (halves_arr == h)
|
|
112
|
+
idx_right = np.isin(teams_ev_str, list(right_set))
|
|
113
|
+
flip_mask |= (idx_h & idx_right)
|
|
114
|
+
|
|
115
|
+
x, y = self._flip_coords(x, y, flip_mask)
|
|
116
|
+
xd, yd = self._flip_coords(xd, yd, flip_mask)
|
|
117
|
+
|
|
118
|
+
# Masks
|
|
119
|
+
src_nan = np.isnan(x) | np.isnan(y)
|
|
120
|
+
dest_nan = np.isnan(xd) | np.isnan(yd)
|
|
121
|
+
eq_eps = 1e-6
|
|
122
|
+
eq_mask = (~dest_nan) & np.isclose(xd, x, atol=eq_eps) & np.isclose(yd, y, atol=eq_eps)
|
|
123
|
+
|
|
124
|
+
# Build tracking frames map
|
|
125
|
+
frames = self._build_frames()
|
|
126
|
+
secs = self.events['sec_tracking'].to_numpy()
|
|
127
|
+
pids = self.events['player_id'].to_numpy() if 'player_id' in self.events.columns else np.full(n, -1)
|
|
128
|
+
|
|
129
|
+
# Preallocate outputs
|
|
130
|
+
# Source basic counts
|
|
131
|
+
src_tm_lt_x = np.full(n, np.nan)
|
|
132
|
+
src_tm_gt_x = np.full(n, np.nan)
|
|
133
|
+
src_opp_lt_x = np.full(n, np.nan)
|
|
134
|
+
src_opp_gt_x = np.full(n, np.nan)
|
|
135
|
+
|
|
136
|
+
# Dest basic counts
|
|
137
|
+
dst_tm_lt_x = np.full(n, np.nan)
|
|
138
|
+
dst_tm_gt_x = np.full(n, np.nan)
|
|
139
|
+
dst_opp_lt_x = np.full(n, np.nan)
|
|
140
|
+
dst_opp_gt_x = np.full(n, np.nan)
|
|
141
|
+
|
|
142
|
+
# Radii counts: dict rad -> arrays
|
|
143
|
+
radii = list(radii or [])
|
|
144
|
+
src_tm_in_r = {rad: np.full(n, np.nan) for rad in radii}
|
|
145
|
+
src_opp_in_r = {rad: np.full(n, np.nan) for rad in radii}
|
|
146
|
+
dst_tm_in_r = {rad: np.full(n, np.nan) for rad in radii}
|
|
147
|
+
dst_opp_in_r = {rad: np.full(n, np.nan) for rad in radii}
|
|
148
|
+
|
|
149
|
+
# Cone counts by degrees
|
|
150
|
+
cone_degrees = list(cone_degrees or [])
|
|
151
|
+
src_tm_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
|
|
152
|
+
src_opp_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
|
|
153
|
+
dst_tm_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
|
|
154
|
+
dst_opp_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
|
|
155
|
+
|
|
156
|
+
# k-NN mean distances
|
|
157
|
+
k_list = list(k_list or [])
|
|
158
|
+
src_tm_mean_k = {k: np.full(n, np.nan) for k in k_list}
|
|
159
|
+
src_opp_mean_k = {k: np.full(n, np.nan) for k in k_list}
|
|
160
|
+
dst_tm_mean_k = {k: np.full(n, np.nan) for k in k_list}
|
|
161
|
+
dst_opp_mean_k = {k: np.full(n, np.nan) for k in k_list}
|
|
162
|
+
|
|
163
|
+
# Pos-only opponent extremes
|
|
164
|
+
src_opp_second_back_x = np.full(n, np.nan)
|
|
165
|
+
src_opp_farthest_x = np.full(n, np.nan)
|
|
166
|
+
|
|
167
|
+
for i in range(n):
|
|
168
|
+
half = int(halves_arr[i])
|
|
169
|
+
sec = float(secs[i])
|
|
170
|
+
team_i = teams_ev_str[i]
|
|
171
|
+
pid_i = pids[i]
|
|
172
|
+
|
|
173
|
+
frame = frames.get((half, sec))
|
|
174
|
+
if frame is None:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
teams_f = frame['team']
|
|
178
|
+
xs = frame['x']
|
|
179
|
+
ys = frame['y']
|
|
180
|
+
pids_f = frame['pid']
|
|
181
|
+
|
|
182
|
+
# Masks relative to event team
|
|
183
|
+
tm_mask = (teams_f == team_i)
|
|
184
|
+
opp_mask = ~tm_mask
|
|
185
|
+
|
|
186
|
+
# Source position features
|
|
187
|
+
if not src_nan[i]:
|
|
188
|
+
xi, yi = float(x[i]), float(y[i])
|
|
189
|
+
|
|
190
|
+
# Align x with attack direction: for right-side team, ahead is smaller TV-x
|
|
191
|
+
side_x = self.side_by_half.get(half, {}).get(team_i, np.nan)
|
|
192
|
+
sign = 1.0 if side_x == 'left' else (-1.0 if side_x == 'right' else 1.0)
|
|
193
|
+
src_tm_lt_x[i] = np.sum(tm_mask & ((sign * (xs - xi)) < 0))
|
|
194
|
+
src_tm_gt_x[i] = np.sum(tm_mask & ((sign * (xs - xi)) > 0))
|
|
195
|
+
|
|
196
|
+
src_opp_lt_x[i] = np.sum(opp_mask & ((sign * (xs - xi)) < 0))
|
|
197
|
+
src_opp_gt_x[i] = np.sum(opp_mask & ((sign * (xs - xi)) > 0))
|
|
198
|
+
|
|
199
|
+
if radii:
|
|
200
|
+
d2 = (xs - xi) ** 2 + (ys - yi) ** 2
|
|
201
|
+
not_self = (pids_f != pid_i)
|
|
202
|
+
for rad in radii:
|
|
203
|
+
mask_r = d2 <= (float(rad) * float(rad))
|
|
204
|
+
src_tm_in_r[rad][i] = np.sum(tm_mask & not_self & mask_r)
|
|
205
|
+
src_opp_in_r[rad][i] = np.sum(opp_mask & mask_r)
|
|
206
|
+
|
|
207
|
+
# Cones (pos): counts within +/- deg around forward-to-goal direction
|
|
208
|
+
if cone_degrees:
|
|
209
|
+
side = self.side_by_half.get(half, {}).get(team_i, np.nan)
|
|
210
|
+
if isinstance(side, str):
|
|
211
|
+
f_sign = 1.0 if side == 'left' else -1.0 # +x if left-side team, else -x
|
|
212
|
+
dx = xs - xi
|
|
213
|
+
dy = ys - yi
|
|
214
|
+
d = np.sqrt(dx * dx + dy * dy) + 1e-8
|
|
215
|
+
cosang = (f_sign * dx) / d # dot with unit forward vector
|
|
216
|
+
not_self = (pids_f != pid_i)
|
|
217
|
+
for deg in cone_degrees:
|
|
218
|
+
cth = np.cos(np.deg2rad(float(deg)))
|
|
219
|
+
in_cone = cosang >= cth
|
|
220
|
+
src_tm_cone[deg][i] = np.sum(in_cone & tm_mask & not_self)
|
|
221
|
+
src_opp_cone[deg][i] = np.sum(in_cone & opp_mask)
|
|
222
|
+
|
|
223
|
+
# k-NN mean distances (pos)
|
|
224
|
+
if k_list:
|
|
225
|
+
dx = xs - xi
|
|
226
|
+
dy = ys - yi
|
|
227
|
+
d = np.sqrt(dx * dx + dy * dy)
|
|
228
|
+
# teammates excluding self
|
|
229
|
+
mask_tm_valid = tm_mask & (pids_f != pid_i)
|
|
230
|
+
dist_tm = d[mask_tm_valid]
|
|
231
|
+
dist_opp = d[opp_mask]
|
|
232
|
+
dist_tm.sort()
|
|
233
|
+
dist_opp.sort()
|
|
234
|
+
for k in k_list:
|
|
235
|
+
if dist_tm.size > 0:
|
|
236
|
+
src_tm_mean_k[k][i] = float(np.mean(dist_tm[:min(k, dist_tm.size)]))
|
|
237
|
+
if dist_opp.size > 0:
|
|
238
|
+
src_opp_mean_k[k][i] = float(np.mean(dist_opp[:min(k, dist_opp.size)]))
|
|
239
|
+
|
|
240
|
+
# Opponent extremes (TV orientation)
|
|
241
|
+
side = self.side_by_half.get(half, {}).get(team_i, np.nan)
|
|
242
|
+
if isinstance(side, str):
|
|
243
|
+
# Extremes in TV orientation (left-to-right)
|
|
244
|
+
x_opp_tv = xs[opp_mask]
|
|
245
|
+
if x_opp_tv.size >= 2:
|
|
246
|
+
opp_side = 'left' if side == 'right' else 'right'
|
|
247
|
+
if opp_side == 'left':
|
|
248
|
+
x_sorted = np.sort(x_opp_tv)
|
|
249
|
+
src_opp_farthest_x[i] = float(np.max(x_opp_tv))
|
|
250
|
+
if x_sorted.size >= 2:
|
|
251
|
+
src_opp_second_back_x[i] = float(x_sorted[1]) # 2nd smallest
|
|
252
|
+
else: # opp_side == 'right'
|
|
253
|
+
x_sorted = np.sort(x_opp_tv)[::-1]
|
|
254
|
+
src_opp_farthest_x[i] = float(np.min(x_opp_tv))
|
|
255
|
+
if x_sorted.size >= 2:
|
|
256
|
+
src_opp_second_back_x[i] = float(x_sorted[1]) # 2nd largest
|
|
257
|
+
|
|
258
|
+
# Destination position features
|
|
259
|
+
if eq_mask[i]:
|
|
260
|
+
# copy from source if dest coincides with source
|
|
261
|
+
dst_tm_lt_x[i] = src_tm_lt_x[i]
|
|
262
|
+
dst_tm_gt_x[i] = src_tm_gt_x[i]
|
|
263
|
+
dst_opp_lt_x[i] = src_opp_lt_x[i]
|
|
264
|
+
dst_opp_gt_x[i] = src_opp_gt_x[i]
|
|
265
|
+
for rad in radii:
|
|
266
|
+
dst_tm_in_r[rad][i] = src_tm_in_r[rad][i]
|
|
267
|
+
dst_opp_in_r[rad][i] = src_opp_in_r[rad][i]
|
|
268
|
+
elif not dest_nan[i]:
|
|
269
|
+
xdi, ydi = float(xd[i]), float(yd[i])
|
|
270
|
+
|
|
271
|
+
side_x = self.side_by_half.get(half, {}).get(team_i, np.nan)
|
|
272
|
+
sign = 1.0 if side_x == 'left' else (-1.0 if side_x == 'right' else 1.0)
|
|
273
|
+
dst_tm_lt_x[i] = np.sum(tm_mask & ((sign * (xs - xdi)) < 0))
|
|
274
|
+
dst_tm_gt_x[i] = np.sum(tm_mask & ((sign * (xs - xdi)) > 0))
|
|
275
|
+
|
|
276
|
+
dst_opp_lt_x[i] = np.sum(opp_mask & ((sign * (xs - xdi)) < 0))
|
|
277
|
+
dst_opp_gt_x[i] = np.sum(opp_mask & ((sign * (xs - xdi)) > 0))
|
|
278
|
+
|
|
279
|
+
if radii:
|
|
280
|
+
d2d = (xs - xdi) ** 2 + (ys - ydi) ** 2
|
|
281
|
+
not_self = (pids_f != pid_i)
|
|
282
|
+
for rad in radii:
|
|
283
|
+
mask_rd = d2d <= (float(rad) * float(rad))
|
|
284
|
+
dst_tm_in_r[rad][i] = np.sum(tm_mask & not_self & mask_rd)
|
|
285
|
+
dst_opp_in_r[rad][i] = np.sum(opp_mask & mask_rd)
|
|
286
|
+
|
|
287
|
+
# Cones (dest)
|
|
288
|
+
if cone_degrees:
|
|
289
|
+
side = self.side_by_half.get(half, {}).get(team_i, np.nan)
|
|
290
|
+
if isinstance(side, str):
|
|
291
|
+
f_sign = 1.0 if side == 'left' else -1.0
|
|
292
|
+
dx = xs - xdi
|
|
293
|
+
dy = ys - ydi
|
|
294
|
+
d = np.sqrt(dx * dx + dy * dy) + 1e-8
|
|
295
|
+
cosang = (f_sign * dx) / d
|
|
296
|
+
not_self = (pids_f != pid_i)
|
|
297
|
+
for deg in cone_degrees:
|
|
298
|
+
cth = np.cos(np.deg2rad(float(deg)))
|
|
299
|
+
in_cone = cosang >= cth
|
|
300
|
+
dst_tm_cone[deg][i] = np.sum(in_cone & tm_mask & not_self)
|
|
301
|
+
dst_opp_cone[deg][i] = np.sum(in_cone & opp_mask)
|
|
302
|
+
|
|
303
|
+
# k-NN mean distances (dest)
|
|
304
|
+
if k_list:
|
|
305
|
+
dx = xs - xdi
|
|
306
|
+
dy = ys - ydi
|
|
307
|
+
d = np.sqrt(dx * dx + dy * dy)
|
|
308
|
+
mask_tm_valid = tm_mask & (pids_f != pid_i)
|
|
309
|
+
dist_tm = d[mask_tm_valid]
|
|
310
|
+
dist_opp = d[opp_mask]
|
|
311
|
+
dist_tm.sort()
|
|
312
|
+
dist_opp.sort()
|
|
313
|
+
for k in k_list:
|
|
314
|
+
if dist_tm.size > 0:
|
|
315
|
+
dst_tm_mean_k[k][i] = float(np.mean(dist_tm[:min(k, dist_tm.size)]))
|
|
316
|
+
if dist_opp.size > 0:
|
|
317
|
+
dst_opp_mean_k[k][i] = float(np.mean(dist_opp[:min(k, dist_opp.size)]))
|
|
318
|
+
|
|
319
|
+
# Write columns to events
|
|
320
|
+
self.events['tf_src_tm_lt_x'] = src_tm_lt_x
|
|
321
|
+
self.events['tf_src_tm_gt_x'] = src_tm_gt_x
|
|
322
|
+
self.events['tf_src_opp_lt_x'] = src_opp_lt_x
|
|
323
|
+
self.events['tf_src_opp_gt_x'] = src_opp_gt_x
|
|
324
|
+
|
|
325
|
+
self.events['tf_dest_tm_lt_x'] = dst_tm_lt_x
|
|
326
|
+
self.events['tf_dest_tm_gt_x'] = dst_tm_gt_x
|
|
327
|
+
self.events['tf_dest_opp_lt_x'] = dst_opp_lt_x
|
|
328
|
+
self.events['tf_dest_opp_gt_x'] = dst_opp_gt_x
|
|
329
|
+
|
|
330
|
+
for rad in radii:
|
|
331
|
+
r_tag = f"r{float(rad):g}"
|
|
332
|
+
self.events[f'tf_src_tm_in_{r_tag}'] = src_tm_in_r[rad]
|
|
333
|
+
self.events[f'tf_src_opp_in_{r_tag}'] = src_opp_in_r[rad]
|
|
334
|
+
self.events[f'tf_dest_tm_in_{r_tag}'] = dst_tm_in_r[rad]
|
|
335
|
+
self.events[f'tf_dest_opp_in_{r_tag}'] = dst_opp_in_r[rad]
|
|
336
|
+
|
|
337
|
+
# Cone features
|
|
338
|
+
for deg in cone_degrees:
|
|
339
|
+
d_tag = f"deg{float(deg):g}"
|
|
340
|
+
self.events[f'tf_src_tm_cone_{d_tag}'] = src_tm_cone[deg]
|
|
341
|
+
self.events[f'tf_src_opp_cone_{d_tag}'] = src_opp_cone[deg]
|
|
342
|
+
self.events[f'tf_dest_tm_cone_{d_tag}'] = dst_tm_cone[deg]
|
|
343
|
+
self.events[f'tf_dest_opp_cone_{d_tag}'] = dst_opp_cone[deg]
|
|
344
|
+
|
|
345
|
+
# k-NN mean distances
|
|
346
|
+
for k in k_list:
|
|
347
|
+
self.events[f'tf_src_tm_mean_dist_k{k}'] = src_tm_mean_k[k]
|
|
348
|
+
self.events[f'tf_src_opp_mean_dist_k{k}'] = src_opp_mean_k[k]
|
|
349
|
+
self.events[f'tf_dest_tm_mean_dist_k{k}'] = dst_tm_mean_k[k]
|
|
350
|
+
self.events[f'tf_dest_opp_mean_dist_k{k}'] = dst_opp_mean_k[k]
|
|
351
|
+
|
|
352
|
+
# Pos-only opponent extremes
|
|
353
|
+
self.events['tf_src_opp_second_back_x'] = src_opp_second_back_x
|
|
354
|
+
self.events['tf_src_opp_farthest_x'] = src_opp_farthest_x
|
|
@@ -15,4 +15,8 @@ rustat_python_api.egg-info/dependency_links.txt
|
|
|
15
15
|
rustat_python_api.egg-info/requires.txt
|
|
16
16
|
rustat_python_api.egg-info/top_level.txt
|
|
17
17
|
rustat_python_api/kernels/__init__.py
|
|
18
|
-
rustat_python_api/kernels/maha.py
|
|
18
|
+
rustat_python_api/kernels/maha.py
|
|
19
|
+
rustat_python_api/matching/__init__.py
|
|
20
|
+
rustat_python_api/matching/dataloader.py
|
|
21
|
+
rustat_python_api/matching/pc_adder.py
|
|
22
|
+
rustat_python_api/matching/tr_adder.py
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{rustat-python-api-0.7.1 → rustat-python-api-0.7.2}/rustat_python_api.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|