本文目录导读:

分析球员跑动热点图,本质上是对时空轨迹数据进行密度估计和可视化,下面从数据到洞察,给出一套可直接落地的实用脚本思路。
数据准备
热点图分析的输入通常是两类数据:
| 数据类型 | 来源 | 格式 |
|---|---|---|
| 追踪数据 | Hawk-Eye / Stats Perform / Catapult GPS | 每秒 x,y 坐标 |
| 事件数据 | Opta / Wyscout | 传球、跑动事件点 |
import pandas as pd
import numpy as np
# 示例:追踪数据格式
# columns: timestamp, player_id, x, y
df = pd.read_csv("tracking.csv")
# 标准球场:105m × 68m,坐标常归一化到 0-1 或 0-100
核心分析脚本
坐标标准化 + 方向对齐
def normalize_coords(df, pitch_length=105, pitch_width=68):
df['x_norm'] = df['x'] / pitch_length
df['y_norm'] = df['y'] / pitch_width
return df
# 若需统一进攻方向(如把左向右进攻统一)
def flip_if_needed(df, team_attacking_left):
if team_attacking_left:
df['x_norm'] = 1 - df['x_norm']
df['y_norm'] = 1 - df['y_norm']
return df
核密度估计(KDE)生成热点
from scipy.stats import gaussian_kde
import matplotlib.pyplot as plt
from mplsoccer import Pitch
def plot_heatmap(df, player_name, bw=0.15):
x = df['x_norm'].values
y = df['y_norm'].values
# 核密度估计
kde = gaussian_kde(np.vstack([x, y]), bw_method=bw)
# 在网格上求密度
grid_x, grid_y = np.mgrid[0:1:200j, 0:1:200j]
positions = np.vstack([grid_x.ravel(), grid_y.ravel()])
density = kde(positions).reshape(grid_x.shape)
# 绘图
pitch = Pitch(pitch_type='statsbomb', line_color='black')
fig, ax = pitch.draw(figsize=(10, 7))
pitch.kdeplot(x, y, ax=ax, cmap='YlOrRd', fill=True, levels=100, alpha=0.7)
ax.set_title(f"{player_name} 跑动热点图")
plt.savefig(f"{player_name}_heatmap.png", dpi=150)
按比赛阶段拆分(进攻/防守/不同15分钟)
def split_by_phase(df, phase_col='phase'):
"""phase: 'attack', 'defense', 'transition'"""
return {phase: sub for phase, sub in df.groupby(phase_col)}
# 或按时间分段查看体能衰减
df['period'] = pd.cut(df['minute'], bins=[0,15,30,45,60,75,90],
labels=['0-15','15-30','30-45','45-60','60-75','75-90'])
进阶分析指标
单纯热点图只能看“在哪”,结合以下指标才能回答“为什么”:
覆盖面积与集中度
from scipy.spatial import ConvexHull
def coverage_metrics(df):
points = df[['x_norm','y_norm']].values
hull = ConvexHull(points)
area = hull.volume # 2D 中 volume 即面积
# 熵:分布均匀度
hist, _, _ = np.histogram2d(df['x_norm'], df['y_norm'], bins=10)
p = hist.flatten() / hist.sum()
p = p[p > 0]
entropy = -np.sum(p * np.log(p))
return {'覆盖面积': area, '分布熵': entropy}
分区停留时间
def zone_time(df, zone='final_third'):
if zone == 'final_third':
mask = df['x_norm'] > 2/3
elif zone == 'box':
mask = (df['x_norm'] > 0.83) & (df['y_norm'].between(0.21, 0.79))
return mask.sum() / len(df) # 占比即时间占比
与其他球员对比(相对热点)
def relative_heatmap(df, league_avg_density):
"""减去同位置平均密度,得到'异常'区域"""
player_density = compute_kde(df)
diff = player_density - league_avg_density
# 红色=异常活跃区,蓝色=异常沉寂区
return diff
可视化升级方向
| 技术 | 用途 |
|---|---|
| mplsoccer | 标准球场底图,一行代码出图 |
| Plotly + Dash | 交互式热点,可切换球员/时段 |
| Hexbin | 比 KDE 更快,适合大样本 |
| 箭头场 | 展示平均跑动方向(quiver) |
| 3D 曲面 | 密度 + 时间轴,看动态变化 |
# 交互式版本(plotly)
import plotly.express as px
fig = px.density_heatmap(df, x='x_norm', y='y_norm',
nbinsx=30, nbinsy=20,
color_continuous_scale='Hot')
fig.show()
典型分析场景
- 边后卫:看是否覆盖整条边路走廊 → 纵向分布应偏窄、横向跨度大
- 后腰:应形成“盾形”热点,集中在中圈附近
- 伪9号:热点应回撤到中场,与禁区内形成两个峰
- 体能对比:下半场热点收缩 → 跑动能力下降信号
完整工作流脚本结构
project/
├── data/
│ ├── tracking.csv
│ └── events.csv
├── src/
│ ├── load.py # 数据读取 + 清洗
│ ├── normalize.py # 坐标标准化
│ ├── kde.py # 密度计算
│ ├── metrics.py # 覆盖率/熵/分区
│ └── visualize.py # mplsoccer/plotly 出图
└── notebooks/
└── analysis.ipynb
如果告诉我你手上的数据格式(比如是 Opta 事件数据、Catapult GPS,还是 StatsBomb 360),我可以给出更具体的字段映射和可运行代码。