用Python分析球员跑动热点图案例
热点图(Heatmap)是体育数据分析中最直观的可视化方式之一,下面我给出一个完整的实战案例,从数据获取、坐标处理到可视化,覆盖核心流程。

分析思路总览
原始追踪数据 → 坐标标准化 → 网格化统计 → 核密度估计(KDE) → 可视化 → 战术解读
关键点:
- 坐标系统一(不同厂商数据坐标不同)
- 区分"出现在该位置"和"跑动密度"(两种热点图含义不同)
- 按时间/比赛阶段切分(上半场 vs 下半场热量差异)
- 叠加球场底图才能读图
完整代码案例
准备数据
真实数据通常来自 StatsBomb / Opta / Catapult / 自建GPS,这里用模拟数据演示(结构一致):
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle, Arc
from scipy.stats import gaussian_kde
# 模拟:某球员90分钟内每0.1秒的坐标(真实数据来自追踪系统)
np.random.seed(42)
def simulate_player_track(n=54000, style="winger"):
"""模拟边锋跑动轨迹:偏左路,前场多"""
t = np.linspace(0, 90, n)
# 基础位置:左边路
base_x = 30 + 20 * np.sin(t / 15) # 纵向摆动
base_y = 15 + 5 * np.sin(t / 8) # 靠左路(y小=左)
# 加入冲刺突刺(模拟前插)
sprints = np.random.randn(n) * 3
x = np.clip(base_x + sprints, 0, 120)
y = np.clip(base_y + np.random.randn(n) * 2, 0, 80)
return pd.DataFrame({"x": x, "y": y, "minute": t})
df = simulate_player_track()
print(df.head())
真实场景替换:
- StatsBomb:
events.json中的location字段 → 只需[x, y]- Catapult GPS:CSV 中
Longitude/Latitude或x/y字段- Opta:
x, y范围为 0-100,需缩放
绘制标准球场底图
def draw_pitch(ax, pitch_color="#2d5a3d", line_color="white"):
"""绘制标准 105x68 米球场 (FIFA标准)"""
ax.set_facecolor(pitch_color)
# 边线
ax.add_patch(Rectangle((0, 0), 105, 68, fill=False,
edgecolor=line_color, linewidth=2))
# 中线
ax.plot([52.5, 52.5], [0, 68], color=line_color, lw=2)
# 中圈
ax.add_patch(Circle((52.5, 34), 9.15, fill=False,
edgecolor=line_color, lw=2))
# 禁区 (左)
ax.add_patch(Rectangle((0, 13.85), 16.5, 40.3, fill=False,
edgecolor=line_color, lw=2))
ax.add_patch(Rectangle((0, 24.85), 5.5, 18.3, fill=False,
edgecolor=line_color, lw=2))
# 禁区 (右)
ax.add_patch(Rectangle((105-16.5, 13.85), 16.5, 40.3, fill=False,
edgecolor=line_color, lw=2))
ax.add_patch(Rectangle((105-5.5, 24.85), 5.5, 18.3, fill=False,
edgecolor=line_color, lw=2))
# 角球弧
for (x, y, a) in [(0,0,0),(105,0,0),(0,68,0),(105,68,0)]:
ax.add_patch(Arc((x, y), 2, 2, theta1=0, theta2=90,
edgecolor=line_color, lw=2))
ax.set_xlim(0, 105); ax.set_ylim(0, 68)
ax.set_aspect('equal'); ax.axis('off')
方法A:网格频率热点图(最简单,易解读)
def grid_heatmap(df, ax, bin_size=5):
"""按5m×5m网格统计出现次数"""
H, xedges, yedges = np.histogram2d(
df.x, df.y,
bins=[np.arange(0, 105+bin_size, bin_size),
np.arange(0, 68+bin_size, bin_size)]
)
im = ax.imshow(H.T, extent=[0, 105, 0, 68],
origin='lower', cmap='hot', alpha=0.75,
interpolation='bicubic')
plt.colorbar(im, ax=ax, label='出现次数')
优点:客观量化,适合"停留时长统计"。
缺点:不连续,分辨率依赖网格大小。
方法B:核密度估计 KDE 热点图(最常用,平滑美观)
def kde_heatmap(df, ax, bw=1.5):
"""高斯核密度估计:反映跑动密度"""
k = gaussian_kde(np.vstack([df.x, df.y]))
k.set_bandwidth(bw_method=bw / np.std(df[['x','y']].values, axis=0))
xi, yi = np.mgrid[0:105:200j, 0:68:140j]
zi = k(np.vstack([xi.flatten(), yi.flatten()]))
im = ax.contourf(xi, yi, zi.reshape(xi.shape),
levels=15, cmap='hot', alpha=0.75)
ax.contour(xi, yi, zi.reshape(xi.shape),
levels=15, colors='white', linewidths=0.3, alpha=0.4)
plt.colorbar(im, ax=ax, label='密度')
参数 bw(bandwidth) 是关键:
- 太小 → 过于碎片化,看不清整体
- 太大 → 都糊成一坨,失去细节
- 一般取 0~2.0 米 起步
组合成完整分析图
def plot_player_heatmap(df, player_name="球员 #10"):
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
fig.suptitle(f"{player_name} 跑动热点分析", fontsize=15, weight='bold')
# 左图:KDE密度
draw_pitch(axes[0])
kde_heatmap(df, axes[0])
axes[0].set_title("核密度图(跑动密度)")
# 右图:网格频率
draw_pitch(axes[1])
grid_heatmap(df, axes[1], bin_size=5)
axes[1].set_title("网格分布图(停留频次)")
plt.tight_layout()
plt.savefig("heatmap.png", dpi=150, bbox_inches='tight')
plt.show()
plot_player_heatmap(df, "案例球员-左边锋")
进阶分析:让热点图"能讲故事"
单纯密度图只是热身,实战中,热点图通常叠加多层信息:
按比赛阶段切分
# 上下半场对比
first_half = df[df.minute <= 45]
second_half = df[df.minute > 45]
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
for ax, sub, title in zip(axes, [first_half, second_half],
["上半场", "下半场"]):
draw_pitch(ax)
kde_heatmap(sub, ax)
ax.set_title(title)
怎么看?
- 半场后热点收缩到中场 → 体能下降 / 战术回收
- 禁区热点增加 → 下半场加强进攻
叠加事件点(传球、射门、抢断)
# 假设已有事件点列表
shots = pd.DataFrame({'x': [95, 89, 100], 'y': [34, 28, 45]})
draw_pitch(ax)
kde_heatmap(df, ax)
ax.scatter(shots.x, shots.y, c='cyan', marker='*',
s=200, edgecolor='black', label='射门')
ax.legend()
这样一眼能看出:他的热点是否在创造射门机会的区域。
区分"停留"和"跑动"
# 计算速度 → 只保留高速奔跑的点(>5.5 m/s) df['vx'] = df.x.diff() / (df.minute.diff() * 60 / len(df)) # 简化 df['speed'] = np.sqrt(df.x.diff()**2 + df.y.diff()**2) * 10 # 近似 m/s sprint_df = df[df.speed > 5.5]
停留热图 vs 冲刺热图 是两个完全不同的故事:
- 停留热图:反映战术站位
- 冲刺热图:反映爆发区域(更接近"威胁"信号)
数据源推荐(实战)
| 数据源 | 类型 | 获取方式 | 特点 |
|---|---|---|---|
| StatsBomb Open | 事件数据 | statsbombpy 库 |
免费,含坐标 |
| Opta/Stats Perform | 事件+追踪 | 商业 | 精度高 |
| Catapult/SSTATSports | GPS追踪 | 俱乐部采购 | 每秒多帧 |
| FIFA训练数据 | 追踪 | Kaggle有售 | 学习友好 |
| Wyscout/Football API | 事件 | 订阅 | 覆盖广 |
快速拉 StatsBomb 数据示例:
from statsbombpy import sb events = sb.events(match_id=3788741) # 欧冠某场 passes = events[events.type == 'Pass'] x = passes.location.apply(lambda p: p[0]) # 事件坐标 y = passes.location.apply(lambda p: p[1])
常见坑 & 调优建议
| 问题 | 解决方案 |
|---|---|
| 坐标系统不统一 | 统一缩放到 FIFA 105×68 米;注意 y 轴方向(有的数据以球场上方为 y=0) |
| 采样频率差异大 | GPS 10Hz vs 事件触发式;KDE 前应重采样为等时间间隔 |
| 替补球员数据 | 按上场时间归一化,避免"少上场=热点稀" |
| 颜色误导 | 用 hot 或 YlOrRd,不用 jet(感知不均) |
| 门将专属 | 单独缩放 y 轴范围内做 KDE,否则热点被压扁 |
| 多球员对比 | 用相同色阶归一化(vmin/vmax 固定),否则不可比 |
战术解读速查表
| 热点形态 | 解读 |
|---|---|
| 集中在边路+后场 | 边后卫/防守型边翼 |
| 中圈为中心大团 | 后腰/组织核心 |
| 两个禁区都有 | 全能中场(Box-to-Box) |
| 禁区前沿热点 | 影锋/前腰 |
| 热图很"瘦"纵向延伸 | 边锋/边卫 |
| 热点集中在禁区中央 | 中锋/抢点型前锋 |
一句话总结
Python 分析热点图 =
pandas 处理坐标+绘图底图+scipy.stats.gaussian_kde或np.histogram2d;真正的价值不在于图好不好看,而在于用切分维度(时间/速度/事件)让热图回答具体战术问题。
需要我把上面代码整合成一个可直接运行的完整脚本,或者针对某个数据源(StatsBomb / Opta / GPS CSV)写出具体读取方案吗?