Dixon-Coles 的 τ 函数,我写错了三次

面向:自己实现过足球比分模型的工程师 产出:τ 函数的正确实现 + 三个错误的症状与诊断方法


一、为什么要有 τ

先说清楚这个函数要解决什么问题。

Dixon-Coles 模型的基础假设是:主队进球数 X 和客队进球数 Y 相互独立,各自服从泊松分布:

scss 复制代码
X ~ Poisson(λ)
Y ~ Poisson(μ)

如果只到这里,模型就能跑了。但它有个致命缺陷------低估低比分。

真实足球里,0-0、1-0、0-1、1-1 这四种比分出现的频率,明显高于两个独立泊松相乘的结果。原因不难理解:比赛是相互影响的。领先一方收缩防守,落后一方压上冒险,这会让比分从 1-1 走向 2-1 或者 1-2,而不是停在 1-1。

Dixon 和 Coles 在 1997 年的论文里加了一个修正项 τ(x,y):

ini 复制代码
        ⎧ 1 - λμρ    若 x=0, y=0
        ⎪ 1 + λρ     若 x=0, y=1
τ(x,y) = ⎨ 1 + μρ     若 x=1, y=0
        ⎪ 1 - ρ      若 x=1, y=1
        ⎩ 1          其他

修正后的联合概率:

scss 复制代码
P(X=x, Y=y) = τ(x,y) · Poisson(x; λ) · Poisson(y; μ)

ρ 是待估参数,实践中通常是负数 (大约 -0.03 到 -0.10)。因为 τ 在 1-1 处是 1-ρ,ρ 为负时 1-ρ > 1,正好放大了平局概率。

理论上很清晰。但我第一次实现时,卡了整整两天。


二、第一次错:符号反了

症状

模型训练完,预测下一场比赛,最可能的比分永远给 0-0。

不是偶尔,是每场都是。强队打弱队,λ=2.8、μ=0.6,模型说最可能是 0-0。

原因

我写的是:

python 复制代码
def tau(x, y, lam, mu, rho):
    if x == 0 and y == 0:
        return 1 + lam * mu * rho     # ← 这里符号错了
    if x == 0 and y == 1:
        return 1 - lam * rho
    if x == 1 and y == 0:
        return 1 - mu * rho
    if x == 1 and y == 1:
        return 1 + rho
    return 1.0

我把 "在 0-0 处减小、在 1-1 处增大" 记反了。因为 ρ 估计出来是负的(-0.06),我的代码变成了:

  • 0-0 处:1 + λμρ = 1 - 0.06λμ → 概率被压低
  • 1-1 处:1 + ρ = 0.94 → 概率被压低

两处都压低,而 λμ 在强弱对阵时数值很大(比如 2.8 × 0.6 = 1.68),1 - 0.06×1.68 = 0.899,0-0 概率被砍掉 10%。

结果就是:模型把概率质量从低比分移走,但移错了方向,反而让 0-0 在归一化后成了相对最高的比分(因为其他比分被 τ=1 处理,没动)。

诊断方法

看 ρ 的符号和 τ 值的分布:

python 复制代码
# 快速自检
rho = -0.06
lam, mu = 2.8, 0.6
print(tau(0,0,lam,mu,rho))  # 应该 < 1(压低 0-0)
print(tau(1,1,lam,mu,rho))  # 应该 > 1(放大 1-1)

如果两个都 < 1 或者两个都 > 1,符号一定写错了。

正确写法:

python 复制代码
def tau(x, y, lam, mu, rho):
    if x == 0 and y == 0:
        return 1 - lam * mu * rho    # ρ负 → 该值 > 1,放大0-0
    if x == 0 and y == 1:
        return 1 + lam * rho         # ρ负 → 该值 < 1
    if x == 1 and y == 0:
        return 1 + mu * rho
    if x == 1 and y == 1:
        return 1 - rho               # ρ负 → 该值 > 1,放大1-1
    return 1.0

等一下------这里有个容易混的点。论文里 ρ 是"相关性参数",不同资料对它的符号约定不一样:

  • 有的资料定义 ρ 为正相关 (低比分相互吸引),此时 0-0 用 1 + λμρ
  • 有的资料(包括我采用的)定义 ρ 为负值 ,公式写成 1 - λμρ

关键是保持一致:如果你在 MLE 里让 ρ 自由估计,它会自己找到正确符号。但如果你手工设 rho=-0.05,就必须用"减号"版本。

我的第三个错误正是踩在这里------我一开始手工设了 rho=0.05(以为相关性是正的),公式又用了减号版,两个错误正好互相抵消了一段时间,让 bug 藏得更深。


三、第二次错:λ 和 μ 的位置搞反

症状

模型能跑,ρ 也能估出来,但训练完发现:客场球队的 1-0 概率异常低。

具体表现为,同样实力的两支球队,主场作战时 1-0 的概率比 0-1 高出一大截,远超正常的主场优势。

原因

τ 函数里有两行非常像:

