tanzhijian.org

关于 that-game

我在一些足球事件的项目中大量使用了 kloppy, 也有一些项目使用了 socceraction 的 spadl,这两个都可以看成“标准化的足球事件数据结构”,也就是把各个数据源的事件数据读取成一个统一的数据结构,方便后面的一系列计算。

但他们各有各的不方便。socceraction 是基于 pandas,使用 pandera 预设一些字段,也可以做数据验证。既然本质上是个 pd.DataFrame, 就有着 df 不可避免的优点和缺点,数据科学家会很习惯他的操作,但在编写实际代码的时候缺乏很好的自动补全,类型推断,在提供的字段上面也偏少:

sbl = StatsBombLoader(root=data_path, getter="local")
df_games = sbl.games(competition_id=43, season_id=3).set_index("game_id")
game_id = 8657
df_teams = sbl.teams(game_id)
df_players = sbl.players(game_id)
df_events = sbl.events(game_id)

home_team_id = df_games.at[game_id, "home_team_id"]
df_actions = spadl.statsbomb.convert_to_actions(df_events, home_team_id)
df_actions.sample(5)
game_id original_event_id period_id time_seconds team_id player_id start_x start_y end_x end_y type_id result_id bodypart_id action_id
2307 8657 206ac85d-9b2d-475f-88c7-bc4ccc2738e7 2 2482.471 768 3308.0 50.3125 59.075 39.8125 64.175 21 1 0 2307
168 8657 5e1b54a1-a93b-4c78-a69f-4ed8c708dbd9 1 346.439 782 3101.0 43.3125 27.625 42.4375 28.475 21 1 0 168
938 8657 19d08ada-edbc-4fe6-bfd3-00aa03977447 1 1821.052 768 3468.0 98.4375 30.175 97.5625 30.175 21 1 0 938
2310 8657 fe9150f6-60d7-429b-8116-115f1b49a1b8 2 2489.351 782 3077.0 25.8125 65.025 31.9375 64.175 21 1 0 2310
903 8657 9f489405-7d17-4a0b-9c77-089c49e0b056 1 1770.533 782 3176.0 65.1875 4.675 65.1875 12.325 0 1 5 903

读取过程稍显麻烦,一些在我看来的重要信息,比如 team,player,type, result, bodypart 等都是使用数字作为类别,不存在可读性,如果想要搞清楚数字代表的具体含义需要再调用一个函数:

from socceraction.spadl import results_df

results_df()
result_id result_name
0 0 fail
1 1 success
2 2 offside
3 3 owngoal
4 4 yellow_card
5 5 red_card

都是可哈希对象,其实可以使用字符串使其更具有可读性。而且 spadl 作为为这个库主要的目的,计算 xT, VAEP 的数据结构,支持的 type 也偏少。

kloppy 是则是使用了更为灵活的基于对象的数据模型,有更多的预设读取,如果你只是用他预设的数据源进行操作没什么问题,想更多的自定义操作,他的数据类使用 python dataclass,就没办法有很好的数据验证:

from kloppy.domain import Team, Ground

# 这个是正确输入
team = Team(team_id="ars", name="Arsenal", ground=Ground.HOME)

# 但即使输入一些错误的类型也不会有问题
team = Team(team_id=123, name=123, ground="HOME")

只能通过 mypy 之类的工具寄希望于代码执行之前检查类型,并不具有强制性。同时如果想自定义创建一个 EventDataset 需要太多步骤:

