Python 分析球员跑动热点图完整案例
下面我提供一个从数据获取 → 数据处理 → 可视化 → 分析的完整流程,基于足球比赛常用的 StatsBomb 开放数据和轨迹追踪数据。

核心思路
跑动热点图(Heatmap)本质是二维核密度估计(KDE),把球员在场上的位置点分布转换为概率密度,颜色越亮表示停留时间越长。
分析维度通常包括:
- 位置偏好(活动区域)
- 覆盖范围(活动半径/面积)
- 强度分布(结合速度数据)
- 分时段对比(上半场 vs 下半场)
方案一:基于事件数据(无免费轨迹数据时的替代方案)
StatsBomb、Opta 等事件数据里,每个动作都自带坐标 (x, y),可以直接做 KDE。
安装依赖
pip install mplsoccer pandas matplotlib scipy seaborn
完整代码
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
from scipy.ndimage import gaussian_filter
from mplsoccer import Pitch, Sbopen
# ---------- 1. 加载 StatsBomb 开放数据 ----------
parser = Sbopen()
df_event, df_related, df_freeze, df_tactics = parser.event(3788741) # 某场欧冠比赛
# ---------- 2. 过滤某球员的所有事件 ----------
player_name = "Lionel Messi"
df_player = df_event[df_event['player_name'] == player_name].copy()
# StatsBomb 坐标是 120x80,过滤掉 NaN
df_player = df_player.dropna(subset=['x', 'y'])
print(f"{player_name} 的事件数: {len(df_player)}")
# ---------- 3. 生成热力图 ----------
pitch = Pitch(pitch_type='statsbomb', line_zorder=2,
pitch_color='#22312b', line_color='#efefef')
fig, ax = pitch.draw(figsize=(10, 7))
fig.set_facecolor('#22312b')
# 关键:mplsoccer 内置 kdeplot,底层是 seaborn 的 KDE
bin_statistic = pitch.bin_statistic(df_player.x, df_player.y,
statistic='count', bins=(25, 25))
bin_statistic['statistic'] = gaussian_filter(bin_statistic['statistic'], sigma=1.5)
# 使用自定义颜色映射(类似 "热" 色)
cmap = LinearSegmentedColormap.from_list('hot_custom',
['#22312b', '#2b6a3a', '#e2c62d', '#f06926', '#d43d51'])
pcm = pitch.heatmap(bin_statistic, ax=ax, cmap=cmap, edgecolors='#22312b')
# 色条
cbar = fig.colorbar(pcm, ax=ax, shrink=0.6)
cbar.set_label('活动频率 (smooth density)', color='white')
cbar.ax.yaxis.set_tick_params(color='white')
plt.setp(cbar.ax.get_yticklabels(), color='white')
f'{player_name} 跑动热点图', color='white', fontsize=16)
plt.savefig('messi_heatmap.png', dpi=150, bbox_inches='tight',
facecolor=fig.get_facecolor())
plt.show()
改进:去量纲化 + 分时段对比
# 上/下半场分别 KDE
periods = {1: "上半场", 2: "下半场"}
fig, axes = plt.subplots(1, 2, figsize=(18, 6), facecolor='#22312b')
for ax, period in zip(axes, [1, 2]):
df_p = df_player[df_player['period'] == period]
pitch = Pitch(pitch_type='statsbomb', line_zorder=2,
pitch_color='#22312b', line_color='#efefef')
pitch.draw(ax=ax)
if len(df_p) > 5:
bs = pitch.bin_statistic(df_p.x, df_p.y, statistic='count', bins=(20, 20))
bs['statistic'] = gaussian_filter(bs['statistic'], sigma=1.5)
# 归一化,使两图色阶可比
bs['statistic'] = bs['statistic'] / bs['statistic'].max()
pitch.heatmap(bs, ax=ax, cmap=cmap)
ax.set_title(f'{player_name} - {periods[period]}', color='white')
plt.savefig('messi_split.png', dpi=150, bbox_inches='tight',
facecolor=fig.get_facecolor())
方案二:基于轨迹追踪数据(最理想)
如果你有 SkillCorner / Metrica / 自有 GPS 等追踪数据,每个球员每帧 (25Hz或10Hz) 都有 xy,直接统计会更精确。
数据结构示例
# tracking.csv # frame, player_id, x, y, speed # 1, 3, 52.1, 34.2, 2.4 # 1, 4, 45.3, 20.1, 0.0
带速度加权的热点图
import pandas as pd, numpy as np, matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
from scipy.ndimage import gaussian_filter
from mplsoccer import Pitch
df = pd.read_csv('tracking.csv')
player_id = 3
df_p = df[df['player_id'] == player_id].copy()
# 3. 速度加权:静止/慢速时权重高,高速冲刺权重低 → 反映"停留热区"
df_p['weight'] = np.clip(1 - df_p['speed'] / 8, 0, 1)
# 若数据单位是米(105x68),需转换成 StatsBomb 的 120x80
# df_p['x'] = df_p['x'] / 105 * 120
# df_p['y'] = df_p['y'] / 68 * 80
# 4. 手动 KDE(用 numpy histogram2d + 高斯滤波)
bins = (60, 40)
H, xedges, yedges = np.histogram2d(
df_p['x'], df_p['y'],
bins=bins, range=[[0,120],[0,80]],
weights=df_p['weight']
)
H = gaussian_filter(H, sigma=2.0)
pitch = Pitch(pitch_type='statsbomb', pitch_color='#0e1117', line_color='#888')
fig, ax = pitch.draw(figsize=(10, 7))
fig.set_facecolor('#0e1117')
cmap = LinearSegmentedColormap.from_list('h', ['#0e1117','#1f4a8a','#33a02c','#ffd92f','#e31a1c'])
# 注意 imshow 的 extent 要跟球场对齐
im = ax.imshow(H.T, extent=[0,120,0,80], origin='lower',
cmap=cmap, alpha=0.85, aspect='auto', zorder=1)
plt.colorbar(im, ax=ax, shrink=0.7).set_label('加权停留时间', color='white')f'球员 {player_id} 速度加权热点图', color='white')
plt.show()
进一步分析指标
只画图还不够,做量化分析才有价值。
from scipy.stats import gaussian_kde
xy = df_p[['x','y']].values.T
kde = gaussian_kde(xy, bw_method=0.25)
# ---------- 指标 1:活动重心(质心) ----------
centroid = xy.mean(axis=1)
print(f"活动重心: x={centroid[0]:.1f}, y={centroid[1]:.1f}")
# ---------- 指标 2:覆盖半径(IQR 的几何平均) ----------
cov_radius = np.sqrt(np.std(xy[0])**2 + np.std(xy[1])**2)
print(f"活动半径(1σ): {cov_radius:.1f} 米")
# ---------- 指标 3:高密度区域占比(定义"热区") ----------
# 用网格近似
grid_x, grid_y = np.mgrid[0:120:1, 0:80:1]
density = kde(np.vstack([grid_x.ravel(), grid_y.ravel()])).reshape(grid_x.shape)
thresh = np.percentile(density, 85) # 前 15% 密度区域
hot_area = (density >= thresh).sum() # 单位: 1x1 单位格
print(f"高密度热区面积: {hot_area} (占全场 {hot_area/(120*80)*100:.1f}%)")
# ---------- 指标 4:左右/前后不对称性 ----------
left_right = (xy[0] < 60).mean()
print(f"左半场占比: {left_right:.1%}")
进阶:进攻/防守分离 + 分区间热图
import seaborn as sns
# 假设有根据事件类型区分进攻/防守(方案一)
df_attack = df_player[df_player['type_name'].isin(['Pass','Shot','Dribble'])]
df_defend = df_player[df_player['type_name'].isin(['Pressure','Interception','Duel'])]
fig, axes = plt.subplots(1, 2, figsize=(18, 6), facecolor='#22312b')
for ax, (df_, title) in zip(axes, [(df_attack,'进攻'),
(df_defend,'防守')]):
pitch = Pitch(pitch_type='statsbomb', pitch_color='#22312b', line_color='#cfcfcf')
pitch.draw(ax=ax)
if len(df_) > 3:
sns.kdeplot(x=df_.x, y=df_.y, ax=ax, cmap='mako',
fill=True, levels=15, thresh=0.05, zorder=2)
ax.set_title(f'{player_name} - {title}', color='white', fontsize=14)
plt.show()
常见坑与建议
| 问题 | 建议 |
|---|---|
| 坐标系统不统一 | 统一到 120×80 (StatsBomb) 或 105×68 (米) |
| 不同比赛事件数差异大 | 用 密度 而非 计数,或做 min-max 归一化 |
KDE 带宽 bw_method 过大 |
15~0.3 之间试,过大像"糊" |
| 只看一张图缺少对比 | 加 分时段/分场景(持球/无球) 对比 |
| 静止点淹没热点 | 用速度倒数加权,或先抽稀(每 N 帧取 1) |
| 防守方向未翻转 | 把客队进攻方向统一翻转(左右镜像) |
推荐工具组合
| 场景 | 工具 |
|---|---|
| 事件数据的球场绘图 | mplsoccer (首选) |
| KDE 计算 | scipy.stats.gaussian_kde 或 seaborn |
| 追踪大数据 (>10⁶ 行) | datashader + holoviews |
| 交互可视化 | Plotly / Bokeh / streamlit |
| 数据源 | StatsBomb Open Data、FBref、Wyscout、Metrica Sports |
如果你能告诉我:
- 你手头的数据是什么格式(事件?追踪?GPS?),
- 想分析的结论是什么(找跑位习惯?体能分配?战术角色?),
我可以给你写一个更贴合具体数据的脚本。