☰
Python机器学习预测篮球总冠军:Elo评分与蒙特卡洛模拟
2026/10/26 2:10:36 网站建设 项目流程

简介:面向希望用Python完成体育赛事预测实战的学习者,这份资料以NBA总冠军预测为主线,串联数据清洗、特征工程、模型训练与结果解释等环节,适合具备Python与机器学习基础、想积累完整赛题经验的中级读者。压缩包共131个文件,约4.22MB,以113个CSV赛程与球员统计数据集为主体,辅以PDF说明、Python与R脚本、docx/md笔记及RData等工作文件,覆盖从原始数据到建模复现的主要材料。目前已有140人学习下载。读者可据此练习缺失值与异常值处理、主场客场及历史战绩等特征构造,比较逻辑回归、随机森林、XGBoost等模型,并用交叉验证、AUC-ROC与特征重要性完成评估和可视化,逐步理解体育预测中样本不平衡与模型解释的难点,形成可复用的项目流程。

1. 从一份赛季中期的夺冠概率表说起:这个 python 机器学习项目到底预测了什么

赛季打到一半,有人丢过来一张表:十几支球队的夺冠概率从 3.1% 到 21.7% 排开,排在第一的不是当时战绩最好的那支。这张表就是一个 python 机器学习预测篮球联赛总冠军的项目跑出来的结果。它跟常见的“跑个逻辑回归看看准确率”完全不是一回事:整套流程从原始比赛记录开始,先做特征工程算出球队实力评分和近期状态,再训练单场胜负概率模型,最后用蒙特卡洛把单场胜率换算成七场四胜制的晋级概率,一路推到总冠军。

适合两类人看。一类是想搞明白“体育赛事到底能不能建模预测”的开发者,这个项目的每一层都能单独拎出来验证;另一类是手上有别的联赛数据、想找个现成骨架改的人,它的数据层和模型层是解耦的,换数据源不用动模型代码。

2. 目录结构与技术栈拆解:从 data/raw 到 outputs 的一条直线

2.1 目录分层:为什么 raw 和 processed 必须分开

拿到这个包,我第一件事是看目录。很多数据类项目最容易犯的毛病是把清洗逻辑写进 notebook,过两周自己都复现不出来。这个包的分层是干净的,数据从原始到成品单向流动,中间产物可丢弃、可重算:

champion-predictor/ ├── data/ │ ├── raw/ # 原始比赛记录、球队赛季统计,只读不改 │ ├── interim/ # 长表展开、缺失修补后的中间结果 │ └── processed/ # 特征宽表,训练脚本唯一入口 ├── src/ │ ├── config.py # 路径、赛季、超参,全部集中在这 │ ├── data/ │ │ ├── loader.py # 读 raw,统一列名与类型 │ │ ├── clean.py # 去重、补赛、时间字段标准化 │ │ └── features.py # Elo 迭代、滚动窗口、差分特征 │ ├── models/ │ │ ├── train.py # 训练 + 时间切分验证 │ │ ├── evaluate.py # logloss / brier / accuracy │ │ └── predict.py # 产出单场概率矩阵 │ ├── simulate/ │ │ ├── bracket.py # 对阵图构建、种子排位 │ │ └── monte_carlo.py # 十万次模拟,出夺冠概率 │ └── utils/ │ ├── logger.py │ └── io.py ├── models/ # 训练产物 .joblib + feature_list.json ├── notebooks/ # 探索性分析,不参与生产链路 ├── outputs/ # 概率表 CSV、柱状图 PNG ├── requirements.txt └── run_all.sh # 一条命令跑完全链路

interim和processed分开这一层,是我认为最值得抄的设计。interim里放的是“清洗完但还是行级记录”的数据,谁想看某场比赛为什么是这个特征值,都能倒推回去;processed是喂给模型的宽表,一旦特征定义变了就整表重建,不心疼。models/里除了模型文件还强制存一份feature_list.json,这个后面避坑那章会讲为什么必须存。

run_all.sh的内容很朴素,就是按顺序调三个脚本:

#!/usr/bin/env bash set -euo pipefail # 任何一步失败就停,别带着坏数据往下跑 python -m src.data.features # raw -> interim -> processed python -m src.models.train # 训练 + 验证 + 落盘 python -m src.simulate.monte_carlo # 出夺冠概率表