from kloppy.domain import (
    BallState,
    DatasetFlag,
    EventDataset,
    Ground,
    KloppyCoordinateSystem,
    Metadata,
    Orientation,
    Period,
    PitchDimensions,
    Player,
    Point,
    Provider,
    ShotEvent,
    ShotResult,
    Team,
)
period = Period(id=1, start_timestamp=0.0, end_timestamp=2827.0)
team = Team(team_id="ars", name="Arsenal", ground=Ground.HOME)
team_2 = Team(team_id="che", name="Chelsea", ground=Ground.AWAY)
player = Player(player_id="saka", team=team, jersey_no=7, name="Bukayo Saka")
coordinates = Point(x=100, y=50)
event = ShotEvent(
    event_id="1",
    period=period,
    timestamp=217.32,
    team=team,
    player=player,
    coordinates=coordinates,
    result=ShotResult.OFF_TARGET,
    raw_event=None,
    ball_state=BallState.ALIVE,
    ball_owning_team=team,
    related_event_ids=[],
    state={},
    qualifiers=[],
    freeze_frame=None,
)
pitch_dimensions = PitchDimensions(x_dim=108, y_dim=68)
coordinate_system = KloppyCoordinateSystem(normalized=True, length=108, width=68)
metadata = Metadata(
    teams=[team, team_2],
    periods=[period],
    pitch_dimensions=pitch_dimensions,
    orientation=Orientation.ACTION_EXECUTING_TEAM,
    flags=DatasetFlag.BALL_OWNING_TEAM,
    provider=Provider.OTHER,
    coordinate_system=coordinate_system,
)
custom_dataset = EventDataset(records=[event], metadata=metadata)

他在描述状态的时候用了大量的 enum,每一个都需要导入,以及确定每一个字段的含义。而我使用最不舒服的一点在构建 team 和 player,他在设计时使用了循环引用:

@dataclass
class Player:
    team: "Team"

@dataclass
class Team:
    players: List[Player] = field(default_factory=list)

可以读他源代码中读取 statsbomb 的一段:

        def create_team(lineup, ground_type):
            team = Team(
                team_id=str(lineup["team_id"]),
                name=lineup["team_name"],
                ground=ground_type,
                starting_formation=starting_formations[lineup["team_id"]],
            )
            team.players = [
                Player(
                    player_id=str(player["player_id"]),
                    team=team,
                    name=player["player_name"],
                    jersey_no=int(player["jersey_number"]),
                    starting=str(player["player_id"]) in player_positions,
                    position=player_positions.get(str(player["player_id"])),
                )
                for player in lineup["lineup"]
            ]
            return team

循环引用有什么后果不多讨论,单从使用上来说,需要先创建 team,然后创建 player,把 team 塞到 player,再把 player 塞到 team。

在使用那么久之后,我还是想自己创建一个 Events 的数据格式,便写了 that-game。

我中和了上面两个库的特性,以及长期的使用习惯,that-game 需要有以下的特点:

使用 pydantic 来创建数据类可以解决大部分,常用状态使用 typing.Literal 预设字段,既能很好的配合编辑器补全,也能通过 pydantic 进行输入验证。

在创建新对象的时候,可以导入每个数据类,嫌麻烦也可以这样:

from that_game import Shot

shot = Shot(
    id="0001",
    type="shot",
    period="first_half",
    seconds=62.0,
    team={"id": "ars", "name": "Arsenal"},
    player={"id": "a7", "name": "Bukayo Saka", "position": "FW"},
    location={
        "x": 100.1,
        "y": 43.2,
        "pitch": {"length": 108, "width": 68},
    },
    end_location={
        "x": 108.0,
        "y": 43.2,
        "pitch": {"length": 108, "width": 68},
    },
    pattern="open_play",
    body_part="left_foot",
    result="saved",
)

每个数据源之间最大的差异便是坐标系统,使用球场长宽高不同,方向不同,如何方便的转换是一个很大的问题,that-game 的 Location 可以很直观方便的转换:

from that_game import Location, Pitch

location = Location(
    x=0.4,
    y=0.6,
    pitch=Pitch(length=1, width=1),
)

# 只需要设定好新的 pitch 标准
pitch = Pitch(
    length=100,
    width=100,
    length_direction="left",
    width_direction="down",
)
location.transform(pitch)
print(location.x, location.y)
60.0 40.0

我会在完善 Location 类后添加更多的预设 pitch,更加方便转换。

目前支持的 type 仅有 shot 和 pass,支持的数据源也仅是 statsbomb,同时还有一个 fusion-events 的调试项目,在这里可以通过网络请求获取一些网站,比如 understat 的 events 进行操作。之后的工作重点便是添加更多的事件类型,添加更多的事件状态,添加更多的字段,比如 shot 预设计算 distance 和 angle, 以及不断调整它们之间的兼容性,更多的 loaders。这个库的难点不在于复杂度,而在于取舍,和更方便的使用。大概会用一年的时间让他变得可用吧。