Python 分析球员跑动热点图完整案例
下面我用一个完整的案例,带你从数据准备 → 数据清洗 → 坐标转换 → 热点图生成 → 深度分析走一遍流程。

核心思路
足球/篮球球员跑动热点图本质是 二维空间上的点密度分布,分析流程:
原始追踪数据 → 坐标归一化 → 密度估计(KDE/直方图) → 可视化 → 战术解读
关键库:
pandas/numpy:数据处理matplotlib/seaborn:可视化scipy.stats.gaussian_kde或sklearn.KernelDensity:核密度估计mplsoccer:足球专用绘图(强烈推荐)
数据准备
假设你有一份球员追踪数据(通常来自 StatsBomb、Metrica、Sportec 或自研 GPS):
player_id,timestamp,x,y 1,0.0,45.2,30.1 1,0.04,45.8,30.5 1,0.08,46.1,31.0 ...
坐标一般有两种:
- 米制:0~105 × 0~68(全场)
- 归一化:0~1
完整代码案例
数据加载与清洗
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import gaussian_kde
import matplotlib.patches as patches
# 读取数据
df = pd.read_csv("player_tracking.csv")
# 筛选目标球员
player_df = df[df["player_id"] == 1].copy()
# 剔除异常值(超出场地的坐标)
pitch_length, pitch_width = 105, 68
player_df = player_df[
(player_df["x"] >= 0) & (player_df["x"] <= pitch_length) &
(player_df["y"] >= 0) & (player_df["y"] <= pitch_width)
]
print(f"有效点数: {len(player_df)}")
绘制球场底图(自定义函数)
def draw_pitch(ax, length=105, width=68):
"""画一个标准足球场底图"""
ax.set_xlim(0, length)
ax.set_ylim(0, width)
ax.set_facecolor("#3a8c3a") # 草地绿
ax.add_patch(patches.Rectangle((0, 0), length, width,
edgecolor="white", facecolor="none"))
# 中线
ax.plot([length/2, length/2], [0, width], color="white")
# 中圈
ax.add_patch(patches.Circle((length/2, width/2), 9.15,
edgecolor="white", facecolor="none"))
# 禁区(左右)
ax.add_patch(patches.Rectangle((0, width/2-20.15), 16.5, 40.3,
edgecolor="white", facecolor="none"))
ax.add_patch(patches.Rectangle((length-16.5, width/2-20.15), 16.5, 40.3,
edgecolor="white", facecolor="none"))
ax.set_xticks([]); ax.set_yticks([])
方法一:直方图热力图(简单直观)
fig, ax = plt.subplots(figsize=(12, 8))
draw_pitch(ax)
# 2D 直方图
h = ax.hist2d(player_df["x"], player_df["y"],
bins=(30, 20), cmap="hot_r", alpha=0.7)
plt.colorbar(h[3], ax=ax, label="停留频次")
ax.set_title("球员跑动热点图(直方图)")
plt.show()
方法二:高斯核密度(KDE)— 更平滑、更专业
from scipy.stats import gaussian_kde
x = player_df["x"].values
y = player_df["y"].values
xy = np.vstack([x, y])
# 计算带宽(Scott's rule 自动)
kde = gaussian_kde(xy, bw_method=0.3)
# 生成网格
xi, yi = np.mgrid[0:105:200j, 0:68:150j]
zi = kde(np.vstack([xi.flatten(), yi.flatten()])).reshape(xi.shape)
fig, ax = plt.subplots(figsize=(12, 8))
draw_pitch(ax)
# 热力图 + 等高线
contour = ax.contourf(xi, yi, zi, levels=20, cmap="hot_r", alpha=0.75)
ax.contour(xi, yi, zi, levels=8, colors="white", linewidths=0.5, alpha=0.6)
plt.colorbar(contour, ax=ax, label="密度")
ax.set_title("球员跑动热点图(KDE)")
plt.show()
方法三:用 mplsoccer 一行生成(推荐)
pip install mplsoccer
from mplsoccer import Pitch
import matplotlib.pyplot as plt
pitch = Pitch(pitch_type='statsbomb', line_zorder=2,
pitch_color='#22312b', line_color='white')
fig, ax = pitch.draw(figsize=(12, 8))
kde_plot = pitch.kdeplot(player_df["x"], player_df["y"],
ax=ax, cmap='hot_r', levels=100, fill=True)"球员跑动热点图(mplsoccer)", color="white")
plt.show()
进阶分析方向
区分持球 / 无球跑动
# 假设数据里有 has_ball 字段
ball_on = player_df[player_df["has_ball"] == 1]
ball_off = player_df[player_df["has_ball"] == 0]
fig, axes = plt.subplots(1, 2, figsize=(18, 6))
for ax, data, title in zip(axes, [ball_on, ball_off], ["持球", "无球"]):
draw_pitch(ax)
ax.hist2d(data["x"], data["y"], bins=(30, 20), cmap="hot_r")
ax.set_title(f"{title}跑动分布")
与队友对比
fig, ax = plt.subplots(figsize=(12, 8))
draw_pitch(ax)
for pid, color in [(1, "Reds"), (2, "Blues")]:
sub = df[df["player_id"] == pid]
ax.hist2d(sub["x"], sub["y"], bins=(30, 20),
cmap=color, alpha=0.5, label=f"球员{pid}")
ax.legend()
plt.show()
按时间分段(前/中/后 15 分钟)
df["minute"] = df["timestamp"] / 60
periods = [(0, 15), (15, 30), (30, 45)]
fig, axes = plt.subplots(1, 3, figsize=(20, 6))
for ax, (s, e) in zip(axes, periods):
sub = player_df[(player_df["minute"] >= s) & (player_df["minute"] < e)]
draw_pitch(ax)
ax.hist2d(sub["x"], sub["y"], bins=(25, 17), cmap="hot_r")
ax.set_title(f"{s}-{e} 分钟")
覆盖率 / 活动面积指标
from scipy.spatial import ConvexHull
points = player_df[["x", "y"]].values
hull = ConvexHull(points)
convex_area = hull.volume # 2D 中 volume 就是面积
print(f"活动覆盖面积: {convex_area:.1f} 平方米")
# 简易"活动范围"半径
center = points.mean(axis=0)
distances = np.linalg.norm(points - center, axis=1)
print(f"平均活动半径: {distances.mean():.1f} 米")
战术解读常用指标
| 指标 | 含义 | 计算方法 |
|---|---|---|
| 平均位置 | 球员习惯站位 | (x.mean(), y.mean()) |
| 活动热区 | 停留最久区域 | KDE 峰值坐标 |
| 覆盖面积 | 跑动范围 | Convex Hull |
| 左右倾向 | 偏向哪一路 | y.mean() vs 球场中线 |
| 前后倾向 | 进攻/防守型 | x.mean() vs 中线 |
| 冲刺区域 | 高速移动集中区 | 过滤速度 > 7 m/s 的点 |
输出解读示例
生成热点图后,可以总结出类似结论:
- 该球员 70% 的时间集中在中前场右路(x: 60-90, y: 0-30)
- 持球热点位于右边路,无球时回撤至中线附近
- 平均站位 x=62, y=18,属于进攻型右边卫
- 覆盖面积 1,850 m²,活动范围偏大,符合边翼卫特征
常见坑与建议
- 带宽选择:
bw_method太小→噪声大;太大→过平滑,足球推荐 0.2~0.4。 - 坐标统一:不同数据源坐标定义不同(如 StatsBomb 是 120×80),绘图前务必归一化或转换。
- 采样频率:25 Hz 和 10 Hz 密度不同,直方图 bin 数要适配。
- 时间加权:短时高频采样会夸大静止区域,可对速度<0.5 m/s 的点降权。
- 可视化配色:
hot_r、Reds直观;viridis在深色底图上更专业。
如果你能告诉我数据来源格式(StatsBomb / Metrica / 自采 GPS)和目标是足球还是篮球,我可以给你一段即拿即用的完整脚本。