set -euo pipefail这行别省。数据管线最坑的不是报错,是某一步静默产出了空表,后面模型照样能训练,只是结果一片噪声,你要花半天才发现源头是空的。

2.2 技术栈与依赖锁定

这套东西没有用深度学习框架,选型很克制。样本量摆在那儿——一个赛季一千多场常规赛,历史数据撑死几万条,上神经网络基本是给自己找麻烦。核心依赖就四个:pandas 做表处理,scikit-learn 做流水线和基线模型,LightGBM 处理非线性交互,joblib 负责模型持久化。

pandas==2.1.4 numpy==1.26.2 scikit-learn==1.3.2 lightgbm==4.1.0 matplotlib==3.8.2 joblib==1.3.2 pyyaml==6.0.1

版本号建议照抄,别自作聪明升到最新。LightGBM 4.x 之后有几个默认参数改过(比如min_child_samples相关的分箱行为),你用新版本加载老模型,预测值可能对不上但不报错,这个坑很安静。

搭环境就三步,虚拟环境一定要单独开:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt

提示:Windows 上如果 LightGBM 装完 import 报缺少 DLL,先装一遍 Microsoft 的 C++ 运行库再重装,别急着怀疑包本身。

2.3 配置集中化:换赛季只改一个字段

src/config.py用 dataclass 把所有可变项收在一处。这个文件小,但决定了项目好不好改——所有硬编码的路径、赛季名、超参都从这里走,后面想跑新赛季只需要改season和cutoff:

# src/config.py from dataclasses import dataclass, field from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @dataclass class Config: data_dir: Path = ROOT / "data" / "processed" model_dir: Path = ROOT / "models" out_dir: Path = ROOT / "outputs" season: str = "2023-24" # 换赛季改这里 cutoff: str = "2023-10-01" # 训练/验证的时间分界 elo_k: float = 20.0 # Elo 更新步长 elo_hca: float = 100.0 # 主场优势,折算成 Elo 分 n_sims: int = 100_000 # 蒙特卡洛次数 seed: int = 42 # 固定种子,结果可复现 features: list = field(default_factory=lambda: [ "elo_diff", "win_rate_l10_diff", "margin_l10_diff", "rest_days_diff", "is_back_to_back", ])

elo_k和elo_hca这两个参数是调参时最常动的。K 值大了球队排名变化快、噪声也大,小了又太迟钝;主场优势折算成 100 分 Elo 大致对应 3 到 3.5 分的让分,如果数据里主场哨特别明显,可以往 110 到 120 试。seed固定住之后,同一份数据跑两次结果完全一致,排查问题时省掉“到底是我改了代码还是随机性”的纠结。

3. 数据管线:Elo 与滚动窗口怎么把比赛记录变成特征表

3.1 原始数据长什么样,需要哪些字段

data/raw/里通常是逐场比赛的记录,一行一场,最小可用字段集大概是这样:

字段类型说明
game_idstr比赛唯一标识
game_datedate比赛日期,排序和切分全靠它
home_team/away_teamstr球队标识,建议用稳定缩写
home_score/away_scoreint最终比分
seasonstr赛季标识,跨赛季时用于分组
season_typestr常规赛 / 季后赛,训练集一般只用常规赛

如果包里还带了球队赛季统计(每百回合得分、失分、节奏值这些),那更好,但要注意这些统计是赛季末的汇总值,直接拿来预测赛季初的比赛就是泄漏,具体怎么处理放到第 5 章讲。加载环节我一般会强制一遍 schema 校验,列名对不上就当场炸掉:

# src/data/loader.py import pandas as pd REQUIRED = ["game_id", "game_date", "home_team", "away_team", "home_score", "away_score", "season_type"] def load_games(path) -> pd.DataFrame: df = pd.read_csv(path, parse_dates=["game_date"]) missing = set(REQUIRED) - set(df.columns) if missing: raise ValueError(f"缺少字段: {missing}") # 早失败,别让脏数据流进特征层 df = df.sort_values("game_date").reset_index(drop=True) df = df.drop_duplicates(subset="game_id", keep="first") df["home_win"] = (df["home_score"] > df["away_score"]).astype(int) return df

