阅读时间 19 分钟

完成五大联赛 Elo 模型:新手完整实战

足球预测elo模型
完成五大联赛 Elo 模型:新手完整实战

这篇教程不要求你会爬虫,也不要求你先学机器学习。

我们会用 NJSTATS 的真实联赛与赛程数据,完成一个可以持续更新的 Elo 球队强度模型。范围只包含五大联赛:英超、西甲、德甲、意甲和法甲。

完成后,你会得到三个可以直接用 Excel 打开的文件:

文件

用途

elo_training_rows.csv

每场历史比赛在开赛前的 Elo 特征和赛后结果

elo_ratings.csv

各支球队当前的 Elo 强度评分

elo_upcoming_matches.csv

今天起 6 个自然日内比赛的赛前 Elo 特征

本文的目标是建立透明、可复现的建模基线。

数据来源:njstats.cn

一、Elo 到底在计算什么

Elo 给每支球队一个会随比赛结果变化的强度分数。

本教程规定:

  • 所有新球队初始评分为 1500
  • 主场球队临时增加 65 个主场优势分
  • 主胜记 1 分,平局记 0.5 分,主负记 0
  • 每场完赛后,按结果更新双方评分

赛前主队期望得分为:

1 / (1 + 10 ^ ((客队 Elo - 主队 Elo - 主场优势) / 400))

如果弱队战胜强队,实际结果与赛前预期差距很大,评分变化也会更大。

一个必须先说清楚的概念

代码输出的 expectedHomeScoreElo 期望得分,不是经过校准的“主胜概率”。

因为 Elo 把主胜、平局、主负分别记为 10.50,所以 0.62 表示主队的期望结果更好,不能直接写成“主胜概率 62%”。要得到真正的胜平负概率,还需要用历史样本做概率校准,或训练三分类模型。

二、建模到底要调用哪些接口

第一版模型只调用两个接口。

API文档地址:https://www.njstats.cn/client/data-api/docs/1-0

必需接口 1:联赛列表

GET https://api.njstats.cn/api/v1/league/list

用途:找到五大联赛及它们的 seasonId

需要读取的字段:

字段

用途

id

联赛 ID

nameCn

用中文名称筛选五大联赛

seasons[].seasonId

查询对应赛季赛程

seasons[].season

标记赛季名称

必需接口 2:比赛列表,也就是联赛赛程

API文档地址:https://www.njstats.cn/client/data-api/docs/1-1

GET https://api.njstats.cn/api/v1/match/list

历史赛季查询:

?seasonId=215543&page=1&pageSize=100

按日期查询未来赛程:

?date=2026-08-15&page=1&pageSize=100

需要读取的字段:

字段

建模用途

matchId

比赛唯一标识,负责去重与关联

leagueIdleagueName

只保留五大联赛

seasonIdseason

标记样本属于哪个赛季

matchTime

按时间从早到晚更新 Elo

realStatus

只用 -10 的完赛数据训练

homeIdawayId

找到双方当前 Elo

homeNameawayName

方便人阅读结果

homeCountawayCount

生成主胜、平局、主负标签

homeCornerawayCorner、红黄牌和半场比分虽然也会返回,但第一版 Elo 不使用。先把最小模型跑通,比一开始堆几十个字段更重要。

三、五大联赛的真实 ID

下面是 2026 年 7 月 3 日从接口实际读取的结果:

联赛

leagueId

最近完整赛季

seasonId

英超

122078

2025/2026

215543

西甲

122158

2025/2026

214222

德甲

122037

2025/2026

214389

意甲

122286

2025/2026

215789

法甲

122270

2025/2026

213887

不要把这张表永久写死在业务代码里。新赛季会产生新的 seasonId,所以示例程序每次先读取联赛列表,再按 nameCn 自动找到五大联赛。

还有一个容易踩的坑:当前真实返回中的 matchIdleagueIdseasonIdhomeIdawayId 是字符串。即使文档把 ID 描述为数字,你的程序也应该把 ID 当作字符串保存,不要参与加减运算。

四、准备运行环境

这个示例只使用 Python 自带功能,不需要安装第三方库。

先进入示例目录:

cd football-model-blog/demos/elo-team-strength

确认 Python 已安装:

python3 --version

能看到 Python 3.x.x 就可以继续。

五、安全放置 API Key

复制密钥模板:

cp njstats.env.example njstats.env

打开 njstats.env,把占位内容改成自己的 Key:

NJSTATS_API_KEY=YOUR_API_KEY

真实的 njstats.env 已被 .gitignore 排除,不会被正常提交到 GitHub。仍然不要把它截图、复制到文章或发给其他人。

如果密钥保存在其他位置,也可以在运行时指定:

python3 njstats_five_leagues.py --env-file "/你的路径/njstats.env"

六、先做一次小样本测试

