完成五大联赛 Elo 模型:新手完整实战
这篇教程不要求你会爬虫,也不要求你先学机器学习。
我们会用 NJSTATS 的真实联赛与赛程数据,完成一个可以持续更新的 Elo 球队强度模型。范围只包含五大联赛:英超、西甲、德甲、意甲和法甲。

完成后,你会得到三个可以直接用 Excel 打开的文件:
文件 | 用途 |
|---|---|
| 每场历史比赛在开赛前的 Elo 特征和赛后结果 |
| 各支球队当前的 Elo 强度评分 |
| 今天起 6 个自然日内比赛的赛前 Elo 特征 |
本文的目标是建立透明、可复现的建模基线。
数据来源:njstats.cn
一、Elo 到底在计算什么
Elo 给每支球队一个会随比赛结果变化的强度分数。
本教程规定:
- 所有新球队初始评分为
1500 - 主场球队临时增加
65个主场优势分 - 主胜记
1分,平局记0.5分,主负记0分 - 每场完赛后,按结果更新双方评分
赛前主队期望得分为:
1 / (1 + 10 ^ ((客队 Elo - 主队 Elo - 主场优势) / 400))如果弱队战胜强队,实际结果与赛前预期差距很大,评分变化也会更大。
一个必须先说清楚的概念
代码输出的 expectedHomeScore 是 Elo 期望得分,不是经过校准的“主胜概率”。
因为 Elo 把主胜、平局、主负分别记为 1、0.5、0,所以 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 |
| 用中文名称筛选五大联赛 |
| 查询对应赛季赛程 |
| 标记赛季名称 |
必需接口 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需要读取的字段:
字段 | 建模用途 |
|---|---|
| 比赛唯一标识,负责去重与关联 |
| 只保留五大联赛 |
| 标记样本属于哪个赛季 |
| 按时间从早到晚更新 Elo |
| 只用 |
| 找到双方当前 Elo |
| 方便人阅读结果 |
| 生成主胜、平局、主负标签 |
homeCorner、awayCorner、红黄牌和半场比分虽然也会返回,但第一版 Elo 不使用。先把最小模型跑通,比一开始堆几十个字段更重要。
三、五大联赛的真实 ID
下面是 2026 年 7 月 3 日从接口实际读取的结果:
联赛 |
| 最近完整赛季 |
|
|---|---|---|---|
英超 |
| 2025/2026 |
|
西甲 |
| 2025/2026 |
|
德甲 |
| 2025/2026 |
|
意甲 |
| 2025/2026 |
|
法甲 |
| 2025/2026 |
|
不要把这张表永久写死在业务代码里。新赛季会产生新的 seasonId,所以示例程序每次先读取联赛列表,再按 nameCn 自动找到五大联赛。
还有一个容易踩的坑:当前真实返回中的 matchId、leagueId、seasonId、homeId 和 awayId 是字符串。即使文档把 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这些参数表示:
参数 | 含义 |
|---|---|
| 每个联赛先测试一个含完赛数据的赛季 |
| 每个赛季只读一页,最多 100 场 |
| 测试阶段不读取未来赛程 |
看到下面这类提示就说明流程跑通了:
已找到五大联赛:英超、西甲、德甲、意甲、法甲
完赛样本:...
球队数量:...
未来赛程: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
一行代表一场已经结束的比赛。
最重要的列:
列 | 解释 |
|---|---|
| 本场开赛前主队评分 |
| 本场开赛前客队评分 |
| 主队评分加主场优势后与客队的差值 |
| Elo 主队期望得分,不是主胜概率 |
| 实际结果: |
| 本场完赛后更新的评分 |
这个文件可以继续给 XGBoost、逻辑回归等模型使用。其中 result 是要预测的标签,所有 pre... 字段才是赛前可用特征。
2. elo_ratings.csv
这是处理完全部历史比赛后,各支球队的最新评分。
评分高只代表模型根据所选历史样本判断它更强,不代表下一场一定获胜。不同参数、不同历史长度和升降级处理方式,都会改变评分。
3. elo_upcoming_matches.csv
这是未来比赛的赛前 Elo 特征。
如果文件只有表头,通常不是程序坏了,而是查询范围内没有五大联赛比赛,或新赛季赛程还没有进入接口。可以等赛程发布后加 --refresh 再运行。
十、怎样做最基本的滚动回测
程序按时间把最后 20% 比赛作为测试部分。
测试部分里的每一场比赛都只使用它之前已经结束的比赛更新 Elo,然后比较 expectedHomeScore 与实际结果得分:
实际结果 | 结果得分 |
|---|---|
主胜 |
|
平局 |
|
客胜 |
|
程序计算均方误差,也就是 MSE。MSE 越低,表示期望得分与真实结果得分的整体距离越小。它不是胜平负命中率。
2026 年 7 月 3 日的完整实测结果为:
项目 | 结果 |
|---|---|
完赛样本 | 3470 场 |
球队 | 110 支 |
测试样本 | 694 场 |
Elo 测试 MSE |
|
固定基线测试 MSE |
|
本次运行中 Elo 的误差低于固定基线,说明这个简单评分至少提供了比“所有比赛使用同一个期望值”更多的信息。但这还不能证明参数已经最优,也不能把它解释为单场结果保证。
具体结果保存在 model_summary.json 的 evaluation 中。每次接口数据或参数变化后,都应该重新查看这一段,而不是只看某几场比赛。
十一、Elo 对赛前预测到底有什么实际帮助
Elo 最大的实战价值,不是单独宣布哪支球队会赢,而是把球队过去几十场比赛压缩成一个会持续更新的强度特征。
它主要解决五个问题:
- 统一衡量球队强弱:积分榜只反映当前赛季累计结果,Elo 会根据对手强弱调整每场比赛带来的变化。
- 跟踪强度变化:连续战胜强队时评分上升更明显,输给弱队时下降也更明显。
- 形成赛前基线:在伤停、阵容等信息尚未加入前,先得到一个只依赖历史结果的透明判断。
- 成为机器学习特征:
preHomeElo、preAwayElo和 Elo 差值可以直接输入 XGBoost 等分类模型。 - 支持历史复盘:每场比赛都保存开赛前评分,因此可以按时间检查模型长期表现,而不是挑选少数成功案例。
一个真实的对阵示例
以完整运行后的德甲评分为例:
拜仁慕尼黑 Elo:1763.94
多特蒙德 Elo:1645.97
主场优势:65
含主场优势的差值:1763.94 + 65 - 1645.97 = 182.97
主队 Elo 期望得分:0.7410.741 还不是“拜仁主胜概率 74.1%”。它表示 Elo 判断主队赛果期望明显占优。
接下来可以查看最后 694 场滚动测试中,主队期望得分同样处于 0.70-0.80 区间的比赛:
测试样本 | 主胜 | 平局 | 客胜 |
|---|---|---|---|
133 场 | 57.1% | 24.1% | 18.8% |
这组数据给出了一个可解释的历史基线:当 Elo 产生相似强度信号时,真实赛果过去如何分布。
它仍然不能直接等同于这场具体比赛的最终概率,因为具体比赛还存在近期状态、伤停、阵容、赛程密度和其他赛前信息差异。单场示例也不能证明模型能力,真正证据仍然是完整滚动测试。
实战中的完整使用流程
获取未来赛程
-> 读取两队在开赛前的最新 Elo
-> 计算 Elo 差值和主队期望得分
-> 映射到历史测试区间,得到可解释基线
-> 加入近期状态、赛季统计、伤停和赛前固定时点数据
-> 输入三分类模型
-> 输出主胜、平局、客胜概率
-> 赛后写入真实结果并继续更新、回测第一版 Elo 负责的是“球队基础强度”。最终预测模型还可以加入:
特征 | 作用 | 时间要求 |
|---|---|---|
| 两队长期强度差 | 开赛前最新值 |
近 5 场场均积分 | 近期状态 | 必须是本场之前的比赛 |
近 5 场进球与失球 | 攻防变化 | 必须是本场之前的比赛 |
赛季主客场表现 | 场地差异 | 只能累计到本场之前 |
伤停与阵容 | 人员变化 | 保存发布时间或采集时间 |
固定时点的赛前指数 | 外部赛前基线 | 统一使用例如开赛前 60 分钟 |
最终给 XGBoost 的一行赛前数据可以是:
matchId
preHomeElo
preAwayElo
eloDifferenceWithHomeAdvantage
homeRecentPointsPerGame
awayRecentPointsPerGame
homeRecentGoalsScoredPerGame
awayRecentGoalsScoredPerGame
homeInjuryCount
awayInjuryCount
preMatchIndexFeatures模型预测目标是 H、D、A,也就是主胜、平局和客胜。训练时必须按时间切分,不能随机把未来比赛混入训练数据。
十二、把模型结果做成可视化成果页




运行:
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 基线跑通,再单独加入赛前指数,并比较加入前后的回测表现。
十四、第一版模型有哪些局限
这个模型简单,所以它的边界也很清楚:
- 所有球队从
1500开始,没有处理跨联赛强度差异。 - 升班马如果没有此前数据,会从
1500开始。 - 主场优势固定为
65,还没有根据历史数据校准。 K=24只是透明的起始值,不是五大联赛的最优参数。- 没有考虑近期状态、进球能力、伤停和赛前指数。
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
评论交流