这里有两个动作值得说。parse_dates在读取阶段就把日期转成 datetime,后面所有diff()、rolling()都依赖这个类型,晚转一步就容易在别处对字符串做减法而报错。drop_duplicates按game_id去重是必须的,历史数据合并时重复记录太常见,一场比赛出现两次会让 Elo 更新两次,排名直接偏掉。

3.2 Elo 评分:不依赖统计汇总的实力度量

这个项目用 Elo 作为核心特征,原因很实际:Elo 是逐场迭代的,第 N 场比赛的评分只由前 N-1 场决定,天生不存在未来信息泄漏。而且它的量纲统一,不同赛季之间可以直接比较。

# src/data/features.py BASE = 1505.0 # 新球队初始分,略高于 1500 表示给主场方的先验 def expected_home_win(home_elo: float, away_elo: float, hca: float) -> float: """Elo 差值经 logistic 变换得到主场胜率期望""" diff = (home_elo + hca) - away_elo return 1.0 / (1.0 + 10 ** (-diff / 400.0)) def update_elo(elo: dict, home: str, away: str, home_win: int, k: float = 20.0, hca: float = 100.0) -> dict: h = elo.get(home, BASE) a = elo.get(away, BASE) p_home = expected_home_win(h, a, hca) delta = k * (home_win - p_home) # 意外结果调整幅度更大 elo[home] = h + delta elo[away] = a - delta # 零和,总分守恒 return elo def add_elo_column(games, k=20.0, hca=100.0): elo, rows = {}, [] for r in games.itertuples(): h_before = elo.get(r.home_team, BASE) a_before = elo.get(r.away_team, BASE) rows.append((r.game_id, h_before, a_before, h_before - a_before)) elo = update_elo(elo, r.home_team, r.away_team, r.home_win, k, hca) elo_df = pd.DataFrame(rows, columns=["game_id", "home_elo", "away_elo", "elo_diff"]) return games.merge(elo_df, on="game_id", how="left")

需要注意的关键点:记录的是h_before和a_before,也就是这场比赛开打之前的评分,更新放到记录之后。这个顺序反了,elo_diff里就混进了这场比赛的结果,模型准确率会虚高一大截,上线就现原形。

10 ** (-diff / 400)里的 400 是 Elo 的标准刻度,意思是差值 400 分对应约 10:1 的胜率比,一般不用改。K 值可以按赛季阶段动态调,常规赛前期给大一点(比如 24)让排名快速收敛,后期调小(比如 16)保持稳定,这个改起来就一行。

3.3 滚动窗口与休息天数:把“近期状态”变成数字

Elo 反映的是长期实力,但赛季中期的球队状态波动很大——主力伤停、交易截止日换人,这些 Elo 要好几场才追得上。所以还要补一组滚动窗口特征。

def build_team_form(games: pd.DataFrame, window: int = 10) -> pd.DataFrame: # 把一场比赛拆成两行,每行代表一支球队的视角 home = games[["game_id", "game_date", "home_team", "home_score", "away_score"]].copy() home.columns = ["game_id", "game_date", "team", "pts_for", "pts_against"] home["is_home"] = 1 away = games[["game_id", "game_date", "away_team", "away_score", "home_score"]].copy() away.columns = ["game_id", "game_date", "team", "pts_for", "pts_against"] away["is_home"] = 0 long = pd.concat([home, away]).sort_values(["team", "game_date"]).reset_index(drop=True) long["win"] = (long["pts_for"] > long["pts_against"]).astype(int) long["margin"] = long["pts_for"] - long["pts_against"] g = long.groupby("team") # shift(1) 是命门:当前这场的数据不能进当前这场的特征 long["win_rate_l10"] = g["win"].transform( lambda s: s.shift(1).rolling(window, min_periods=3).mean()) long["margin_l10"] = g["margin"].transform( lambda s: s.shift(1).rolling(window, min_periods=3).mean()) # 休息天数,背靠背算 0 天,clip 到 7 天封顶避免长休赛期异常值 long["rest_days"] = g["game_date"].transform( lambda s: s.diff().dt.days.fillna(7).clip(upper=7)) long["is_back_to_back"] = (long["rest_days"] <= 1).astype(int) return long