ini 复制代码
x=0, y=1  →  1 + λρ     ← 注意是 λ
x=1, y=0  →  1 + μρ     ← 注意是 μ

直觉上很容易写成 "x=0,y=1 时客队进了1球,所以用 μ"。但其实反过来------这一项修正的是"主队 0 球"这个事件的影响,所以用主队的期望 λ。

我当时的理解是"客队进球了,所以修正跟客队的 λ 有关",直接写成了:

python 复制代码
if x == 0 and y == 1:
    return 1 + mu * rho    # 错
if x == 1 and y == 0:
    return 1 + lam * rho   # 错

症状为什么是"客场 1-0 偏低"

因为 λ 通常大于 μ(主场优势),λρ 和 μρ 的绝对值差距明显。位置一换,主队 1-0 和客队 0-1 的修正幅度就颠倒了。

这个 bug 比第一个隐蔽得多------模型照样收敛,照样能出概率,只是主客场结构被压歪。

诊断方法

固定 ω(主场优势)为 0,让两队实力完全相等(λ=μ=1.3),ρ=-0.05,然后看:

python 复制代码
from scipy.stats import poisson
lam = mu = 1.3
rho = -0.05
for (x,y) in [(0,0),(1,0),(0,1),(1,1)]:
    p = tau(x,y,lam,mu,rho) * poisson.pmf(x,lam) * poisson.pmf(y,mu)
    print(f"{x}-{y}: {p:.4f}")

λ=μ 时,P(1-0) 和 P(0-1) 应该完全相等。如果不等,位置一定错了。

这是一个非常干净的自检------不需要真实数据,不需要训练,10 行代码就能验证。


四、第三次错:忘记归一化

症状

算出来的概率矩阵,所有元素加起来不等于 1。

一开始我没注意,因为 P(0,0)+P(0,1)+... 只是轻微偏离 1(约 0.97~1.03)。但当 ρ 估计得比较"极端"(比如 -0.15)时,偏差能到 5% 以上。

后果很直接:LogLoss 和 Brier Score 全部失真,模型对比实验的结论全错。

原因

泊松分布本身归一(Σ P = 1),但 τ 只修正了 4 个格子,其他格子乘 1。修正后:

css 复制代码
Σ τ(x,y)·Pₓ·P_Y  ≠ 1

τ 在 0-0 处放大、在 0-1/1-0 处缩小、在 1-1 处放大,四项的净效果不为零。

修法

一行代码:

python 复制代码
def score_matrix(lam, mu, rho, max_goals=8):
    P = np.outer(
        poisson.pmf(np.arange(max_goals), lam),
        poisson.pmf(np.arange(max_goals), mu)
    )
    P[0,0] *= 1 - lam*mu*rho
    P[0,1] *= 1 + lam*rho
    P[1,0] *= 1 + mu*rho
    P[1,1] *= 1 - rho
    return P / P.sum()      # ← 这一行是必须的

但是,这里有个更深的问题:归一化只是让概率和等于 1,不改变"τ 修正被稀释"的事实。

在 MLE 估计时,如果你用未归一化的 P 计算似然,和用归一化的 P 计算似然,得到的 ρ 估计值会不一样。严格做法是在似然函数里就归一化,让 ρ 在正确的尺度上被估计。

我一开始是先估计再归一化,结果 ρ 估出来是 -0.02(偏小),归一化后实际效果只有理论值的一半。改成似然里归一化后,ρ 变成 -0.06,低比分修正明显更准。


五、还有第四次:梯度的符号

严格说这不算 τ 的错,但它是同一套代码里最致命的 bug。

症状

训练时 loss 不降反升,或者震荡不收敛。

原因

最大似然估计里,我们要最大化 log-likelihood,等价于最小化负对数似然:

ini 复制代码
loss = -Σ log P(xᵢ, yᵢ)

梯度下降要的是往负梯度方向走:

复制代码
θ ← θ - lr · ∂loss/∂θ

而 MLE 的梯度更新是往正梯度方向走(因为要最大化):

复制代码
θ ← θ + lr · ∂logL/∂θ

我把 loss 定义了,但更新公式写成了 θ ← θ - lr·grad,而 grad 是从 logL 算的------两个负号抵消,变成往错误方向走。

诊断很简单:打印 loss 的前 20 次迭代。健康的训练应该单调下降,出现上升就立即停。

还有个隐蔽版本:学习率

我最初用 lr=0.01 跑 3040 场(英超+意甲 4 赛季),参数是 20 队 × 2(攻/防)+ 1(主场)+ 1(ρ),梯度尺度差异很大。

结果:攻击参数震荡,防守参数几乎不动,ρ 直接跑到 -0.9(物理上不可能的值)。

改成分参数学习率(攻击/防守用 0.005,ρ 用 0.0005),并且给 ρ 加约束(限制在 -0.2, 0.2),才稳定收敛。


六、正确的完整实现

把上面的坑都填上,代码是这样的:

python 复制代码
import numpy as np
from scipy.stats import poisson
from scipy.optimize import minimize