第一次运行先不要下载全部数据:

python3 njstats_five_leagues.py \
  --completed-seasons 1 \
  --max-pages 1 \
  --upcoming-days 0

这些参数表示:

参数

含义

--completed-seasons 1

每个联赛先测试一个含完赛数据的赛季

--max-pages 1

每个赛季只读一页,最多 100 场

--upcoming-days 0

测试阶段不读取未来赛程

看到下面这类提示就说明流程跑通了:

已找到五大联赛:英超、西甲、德甲、意甲、法甲
完赛样本:...
球队数量:...
未来赛程:0 场
结果目录:.../outputs/elo-five-leagues

测试模式只验证接口和代码,数据不完整,不能作为正式结论。

七、下载完整数据并建模

测试成功后,去掉 --max-pages

python3 njstats_five_leagues.py \
  --completed-seasons 2 \
  --upcoming-days 6

程序会自动完成:

读取联赛列表
-> 只保留英超、西甲、德甲、意甲、法甲
-> 找到每个联赛最近两个有完赛数据的赛季
-> 每页读取 100 场,直到该赛季全部读取完
-> 删除取消、推迟、未开始和没有比分的记录
-> 所有历史比赛按 matchTime 从早到晚排序
-> 每场开赛前保存双方 Elo
-> 赛后用真实结果更新 Elo
-> 读取今天与未来 5 天赛程并生成赛前特征
-> 输出 CSV 和模型摘要

第一次完整运行通常需要约 50 次请求。程序不会并发请求接口,每次新请求默认间隔 1.2 秒,并把结果缓存在本地。第二次运行会优先使用缓存,速度会快很多,也不会反复消耗接口调用量。

缓存也区分更新速度:历史赛季页面长期复用,联赛列表缓存 6 小时,按日期查询的近期赛程缓存 1 小时。这样更新未来比赛时,不需要重新下载全部历史数据。

比赛列表的日期参数目前只允许查询到“今天后 5 天”,所以程序最多读取今天起 6 个自然日。即使误填 --upcoming-days 7,程序也会自动调整为 6,避免接口报错。

只有在确认历史数据被修正,或需要强制忽略全部缓存时,才执行:

python3 njstats_five_leagues.py --refresh

--refresh 会重新请求所有历史和近期数据,不要频繁使用。普通的未来赛程更新等待 1 小时缓存到期后直接运行即可。

八、程序如何避免偷看答案

这是整份教程最重要的部分。

每场历史比赛必须按下面的顺序处理:

读取比赛开始前的主队 Elo 和客队 Elo
-> 保存为 preHomeElo 和 preAwayElo
-> 计算 expectedHomeScore
-> 读取这场比赛的最终比分
-> 生成 H、D、A 标签
-> 最后才更新双方 Elo

如果先用本场结果更新 Elo,再把更新后的 Elo 当成本场赛前特征,就是把答案泄露给模型。结果会看起来很好,实际无法用于未来比赛。

九、看懂三个结果文件

结果位于:

football-model-blog/outputs/elo-five-leagues/

1. elo_training_rows.csv

一行代表一场已经结束的比赛。

最重要的列:

解释

preHomeElo

本场开赛前主队评分

preAwayElo

本场开赛前客队评分

eloDifferenceWithHomeAdvantage

主队评分加主场优势后与客队的差值

expectedHomeScore

Elo 主队期望得分,不是主胜概率

result

实际结果:H 主胜、D 平、A 客胜

postHomeElopostAwayElo

本场完赛后更新的评分

这个文件可以继续给 XGBoost、逻辑回归等模型使用。其中 result 是要预测的标签,所有 pre... 字段才是赛前可用特征。

2. elo_ratings.csv

这是处理完全部历史比赛后,各支球队的最新评分。

评分高只代表模型根据所选历史样本判断它更强,不代表下一场一定获胜。不同参数、不同历史长度和升降级处理方式,都会改变评分。

3. elo_upcoming_matches.csv

这是未来比赛的赛前 Elo 特征。

如果文件只有表头,通常不是程序坏了,而是查询范围内没有五大联赛比赛,或新赛季赛程还没有进入接口。可以等赛程发布后加 --refresh 再运行。

十、怎样做最基本的滚动回测

程序按时间把最后 20% 比赛作为测试部分。

测试部分里的每一场比赛都只使用它之前已经结束的比赛更新 Elo,然后比较 expectedHomeScore 与实际结果得分:

实际结果

结果得分

主胜

1

平局

0.5

客胜

0

程序计算均方误差,也就是 MSE。MSE 越低,表示期望得分与真实结果得分的整体距离越小。它不是胜平负命中率。

2026 年 7 月 3 日的完整实测结果为:

项目

结果

完赛样本

3470 场

球队

110 支

测试样本

694 场

Elo 测试 MSE