参数上,window=10大致对应两周半的比赛量,是个比较稳的折中;想更敏感可以降到 5,但样本太少噪声会盖过信号。min_periods=3是为了让新赛季前几场不至于全是 NaN——虽然这几场质量差,但总比丢掉好,反正后面还有SimpleImputer兜底。

这段代码里隐藏着一个常被忽略的细节:拆成长表之后,同一场比赛会出现两行(主队一行、客队一行),各自按自己的时间线滚动。合并回比赛粒度时要按(game_id, team)对齐,再把主客两边的特征相减,得到*_diff系列:

def to_game_level(long: pd.DataFrame) -> pd.DataFrame: cols = ["win_rate_l10", "margin_l10", "rest_days", "is_back_to_back"] home = long[long["is_home"] == 1].set_index("game_id")[cols] away = long[long["is_home"] == 0].set_index("game_id")[cols] out = home.join(away, lsuffix="_h", rsuffix="_a") for c in cols: out[f"{c}_diff"] = out[f"{c}_h"] - out[f"{c}_a"] # 差分比绝对值更有解释力 return out.reset_index()

差分这一步是有讲究的。绝对值特征(比如主队近十场胜率 0.7)单独看意义不大,模型真正要的是“主队比客队强多少”。做差分之后特征分布更对称,逻辑回归这类线性模型的系数也更好解释。像rest_days这种,主客两边都是 2 天的话差值是 0,模型就知道“休息优势不存在”,这比喂两个绝对值让模型自己学要干净得多。

3.4 时间切分:不能随机打乱

训练集和验证集的切分方式,是数据类项目里最容易翻车的地方之一。这里必须按时间切,而且切点要卡在赛季边界:

def time_split(df: pd.DataFrame, cutoff: str): train = df[df["game_date"] < cutoff].copy() valid = df[df["game_date"] >= cutoff].copy() assert train["game_date"].max() < valid["game_date"].min(), "切分有交叠" return train, valid

sklearn的train_test_split默认随机打乱,用在这种时序数据上就等于让模型偷看未来——随机切完之后,训练集里混着赛季末的比赛,验证集里有赛季初的,模型学到了“某队赛季末很强”这个结论去预测它赛季初的比赛,指标漂亮得不像话,实际毫无用处。那个assert是我后来加的,纯粹是被自己坑怕了。

4. 模型层:逻辑回归打底、LightGBM 接力、蒙特卡洛收尾

4.1 基线先行:逻辑回归不是凑数

做这类预测,我习惯第一个模型永远是最简单的那个。逻辑回归在这个场景里不是走过场,它的输出天然是概率,系数可解释,而且给了后面所有模型一个“及格线”——如果 LightGBM 打不过它,说明特征里根本没什么非线性关系,上复杂模型纯属浪费。