MAX_GOALS = 8

def score_matrix(lam, mu, rho):
    """返回 (MAX_GOALS+1)^2 的比分概率矩阵,已归一化"""
    goals = np.arange(MAX_GOALS + 1)
    P = np.outer(poisson.pmf(goals, lam), poisson.pmf(goals, mu))
    P[0, 0] *= 1 - lam * mu * rho
    P[0, 1] *= 1 + lam * rho
    P[1, 0] *= 1 + mu * rho
    P[1, 1] *= 1 - rho
    P = np.clip(P, 1e-12, None)      # 防止负概率
    return P / P.sum()

def neg_log_likelihood(params, home_goals, away_goals, n_teams):
    """参数顺序:[att_1..att_n, def_1..def_n, home_adv, rho]"""
    att = params[:n_teams]
    dfn = params[n_teams:2*n_teams]
    home_adv = params[2*n_teams]
    rho = params[2*n_teams + 1]

    ll = 0.0
    for i, (h, a) in enumerate(zip(home_goals, away_goals)):
        # 这里的 att/dfn 索引需要按比赛映射,示范用 0/1
        lam = np.exp(att[0] - dfn[1] + home_adv)
        mu  = np.exp(att[1] - dfn[0])
        P = score_matrix(lam, mu, rho)
        ll += np.log(P[h, a])
    return -ll

def fit(...):
    # 关键:给 rho 加边界,避免跑到物理不可能的区域
    bounds = [(None, None)] * (2 * n_teams + 1) + [(-0.2, 0.2)]
    res = minimize(neg_log_likelihood, x0, args=(...), method='L-BFGS-B', bounds=bounds)
    return res.x

七、三个 10 行内的自检(强烈建议)

写完就测,别等训练完再看结果:

python 复制代码
# 自检 1:τ 的方向对不对
assert tau(0,0,2.8,0.6,-0.06) > 1, "0-0 应该被放大"
assert tau(1,1,2.8,0.6,-0.06) > 1, "1-1 应该被放大"
assert tau(0,1,2.8,0.6,-0.06) < 1, "0-1 应该被缩小"
assert tau(1,0,2.8,0.6,-0.06) < 1, "1-0 应该被缩小"

# 自检 2:对称性(λ=μ 时主客对称)
P = score_matrix(1.3, 1.3, -0.05)
assert abs(P[1,0] - P[0,1]) < 1e-9, "λ=μ 时 P(1-0) 必须等于 P(0-1)"

# 自检 3:归一化
assert abs(P.sum() - 1.0) < 1e-9, "概率和必须为 1"

这三个断言能在 1 秒内抓到我前面踩的那三个坑里的两个。


八、写对之后,效果差多少

在 3040 场真实数据(英超+意甲 4 赛季)上,参数收敛后:

  • 曼城攻击力 1.98×(相对联赛平均),符合当时的实际观感
  • ρ 收敛到 -0.058,落在文献报告的范围内
  • 1-1 的概率比纯泊松高约 9%,0-0 高约 6%

更重要的是下游:用这个模型做 8,800 场双样本外测试,命中率 50.2%,比"无脑押主胜"的 44.4% 高 5.8pp。如果把 τ 写错,这个增量会掉到 2pp 以内------因为概率系统被系统性扭曲了。


九、一句话总结

τ 函数是一个只有 5 行、但错了不会报错的函数。

写错的表现不是崩溃,而是"模型能跑、能出概率、只是全错"。这类 bug 最贵------你会在一个错的地基上做几十次实验,然后得出"模型没用"的结论。

所以我现在的习惯是:任何概率模型的标量修正项,先写断言,再写训练。


本文的所有实现与数据均来自真实的个人项目(14 联赛 3 赛季 10,617 场),代码可复现,不涉及任何投注建议。

相关推荐
凯瑟琳.奥古斯特4 小时前
力扣1015C++解法技巧
c++·算法·leetcode·链表·职场和发展
seowmt4 小时前
GEO效果该什么时候看数据:从立项到复盘的时间线复盘
算法·ip·seo·geo·faq
悠仁さん4 小时前
【算法C++】双指针(一)
数据结构·c++·算法
小豆庄4 小时前
本地视频脱敏的追踪系统踩坑——车牌过滤、转场误判与ID冲突排查
人工智能·算法·目标检测·音视频·视频·敏捷流程·纹理贴图
忆挽篱笙歌4 小时前
进程替换函数与shenll命令行解释器
前端·javascript·算法
YukiRinLL4 小时前
字符串存储与 BF、KMP 匹配算法
数据结构·算法
小超人诞生日记4 小时前
Linux:环境变量 & 进程虚拟地址空间
linux·算法
小白起 v4 小时前
【算法学习 · 01】深入理解大 O 表示法:从「能跑就行」到「专业评估」算法性能
算法
梦帮科技4 小时前
脑机接口(BCI)与神经系统安全:EEG 神经电信号欺骗注入、神经闭环解码算法与生物黑客反制
算法·安全