简介这份压缩包面向希望用 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/放原始 csvsrc/放特征构造和训练脚本notebooks/是探索性分析models/存序列化后的模型文件。先把环境隔离出来别用系统 Python 直接装sklearn 版本差异会直接改变默认参数行为。# 建议 python 3.9 ~ 3.113.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_home1) # 客队视角注意 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_home0) long ( pd.concat([home, away], ignore_indexTrue) .sort_values([team_id, game_date]) # 排序是滚动的先决条件 .reset_index(dropTrue) ) 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_indexTrue是为了后面按下标对齐时不出重号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], howleft, validateone_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())validateone_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_keysFalse) for w in windows: # 关键先 shift(1) 把本场结果剔除再 rolling df[fmargin_avg_{w}] g[margin].transform( lambda s, ww: s.shift(1).rolling(w, min_periodsmax(3, w // 2)).mean() ) df[fwin_rate_{w}] g[win].transform( lambda s, ww: s.shift(1).rolling(w, min_periodsmax(3, w // 2)).mean() ) df[fpace_avg_{w}] g[pace].transform( lambda s, ww: s.shift(1).rolling(w, min_periodsmax(3, w // 2)).mean() ) # 休息天数季后赛里 2 天 vs 4 天差别很大封顶 10 天防止赛季间隔污染 df[rest_days] g[game_date].diff().dt.days.clip(upper10) 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(likemargin_avg_).describe().T[[mean, std, min, max]])shift(1)是整段代码的命门也是最容易漏的一行。不加它第 8 场的「近 10 场净胜分」里就包含了第 8 场自己的结果模型在训练集上会学到一个近乎作弊的映射验证准确率能冲到 0.8 以上然后季后赛预测一片乱。这个坑我在不同项目里见过不下五次形态各异但根因一致。min_periodsmax(3, w//2)控制的是冷启动。赛季前几场没有足够历史如果不设这个下限前 5 场全是 NaN后面要么整行丢弃要么乱填。设成窗口一半意味着 10 场窗口至少要有 5 场历史才出值前 4 场留空由后续填充策略处理比强行算一个 2 场均值靠谱得多。rest_days用diff()而不是shift后相减效果一样但少一行clip(upper10)处理的是跨赛季的第一场——那场比赛的上一场可能是八个月前的总决赛不封顶的话会生成一个 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], axis1).dropna() # 冷启动期的行直接丢 # 差值特征让模型专注学「相对强弱」而不是绝对水平 for c in feat_cols: X[fd_{c}] X[fh_{c}] - X[fa_{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( C0.5, # 弱正则样本量不大时别开太狠 max_iter2000, class_weightbalanced, random_state42, )), ]) 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], indexX_train.columns) print(coef.reindex(coef.abs().sort_values(ascendingFalse).index).head(10))C0.5是正则强度的倒数值越小正则越强。特征只有几十个、样本上万的情况下C在 0.1 到 1 之间调基本没区别别在这上面耗时间。class_weightbalanced在主场胜率接近 0.6 的场景下其实可有可无但加上之后概率分布会往 0.5 靠如果你后面要用概率做蒙特卡洛反而建议去掉保持原始先验。系数打印这一步别跳过。曾经有一次d_margin_avg_10的系数是负的查了半天发现是差值方向算反了客队减主队模型自己在训练集上「学会」了反向映射验证集上看着还行一到真实预测就反着来。这类符号错误靠指标看不出来只能靠读系数。4.2 树模型非线性交互和它的边界梯度提升树在这类数据上的优势是自动捕捉交互比如「休息天数少的强队」和「休息天数少的弱队」影响方向可能相反LR 需要手工构造交叉项树模型分裂几次就有了。from sklearn.ensemble import HistGradientBoostingClassifier gbdt HistGradientBoostingClassifier( max_iter400, learning_rate0.05, # 小学习率 多轮比大学习率稳 max_depthNone, # 让树自己长用 leaf 数控制复杂度 max_leaf_nodes31, min_samples_leaf40, # 防过拟合的关键参数样本少时往上调 l2_regularization1.0, early_stoppingTrue, validation_fraction0.15, # 注意这里的验证集是从训练集内部再切的 random_state42, ) gbdt.fit(X_train, y_train) proba_g gbdt.predict_proba(X_test)[:, 1]early_stoppingTrue时sklearn 会从训练集里再切 15% 做早停验证。这个内部切分是随机的对时间序列数据其实不严谨——它可能用「后半段」的数据去决定「前半段」该训练几轮。影响不算大但如果你的数据量足够手动用时间序列切一个内部验证集传给X_val, y_val参数更干净。min_samples_leaf40是这套模型里最该调的参数。样本总量几千条的时候叶子最小样本数低于 20 基本就是在背训练集验证集 logloss 会先降后升曲线很典型。我一般从 20 开始每次翻倍看验证 logloss找到拐点就停。树模型有个边界要说清楚它输出的是「样本落在正类叶子里的比例」这个值在排序上很好用但作为概率是偏的尤其在样本不平衡或者叶子样本少的时候。如果你只是做「谁强谁弱」的排序直接用没问题如果要拿去做蒙特卡洛累乘必须校准。4.3 概率校准与评估口径准确率会骗人一场比赛预测对了还是错了是个二值事件准确率对概率质量完全不敏感。模型给 0.51 和给 0.95只要都过 0.5准确率一样。但蒙特卡洛要的是概率本身所以看 logloss 和 Brier score。指标衡量什么参考区间主场胜负预测什么时候看Accuracy二分类命中率0.62 ~ 0.68只做粗略 sanity checkLog 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_splits5) cal CalibratedClassifierCV(gbdt, methodisotonic, cvtscv) 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))methodisotonic是非参数单调回归样本量大于 1000 时表现好但容易在小样本上过拟合样本更少就换sigmoidPlatt 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_weightbalanced会把概率往 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_state42并且把它写进配置文件而不是散落在脚本里。蒙特卡洛部分单独用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, dtypenp.int8) wins_b np.zeros(n_sim, dtypenp.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_sim10000))venue列表是位置固定的提前结束不影响后续位置的正确性因为第 g 场永远在同一个场地。n_sim10000时标准误大约 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 场再往下走。希望帮到你。本文还有配套的精品资源点击获取