用脚本统计头球争顶成功率的方法
下面给你一套完整的思路和可运行脚本(Python),适用于足球比赛事件数据(如 StatsBomb、Opta、Wyscout 等格式)。

核心逻辑
头球争顶成功率 = 争顶成功次数 / 总争顶次数
关键难点在于数据源如何标记头球争顶,常见字段:
| 数据源 | 争顶事件字段 | 成功标记 |
|---|---|---|
| StatsBomb | duel + duel.type = "Aerial Lost" |
无"成功"版本,用对手的 Aerial Lost 反推 |
| Opta | Aerial / Challenge |
outcome = 1(won) |
| Wyscout | Air duel |
successful = 1 |
| FotMob/Sofascore | duel_won / aerial_won |
已聚合 |
⚠️ 注意:StatsBomb 只记录 Aerial Lost(争顶失败方),成功方通过"同一 duel 的另一名球员"推断,这是统计时最容易出错的点。
StatsBomb 版本脚本(最常用)
import pandas as pd
from statsbombpy import sb
def aerial_duel_stats(match_id: int) -> pd.DataFrame:
"""
统计单场比赛每名球员的头球争顶数据
返回: player, team, aerials_total, aerials_won, success_rate
"""
events = sb.events(match_id=match_id)
# 1. 筛选空中对抗事件
duels = events[events["type"] == "Duel"].copy()
duels = duels[duels["duel_type"] == "Aerial Lost"] # 只记录失败方
# 2. 每名球员的"争顶失败"次数
lost = (
duels.groupby(["player", "team"])
.size()
.rename("aerials_lost")
.reset_index()
)
# 3. 通过 duel 配对推断"争顶成功"次数
# StatsBomb 中一次争顶 = 一条 Aerial Lost 事件
# 该事件属于失败方;成功方为"同一时间段内的对手"
# 简化做法:总争顶 = 双方各计一次,成功 = 总 - 失败
# 更严谨:用 duel 的 related_events 配对(见下方进阶版)
# 简化统计:以球队为单位反推
# 每名球员参与的争顶总数 = 该球员所在 duel 数
# 此处用近似:成功 = 该队总争顶数 - 该球员失败数(需精确配对)
print("提示:精确统计请使用下方配对版本")
return lost
def aerial_duel_stats_paired(match_id: int) -> pd.DataFrame:
"""
精确版:通过 duel 配对统计成功/失败
原理:每条 Aerial Lost 对应两名球员,失败方已标记,
成功方 = 该事件中另一名参与球员
"""
events = sb.events(match_id=match_id).reset_index(drop=True)
# StatsBomb 的 Duel 事件本身不含对手ID,需借助 duel 的 related_events
# 实际做法:一次争顶通常生成两条事件(双方各一条,互为 related)
duels = events[events["type"] == "Duel"].copy()
# 通过 related_events 找到配对事件
# 这里用更稳的方法:按 (timestamp, 位置相近) 匹配
# ——实际生产中推荐直接用 Wyscout/Opta 的 outcome 字段
# 若数据源已带 outcome 字段(如 Opta 转换后):
if "duel_outcome" in duels.columns:
duels["won"] = duels["duel_outcome"] == "Won"
stats = (
duels.groupby(["player", "team"])
.agg(aerials_total=("won", "size"), aerials_won=("won", "sum"))
.reset_index()
)
stats["success_rate"] = (stats["aerials_won"] / stats["aerials_total"] * 100).round(1)
return stats.sort_values("success_rate", ascending=False)
raise ValueError("该数据源需先做 duel 配对,建议改用带 outcome 的字段")
通用版(适用于已带 outcome 的数据)
适合 Wyscout / Opta / 自己整理的事件表:
import pandas as pd
def aerial_success_rate(df: pd.DataFrame, min_duels: int = 5) -> pd.DataFrame:
"""
df 需包含列: player, team, event_type, outcome
仅统计 event_type 含 'aerial' 或 'air duel' 的行
"""
aerials = df[df["event_type"].str.lower().str.contains("aerial|air duel", na=False)].copy()
aerials["won"] = aerials["outcome"].astype(str).str.lower().isin(["won", "success", "1", "true"])
stats = (
aerials.groupby(["player", "team"])
.agg(aerials_total=("won", "size"), aerials_won=("won", "sum"))
.reset_index()
)
stats["success_rate_%"] = (stats["aerials_won"] / stats["aerials_total"] * 100).round(1)
# 过滤样本过少的球员(避免 1/1 = 100% 的噪声)
stats = stats[stats["aerials_total"] >= min_duels]
return stats.sort_values("success_rate_%", ascending=False).reset_index(drop=True)
# 示例
# result = aerial_success_rate(events_df, min_duels=10)
# print(result.head(20))
进阶:按位置/区域拆分
def aerial_by_zone(df):
"""按球场区域统计争顶成功率(需 x,y 坐标字段)"""
aerials = df[df["event_type"].str.contains("aerial", case=False, na=False)].copy()
aerials["won"] = aerials["outcome"].isin(["won", 1, True])
# 划分为后场/中场/前场(按 x 坐标 0-120 或 0-100)
aerials["zone"] = pd.cut(
aerials["x"], bins=[0, 40, 80, 120],
labels=["后场", "中场", "前场"]
)
return (
aerials.groupby(["player", "zone"], observed=True)
.agg(total=("won", "size"), won=("won", "sum"))
.assign(rate=lambda d: (d["won"] / d["total"] * 100).round(1))
.reset_index()
)
常见坑与建议
- StatsBomb 的 Aerial Lost 陷阱:它只记录失败方,直接
count(Aerial Lost)会得到"失败数"而非"总数",必须配对或反推。 - 样本量过滤:
min_duels很关键,否则替补球员 1/1 = 100% 会污染排名。 - "争顶"≠"头球":Aerial duel 包含用胸、肩的争顶;若只统计头球,需再过滤
body_part = Head。 - 门将排除:门将的争顶多为出击,建议按位置过滤。
- 跨比赛聚合:先按 match 统计再汇总,避免单场噪声。
快速上手(StatsBomb 实战)
# 统计某场比赛头球争顶成功率 match_id = 3788741 # 举例 stats = aerial_duel_stats_paired(match_id) print(stats.head(15))
如果你告诉我具体数据源格式(StatsBomb JSON / Opta CSV / Wyscout / 自建表),我可以把脚本改成直接能跑的版本,包括 duel 配对的精确实现,你需要哪一种?