0.164500

固定基线测试 MSE

0.183573

本次运行中 Elo 的误差低于固定基线,说明这个简单评分至少提供了比“所有比赛使用同一个期望值”更多的信息。但这还不能证明参数已经最优,也不能把它解释为单场结果保证。

具体结果保存在 model_summary.jsonevaluation 中。每次接口数据或参数变化后,都应该重新查看这一段,而不是只看某几场比赛。

十一、Elo 对赛前预测到底有什么实际帮助

Elo 最大的实战价值,不是单独宣布哪支球队会赢,而是把球队过去几十场比赛压缩成一个会持续更新的强度特征。

它主要解决五个问题:

  1. 统一衡量球队强弱:积分榜只反映当前赛季累计结果,Elo 会根据对手强弱调整每场比赛带来的变化。
  2. 跟踪强度变化:连续战胜强队时评分上升更明显,输给弱队时下降也更明显。
  3. 形成赛前基线:在伤停、阵容等信息尚未加入前,先得到一个只依赖历史结果的透明判断。
  4. 成为机器学习特征preHomeElopreAwayElo 和 Elo 差值可以直接输入 XGBoost 等分类模型。
  5. 支持历史复盘:每场比赛都保存开赛前评分,因此可以按时间检查模型长期表现,而不是挑选少数成功案例。

一个真实的对阵示例

以完整运行后的德甲评分为例:

拜仁慕尼黑 Elo:1763.94
多特蒙德 Elo:1645.97
主场优势:65
含主场优势的差值:1763.94 + 65 - 1645.97 = 182.97
主队 Elo 期望得分:0.741

0.741 还不是“拜仁主胜概率 74.1%”。它表示 Elo 判断主队赛果期望明显占优。

接下来可以查看最后 694 场滚动测试中,主队期望得分同样处于 0.70-0.80 区间的比赛:

测试样本

主胜

平局

客胜

133 场

57.1%

24.1%

18.8%

这组数据给出了一个可解释的历史基线:当 Elo 产生相似强度信号时,真实赛果过去如何分布。

它仍然不能直接等同于这场具体比赛的最终概率,因为具体比赛还存在近期状态、伤停、阵容、赛程密度和其他赛前信息差异。单场示例也不能证明模型能力,真正证据仍然是完整滚动测试。

实战中的完整使用流程

获取未来赛程
-> 读取两队在开赛前的最新 Elo
-> 计算 Elo 差值和主队期望得分
-> 映射到历史测试区间,得到可解释基线
-> 加入近期状态、赛季统计、伤停和赛前固定时点数据
-> 输入三分类模型
-> 输出主胜、平局、客胜概率
-> 赛后写入真实结果并继续更新、回测

第一版 Elo 负责的是“球队基础强度”。最终预测模型还可以加入:

特征

作用

时间要求

eloDifferenceWithHomeAdvantage

两队长期强度差

开赛前最新值

近 5 场场均积分

近期状态

必须是本场之前的比赛

近 5 场进球与失球

攻防变化

必须是本场之前的比赛

赛季主客场表现

场地差异

只能累计到本场之前

伤停与阵容

人员变化

保存发布时间或采集时间

固定时点的赛前指数

外部赛前基线

统一使用例如开赛前 60 分钟

最终给 XGBoost 的一行赛前数据可以是:

matchId
preHomeElo
preAwayElo
eloDifferenceWithHomeAdvantage
homeRecentPointsPerGame
awayRecentPointsPerGame
homeRecentGoalsScoredPerGame
awayRecentGoalsScoredPerGame
homeInjuryCount
awayInjuryCount
preMatchIndexFeatures

模型预测目标是 HDA,也就是主胜、平局和客胜。训练时必须按时间切分,不能随机把未来比赛混入训练数据。

十二、把模型结果做成可视化成果页

运行:

python3 generate_results_dashboard.py

程序会生成:

football-model-blog/showcase/elo-five-leagues-dashboard.html

成果页包含:

  • 3470 场完赛样本与 110 支球队的总体规模
  • Elo 相比固定基线的滚动测试误差降幅
  • 五大联赛切换与赛果构成
  • 球队 Elo 强度排行
  • 任意球队两赛季评分走势
  • 同联赛任意两队的赛前 Elo 对阵模拟与历史结果区间
  • 最近比赛、胜平负记录与评分变化
  • 球队排名 CSV 和完整训练数据 CSV 下载

HTML 已经嵌入全部展示数据,不依赖数据库或前端框架。每次重新运行模型后,再运行一次生成脚本,就会得到对应最新数据的成果页。

当前 Elo 只由各联赛内部比赛更新,因此同联赛球队之间更适合直接比较。看板的“全部联赛”排行用于总体浏览,跨联赛评分尚未经过欧战等共同比赛数据校准。

