socr / spadl /opta.py
scfive's picture
Upload 203 files
d6ea71e verified
"""Opta event stream data to SPADL converter."""
from typing import Any, cast
import pandas as pd # type: ignore
from pandera.typing import DataFrame
from . import config as spadlconfig
from .base import (
_add_dribbles,
_fix_clearances,
_fix_direction_of_play,
min_dribble_length,
)
from .schema import SPADLSchema
def convert_to_actions(events: pd.DataFrame, home_team_id: int) -> DataFrame[SPADLSchema]:
"""
Convert Opta events to SPADL actions.
Parameters
----------
events : pd.DataFrame
DataFrame containing Opta events from a single game.
home_team_id : int
ID of the home team in the corresponding game.
Returns
-------
actions : pd.DataFrame
DataFrame with corresponding SPADL actions.
"""
actions = pd.DataFrame()
actions["game_id"] = events.game_id
actions["original_event_id"] = events.event_id.astype(object)
actions["period_id"] = events.period_id
actions["time_seconds"] = (
60 * events.minute
+ events.second
- ((events.period_id > 1) * 45 * 60)
- ((events.period_id > 2) * 45 * 60)
- ((events.period_id > 3) * 15 * 60)
- ((events.period_id > 4) * 15 * 60)
)
actions["team_id"] = events.team_id
actions["player_id"] = events.player_id
for col in ["start_x", "end_x"]:
actions[col] = events[col].clip(0, 100) / 100 * spadlconfig.field_length
for col in ["start_y", "end_y"]:
actions[col] = events[col].clip(0, 100) / 100 * spadlconfig.field_width
actions["type_id"] = events[["type_name", "outcome", "qualifiers"]].apply(_get_type_id, axis=1)
actions["result_id"] = events[["type_name", "outcome", "qualifiers"]].apply(
_get_result_id, axis=1
)
actions["bodypart_id"] = events[["type_name", "outcome", "qualifiers"]].apply(
_get_bodypart_id, axis=1
)
actions = _fix_recoveries(actions, events.type_name)
actions = _fix_unintentional_ball_touches(actions, events.type_name, events.outcome)
actions = (
actions[actions.type_id != spadlconfig.actiontypes.index("non_action")]
.sort_values(["game_id", "period_id", "time_seconds"], kind="mergesort")
.reset_index(drop=True)
)
actions = _fix_owngoals(actions)
actions = _fix_direction_of_play(actions, home_team_id)
actions = _fix_clearances(actions)
actions = _fix_interceptions(actions)
actions["action_id"] = range(len(actions))
actions = _add_dribbles(actions)
return cast(DataFrame[SPADLSchema], actions)
def _get_bodypart_id(args: tuple[str, bool, dict[int, Any]]) -> int:
e, outcome, q = args
if 15 in q or 3 in q or 168 in q:
b = "head"
elif 21 in q:
b = "other"
elif 20 in q:
b = "foot_right"
elif 72 in q:
b = "foot_left"
elif 107 in q: # throw-in
b = "other"
else:
if e in ["save", "claim", "punch", "keeper pick-up"]:
b = "other"
else:
b = "foot"
return spadlconfig.bodyparts.index(b)
def _get_result_id(args: tuple[str, bool, dict[int, Any]]) -> int:
e, outcome, q = args
if e == "offside pass":
r = "offside" # offside
elif e == "foul":
r = "fail"
elif e in ["attempt saved", "miss", "post"]:
r = "fail"
elif e == "goal":
if 28 in q:
r = "owngoal" # own goal, x and y must be switched
else:
r = "success"
elif e == "ball touch":
r = "fail"
elif outcome:
r = "success"
else:
r = "fail"
return spadlconfig.results.index(r)
def _get_type_id(args: tuple[str, bool, dict[int, Any]]) -> int: # noqa: C901
eventname, outcome, q = args
fairplay = 238 in q
if fairplay:
a = "non_action"
elif eventname in ("pass", "offside pass"):
cross = 2 in q
longball = 1 in q
chipped = 155 in q
freekick = 5 in q
corner = 6 in q
throw_in = 107 in q
goalkick = 124 in q
if throw_in:
a = "throw_in"
elif freekick and (cross or longball or chipped):
a = "freekick_crossed"
elif freekick:
a = "freekick_short"
elif corner and cross:
a = "corner_crossed"
elif corner:
a = "corner_short"
elif cross:
a = "cross"
elif goalkick:
a = "goalkick"
else:
a = "pass"
elif eventname == "take on":
a = "take_on"
elif eventname == "foul" and outcome is False:
a = "foul"
elif eventname == "tackle":
a = "tackle"
elif eventname in ("interception", "blocked pass"):
a = "interception"
elif eventname in ["miss", "post", "attempt saved", "goal"]:
if 9 in q:
a = "shot_penalty"
elif 26 in q:
a = "shot_freekick"
else:
a = "shot"
elif eventname == "save":
if 94 in q:
a = "non_action"
else:
a = "keeper_save"
elif eventname == "claim":
a = "keeper_claim"
elif eventname == "punch":
a = "keeper_punch"
elif eventname == "keeper pick-up":
a = "keeper_pick_up"
elif eventname == "clearance":
a = "clearance"
elif eventname == "ball touch" and outcome is False:
a = "bad_touch"
else:
a = "non_action"
return spadlconfig.actiontypes.index(a)
def _fix_owngoals(actions: pd.DataFrame) -> pd.DataFrame:
owngoals_idx = (actions.result_id == spadlconfig.results.index("owngoal")) & (
actions.type_id == spadlconfig.actiontypes.index("shot")
)
actions.loc[owngoals_idx, "end_x"] = (
spadlconfig.field_length - actions[owngoals_idx].end_x.values
)
actions.loc[owngoals_idx, "end_y"] = (
spadlconfig.field_width - actions[owngoals_idx].end_y.values
)
actions.loc[owngoals_idx, "type_id"] = spadlconfig.actiontypes.index("bad_touch")
return actions
def _fix_recoveries(df_actions: pd.DataFrame, opta_types: pd.Series) -> pd.DataFrame:
"""Convert ball recovery events to dribbles.
This function converts the Opta 'ball recovery' event (type_id 49) into
a dribble.
Parameters
----------
df_actions : pd.DataFrame
Opta actions dataframe
opta_types : pd.Series
Original Opta event types
Returns
-------
pd.DataFrame
Opta event dataframe without any ball recovery events
"""
df_actions_next = df_actions.shift(-1)
df_actions_next = df_actions_next.mask(
df_actions_next.type_id == spadlconfig.actiontypes.index("non_action")
).bfill()
selector_recovery = opta_types == "ball recovery"
same_x = abs(df_actions["end_x"] - df_actions_next["start_x"]) < min_dribble_length
same_y = abs(df_actions["end_y"] - df_actions_next["start_y"]) < min_dribble_length
same_loc = same_x & same_y
df_actions.loc[selector_recovery & ~same_loc, "type_id"] = spadlconfig.actiontypes.index(
"dribble"
)
df_actions.loc[selector_recovery & same_loc, "type_id"] = spadlconfig.actiontypes.index(
"non_action"
)
df_actions.loc[selector_recovery, ["end_x", "end_y"]] = df_actions_next.loc[
selector_recovery, ["start_x", "start_y"]
].values
return df_actions
def _fix_interceptions(df_actions: pd.DataFrame) -> pd.DataFrame:
"""Set the result of interceptions to 'fail' if they do not regain possession.
Parameters
----------
df_actions : pd.DataFrame
Opta actions dataframe.
Returns
-------
pd.DataFrame
Opta event dataframe without any ball recovery events
"""
mask_interception = df_actions.type_id == spadlconfig.actiontypes.index("interception")
same_team = df_actions.team_id == df_actions.shift(-1).team_id
df_actions.loc[mask_interception & ~same_team, "result_id"] = spadlconfig.results.index("fail")
return df_actions
def _fix_unintentional_ball_touches(
df_actions: pd.DataFrame, opta_type: pd.Series, opta_outcome: pd.Series
) -> pd.DataFrame:
"""Discard unintentional ball touches.
Passes that are deflected but still reach their target are registered as
successful passes. The (unintentional) deflection is not recored as an
action, because players should not be credited for it.
Parameters
----------
df_actions : pd.DataFrame
Opta actions dataframe
opta_type : pd.Series
Original Opta event types
opta_outcome : pd.Series
Original Opta event outcomes
Returns
-------
pd.DataFrame
Opta event dataframe without any unintentional ball touches.
"""
df_actions_next = df_actions.shift(-2)
selector_pass = df_actions["type_id"] == spadlconfig.actiontypes.index("pass")
selector_deflected = (opta_type.shift(-1) == "ball touch") & (opta_outcome.shift(-1))
selector_same_team = df_actions["team_id"] == df_actions_next["team_id"]
df_actions.loc[selector_deflected, ["end_x", "end_y"]] = df_actions_next.loc[
selector_deflected, ["start_x", "start_y"]
].values
df_actions.loc[selector_pass & selector_deflected & selector_same_team, "result_id"] = (
spadlconfig.results.index("success")
)
return df_actions