射门质量与xG差异原因分析
下面给你一套可直接落地的分析框架+Python脚本,用于诊断"某球员/球队实际进球 vs xG 差异"到底来自哪里。

先理清:xG差异的归因框架
实际进球(Goals) − 预期进球(xG) = G−xG 差值,原因可拆为四层:
| 层级 | 含义 | 关键指标 |
|---|---|---|
| ① 射门数量 | 创造机会多少 | 射门数/90、射门占比 |
| ② 射门位置质量 | 射门在哪 | 平均xG/射门、禁区内射门占比、平均距离、角度 |
| ③ 终结能力 | 同样机会把握 | G−xG、射正率、转化率、post-shot xG |
| ④ 样本与噪声 | 是否真实能力 | 样本量、稳定性、回归检验 |
核心结论先行:G−xG 的差异大部分是噪声(尤其小样本),真实"终结能力"差异在统计上很弱且难稳定,脚本要做的就是把"数量/位置/终结/噪声"分开量化。
综合实用脚本(Python)
"""
shot_xg_diagnosis.py
诊断射门质量与xG差异来源。
输入: 射门级数据 shots.csv
必填列: player, team, xg, goal, distance, angle(可选), body_part(可选), match_id
"""
import pandas as pd
import numpy as np
from scipy import stats
# ---------- 1. 读取 ----------
df = pd.read_csv("shots.csv")
df["goal"] = df["goal"].astype(int)
df["xg"] = df["xg"].astype(float)
# ---------- 2. 单实体汇总 ----------
def summarize(d, entity_col="player"):
g = d.groupby(entity_col).agg(
shots = ("xg", "size"),
goals = ("goal", "sum"),
xg = ("xg", "sum"),
xg_per_shot= ("xg", "mean"),
avg_dist = ("distance", "mean"),
).reset_index()
g["g_minus_xg"] = g["goals"] - g["xg"]
g["conversion"] = g["goals"] / g["shots"]
g["shots_per_90"] = np.nan # 若有分钟数据再填
return g.sort_values("g_minus_xg", ascending=False)
player_sum = summarize(df, "player")
# ---------- 3. 位置质量分层: 禁区内/外 ----------
df["in_box"] = df["distance"] <= 16.5 # 近似禁区半径(米), 或直接用坐标列
box = df.groupby("player").apply(lambda x: pd.Series({
"box_shot_share": x["in_box"].mean(),
"box_xg_per_shot": x.loc[x.in_box, "xg"].mean() if x.in_box.any() else np.nan,
"out_xg_per_shot": x.loc[~x.in_box, "xg"].mean() if (~x.in_box).any() else np.nan,
}), include_groups=False).reset_index()
player_sum = player_sum.merge(box, on="player", how="left")
# ---------- 4. 分解 G-xG:数量 vs 位置 vs 终结 ----------
# 用"同位置基准转化率"估算位置应得,剩余归为终结
base_conv_by_zone = df.groupby("in_box")["goal"].mean() # 每区域平均转化
df["expected_by_zone"] = df["in_box"].map(base_conv_by_zone)
decomp = df.groupby("player").apply(lambda x: pd.Series({
"goals": x["goal"].sum(),
"xg": x["xg"].sum(),
"zone_expected": x["expected_by_zone"].sum(), # 按区域平均转化应得
}), include_groups=False).reset_index()
decomp["finishing_effect"] = decomp["goals"] - decomp["zone_expected"] # 终结超出
decomp["position_effect"] = decomp["zone_expected"] - decomp["xg"] # 位置质量差
decomp["g_minus_xg"] = decomp["goals"] - decomp["xg"]
# ---------- 5. 显著性检验:G-xG 是否显著偏离0 ----------
def binom_test_player(x):
n, k = len(x), x["goal"].sum()
p = x["xg"].mean() # 以平均xG作基准概率(近似)
if n < 10: return np.nan
return stats.binomtest(k, n, p).pvalue
pvals = df.groupby("player").apply(
lambda x: pd.Series({"p_value": binom_test_player(x)}), include_groups=False
).reset_index()
player_sum = player_sum.merge(decomp[["player","finishing_effect","position_effect"]], on="player", how="left")
player_sum = player_sum.merge(pvals, on="player", how="left")
# ---------- 6. 稳定性检验(分半信度): 前半赛季 vs 后半赛季 G-xG 相关 ----------
if "match_id" in df:
df["half"] = df["match_id"] % 2
halves = df.groupby(["player","half"]).apply(
lambda x: x["goal"].sum() - x["xg"].sum(), include_groups=False
).unstack()
if {0,1}.issubset(halves.columns):
r, p = stats.pearsonr(halves[0].fillna(0), halves[1].fillna(0))
print(f"[稳定性] 分半 G-xG 相关系数 r={r:.3f}, p={p:.3f} (接近0=多为噪声)")
# ---------- 7. 输出 ----------
cols = ["player","shots","goals","xg","g_minus_xg","xg_per_shot",
"box_shot_share","position_effect","finishing_effect","p_value"]
print(player_sum[cols].head(20).to_string(index=False))
player_sum.to_csv("xg_diagnosis_output.csv", index=False)
如何读结果(归因判断表)
| 现象 | 判断 | 建议 |
|---|---|---|
| shots少但 xg_per_shot 高 | 机会少而精 | 提升创造量 |
| shots多但 xg_per_shot 低 | 浪射/远射多 | 优化射门选择 |
| box_shot_share 低 | 位置质量差 | 打进禁区 |
finishing_effect 大且 p<0.05 |
可能有真实终结力 | 需更大样本确认 |
finishing_effect 大但 p>0.05 |
大概率噪声 | 别过度解读 |
| 分半相关 r≈0 | G−xG 不稳定 | 视为随机波动 |
实用建议(避免踩坑)
- 样本量门槛:<50 射门的 G−xG 基本不可信;单赛季球员波动极大。
- 优先看 xG/射门 和 位置分布,而不是只看 G−xG。
- 用 post-shot xG(射门后门将位置)可分离"射门质量"与"门将扑救",比单纯 G−xG 更细。
- 回归到均值:对 G−xG 极端值做收缩(如贝叶斯/经验贝叶斯),预测下赛季更准。
- 区分点球:点球 xG≈0.76 固定,会污染终结力评估,建议单独剔除。
需要我把脚本改成球队维度、接入 Understat/FBref 抓取,或加上经验贝叶斯收缩预测版本吗?告诉我你的数据源格式即可。