十三、为什么暂时不调用更多接口

NJSTATS 还有球队赛季统计、球队近期状态、比赛统计、阵容、伤停、指数和概率等接口。它们确实可以增强模型,但不是第一版 Elo 的必需数据。

数据

第一版是否调用

以后怎么用

球队近期状态

作为未来比赛的近期状态特征

球队赛季统计

加入进球、失球、主客场表现

赛前指数

作为市场基线或额外特征

伤停

在固定赛前时间点形成缺阵特征

阵容

临近开赛模型使用,必须记录获取时间

概率数据

用作外部基准,和自己的模型比较

赛后比赛统计

用于复盘或生成下一场之前的滚动特征

历史建模不能直接套用“今天的统计”

例如你在 2026 年 7 月调用球队近期状态接口,得到的是当前时点的近期数据。它不能直接拿去预测 2025 年 10 月的一场历史比赛。

历史回测需要的是“当时开赛前已经知道的数据”。如果接口没有提供历史快照,就应该像本教程处理 Elo 一样,根据按时间排序的历史比赛自己滚动计算。

指数数据没有被忽略

指数非常适合后续版本,但必须带明确时间边界:

  • 使用初始指数时,要保存它首次出现的时间
  • 使用临场指数时,要规定统一截止点,例如开赛前 60 分钟
  • 不能把开赛后变化或最终指数放进赛前模型
  • 历史训练和未来推理必须使用相同的截止规则

对于新手,建议先把 Elo 基线跑通,再单独加入赛前指数,并比较加入前后的回测表现。

十四、第一版模型有哪些局限

这个模型简单,所以它的边界也很清楚:

  1. 所有球队从 1500 开始,没有处理跨联赛强度差异。
  2. 升班马如果没有此前数据,会从 1500 开始。
  3. 主场优势固定为 65,还没有根据历史数据校准。
  4. K=24 只是透明的起始值,不是五大联赛的最优参数。
  5. 没有考虑近期状态、进球能力、伤停和赛前指数。
  6. expectedHomeScore 还没有转换成胜平负三项概率。

这不是缺陷清单,而是下一步优化清单。先有一个能运行、不会数据泄露的基线,后续每加一项数据,才能知道它到底有没有带来改善。

十五、推荐升级顺序

第 1 级:把 Elo 参数调稳

  • 至少使用两个赛季的历史样本
  • 按时间留出最后几轮做测试
  • 比较不同 K_FACTOR
  • 比较不同 HOME_ADVANTAGE

第 2 级:加入自己滚动计算的近期状态

从历史比赛中计算每支球队开赛前最近 3、5、8 场的:

  • 场均积分
  • 场均进球
  • 场均失球
  • 主客场拆分表现

第 3 级:加入 NJSTATS 赛前快照

  • 球队赛季统计
  • 球队近期状态
  • 伤停
  • 赛前指数

每条数据都要保存 asOf 或采集时间,确保它早于开赛时间。

第 4 级:训练胜平负三分类模型

把下面这些赛前字段放进 XGBoost 或逻辑回归:

preHomeElo
preAwayElo
eloDifferenceWithHomeAdvantage
homeRecentPointsPerGame
awayRecentPointsPerGame
homeGoalsScoredPerGame
awayGoalsScoredPerGame
赛前固定截止点的指数特征

标签使用 result,按时间切分训练集和测试集,不要随机打乱未来与过去。

十六、常见问题

为什么只用五大联赛?

先缩小范围可以减少联赛规则、赛制和球队强度层级的差异。流程稳定以后,再扩展其他联赛。

为什么按联赛名称筛选,不直接写死 ID?

名称让新手更容易配置;接口返回后仍然使用稳定的 leagueId 做后续过滤和关联。

为什么要使用两个赛季?

一个赛季对升降级、换帅和短期波动比较敏感。两个赛季是教学版在数据量与时效性之间的简单折中,不代表固定最优答案。

可以把五个联赛混在一起训练吗?

可以把样本放进同一张训练表,但应保留 leagueId 或联赛编码。Elo 本身主要在各联赛内部通过球队交手更新,不能因为两个联赛的评分都为 1600,就直接断言两队绝对实力完全相同。

下一篇应该学什么?

下一步是把这份 elo_training_rows.csv 和其他赛前特征组合起来,训练 XGBoost 胜平负三分类模型,并用时间切分做真正的历史回测。

总结

五大联赛 Elo 基线实际只需要两类数据:联赛与赛季信息,以及按时间排列的完赛赛程。

真正决定模型是否可信的,不是字段越多越好,而是每个字段都必须在比赛开始前可获得,历史训练和未来使用采用相同规则,并且保留完整的数据时间边界。

先把这个小模型稳定跑起来,你就已经完成了从 API 数据到可训练样本的第一条完整链路。

数据来源:njstats.cn