fern://programming
← 日志

一个赛季的 Sofascore 数据,以及它在哪里说谎

抓取 2025 年巴西甲级联赛的全部球员评分,以及数据里的三个陷阱:时区错误的日期、从不发送的零值,以及打乱顺序的轮次。

我的毕业论文需要 2025 年巴西甲级联赛的全部球员评分。Sofascore 有这些数据,就在它自己网站所用的内部 API 后面。由于这不是官方的公开 API,爬虫被刻意做得很"无聊":每 1.2 到 2.4 秒发一次请求,失败时带退避重试,每一轮结束后保存断点,崩溃后可以接着跑而不必从头开始,并且每一行数据在写入 CSV 之前都要经过 Pydantic 校验。

最终从全部 380 场比赛中抓取了 11,977 条球员单场记录,比赛时间从 2025 年 3 月 29 日到 12 月 7 日。采集是最简单的部分。数据里有三个陷阱,而且没有一个会报错。

时区错误的日期

爬虫用 UTC 存储比赛日期。巴西的夜场比赛在 21:00 或更晚开球,按 UTC 已经是第二天了,所以 380 场比赛中有 67 场的日期是错的。修正方法是根据开球时间戳在 America/Sao_Paulo 时区下重新计算日期,清洗步骤和爬虫本身都做了修改。

三种缺失

一个空单元格可能代表三种不同的含义,每种都有各自的处理规则:

  • 不适用。 比如中锋的扑救数。这些字段对非门将球员根本不会出现,所以填 -1,这是任何真实计数都不可能取到的值。
  • 未测量。 体能数据(跑动距离、冲刺次数、最高速度)只在 380 场中的 349 场有,带球推进数据只在 83 场有。这些保持缺失。如果填成零,就等于声称球员没有跑动。
  • 为零而被省略。 Sofascore 不会发送值为 0 的数据。在原始数据中,goals 有 11,124 行为空,expected_goals 有 6,314 行为空,恰好都是球员没有射门的那些行。这些填为 0。

在体能和带球推进这两组数据中,规则按球员判断:如果某球员有该组中的任意一项指标,缺失的就是被省略的零值;如果一项都没有,说明这组数据没有为他测量。

一个边界情况:门将的 goals_prevented 可能为负(从 -3.79 到 3.12),所以在这里哨兵值 -1 会和真实值冲突。这时用位置列来区分。

打乱顺序的轮次

给一个赛季排序,最直观的方法是按轮次编号。但这是错的。轮次会因为洲际赛事而改期,第 34 轮的大部分比赛直到 11 月才踢,而第 35 到 37 轮早在 10 月就已经踢完了。整个赛季中,72,010 对可能的比赛组合里,有 1,795 对(2.49%)的轮次编号和实际开球时间相矛盾。

一旦要计算球员的历史数据,这就不再是冷知识了。按轮次而不是开球时间排序,会改变 686 名踢过两场或以上的球员中 335 人(48.8%)的比赛顺序。对于一个只能看到过去比赛的模型来说,这就是数据泄漏:一场未来的比赛混进了过去某场比赛的历史里。之后的所有步骤都按 kickoff_timestamp 排序,从不按 round 排序。

清洗之后,删除了 163 条没有评分的出场记录,它们的出场时间都在 7 分钟或以下。最终剩下 732 名球员的 11,814 行数据,也就是 当 R² 好得过头 中模型所用的训练数据。