from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression def build_logit(features): return Pipeline([ ("impute", SimpleImputer(strategy="median")), # 新赛季前几场的 NaN ("scale", StandardScaler()), # 量纲差得远,必须标准化 ("clf", LogisticRegression(C=0.5, max_iter=1000, random_state=42)), ])

C=0.5是往正则化方向压了一点,默认的 1.0 在这个样本量下会稍微过拟合。max_iter提到 1000 是因为标准化之后的特征有时候收敛慢,默认 100 会甩一堆ConvergenceWarning,虽然不影响结果但看着烦。这个流水线可以直接.fit(X, y)之后调.predict_proba(X)[:, 1]拿概率,接口跟下面的树模型完全一致,替换起来零成本。

4.2 LightGBM:处理交互项的主力

Elo 差值和近期状态之间明显有交互——强队背靠背打弱队可能无所谓,两支实力接近的球队里,休息充分的那方优势就很关键。这种交互逻辑回归表达不了,得靠树模型自己切分。

from lightgbm import LGBMClassifier def build_lgbm(): return LGBMClassifier( n_estimators=600, learning_rate=0.03, num_leaves=31, min_child_samples=40, # 叶子最小样本,样本量小时防记忆噪声 subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, # L2 正则 random_state=42, verbose=-1, )

几个参数的经验值:num_leaves=31配上min_child_samples=40,是在几千条样本上比较稳的组合,叶子再细分下去模型就开始记住具体某场比赛了。learning_rate=0.03配 600 棵树,是慢学习率多树的常规搭配,训练时间可接受。subsample和colsample_bytree都设 0.8,特征本来就只有五六个,采样比例再低就采不到关键列了。

两个模型怎么合?最简单的做法是按验证集表现加权平均,但更稳的是直接训练一个逻辑回归做 stacking,把两个模型的输出概率当特征:

import numpy as np from sklearn.model_selection import cross_val_predict def blend_oof(X, y, models, cv=5): """用交叉验证的 out-of-fold 预测做融合权重,避免在训练集上自我加权""" oof = np.column_stack([ cross_val_predict(m, X, y, cv=cv, method="predict_proba")[:, 1] for m in models ]) meta = LogisticRegression(C=1.0, max_iter=1000).fit(oof, y) return meta

cross_val_predict这一步不能省。如果直接在训练集上拟合两个模型再拿它们的预测去训练元模型,元模型看到的是过拟合后的输出,学到的权重会严重偏向那个过拟合最厉害的模型。用 OOF 预测就没这个问题。

模型的评估我固定看三个指标,别只看准确率:

指标含义关注点
logloss概率预测的对数损失越低越好,对“自信地答错”惩罚最重
brier概率与真实结果的均方误差越低越好,直观反映概率校准
accuracy阈值 0.5 下的分类准确率只作参考,单看它会掩盖概率质量问题

在这类赛事预测上,accuracy 一般落在 0.66 到 0.70 这个区间,logloss 在 0.58 到 0.62 之间,具体看赛季和特征集。如果你的结果 accuracy 到了 0.85 以上,先别高兴,回去查泄漏。这三个指标的代码很短:

from sklearn.metrics import log_loss, brier_score_loss, accuracy_score def report(y_true, y_prob, thr=0.5): return { "logloss": round(log_loss(y_true, y_prob), 4), "brier": round(brier_score_loss(y_true, y_prob), 4), "acc": round(accuracy_score(y_true, (y_prob >= thr).astype(int)), 4), }

4.3 从单场概率到系列赛概率:二项式推导

这一步是整个项目里最有意思的地方。模型只能告诉你单场主队胜率,但总冠军是七场四胜制打出来的,得先把单场概率换算成系列赛概率。

from math import comb def series_win_prob(p: float, best_of: int = 7) -> float: """给定单场胜率 p,求赢下 best_of 场系列赛的概率""" need = best_of // 2 + 1 # 7 场制需要 4 胜 total = 0.0 for losses in range(need): # 输 0 到 need-1 场 # 最后一场必须是赢,前面 need+losses-1 场里赢 need-1 场 n = need + losses - 1 total += comb(n, need - 1) * (p ** need) * ((1 - p) ** losses) return total

这里假设每场独立同分布,虽然现实中主场会轮换、系列赛里还会有战术针对,但作为一阶近似够用了。p=0.6 的单场胜率换算成七场制,大概能到 0.71;p=0.55 只到 0.61 左右。也就是说系列赛会放大强队的优势,这也解释了为什么常规赛胜率差一点点,夺冠概率能差好几倍。

4.4 蒙特卡洛:十万次模拟把对阵图跑完

单场概率有了、系列赛公式有了,但整张对阵图是棵树,每一轮的对手取决于上一轮谁赢了。硬算组合数会爆炸,直接模拟最省事。

import numpy as np def simulate_bracket(teams, pair_prob, n_sims=100_000, seed=42): """teams 按种子顺序排好;pair_prob 是 (强, 弱) -> 强队胜率的函数""" rng = np.random.default_rng(seed) champ = {t: 0 for t in teams} for _ in range(n_sims): bracket = list(teams) while len(bracket) > 1: nxt = [] for i in range(0, len(bracket), 2): a, b = bracket[i], bracket[i + 1] p = pair_prob(a, b) # 含主场优势的系列赛胜率 nxt.append(a if rng.random() < p else b) bracket = nxt # 胜者进入下一轮,顺序即下一轮对阵 champ[bracket[0]] += 1 return {t: round(c / n_sims, 4) for t, c in champ.items()}

仿真循环本身很轻,十万次在普通笔记本上也就几秒到十几秒。rng.random() < p这种写法比np.random.binomial(1, p)快,而且不需要每轮重新建分布对象。pair_prob函数里通常要加主场因素:七场制是 2-2-1-1-1 排布,前两场和最后一场在排名高的球队主场,所以系列赛胜率不等于纯实力的那个值,需要按 2:2:1:1:1 的比例对每场分别取 p 再相乘,再对胜负组合求和。简化处理的话,直接给强队加 2% 到 3% 的系列赛胜率偏移也行,差别不大。

输出阶段我建议把“进入各轮的概率”也一起存下来,只存夺冠概率太浪费:

# outputs/champion_prob_2023-24.csv 大致结构 # team, make_playoffs, reach_r2, reach_conf_final, reach_finals, champion

这张表拿去做可视化最直观,一根横向条形图按 champion 倒序排,前面几名的差距一眼就能看出来。

5. 避坑与排查:五个让预测结果失真的真实翻车点

5.1 训练集准确率 0.92,验证集 0.68

现象:模型在训练集上准确率接近 0.92,切到验证集立刻掉到 0.68,logloss 也从 0.30 跳到 0.62。

原因:数据泄漏。最典型的是用了赛季汇总统计当特征——赛季末的场均净胜分、最终排名这些值,被安到了赛季初的比赛上。等价于告诉你“这队最后是冠军”,再去预测它十月那场常规赛。Elo 之所以安全,恰恰因为它是逐场迭代出来的。

解决:所有特征必须能在比赛开打前算出来。做法是给每个特征标注“时间戳”,只允许feature_time < game_date。像赛季汇总统计这类,要么彻底不用,要么改造成expanding()的滚动累计(只累计到前一场),绝不能用全表groupby().mean()。

5.2 预测概率全挤在 0.48 到 0.52

现象:跑出来的单场概率几乎没有区分度,最高的也就 0.58,所有比赛看起来都五五开。

原因:特征里混进了对结果没信息量的列,最典型的是把球队名做了 one-hot。三四十支球队展开成几十维稀疏向量,模型学到的只是一堆噪声,真正的 Elo 差值信号被稀释掉了。另一种可能是StandardScaler放错了位置——在切分之前对全量数据 fit,验证集的均值方差漏进了训练。

解决:球队标识坚决不做 one-hot,用 Elo 或者历史对战记录的目标编码替代。StandardScaler一定要放在 Pipeline 里,让它只在fit阶段看训练数据。检查方法很简单,把特征逐个去掉跑一遍,看 logloss 变化,贡献为负的直接砍。

5.3 同一份数据跑两次,冠军概率差了 4 个百分点

现象:什么都没改,重跑一遍模拟,某支球队的夺冠概率从 12.4% 变成 16.1%。

原因:两个地方。一是没固定随机种子,np.random每次从系统熵初始化;二是模拟次数不够,一万次的时候,12% 的概率标准误大概是 0.3 个百分点,但如果是冷门球队概率只有 1% 到 2%,标准误占比就很大,肉眼看就是“结果在跳”。

解决:default_rng(seed)全局固定,n_sims直接提到十万量级。判断收敛的土办法是跑三次不同种子,看同一支球队的概率极差,超过 0.5 个百分点就继续加次数。还有一个容易漏的点:对阵图构建时如果有平局或者并列排名,必须写死 tie-break 规则,否则每次sort_values的顺序可能不同,对阵图都变了。

5.4 新赛季第一场直接抛 KeyError

现象:把配置里的season改到新赛季,一跑特征工程就报KeyError,指向rest_days或者elo_diff。

原因:新赛季第一场之前没有任何历史比赛,diff()全是 NaT、rolling()全是 NaN,而 Elo 字典是空的,.get(team, BASE)虽然兜住了但下游合并时列不存在。更隐蔽的是,如果data/processed里的旧特征表还在,脚本读到的是上赛季的宽表,字段对不上就炸。

解决:每次跑之前清空interim和processed,别做增量。Elo 起点用BASE而不是继承上赛季末的分数——跨赛季阵容变化太大,直接延续会带着上赛季的偏差进新赛季。给rest_days的fillna(7)和rolling的min_periods=3是同一类兜底思路,宁可前几场特征弱一点,也别让整条链路断掉。

5.5 换个环境加载模型,预测值悄悄变了

现象:本地跑出来的概率是 0.63,部署到另一台机器上同一个模型文件加载出来是 0.61,不报错,就是不一样。

原因:依赖版本不一致。LightGBM 跨版本加载模型时,分箱边界和默认参数的解释可能有细微差异。另一种情况是特征列顺序变了——训练时是[elo_diff, win_rate_l10_diff, ...],推理时从 CSV 读出来按字母序排,模型把is_back_to_back当成了elo_diff喂进去,完全不报错但结果全错。

解决:models/目录里必须同时落三样东西——模型文件、feature_list.json(记录精确的列顺序)、requirements.lock(锁死版本)。推理前强制按feature_list重排:

import json, joblib def load_model(model_dir): model = joblib.load(model_dir / "lgbm.joblib") feats = json.loads((model_dir / "feature_list.json").read_text()) return model, feats def predict_one(model, feats, row: dict): X = [[row[f] for f in feats]] # 严格按训练时的顺序取值 return float(model.predict_proba(X)[0, 1])

row[f]用方括号而不是.get(f),缺字段就当场KeyError,这是好事——总比静默用默认值算出个错结果强。

6. 二次开发:换赛制、加特征、接一个查询接口

这套代码的改造点很清晰,因为它把“算什么”和“怎么算”分开了。想换联赛或换赛制,动的其实只有三处:loader.py里的字段映射、bracket.py里的对阵图构建、config.py里的best_of和主场排布。比如换成单场淘汰的杯赛,series_win_prob直接返回 p 就行,simulate_bracket一行不用改;换成主客场总比分制,就得在pair_prob里把两回合的联合分布算出来,这个稍微麻烦点,但改动范围仍然锁在simulate/目录里。

加特征的方向我更推荐从“信息增量”而不是“模型复杂度”下手。有几个维度这个项目还没覆盖:球员级出勤(核心球员缺阵对胜率的影响明显大于球队整体 Elo 的变化)、旅行距离和时区跨越(连续客场、跨时区背靠背的体能损耗是真实的)、赛程密度。加球员出勤的话,特征是“本场缺阵球员的累计胜率贡献值”,注意必须用比赛日当天的伤停名单,用赛季末的出场统计一样会泄漏。

接接口这块,最小可用的做法是 FastAPI 包一层,把模型和特征表在启动时加载一次常驻内存:

from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() MODEL, FEATS = load_model(CFG.model_dir) # 启动时加载,别每次请求都读盘 class MatchIn(BaseModel): elo_diff: float win_rate_l10_diff: float margin_l10_diff: float rest_days_diff: float is_back_to_back: int @app.post("/predict") def predict(m: MatchIn): p = predict_one(MODEL, FEATS, m.model_dump()) return {"home_win_prob": round(p, 4), "series_prob": round(series_win_prob(p), 4)} # 顺手把系列赛概率也算上

验证改造有没有效,别只看新赛季的准确率。我的习惯是固定一组“回测窗口”——拿过去三个赛季的数据分别训练、分别预测,看 logloss 的方差大不大。如果某个赛季突然变差很多,大概率是那个赛季的数据有问题(比如停摆导致场次不足、赛制临时调整),而不是模型不行。这个回测脚本我一般单独放一个scripts/backtest.py,不混进主链路。

还有个特别实用的技巧:把每次预测的输入特征和输出概率都记一条日志,落到outputs/predictions/下按日期分文件。等赛季结束回头对一遍,哪些比赛模型错得离谱、当时的特征长什么样,一目了然。这比事后猜“当时是不是数据有问题”靠谱得多。

我以前做这类时序预测,习惯是先跑通模型再说数据,结果每次都被泄漏和版本问题教做人。从那以后我每次动特征工程之前,都强制先跑一遍“只用一条历史数据”的极小样本测试,确认整条链路能从头到尾走通、时间字段没有一处倒挂,再上全量数据。慢是慢了点,但比事后从一堆 0.9 的准确率里找哪一步偷看了未来要省事太多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询