资讯详情 用Python机器学习预测总冠军:从特征工程到蒙特卡洛模拟
📅 2026/10/25 2:11:58
简介这份以Python机器学习预测NBA总冠军为主题的实践项目包面向具备Python与数据分析基础、希望完整走通体育赛事预测流程的学习者与建模爱好者可帮助读者把球队战绩、球员统计等数据转化为可复现的冠军预测案例。压缩包共131个文件、约4.22MB主体为113个csv赛季与球员数据表另含py与r建模脚本、pdf和docx说明文档以及少量txt、md记录按数据、代码、文档三类组织便于检索与复现。已有140人学习下载。内容覆盖数据清洗、特征选择、交叉验证、模型调参与结果可视化等环节读者可参考其中的建模脚本与说明文档梳理从数据预处理到预测评估的完整链路并了解LIME、SHAP等模型解释方法在体育预测中的落地方式适合课程设计、竞赛练习与个人项目复盘也可结合数据表与脚本练习特征工程、模型对比和结果复现。1. 从群里吵架到一张概率表这个项目到底解决什么问题每次季后赛开打前一周群里照例开始吵谁能拿冠军争到最后还是各凭感觉。这个 python 机器学习预测总冠军的项目干的就是把这种争吵变成一串可复现的概率数字喂进十几个赛季的比赛日志跑出一张「各队夺冠概率」的排序表而且顺着代码能看清每一场比赛的胜负概率是怎么算出来的。它不是跑完出一张图就完事的玩具特征工程、模型概率校准、七场四胜的蒙特卡洛模拟这条链路是完整的适合已经会用 pandas 做点数据处理、想找一个端到端练手项目的开发者也适合想给体育数据做分析工具的熟手。下面按我拆包的顺序讲目录里有什么、核心模块怎么实现的、怎么跑起来、哪里容易翻车、以及怎么把它改成你自己的东西。2. 项目骨架与技术选型zip 包里六个目录各自管什么拿到压缩包先别急着装依赖把目录结构扫一遍能省掉后面一半的迷糊。这类项目的通病是脚本散落一地train.py、test.py、try2.py并排躺着改一行不知道会影响谁。这份代码的组织方式相对克制按「数据进、特征出、模型学、模拟推、界面看」这条链路分层每一层只依赖上一层接口基本是 DataFrame 进、DataFrame 出。2.1 目录结构与模块职责解压后大致是下面这个样子目录名我按实际读代码时的理解标注了职责champion-predictor/ ├── data/ │ ├── raw/ │ │ ├── games.csv # 历史比赛日志一行一场最核心的输入 │ │ ├── teams.csv # 球队元信息队名、分区、联盟 │ │ └── schedule_post.csv # 待预测赛季的季后赛对阵与赛程 │ └── processed/ │ └── features.parquet # 特征工程产物训练和推理共用 ├── src/ │ ├── config.py # 路径、赛季列表、窗口长度等常量集中处 │ ├── data_loader.py # 读取 字段校验 类型转换 │ ├── features/ │ │ ├── rolling.py # 滚动窗口类特征近 N 场胜率、净胜分 │ │ ├── elo.py # Elo 评分与赛季间回归 │ │ └── build.py # 特征拼装入口对外只暴露一个函数 │ ├── models/ │ │ ├── train.py # 训练 交叉验证 │ │ ├── evaluate.py # log loss / Brier / 准确率 │ │ └── calibrate.py # 概率校准把分数压成概率 │ ├── sim/ │ │ └── playoff.py # 蒙特卡洛模拟七场四胜 │ └── app/ │ └── serve.py # 结果展示streamlit 或 flask 都行 ├── notebooks/ │ └── 01_eda.ipynb # 探索性分析看分布、找异常 ├── requirements.txt └── README.mdconfig.py这个文件看着不起眼但它决定了整个项目能不能一键换赛季。我见过太多人把赛季年份硬编码在七八个脚本里第二年想跑新赛季得全局搜索替换一遍还容易漏。这里的做法是把SEASONS、ROLLING_WINDOWS、ELO_K、HFA这类会反复调的数值全部收口其它模块from src.config import ...拿。features/build.py是整条链路的收口点对外只暴露一个build_features(games, teams)。训练脚本和推理脚本都调它这样就不会出现「训练时算了 12 个特征线上推理只算了 9 个」这种经典事故——特征口径不一致导致线上效果断崖式下跌排查起来能让人怀疑人生。sim/playoff.py是最容易被低估的一块。很多人以为预测总冠军就是把「最强球队」挑出来实际上季后赛是七场四胜制的淘汰赛对阵结构和主场优势对结果的影响非常大一支常规赛第一的球队因为对阵位置差夺冠概率可能比第三名还低。这个模块把对阵表、主场轮次、系列赛长度都参数化了是整套代码里最有复用价值的部分。notebooks/01_eda.ipynb建议第一遍就跑一遍。它能看到原始数据里的坑某些赛季比赛场次不足、球队改名导致 ID 对不上、加时赛比分异常偏高等等。这些在纯脚本里是看不见的。2.2 技术栈与依赖清单数据量级决定了选型。十几年的比赛日志撑死几万行几百支球队样本这种规模上 Spark 或者深度学习框架纯属自找麻烦。项目的requirements.txt大致是这套组合依赖主要用途备注pandas / numpy数据清洗、滚动窗口、特征拼装全项目的底座占比最重scikit-learnPipeline、逻辑回归、交叉验证、概率校准主力建模工具xgboost 或 lightgbm树模型用来对比基线可选装上效果通常更好pyarrowparquet 读写特征文件比 csv 快很多matplotlib / seaborn特征分布图、概率排序条形图只在 notebook 里用streamlit结果展示页面比 flask 少写一半代码pytest特征函数的单元测试强烈建议留着选型上有两个点值得说。第一是坚持用sklearn.pipeline.Pipeline把「特征列选择 缺失值填充 标准化 模型」串起来而不是在训练脚本里手动fit_transform一遍再塞给模型。原因是标准化参数均值、方差必须从训练集拟合手动做的话推理阶段很容易拿全量数据重新拟合或者干脆忘了做。第二是概率校准独立成模块。逻辑回归输出天然是概率树模型输出的分数只是「相对置信度」直接当概率用在蒙特卡洛模拟里会被放大成严重偏差——这一点第 5 章还会展开。2.3 数据字段与特征口径games.csv是整个项目的命根子字段设计决定了后面所有事情。核心列大致是这些字段类型说明game_idstr唯一标识跨表关联用game_datedatetime必须能排序滚动窗口全靠它seasonstr「2023-24」这种格式跨年赛季不能按日历年切home_team_idstr主队统一 ID 不要用队名away_team_idstr客队home_score / away_scoreint最终比分含加时season_typestr常规赛 / 季后赛训练时要能区分rest_home / rest_awayint休息天数背靠背比赛的关键特征有两个口径必须一开始就定死不然后面全是血泪。第一个是赛季的切分方式跨年赛季如果用game_date.year分组一支球队会被硬生生劈成两半滚动窗口在 12 月 31 日和 1 月 1 日之间出现断档。正确做法是用独立的season字段并且保证滚动计算时按season game_date双重排序。第二个是球队 ID 的稳定性。现实里球队会改名、会搬迁队名变了但球队还是那支球队。如果直接拿队名当主键一支球队改名前后会被当成两支不同的队伍滚动胜率直接从零开始算特征立刻失真。常见做法是维护一张teams.csv映射表把历史队名全部映射到稳定的team_id加载时统一替换。特征口径上还要注意主客场的对称处理。同一场比赛对主队来说是「主场、净胜 8」对客队来说是「客场、净胜 -8」。如果特征表只按主队视角生成客队那一侧的特征就得重新拼一遍很容易出错。项目的做法是每场比赛拆成两行主队视角一行、客队视角一行加一个is_home布尔列然后所有滚动特征都在这个「长表」上算。这样一行就是一个「球队-比赛」样本标签是win模型学到的是球队视角下的胜负概率预测时再按主客场分别推理。这个设计看着绕但能省掉大量重复代码也方便后面做中立场地比如集中赛会制的扩展。3. 核心链路拆解一场比赛的胜负概率是怎么被算出来的这一章是整套代码里最值得逐行读的部分。从原始比分到最终的夺冠概率中间要过三道关滚动特征、Elo 评分、概率校准最后再套一层模拟。任何一道关出问题结果都不会报错只会「看起来怪怪的」——这是这类项目最难受的地方没有异常日志只有你自己知道那个数字不对劲。3.1 滚动窗口特征先把「未来」堵死滚动特征是最容易写出数据泄漏的地方。所谓数据泄漏就是你在计算第 30 场比赛的特征时不小心把第 31 场的结果也算进去了。模型在训练集上表现得像神仙一到真实预测就原形毕露。import pandas as pd def add_rolling_features(long_df: pd.DataFrame, windows(5, 10, 20)) - pd.DataFrame: long_df: 长表一行 一支球队在一场比赛里的视角 必须包含列: team_id, game_date, season, win, point_diff df long_df.sort_values([team_id, season, game_date]).copy() for w in windows: # shift(1) 是防泄漏的命门先把自己这一场挪走再往回数 w 场 grp df.groupby(team_id, group_keysFalse) df[fwin_rate_{w}] ( grp[win].apply(lambda s: s.shift(1).rolling(w, min_periods3).mean()) ) df[fpt_diff_{w}] ( grp[point_diff].apply(lambda s: s.shift(1).rolling(w, min_periods3).mean()) ) # 标准差单独算用来表达「稳定性」爆冷多的球队方差大 df[fpt_diff_std_{w}] ( grp[point_diff].apply(lambda s: s.shift(1).rolling(w, min_periods3).std()) ) # 休息天数两场比赛间隔第一次出现时填中位数而不是 0 df[rest_days] ( df.groupby(team_id)[game_date].diff().dt.days.fillna(3).clip(0, 10) ) return df这段代码的逻辑不复杂但有三处细节决定了它能不能用。shift(1)是全篇最重要的一行。rolling(w)默认窗口包含当前行也就是「最近 w 场含本场」——对本场结果来说这就是作弊。加上shift(1)之后窗口变成「本场之前的 w 场」语义正确。这一步漏掉模型准确率能虚高十几个百分点而且不会有任何报错提示你。min_periods3是为了处理赛季初的冷启动。新赛季头两场没有历史rolling会返回 NaN。设成 3 表示至少攒够 3 场才给值不足的后面用中位数填充。如果设成min_periods1赛季首战的特征就是「上一场」的单一值噪声极大设成min_periodsw赛季前 20 场全是 NaN白白浪费样本。groupby(team_id)只按球队分组是不够的严格来说要按team_id season分组让滚动窗口在赛季之间清零。这里留了一个取舍跨赛季携带一点历史信息比如球队核心阵容没变其实是有信息量的但会让赛季初的特征偏「上一个赛季的强度」。我一般会额外加一个is_season_start标记列让模型自己去学这个切换。rest_days的.clip(0, 10)是防御性写法。原始数据里偶尔会出现日期错乱导致的负数间隔或者长休赛期全明星周末导致的 20 多天。不裁剪的话这些极端值会把标准化参数带偏。3.2 Elo 评分一个能带进季后赛的强度指标滚动胜率有个天然缺陷它只看得分结果不看对手强弱。赢三支弱队和赢三支强队胜率都是 100%但实力显然不一样。Elo 评分解决的正是这个问题——赢强队加分多输弱队扣分多天然带对手强度调整。def update_elo(ratings: dict, home: str, away: str, home_score: int, away_score: int, k: float 20.0, hfa: float 100.0) - dict: ratings: {team_id: elo}赛季开始前统一初始化 k: 更新步长越大越敏感越小越平滑 hfa: 主场优势加成等价于给主队先加 100 分 r_home ratings.get(home, 1500.0) hfa # 主场加成只影响预期不写回 r_away ratings.get(away, 1500.0) # 期望胜率Elo 标准公式 exp_home 1.0 / (1.0 10 ** ((r_away - r_home) / 400.0)) actual 1.0 if home_score away_score else 0.0 # 分差修正20 分大胜比 1 分险胜信息量大用 log 压缩防止爆炸 margin abs(home_score - away_score) mov max(1.0, (margin 3) ** 0.8 / 7.5) delta k * mov * (actual - exp_home) ratings[home] ratings.get(home, 1500.0) delta ratings[away] ratings.get(away, 1500.0) - delta return ratingsk20是「每场比赛改动多少分」的旋钮。调大评分反应快但对单场爆冷过度敏感调小评分平滑但跟不上球队中途交易后的真实变化。20 这个值在多个体育项目里被验证过比较稳我一般不做大幅改动。hfa100是主场优势等价于「主队先加 100 分再算期望」。这个数需要按联赛实际情况标定如果主场胜率在 55% 左右100 分是个合理起点如果主场优势特别夸张比如高海拔主场可能要调到 130 以上。标定方法是拿历史数据扫一遍看哪个hfa让主队胜率预测的 log loss 最低。mov那行是分差修正用的是「指数压缩」而不是直接乘分差。直接乘会让 30 分大胜产生巨大更新一场比赛就能把评分打歪取 0.8 次方再用 7.5 除一下20 分左右的大胜大概是 1.8 倍权重比较温和。赛季之间一定要做均值回归ratings[t] 1500 0.75 * (ratings[t] - 1500)。原因是休赛期有交易、选秀、伤病恢复上赛季的强度信息会衰减。系数 0.75 意味着保留 75% 的差异回退 25%。这个值越小模型对新赛季的判断越保守调参时值得单独扫一遍。3.3 模型训练与概率校准特征齐了模型本身反而不复杂。逻辑回归做基线树模型做提升关键是最后的校准步骤。from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.calibration import CalibratedClassifierCV from sklearn.model_selection import TimeSeriesSplit FEATURE_COLS [ elo_diff, win_rate_10, pt_diff_10, pt_diff_std_10, rest_days, is_home, is_playoff, back_to_back, ] def build_model(): base Pipeline([ (imputer, SimpleImputer(strategymedian)), # 赛季初 NaN 兜底 (scaler, StandardScaler()), # 只在训练折上拟合 (clf, LogisticRegression(C0.5, max_iter1000)), ]) # 用时间序列切分绝对不能用随机 KFold return CalibratedClassifierCV(base, methodisotonic, cvTimeSeriesSplit(n_splits5)) def brier_score(y_true, y_prob): 概率预测的评分比准确率更能反映「概率靠不靠谱」 return float(((y_prob - y_true) ** 2).mean())TimeSeriesSplit在这里不是可选项。随机 KFold 会把「未来」的比赛混进训练折模型提前见过 2023 年的比赛去预测 2021 年评估结果毫无意义。改成按时间切分后每一折都是「用过去预测未来」评估分数才可信。CalibratedClassifierCV配methodisotonic是这套代码里我最认可的一处。逻辑回归本身输出概率但它假设特征与 log-odds 是线性关系实际数据里明显不是。等渗回归isotonic会学一个单调映射把模型输出的分数重新压成概率。样本量够的话 isotonic 效果好样本少就换成methodsigmoidPlatt scaling后者参数少、更稳。C0.5是正则强度的倒数比默认的 1.0 更保守一点。体育数据噪声大正则强一些通常更稳。brier_score这个函数建议在evaluate.py里常备它的含义是「预测概率与真实 0/1 结果的均方误差」0.25 是瞎猜全预测 0.5的水平能压到 0.20 以下说明模型确实学到了东西。准确率这个指标在概率预测任务里有欺骗性别只看它。3.4 蒙特卡洛七场四胜怎么模拟到这一步模型能给出任意一场对阵的胜负概率了。但总冠军不是一场比赛是四轮淘汰赛。正确做法是模拟。import numpy as np def simulate_series(p_game1_home: float, n_games: int 7, n_sims: int 10000, rngNone) - float: 模拟一轮系列赛返回主队排名靠前的队晋级概率 主场分配2-2-1-1-1即 G1/G2/G5/G7 在排名靠前一方主场 rng rng or np.random.default_rng(42) home_flags [True, True, False, False, True, False, True][:n_games] wins np.zeros(n_sims, dtypeint) for i, at_home in enumerate(home_flags): p p_game1_home if at_home else (1.0 - p_game1_home) * 0.92 # 客场时把主队胜率折算过去0.92 是客场劣势的粗略修正 won rng.random(n_sims) p wins won if (wins (n_games // 2 1)).all(): break return float((wins (n_games // 2 1)).mean())home_flags这个列表就是 2-2-1-1-1 的主场分配前两场在排名靠前一方主场三四场在客场第五场回家六七场再各一个主场。不同联赛、不同年代的赛制不一样这个列表是最该按实际赛制改的地方。0.92这个魔数代表客场劣势的粗略折算。严格做法是给模型单独喂一个is_home0的样本让它自己输出客场胜率但那样每模拟一场就要调一次模型推理一万次模拟下来慢得离谱。折中方案是赛前一次性把「主队主场胜率」和「主队客场胜率」两个数都算好模拟时直接查表。这套代码里用的是简化版本二次开发时值得优化。rng.random(n_sims) p是向量化写法一次抽一万个随机数比 Python 循环快两个数量级。42这个种子写死是为了结果可复现出报告时很重要——不然每次跑出来的数字都不一样没法解释。模拟完一轮之后把胜者送进下一轮重复四次首轮、半决赛、决赛加上可能的附加赛每个队的夺冠概率就是「一万次模拟里赢到最后」的频率。这个数才是最终输出的那张表。4. 跑通全流程从装依赖到输出夺冠概率排序代码看明白了接下来把它跑起来。这一章按实际执行顺序走每一步都给出命令和预期输出卡住了可以对照第 5 章排查。4.1 环境准备与依赖安装建议用独立虚拟环境Python 版本 3.9 到 3.11 之间都行3.12 有些老版本 xgboost 装不上。# 1. 建环境 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 2. 装依赖先装基础包再装可选包出问题好定位 pip install --upgrade pip pip install pandas numpy scikit-learn pyarrow matplotlib pip install xgboost streamlit # 可选先跑通再装也行 # 3. 验证 python -c import pandas, sklearn; print(pandas.__version__, sklearn.__version__)装依赖这一步最常见的翻车是pyarrow在部分环境下没有预编译轮子会触发源码编译然后卡住。遇到这种情况直接pip install pyarrow --only-binary:all:强制用二进制包或者干脆跳过 parquet把特征存成 csv——几万行数据用 csv 完全够慢不了多少。装完之后先进notebooks/01_eda.ipynb跑一遍。这一步别省它能让你在五分钟内知道数据长什么样赛季覆盖范围、每赛季比赛场次、有没有缺失值扎堆的列。我见过有人上来就python train.py结果数据没下全模型在 200 场比赛上训出来效果当然崩。4.2 数据准备与特征生成原始数据放进data/raw/然后跑特征构建。特征构建这一步建议单独作为一条命令而不是塞在训练脚本里方便你随时检查中间产物。# 生成特征文件产物落在 data/processed/features.parquet python -m src.features.build \ --games data/raw/games.csv \ --teams data/raw/teams.csv \ --out data/processed/features.parquet参数说明--games指向比赛日志--teams指向球队映射表--out是输出路径。跑完之后用 pandas 抽查几行import pandas as pd df pd.read_parquet(data/processed/features.parquet) print(df.shape) print(df[[team_id, game_date, win_rate_10, elo_diff]].head(10)) # 重点检查每个赛季前 3 场 win_rate_10 应该是 NaN 或填充值不能是真实值这一步的验收标准很简单随机挑一支球队、挑一个赛季手动核对它第 10 场比赛的win_rate_10是不是等于前 9 场里赢了 10 场……等等前 9 场算不出 10 场窗口min_periods3会让它有值。核对的正确方式是看前 5 场的胜负算出胜率和表里的数对得上。对不上就说明shift用错了或者排序错了。4.3 训练、评估与预测# 训练 交叉验证评估 python -m src.models.train --features data/processed/features.parquet --model-out models/lr_v1.pkl # 在留出的最后一个赛季上评估 python -m src.models.evaluate --model models/lr_v1.pkl --season 2023-24 # 用季后赛对阵做蒙特卡洛模拟 python -m src.sim.playoff \ --model models/lr_v1.pkl \ --bracket data/raw/schedule_post.csv \ --n-sims 10000 \ --out outputs/champion_probs.csv--n-sims 10000是模拟次数。一万次在几秒内跑完概率的标准误大概在 0.5 个百分点量级够用了。想更稳可以加到 50000代价是线性增长的时间。outputs/champion_probs.csv出来大概长这样team_idconf_finalfinalschampionT010.620.410.24T020.550.330.19T030.480.270.15三列分别是「进分区决赛概率」「进总决赛概率」「夺冠概率」。看这张表有个技巧如果第一名 0.24、第八名 0.01说明模型觉得悬念不大如果前六名都在 0.10 到 0.18 之间说明这赛季是真混战。这个「分布形状」比单个数字更有信息量。4.4 关键参数怎么改跑通之后你就会想改点什么。下面这几个参数是最值得动手的参数位置调大的效果建议范围ROLLING_WINDOWSconfig.py窗口变长特征更平滑但反应慢(5,10,20) 到 (10,20,40)ELO_Kconfig.py评分反应更快对爆冷更敏感15 到 30HFAconfig.py主场优势权重更大80 到 140赛季回归系数elo.py更相信上赛季的强度0.6 到 0.85校准方法calibrate.pyisotonic 更灵活sigmoid 更稳样本少先 sigmoid改完跑一遍evaluate.py看 Brier 分数有没有下降。注意别在同一个赛季上反复调参调到最优——那叫过拟合评估集等新赛季开打就露馅了。老老实实留出最近两个赛季做验证调参只看更早的那些。5. 避坑与排查五个把准确率打回原形的坑这些坑我在拆这类项目时基本都踩过有的甚至踩了不止一次。按「现象 → 原因 → 解决」写对号入座就行。5.1 训练集准确率 0.75上线预测一塌糊涂现象交叉验证分数漂亮但拿最新赛季一跑预测的夺冠热门第一轮就出局Brier 分数比瞎猜还差。原因数据泄漏。最常见的是滚动特征没加shift(1)或者标准化用了全量数据拟合或者train_test_split用了随机切分把未来样本混进训练集。解决按 3.1 的做法检查每个rolling前面有没有shift(1)把KFold全部换成TimeSeriesSplitStandardScaler必须放在Pipeline里跟着折走。检查方法很简单把训练集的最后 10 场比赛拿去预测如果准得离谱比如 90% 以上基本可以断定泄漏了。5.2 某支球队的夺冠概率突然变成 0现象模拟结果里有一支常规赛排名前四的球队夺冠概率是 0.00进总决赛概率也是 0.00。原因球队 ID 对不上。schedule_post.csv里用的是新队名或新 ID而games.csv里是旧 ID特征查找时返回 NaN被填充成中位数后模型给出极低概率或者模拟时这支球队在 bracket 里的位置没被正确解析直接没进模拟循环。解决跑一个断言检查set(schedule_post.home_team_id) - set(games.home_team_id)是否为空。不为空就补映射表。这类问题不会报错只会让结果悄悄错掉所以断言比日志管用。5.3 概率加起来不等于 1或者总冠军概率总和明显偏离现象所有球队的champion列加起来是 0.94 或者 1.07。原因两种情况。一是模拟次数没跑够一万次时蒙特卡洛误差大概 0.5% 到 1%属正常。二是对阵表里有球队重复出现或者某轮比赛的胜者被重复计入。解决先加模拟次数到 50000 看是否收敛。如果还是偏遍历 bracket 结构打印每一轮的球队集合确认没有重复。另外注意「打进总决赛」和「夺冠」是嵌套关系champion finals conf_final必须恒成立这个单调性断言建议写在模拟函数末尾。5.4 赛季初的预测完全没参考价值现象新赛季前 10 场模型给出的概率基本等于按上赛季排名排的冷门完全预测不到。原因滚动窗口特征在赛季初全是 NaN被中位数填充等于所有球队特征都一样模型只能靠 Elo 和上赛季残留信息做判断。解决赛季初主动降权或者引入「上赛季同期特征」作为独立列。更实用的做法是在输出里加一个confidence字段赛季前 10 场的置信度标低让使用者知道这个阶段的预测别太当真。这个字段在展示页面里很有价值。5.5 换了个赛季脚本直接报 KeyError现象跑新赛季数据时抛KeyError: home_team_id或者字段名对不上。原因数据源换了列名跟着变。或者是赛季格式从2023-24变成了2024config.py里的解析逻辑没覆盖。解决在data_loader.py里加一层列名归一化维护一个{主队: home_team_id, HomeTeam: home_team_id}这样的映射字典读进来先统一改名。赛季字段则写一个解析函数正则同时兼容2023-24、2024、2023-2024三种格式。这类脏活看着枯燥但它决定了这套代码能不能活过第二个赛季。6. 二次开发把「预测一届冠军」改造成可回测的评估框架跑通一次预测只是起点。真正有价值的是把它变成一个能回测的框架——给定任意历史赛季都能复现「赛季开始前的预测」和「实际结果」的对比。这件事做完了你才有资格说这套特征和参数是靠谱的。改造的核心是把「训练-预测」这条链路包成一个纯函数输入赛季年份输出该赛季的预测结果中间不碰全局状态def backtest_season(season: str, games: pd.DataFrame, bracket: pd.DataFrame) - dict: 严格用 season 之前的比赛训练预测 season 的季后赛 返回: {brier: float, champion_prob: dict, actual_champion: str} # 1. 时间切分训练集只到上个赛季结束一行未来数据都不能漏 train games[games[season] season] # 2. 特征与模型全在训练集上重算不复用全局缓存 feats build_features(train, teamsNone) model train_model(feats) # 3. 用赛季开打前的 Elo 快照做模拟避免用到赛季中的信息 elo_snapshot compute_elo(train, uptoseason) probs simulate_bracket(model, bracket, elo_snapshot, n_sims10000) # 4. 与实际结果对比算 Brier actual bracket.loc[bracket[round] F, winner].iloc[0] y np.array([1.0 if t actual else 0.0 for t in probs]) p np.array([probs[t] for t in probs]) return {brier: brier_score(y, p), champion_prob: probs, actual_champion: actual}这个函数里最关键的约束是第 3 步的「赛季开打前的快照」。很多人做回测时顺手用了全量数据算 Elo等于让模型提前知道了赛季中的交易和伤病回测分数虚高得一塌糊涂。正确做法是把 Elo 计算截断到目标赛季开始之前只保留历史信息。第二个改造方向是特征层面。这套代码的特征偏「球队整体强度」缺「对阵相克」的信息。加一组「对位特征」会明显提升季后赛预测——季后赛是同一对手打七场针对性极强常规赛的场均数据在这里会失真。具体可以统计两队近两个赛季的直接交手记录、进攻效率对防守效率的匹配度作为独立特征喂进去。样本量会变小所以要注意正则化别调太松。第三个方向是把输出从「一张表」升级成「一段解释」。纯概率数字对普通读者没有说服力加一段「该队夺冠的主要驱动因素是近 10 场净胜分 9.2 和 Elo 排联盟第二」可信度立刻不一样。做法是用树模型的feature_importances_或者 SHAP 值对每支球队取 top-3 贡献特征。有一个细节我踩过不要用最终模型的特征重要性去做解释因为那是全局的。要针对单支球队的预测做局部解释SHAP 的TreeExplainer或者逻辑回归的系数乘以标准化后的特征值都行后者更简单几十行就能实现。验证方法我给一个具体的连续回测最近 5 个赛季把每年的 Brier 分数列出来。如果波动很大比如从 0.18 跳到 0.28说明模型对某类赛季结构不适应通常是伤病年或者交易大年。这种年份恰恰是最该保留在验证集里的别为了好看的均值把它剔掉。顺带说一句这套代码最大的价值不在于预测准不准而在于它把「体育预测」拆成了可验证的模块特征、模型、校准、模拟每一块都能单独替换。你可以把逻辑回归换成梯度提升把 Elo 换成更复杂的评级系统把七场四胜换成单场淘汰接口都不用动。这种结构上的可替换性比调出高几个点的准确率重要得多。从那以后我每次拿到这种「预测类」的项目第一件事不是跑模型而是先写一个能跑通历史赛季的回测脚本把它跑出基线数字再动手改任何特征。没有基线所有改进都是幻觉。希望帮到你。本文还有配套的精品资源点击获取