简介:这份压缩包面向希望用 Python 实践体育数据预测的初学者与进阶学习者,围绕 NBA 总冠军预测这一具体赛题,串联数据清洗、特征工程、模型训练验证、评估与可视化等完整机器学习流程。包内共 131 个文件,以 csv 比赛与球员统计数据为主,辅以 pdf、docx、md 说明文档,py、r 建模脚本及 xlsx 表格等,压缩包约 4.22MB,目录便于按数据、代码和文档分块查阅,也便于先读说明再运行脚本。已有 140 人学习下载,适合作为课程设计、期末项目或数据竞赛的参考案例。读者可从中获得跨赛季样本组织、特征选择、常见分类模型对比、交叉验证与结果解释的实践思路,并借助随包文档和脚本复现从数据处理到冠军预测的关键环节,理解体育预测中样本不平衡与特征有效性等常见问题。
1. python 机器学习预测 NBA 总冠军,这个压缩包真正想解决的不是「谁会赢」
python 机器学习预测 NBA 总冠军,绝大多数人上手的第一反应是拉球星数据、算场均得分,然后被现实打脸——常规赛净胜分排前四的队伍,季后赛首轮出局是常态。这个压缩包走的不是这条路:它把一场比赛拆成主客两行样本,用滚动窗口构造近期状态特征,再用逻辑回归和梯度提升树输出单场胜率概率,最后用蒙特卡洛把单场概率放大成七场系列赛的晋级概率,一路推到总冠军分布。适合已经会 pandas、但没正经做过时间序列特征的人,也适合想搞清楚「概率模型该怎么评估」的从业者。它不是竞猜工具,值钱的是那条防止未来数据泄漏的数据管道。
2. 拆包与数据底座:4 张原始表怎么拼成一张可训练的特征宽表
拿到包先别急着跑main.py。这类预测工程的成败,八成在数据组织方式上就已经定死了。原始比赛日志是「一行一场比赛」,而模型需要的是「一行一个样本」,样本的粒度决定了后面所有特征能不能对齐。这一步做歪了,后面调参调到天亮也救不回来。
2.1 目录结构与依赖环境
包内一般是data/、src/、notebooks/、models/四块。data/放原始 csv,src/放特征构造和训练脚本,notebooks/是探索性分析,models/存序列化后的模型文件。先把环境隔离出来,别用系统 Python 直接装,sklearn 版本差异会直接改变默认参数行为。
# 建议 python 3.9 ~ 3.11,3.12 上部分老版本 sklearn 会编译失败 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # pandas / numpy / scikit-learn / matplotlib pip list | grep -E "pandas|scikit-learn" # 确认版本,写进实验记录依赖里最需要注意的是 scikit-learn 的主次版本。LogisticRegression的penalty默认值、HistGradientBoostingClassifier的early_stopping默认值在不同版本间都动过,同一个脚本换台机器跑出不同指标,十有八九是这里的问题。我的习惯是先把pip freeze > env.lock存一份,后面复现实验直接对照。
原始数据一共四张表,字段口径先对齐再动手:
| 文件 | 粒度 | 关键字段 | 用途 |
|---|---|---|---|
games.csv | 一行一场比赛 | game_id、game_date、season、home_team_id、away_team_id、home_pts、away_pts | 主表,构造标签和滚动特征 |
team_stats.csv | 一行一队一场 | game_id、team_id、fg_pct、fg3_pct、reb、tov、pace | 补充效率类特征 |
rosters.csv | 一行一队一赛季 | team_id、season、player_id、minutes | 判断阵容稳定性、伤病影响 |
playoffs.csv | 一行一轮系列赛 | season、round、team_a、team_b、winner | 只做验证,绝不进训练集 |
playoffs.csv单独拎出来说:很多人图省事把它并进训练数据,结果模型的验证准确率好看得离谱,一上真实赛程就废。季后赛结果是标签的标签,它只能出现在最后一步的对照里。
2.2 从宽表到长表:一行一队样本的构造
主表是宽表结构,主队字段和客队字段挤在同一行。直接喂给模型会有个隐蔽问题:模型天然认为「主队那一列」比「客队那一列」更重要,学出来的是一套偏置的权重。标准做法是先「融化」成长表,让每支球队在每场比赛里都成为一条独立样本,主客身份退化成一个 0/1 特征。
import pandas as pd games = pd.read_csv("data/games.csv", parse_dates=["game_date"]) # 主队视角 home = games.rename(columns={ "home_team_id": "team_id", "away_team_id": "opp_id", "home_pts": "pts", "away_pts": "opp_pts", }).assign(is_home=1) # 客队视角:注意 pts 取的是 away_pts away = games.rename(columns={ "away_team_id": "team_id", "home_team_id": "opp_id", "away_pts": "pts", "home_pts": "opp_pts", }).assign(is_home=0) long = ( pd.concat([home, away], ignore_index=True) .sort_values(["team_id", "game_date"]) # 排序是滚动的先决条件 .reset_index(drop=True) ) long["margin"] = long["pts"] - long["opp_pts"] # 净胜分,正为胜 long["win"] = (long["margin"] > 0).astype(int) # 平局在篮球里不存在,不用管 print(long.shape, long["team_id"].nunique())逻辑上就三步:拆视角、拼回来、算派生列。sort_values(["team_id", "game_date"])这一行是整个特征工程的命门,漏了它,rolling会在时间轴上乱序滑动,算出来的「近 10 场均值」其实是随机 10 场均值,指标会莫名其妙地掉一截,而且不报错——这类黑匣子问题最难查。
参数上有两个细节:ignore_index=True是为了后面按下标对齐时不出重号;margin用净胜分而不是胜负,是因为胜负是二值的,滚动平均后信息量太低,净胜分能保留「赢 20 分」和「赢 1 分」的差别,这在季后赛强弱区分上很关键。
2.3 补齐效率类特征与缺值处理
净胜分只反映结果,不反映过程。一支队伍连续赢球但每场只赢 2 分,和一支队伍场均赢 15 分,滚动均值可能差不多,实际强度差一档。所以要用team_stats.csv补进攻效率、防守效率、节奏这几个过程指标。
stats = pd.read_csv("data/team_stats.csv") long = long.merge( stats[["game_id", "team_id", "fg_pct", "fg3_pct", "tov", "pace"]], on=["game_id", "team_id"], how="left", validate="one_to_one" ) # 三分命中率在小样本下极不稳定,缺失用联盟当季中位数填,而不是 0 for col in ["fg_pct", "fg3_pct", "tov", "pace"]: med = long.groupby("season")[col].transform("median") long[col] = long[col].fillna(med) print(long[["fg_pct", "fg3_pct", "pace"]].isna().mean())validate="one_to_one"这个参数建议常开,它能当场告诉你两表的主键是不是真的唯一。实际数据里经常出现同一场比赛同一支队伍有两条记录(加时统计被拆开),没有这个校验,merge 之后行数悄悄翻倍,样本量看着变多了,其实是重复计数。
缺值填充用「当季中位数」而不是全局中位数,是因为联赛整体三分水平逐年漂移,用 2015 年的中位数填 2023 年的缺失,等于给模型灌了一条错误的时间信号。这类细节单看都不起眼,堆在一起就是验证集和线上表现的差距来源。
3. 特征工程:用 10 场滚动窗口把 82 场常规赛压成 14 个指标
数据底座搭好之后,真正决定模型上限的是特征。赛季均值这类特征有个致命缺陷:它把 10 月份的一场大胜和 4 月份的一场惨败等权平均,而季后赛看的是「你最近什么样」。滚动窗口就是干这个的,但滚动本身极易引入泄漏,这是这类项目翻车最集中的地方。
3.1 为什么用滚动窗口而不是赛季均值
假设某队赛季前半程 30 胜 5 负,后半程因为主力轮换 12 胜 20 负,赛季总胜率 0.62。赛季均值会告诉你这是一支强队,滚动 10 场胜率会告诉你它正在崩。季后赛预测里后者才有区分度。
窗口长度怎么选,常见做法是同时给 5、10、20 三个尺度,让模型自己挑权重。5 场对噪声太敏感,一场加时爆冷就能把均值拉偏;20 场反应太慢,赛季末的状态变化要拖一个多月才体现。10 场是折中,也是我一般会先跑通的那个。
这里有个容易被忽略的点:滚动特征必须「按球队分组」且「按时间排序」。跨球队滚动等于把对手的数据算进自己头上,那才是真正的血泪教训。
3.2 滚动特征的实现:shift 先行,rolling 后到
def add_rolling_features(df: pd.DataFrame, windows=(5, 10, 20)) -> pd.DataFrame: df = df.sort_values(["team_id", "game_date"]).copy() g = df.groupby("team_id", group_keys=False) for w in windows: # 关键:先 shift(1) 把本场结果剔除,再 rolling df[f"margin_avg_{w}"] = g["margin"].transform( lambda s, w=w: s.shift(1).rolling(w, min_periods=max(3, w // 2)).mean() ) df[f"win_rate_{w}"] = g["win"].transform( lambda s, w=w: s.shift(1).rolling(w, min_periods=max(3, w // 2)).mean() ) df[f"pace_avg_{w}"] = g["pace"].transform( lambda s, w=w: s.shift(1).rolling(w, min_periods=max(3, w // 2)).mean() ) # 休息天数:季后赛里 2 天 vs 4 天差别很大,封顶 10 天防止赛季间隔污染 df["rest_days"] = g["game_date"].diff().dt.days.clip(upper=10) df["rest_days"] = df["rest_days"].fillna(7) # 赛季首场给个中性值 df["is_b2b"] = (df["rest_days"] <= 1).astype(int) return df long = add_rolling_features(long) print(long.filter(like="margin_avg_").describe().T[["mean", "std", "min", "max"]])shift(1)是整段代码的命门,也是最容易漏的一行。不加它,第 8 场的「近 10 场净胜分」里就包含了第 8 场自己的结果,模型在训练集上会学到一个近乎作弊的映射,验证准确率能冲到 0.8 以上,然后季后赛预测一片乱。这个坑我在不同项目里见过不下五次,形态各异但根因一致。
min_periods=max(3, w//2)控制的是冷启动。赛季前几场没有足够历史,如果不设这个下限,前 5 场全是 NaN,后面要么整行丢弃要么乱填。设成窗口一半意味着 10 场窗口至少要有 5 场历史才出值,前 4 场留空由后续填充策略处理,比强行算一个 2 场均值靠谱得多。
rest_days用diff()而不是shift后相减,效果一样但少一行;clip(upper=10)处理的是跨赛季的第一场——那场比赛的上一场可能是八个月前的总决赛,不封顶的话会生成一个 240 天的休息天数,直接把特征分布拉出长尾。fillna(7)给赛季首场一个中性值,7 天大约是两个系列赛之间的典型间隔。
3.3 主客拼接与标签构造
长表算完特征,要重新拼回「一行一场比赛」,主队特征加h_前缀,客队加a_前缀,再构造差值特征。差值特征是这套工程里性价比最高的一步:模型不用自己去学「主队净胜分减客队净胜分」,直接给现成的。
feat_cols = [c for c in long.columns if c.startswith(("margin_avg_", "win_rate_", "pace_avg_"))] + \ ["rest_days", "is_b2b"] home_f = (long[long.is_home == 1].set_index("game_id")[feat_cols] .add_prefix("h_")) away_f = (long[long.is_home == 0].set_index("game_id")[feat_cols] .add_prefix("a_")) X = pd.concat([home_f, away_f], axis=1).dropna() # 冷启动期的行直接丢 # 差值特征:让模型专注学「相对强弱」而不是绝对水平 for c in feat_cols: X[f"d_{c}"] = X[f"h_{c}"] - X[f"a_{c}"] y = (games.set_index("game_id") .loc[X.index, "home_pts"] > games.set_index("game_id") .loc[X.index, "away_pts"]).astype(int) print(X.shape, y.mean().round(3)) # y.mean() 应略高于 0.5,主场优势y.mean()打出来应该在 0.55 到 0.60 之间,这是主场胜率的合理区间。如果打出来是 0.5 上下,检查一下主客队的pts有没有接反;如果打到 0.9 以上,几乎必然是标签泄漏,某条特征里混进了比赛结果。
dropna()会吃掉赛季前几场的样本,这是可接受的代价。想少丢样本,可以只对滚动特征做填充,但填充值必须来自「该球队此前的联盟均值」而不是全局均值,否则又引入时间穿越。
时间切分这一步必须在这里做完,不能留到训练阶段:
train = X.loc[X.index.isin(games.query("game_date < '2023-10-01'").game_id)] test = X.loc[X.index.isin(games.query("game_date >= '2023-10-01'").game_id)] # 更严谨的做法是用 season 字段切,跨年边界更清晰用日期硬切容易在跨年赛季上出错——一个赛季从 10 月打到次年 6 月,用自然年切会把同一个赛季劈成两半,训练集里出现「未来」的季前赛。用season字段切是更稳的做法,比如用 2015 到 2022 赛季训练,2023 赛季验证。
4. 建模与评估:LR 基线打底,GBDT 收口,概率校准不能省
特征矩阵成型之后,建模反而快了。这类表格数据(几十个特征、几千到几万样本、二分类)不是什么深度学习的主场,逻辑回归和梯度提升树就够,关键在评估口径上别自欺欺人。
4.1 逻辑回归基线:先跑通再谈调参
先跑逻辑回归不是为了拿最高分,是为了拿到一个「可解释的下限」。如果 GBDT 比 LR 高出一大截,通常说明特征里有非线性交互没被显式构造出来;如果两者差不多,说明信息量已经榨干了,该回去做特征而不是调参。
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.metrics import log_loss, brier_score_loss, accuracy_score pipe = Pipeline([ ("sc", StandardScaler()), # 滚动特征量纲差异大,必须标准化 ("lr", LogisticRegression( C=0.5, # 弱正则,样本量不大时别开太狠 max_iter=2000, class_weight="balanced", random_state=42, )), ]) pipe.fit(X_train, y_train) proba = pipe.predict_proba(X_test)[:, 1] print("logloss", round(log_loss(y_test, proba), 4)) print("brier ", round(brier_score_loss(y_test, proba), 4)) print("acc ", round(accuracy_score(y_test, proba > 0.5), 4)) # 看系数,确认没有反直觉的符号 coef = pd.Series(pipe.named_steps["lr"].coef_[0], index=X_train.columns) print(coef.reindex(coef.abs().sort_values(ascending=False).index).head(10))C=0.5是正则强度的倒数,值越小正则越强。特征只有几十个、样本上万的情况下,C在 0.1 到 1 之间调基本没区别,别在这上面耗时间。class_weight="balanced"在主场胜率接近 0.6 的场景下其实可有可无,但加上之后概率分布会往 0.5 靠,如果你后面要用概率做蒙特卡洛,反而建议去掉,保持原始先验。
系数打印这一步别跳过。曾经有一次d_margin_avg_10的系数是负的,查了半天发现是差值方向算反了(客队减主队),模型自己在训练集上「学会」了反向映射,验证集上看着还行,一到真实预测就反着来。这类符号错误靠指标看不出来,只能靠读系数。
4.2 树模型:非线性交互和它的边界
梯度提升树在这类数据上的优势是自动捕捉交互,比如「休息天数少的强队」和「休息天数少的弱队」影响方向可能相反,LR 需要手工构造交叉项,树模型分裂几次就有了。
from sklearn.ensemble import HistGradientBoostingClassifier gbdt = HistGradientBoostingClassifier( max_iter=400, learning_rate=0.05, # 小学习率 + 多轮,比大学习率稳 max_depth=None, # 让树自己长,用 leaf 数控制复杂度 max_leaf_nodes=31, min_samples_leaf=40, # 防过拟合的关键参数,样本少时往上调 l2_regularization=1.0, early_stopping=True, validation_fraction=0.15, # 注意:这里的验证集是从训练集内部再切的 random_state=42, ) gbdt.fit(X_train, y_train) proba_g = gbdt.predict_proba(X_test)[:, 1]early_stopping=True时,sklearn 会从训练集里再切 15% 做早停验证。这个内部切分是随机的,对时间序列数据其实不严谨——它可能用「后半段」的数据去决定「前半段」该训练几轮。影响不算大,但如果你的数据量足够,手动用时间序列切一个内部验证集传给X_val, y_val参数更干净。
min_samples_leaf=40是这套模型里最该调的参数。样本总量几千条的时候,叶子最小样本数低于 20 基本就是在背训练集,验证集 logloss 会先降后升,曲线很典型。我一般从 20 开始,每次翻倍看验证 logloss,找到拐点就停。
树模型有个边界要说清楚:它输出的是「样本落在正类叶子里的比例」,这个值在排序上很好用,但作为概率是偏的,尤其在样本不平衡或者叶子样本少的时候。如果你只是做「谁强谁弱」的排序,直接用没问题;如果要拿去做蒙特卡洛累乘,必须校准。
4.3 概率校准与评估口径:准确率会骗人
一场比赛预测对了还是错了,是个二值事件,准确率对概率质量完全不敏感。模型给 0.51 和给 0.95,只要都过 0.5,准确率一样。但蒙特卡洛要的是概率本身,所以看 logloss 和 Brier score。
| 指标 | 衡量什么 | 参考区间(主场胜负预测) | 什么时候看 |
|---|---|---|---|
| Accuracy | 二分类命中率 | 0.62 ~ 0.68 | 只做粗略 sanity check |
| Log loss | 概率与真实标签的交叉熵 | 0.60 ~ 0.66 | 主指标,越小越好 |
| Brier score | 概率预测的均方误差 | 0.20 ~ 0.23 | 与 logloss 交叉验证 |
| 校准曲线 | 预测概率 vs 实际频率 | 贴近对角线 | 要用概率累乘时必看 |
from sklearn.calibration import CalibratedClassifierCV from sklearn.model_selection import TimeSeriesSplit # 用时间序列切分做校准,避免随机 KFold 打乱时间顺序 tscv = TimeSeriesSplit(n_splits=5) cal = CalibratedClassifierCV(gbdt, method="isotonic", cv=tscv) cal.fit(X_train, y_train) proba_cal = cal.predict_proba(X_test)[:, 1] print("calibrated logloss", round(log_loss(y_test, proba_cal), 4))method="isotonic"是非参数单调回归,样本量大于 1000 时表现好,但容易在小样本上过拟合;样本更少就换"sigmoid"(Platt scaling),只拟合两个参数,稳但表达能力弱。判断标准很简单:校准之后在测试集上的 logloss 有没有降,降了就用,没降就说明原始概率已经够准。
还有一个细节:校准器必须用训练集内部的切分拟合,绝不能碰测试集。上面用TimeSeriesSplit而不是默认的KFold,就是怕随机切分把未来的比赛混进校准器的拟合过程,导致校准曲线虚高。
5. 避坑排查:5 条让模型准确率虚高的血泪记录
下面这五条,是我在不同项目里反复遇到过的同类问题。共同点是:不报错、指标还挺好看、一到真实使用就原形毕露。
5.1 现象:验证准确率 0.78,季后赛预测几乎全错
原因:滚动特征没做shift(1),本场结果进了本场特征。模型学到的是「净胜分高的队伍会赢」,而不是「近期状态好的队伍会赢」。训练集上这条规则永远成立。
解决:在rolling之前强制shift(1),并且加一条断言做硬校验——用最后一场比赛单独构造特征,手工核对它用的是不是前 10 场的数据。更方便的做法是写一个单测:造 12 场固定的假数据,检查第 11 场的margin_avg_10是否等于前 10 场的均值。
5.2 现象:所有比赛的预测概率都挤在 0.48 到 0.55 之间
原因:特征共线性太严重。10 个窗口的margin_avg加上它们的差值,彼此相关系数 0.9 以上,线性模型在这种输入下会把权重摊平,输出自然趋中。树模型稍好,但如果min_samples_leaf设得太大,叶子里的样本标签接近五五开,输出同样趋中。
解决:先算相关矩阵,把相关系数大于 0.95 的特征成对保留一个;差值特征和原始特征不要同时进模型,二选一。另外检查一下class_weight,balanced会把概率往 0.5 拽,做概率输出时应该去掉。
5.3 现象:换一个赛季的数据重跑,指标从 0.65 掉到 0.58
原因:分布漂移。联赛规则、节奏、三分出手占比逐年变化,用 2015 年的数据训练去预测 2023 年,特征的实际含义已经变了。更隐蔽的一种是「球队 ID 编码」——如果用了 one-hot 球队特征,新赛季有新球队或者球队改名,特征直接对不上。
解决:训练集只保留最近 5 到 7 个赛季,更早的数据权重降低或者直接丢。球队身份不要做 one-hot,用滚动特征代替——球队的强弱应该由它最近的比赛表现体现,而不是由 ID 体现。加一个按赛季分组的指标追踪表,每季单独算 logloss,掉得厉害的赛季单独查原因。
5.4 现象:同一份数据跑两次,指标差 0.01 到 0.02
原因:随机种子没固定。HistGradientBoostingClassifier的内部子采样、CalibratedClassifierCV的切分、蒙特卡洛的抽样,任何一处没设种子都会带来抖动。
解决:全局设一次random_state=42,并且把它写进配置文件而不是散落在脚本里。蒙特卡洛部分单独用np.random.default_rng(seed),这比老的np.random.seed更干净,不会污染全局状态。指标差异如果在 0.005 以内属于正常抖动,超过 0.01 就要怀疑是不是有隐藏的随机性。
5.5 现象:单场胜率算出来 0.62,七场系列赛晋级概率却是 0.55
原因:主场分配搞错了。七场系列赛是 2-2-1-1-1,主客场位置不是均分的,而且常规赛的主场胜率不能直接套到季后赛——季后赛主场优势通常更明显,同时强队的主场场次更多。
解决:把主场序列显式写成列表[1,1,0,0,0,1,1],逐场用对应场地的概率,不要用平均值代替。另外单场概率之间不独立(同一系列赛有战术调整),蒙特卡洛的独立性假设本身是个简化,这一点在解读结果时要说明,别把 0.55 当成精确值。
6. 蒙特卡洛跑完整个季后赛:10000 次模拟与一份上线前自查清单
单场概率出来之后,最后一步是把它放大成系列赛和总冠军的概率分布。这一步不难,但非常容易写错,因为它把前面所有的误差都累乘了。
import numpy as np def sim_bo7(p_home: float, p_away: float, n_sim: int = 10000, seed: int = 42) -> float: """2-2-1-1-1 赛制下,A 队赢下系列赛的概率。 p_home / p_away: A 队在主 / 客场的单场胜率""" rng = np.random.default_rng(seed) venue = [1, 1, 0, 0, 0, 1, 1] # 1 表示 A 主场 wins_a = np.zeros(n_sim, dtype=np.int8) wins_b = np.zeros(n_sim, dtype=np.int8) for i in range(n_sim): for g, h in enumerate(venue): p = p_home if h else p_away if rng.random() < p: wins_a[i] += 1 else: wins_b[i] += 1 if wins_a[i] == 4 or wins_b[i] == 4: # 拿到 4 胜就结束 break return float((wins_a == 4).mean()) print(sim_bo7(0.68, 0.52, n_sim=10000))venue列表是位置固定的,提前结束不影响后续位置的正确性,因为第 g 场永远在同一个场地。n_sim=10000时标准误大约 0.005,够用;如果要区分 0.51 和 0.53 这种差距,得上 10 万次。
把首轮、次轮、分区决赛、总决赛串起来就是冠军分布,每一轮把上一轮的胜率当输入。串联时要注意:上一轮赢球的队伍,其滚动特征其实已经过期了,严格做法是每轮结束后用真实赛果更新特征再重算。工程上常见的简化是直接用赛季末特征,代价是后期轮次误差变大。
上线前我会强制走一遍这张表:
| 检查项 | 通过标准 | 常见失败表现 |
|---|---|---|
| 特征是否含本场结果 | 单测通过 | 验证准确率 > 0.75 |
| 训练/测试是否按时间切 | 训练集日期全部早于测试集 | 随机切分导致指标虚高 |
| 主场胜率基线 | 0.55 ~ 0.60 | 偏离说明 pts 接反 |
| 概率分布是否合理 | 大部分落在 0.3 ~ 0.7 | 全部挤在 0.5 附近 |
| 校准前后 logloss | 校准后不升高 | 升高说明校准集泄漏 |
| 蒙特卡洛种子 | 固定且可复现 | 两次结果差 0.02 以上 |
这套流程我后来在别的时序预测项目上也照搬过——先把「一行一个样本」的粒度定死,再把shift写进单测,最后才谈模型。从那以后我每次动滚动特征,都会先跑一遍那个 12 场假数据的单测,确认第 11 场的特征只来自前 10 场,再往下走。希望帮到你。
本文还有配套的精品资源,点击获取