简介:面向Python课程设计与机器学习实践场景,这份资源以美职篮(NBA)比赛数据为切入点,实现了从爬虫采集、数据清洗到智能预测的比赛结果分析流程。项目取材真实赛季数据,涵盖球队场均统计、对手数据、赛程安排与历史交锋结果等多维信息,适合高校学生用于学期答辩、大作业或课程设计展示,也适合希望快速上手数据采集与建模的初学者参考。压缩包共有十一个文件,以六个数据文件、四个脚本文件和一个说明文档为主,整体大小约三百一十一KB。其中数据文件分别存放球队常规赛统计、对手常规赛统计、历史赛程与比赛结果;脚本文件覆盖爬虫抓取与机器学习模型训练及预测;说明文档则整理了项目背景、设计思路、模块划分和运行步骤。该方案曾获学期优秀项目评选,代码结构清晰、数据资料齐备,可直接运行复现预测结果,也便于后续扩展算法或更换数据源。目前已有一千四百六十二人学习,是完成课程设计或入门赛事数据分析的实用资源。
1. 把NBA比赛结果预测当课程设计:为什么说这是新手最友好的机器学习项目
NBA比赛结果预测,是Python课程设计里少有的"数据、算法、业务"三头都占的实战选题。它不需要深度学习,传统机器学习模型就能把准确率做到60%以上,但数据获取、特征构造、评估方式到处是坑,足够你学到东西。在机器学习实战项目案例里,这个题目的好处是入门顺滑、天花板也够高。
很多同学做完后发现,真正决定成绩的不是模型多高级,而是数据预处理和特征工程做没做干净。这个题目恰好能把Python爬虫、pandas数据处理、scikit-learn建模和可视化完整串一遍,非常适合刚入门机器学习、想补机器学习基础、又想做真实项目的初学者。
NBA比赛数据完全公开,免费接口无需注册,比申请权限的数据集友好得多。下文方案基于nba_api加pandas加scikit-learn的经典组合,全程本地跑通,不需要GPU,每段代码都附参数说明和踩坑记录。
2. 获取NBA比赛数据:nba_api 拉取与本地落地的完整方案
2.1 选型理由:为什么第一选择是 nba_api 而不是现成CSV
先说结论:课程设计阶段,直接用 nba_api 这个第三方库拉数据,比去 Kaggle 下载现成 CSV 更值得做。原因有三个。
第一,现成 CSV 的截止日期是固定的,你拿到手的可能是上个赛季的数据,没法覆盖最新比赛。课程设计答辩时老师大概率会问"你这套流程能不能预测明天的比赛",如果你用的是一份去年的静态数据,这个问题会非常尴尬。用接口拉数据,代码一跑就是最新赛程。
第二,拉数据的过程本身就是评分点。Python课程设计的评分标准里通常有"工作量"这一项,能展示一个从网络获取数据的完整环节,比单纯读CSV文件的工作量要扎实得多。你可以顺带在文档里写清楚请求流程、解析逻辑和异常处理,体现的是从爬取到存储的完整闭环。
第三,nba_api 封装的是 NBA 官网 stats.nba.com 的公开接口,免费、无需注册、无需API key,对校园网络环境也比较友好。相比去爬第三方统计网站,官方接口的可靠性要高不少,也不会遇到网站改版导致解析规则失效的问题。
当然,nba_api 有个前提:你得先把Python环境装好。给新手的建议是直接用 Anaconda 装 Python 3.x,然后把 conda 和 pip 的默认源都切成国内镜像,装包速度会快很多。装完在终端里跑 python --version 确认版本,再 pip install nba_api pandas scikit-learn xgboost matplotlib,机器学习常用包就这么齐了。如果你用 vscode 写代码,记得在右下角把解释器切成 conda 环境,不然容易遇到终端里能 import、vscode 里 import 报错的经典问题。
2.2 最小可用代码:拉取近五个赛季的常规赛日程与比分
nba_api 的核心用法是先构造一个端点对象,再调它的 get_data_frames() 方法拿回 DataFrame。最常见的入口是 leaguegamefinder,它返回每一支球队在指定范围内的所有历史比赛记录,每条记录是一行,包含球队、对手、比分、主客场、赛季、比赛日期等几十个字段。下面这段代码拉取近五个赛季所有常规赛的比赛日程和最终比分:
import pandas as pd from nba_api.stats.endpoints import leaguegamefinder def fetch_games(season_start=2020, season_end=2024): frames = [] for season in range(season_start, season_end + 1): # 每个赛季单独拉取,避免单次请求数据量过大被接口限流 season_str = f"{season}-{str(season + 1)[-2:]}" finder = leaguegamefinder.LeagueGameFinder( season_nullable=season_str, season_type_nullable="Regular Season" ) df = finder.get_data_frames()[0] frames.append(df) print(f"赛季 {season_str} 拉取完成:{df.shape[0]} 行") result = pd.concat(frames, ignore_index=True) return result games = fetch_games() games.to_csv("nba_games_raw.csv", index=False) print(games.shape)这段代码的逻辑很直接:循环遍历每一个赛季,构造 LeagueGameFinder 请求对象,用 get_data_frames() 把响应解析成 DataFrame,最后合并保存为CSV。几个参数要说明:
| 参数 | 取值示例 | 含义 |
|---|---|---|
| season_nullable | "2020-21" | 赛季字符串,格式必须是带横杠的"2020-21" |
| season_type_nullable | "Regular Season" | 赛事类型,常规赛;季后赛填 "Playoffs" |
| league_id_nullable | "00" | 默认就是NBA,一般不用动 |
| timeout | 30 | 单个请求的最大等待秒数,防卡死 |
注意一个重要细节:leaguegamefinder 返回的是"球队视角"的数据,也就是说一场比赛会出现两行——主队一行、客队一行。字段里 matchup 列能看出是 "ATL vs BOS" 还是 "BOS @ ATL",@ 表示客场。做特征工程之前必须先按 GAME_ID 去重或配对,这个后面特征章节会细说。
接口返回的字段里,GAME_ID 是比赛唯一标识,GAME_DATE 是比赛日期字符串,TEAM_ID 是球队ID,PTS 是这支球队本场得分,PLUS_MINUS 是净胜分,FG_PCT 是投篮命中率,REB、AST、TOV 分别是篮板、助攻、失误。一次拉五个赛季的数据量在一万行上下,完整跑完大约一到两分钟,速度完全可以接受。
2.3 数据落地:CSV 与 SQLite 怎么选
数据落地方式我一般分两种情况。如果只是课程设计,数据量在几万行以内,直接存CSV就够了,老师拷贝你的代码和CSV文件就能复现。如果你打算长期维护、每周更新数据,或者想在文档里体现一点数据库能力,用 SQLite 更合适:零配置、单文件、Python 内置 sqlite3 即可读写,不用额外装 MySQL。
存CSV时有一个坑:nba_api 返回的某些列含有特殊字符,直接用默认 to_csv 没问题,但用 Excel 打开中文列名可能出现乱码。保存时我习惯把编码指定为 utf-8-sig,这样 Excel 双击打开也不会乱码;读取时如果遇到解析错误,大概率是某行数据里有多余的逗号或引号,用 on_bad_lines="skip" 就能兜底。
如果你选 SQLite,建表时不要照搬所有列,挑实际会用到的字段建表就行。下面这段代码把数据写入 SQLite,并给 game_id 和 team_id 建了联合唯一索引:
import sqlite3 conn = sqlite3.connect("nba_games.db") cur = conn.cursor() # 建表:只保留特征工程会用到的核心字段 cur.execute(""" CREATE TABLE IF NOT EXISTS games ( game_id TEXT, team_id INTEGER, team_abbreviation TEXT, matchup TEXT, game_date TEXT, pts INTEGER, plus_minus INTEGER, home_away TEXT, season TEXT, PRIMARY KEY (game_id, team_id) ) """) # 写入前先重命名列为干净的小写格式 columns = ["GAME_ID", "TEAM_ID", "TEAM_ABBREVIATION", "MATCHUP", "GAME_DATE", "PTS", "PLUS_MINUS", "HOME_AWAY", "SEASON"] games[columns].to_sql( "games", conn, if_exists="replace", index=False) conn.commit() conn.close() print("SQLite 写入完成")这里用 PRIMARY KEY (game_id, team_id) 做联合主键,能防止重复拉取时插入重复数据。后续做增量更新时,可以先查询当前库里最大的 game_date,只拉那之后的比赛,再把新数据以 if_exists="append" 追加进去,不用每次全量重跑。to_sql 是 pandas 自带的数据库写入方法,很适合新手;需要注意 DataFrame 列名如果有空格或特殊符号,建表时会很麻烦,所以写入前先重命名成干净的小写列名最省事。
2.4 请求频率与异常处理:限速、超时和重试
nba_api 虽然封装得不错,但底层还是去请求 stats.nba.com 的 HTTP 接口。如果把它当成随便调的接口,连续快速请求几十次,就会收到 429 状态码,也就是请求过于频繁被限流。这不是封IP,但会让循环中断。
我的经验是两个约束同时加:一是每次请求之间 sleep 至少 0.5 到 1 秒;二是设置超时和重试机制,遇到 429 或 5xx 错误就等几秒再重试。下面是一段加了重试逻辑的拉取函数:
import time import requests from nba_api.stats.endpoints import leaguegamefinder def fetch_with_retry(season_str, max_retries=4): for attempt in range(max_retries): try: finder = leaguegamefinder.LeagueGameFinder( season_nullable=season_str, season_type_nullable="Regular Season", timeout=30 ) return finder.get_data_frames()[0] except requests.exceptions.HTTPError as e: # 429是限流,5xx是接口临时出错,都值得重试 wait = 2 ** attempt + 1 print(f"请求失败({e}),{wait}秒后重试") time.sleep(wait) raise RuntimeError(f"赛季 {season_str} 拉取失败") for season in range(2020, 2025): season_str = f"{season}-{str(season + 1)[-2:]}" df = fetch_with_retry(season_str) time.sleep(1.0)几个细节说明。timeout=30 表示单个请求最多等30秒,防止网络卡死导致程序挂在那里。重试的等待时间用 2^n + 1 这种指数退避:第一次失败等3秒,第二次等5秒,第三次等9秒,给服务端留出恢复的时间。
还有一个容易被忽略的问题:有的校园网出口会拦截带特定 UA(User-Agent)的请求。如果发现 nba_api 一直在报连接错误或 SSL 错误,先别急着怀疑代码,检查一下系统网络设置,或者换个网络环境再试一次。遇到存疑的报错,把完整异常栈贴到搜索引擎里按报错关键词搜,通常比盲改代码高效得多——这是这个项目里最典型的"不确定是代码问题还是网络问题"的场景。
3. 特征工程:决定准确率的不是模型,而是这四步数据加工
3.1 先定预测目标:预测胜负还是预测分差
动手写模型之前,第一件事是把预测目标定死。NBA比赛结果预测一般有两种定义方式:一是二分类,预测主队赢还是客队赢;二是回归,预测分差。课程设计我建议选二分类,理由很实际:二分类的评估指标(准确率、AUC)对新手来说好解释,答辩时一句"模型对未知比赛预测准确率达到62%"就够直观;回归任务要考虑分差预测误差,解释起来绕,而且分差分布离散,模型很难学出有效规律。
如果你非要做回归,也可以把回归预测的分差转成胜负标签,比如预测分差大于0就判主队胜。这样做的好处是多一个中间量,坏处是误差会叠加。建议主线做二分类,把回归当作补充实验写进文档,对比两种建模方式的结果,这反而是加分项。
目标变量生成时有一个新手常犯的错误:直接把原始数据里的 WL(胜负)字段当标签。还记得前面说过 leaguegamefinder 返回的是球队视角的数据吗?同一场比赛有两行,客队那行的 WL 是客队的胜负。如果不做配对直接喂给模型,模型就学到"看自己球队的胜负去预测自己球队的胜负"这种作弊逻辑。正确的做法是按 GAME_ID 把一场比赛的两行合并成一行,主队一列、客队一列,标签是主队是否获胜。
3.2 滚动均值特征:近五场和近十场统计怎么算才不算错
特征工程里最有价值的特征组,是两支球队各自最近N场比赛的表现统计,业界叫滚动均值(rolling average)。它背后的假设很朴素:一支球队近期的进攻效率和防守效率,最能反映它下一场可能的发挥。
计算滚动均值最核心的一点:只能用比赛日之前的数据,绝不能用之后的数据。举个例子,预测1月15日的比赛,只能用截至1月14日的比赛统计;如果1月15日当天或之后的比赛混进滚动窗口,就造成数据泄漏,训练准确率虚高好几个点,实战预测却崩盘。
实现时先按 TEAM_ID 和 GAME_DATE 排序,再对每支球队单独算最近5场和最近10场的滚动均值。下面这段代码是完整实现:
# 先按球队和日期排序,保证滚动窗口的顺序正确 games = pd.read_csv("nba_games_raw.csv") games["GAME_DATE"] = pd.to_datetime(games["GAME_DATE"]) games = games.sort_values(["TEAM_ID", "GAME_DATE"]).reset_index(drop=True) # 要计算滚动均值的统计列 stat_cols = ["PTS", "PLUS_MINUS", "FG_PCT", "REB", "AST", "TOV"] for col in stat_cols: for window in [5, 10]: # 先按球队分组计算滚动均值 roll = ( games.groupby("TEAM_ID")[col] .rolling(window, min_periods=1) .mean() .reset_index(level=0, drop=True) ) # shift(1) 把均值整体下移一行, # 让第k行不再包含第k场自己的数据,这是防泄漏的关键 games[f"{col}_roll{window}"] = roll.groupby(games["TEAM_ID"]).shift(1) print(games[["TEAM_ID", "GAME_DATE", "PTS", "PTS_roll5", "PTS_roll10"]].head(20))注意:没有 shift(1) 的滚动均值等于把本场比赛的数据喂给了本场比赛,这是整个项目最隐蔽的数据泄漏点,务必检查。
这段代码有两个关键点。第一,groupby("TEAM_ID").rolling(window).mean() 在分组内部计算滚动均值,窗口是5场或10场,互不干扰。第二,shift(1) 是防止数据泄漏的心脏,它把计算结果整体下移一行,这样第k行存的是第k-1场及之前窗口内的均值,当前场比赛本身不参与自己的特征计算。
为什么 min_periods=1 而不是默认的 window?因为赛季初的前几场比赛不够凑满窗口。设成 window 的话,赛季前4场的特征全是空值,要么删样本,要么手工填充;设成1表示至少有一场比赛就开始算均值,缺点是赛季初期特征偏小、波动大,但至少模型有特征可用。认真一点的做法是用上一赛季的均值回填赛季前几场,课程设计阶段不必做这么细。
3.3 赛程特征:休息天数、背靠背和主场优势怎么编码
滚动均值之外,还有三类特征在NBA预测里被反复验证有效:休息天数、背靠背标记、主场优势。
休息天数指距离上一场比赛隔了多少天。NBA常规赛几乎每天都有比赛,强队一个赛季要打多次背靠背(连续两天比赛),球员体能差异会直接影响比赛结果。计算方式是把同一支球队的比赛按日期排序,然后算相邻两场比赛的日期差。
背靠背是个0/1标记,表示该队这场比赛的前一天是否也有比赛。注意背靠背还要区分主客场:如果是客场打完再赶去下一个客场,影响更大;如果是连续两个主场,影响相对小。课程设计阶段做一版简化的0/1标记就够了,想认真雕琢可以拆成"背靠背且客场"和"背靠背且主场"两个特征。
主场优势是从 matchup 字段里解析出来的:包含 "@" 表示客场,否则是主场。这个特征在模型里通常系数显著为正,说明主场优势对NBA比赛结果有真实影响。
下面这段代码把这三类赛程特征一次性生成:
# 从 matchup 解析主客场:包含 "@" 表示客场 games["is_home"] = games["MATCHUP"].apply(lambda x: "@" not in x) # 每支球队的上一场比赛日期,用于推算休息天数和背靠背 games = games.sort_values(["TEAM_ID", "GAME_DATE"]) games["prev_game_date"] = games.groupby("TEAM_ID")["GAME_DATE"].shift(1) games["rest_days"] = (games["GAME_DATE"] - games["prev_game_date"]).dt.days # 背靠背:距离上一场正好1天 games["back_to_back"] = (games["rest_days"] == 1).astype(int) # 主队表和客队表按 GAME_ID 合并,回到比赛视角 home = games[games["is_home"]].copy() away = games[~games["is_home"]].copy() merged = home.merge( away, on="GAME_ID", suffixes=("_home", "_away") ) print(merged.shape) merged.to_csv("nba_games_features.csv", index=False)整体思路是把球队视角数据拆成主队表和客队表,再按 GAME_ID 合回比赛视角。合并之后每一行就是一场比赛:左边是主队近期统计,右边是客队近期统计,中间夹着赛程特征。至此特征工程的骨架就搭完了。
合并后行数会变成原来的一半,因为每场比赛从两行合为一行,这是正常的。合并完记得检查有没有 GAME_ID 重复——如果接口在某个赛季返回了全明星赛或其他非正式比赛,可能出现一对多的匹配,需要提前按赛事类型过滤,只保留常规赛记录。
3.4 时间切分:为什么随机打乱训练集相当于让模型作弊
这个问题几乎每个做时序预测的人都会踩一次:用 train_test_split 默认随机切分,训练集里混着后面的比赛,测试集里混着前面的比赛。对NBA这种强时序依赖的数据,随机切分意味着模型的训练数据里包含了"未来",特征和标签产生隐性的时间交叉,准确率虚高。
正确的做法是按时序切分:比如用2020到2023赛季的数据训练,用2024赛季的数据测试。train_test_split 不支持直接按时间切,但把 shuffle=False 配合已排序的数据即可实现,或者干脆手动切片:
games = pd.read_csv("nba_games_features.csv", parse_dates=["GAME_DATE_home"]) games = games.sort_values("GAME_DATE_home").reset_index(drop=True) split_date = "2023-10-24" # 以2023-24赛季揭幕战为界 train = games[games["GAME_DATE_home"] < split_date] test = games[games["GAME_DATE_home"] >= split_date] print(f"训练集 {train.shape[0]} 场," f"{train['GAME_DATE_home'].min().date()} ~ {train['GAME_DATE_home'].max().date()}") print(f"测试集 {test.shape[0]} 场," f"{test['GAME_DATE_home'].min().date()} ~ {test['GAME_DATE_home'].max().date()}")这么切完会发现,测试集在时间上完全晚于训练集,模型在测试集上的准确率通常比随机切分低2到4个百分点,这才是真实水平。答辩时主动提一句"我没有用随机切分,而是用了时间切分以避免数据泄漏",这一句话就能让评委知道你理解机器学习应用流程里最关键的细节。
4. 机器学习模型选型与调参:从逻辑回归到XGBoost的三个阶梯
4.1 基线模型:先跑逻辑回归,立住"能跑通"的版本
模型部分我强烈建议先跑逻辑回归当基线,不要一上来就上XGBoost。原因有两个:一是逻辑回归训练快,几秒出结果,适合用来验证特征管道有没有问题;二是逻辑回归的系数可以解释,你能看到哪些特征对预测结果影响最大,这对写课程设计文档非常有帮助。
特征列的准备和标签列的定义是这一步的关键。以 nba_games_features.csv 为例,特征列包括主队和客队的滚动均值、休息天数、背靠背标记。标签列定义为 home_win,表示主队是否获胜。
import pandas as pd from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, log_loss games = pd.read_csv("nba_games_features.csv", parse_dates=["GAME_DATE_home"]) games = games.sort_values("GAME_DATE_home").reset_index(drop=True) # 标签:主队得分大于客队得分则主胜 games["home_win"] = (games["PTS_home"] > games["PTS_away"]).astype(int) # 特征列:滚动均值 + 赛程特征 feature_cols = [ "PTS_roll5_home", "PLUS_MINUS_roll5_home", "FG_PCT_roll5_home", "PTS_roll5_away", "PLUS_MINUS_roll5_away", "FG_PCT_roll5_away", "rest_days_home", "rest_days_away", "back_to_back_home", "back_to_back_away", ] X = games[feature_cols] y = games["home_win"] # 逻辑回归对特征尺度敏感,先标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 时间切分:2023-24赛季之前训练,之后测试 split_date = pd.Timestamp("2023-10-24") train_idx = games["GAME_DATE_home"] < split_date test_idx = games["GAME_DATE_home"] >= split_date model = LogisticRegression(max_iter=1000) model.fit(X_scaled[train_idx], y[train_idx]) y_prob = model.predict_proba(X_scaled[test_idx])[:, 1] y_pred = (y_prob > 0.5).astype(int) print(f"逻辑回归准确率:{accuracy_score(y[test_idx], y_pred):.4f}") print(f"对数损失:{log_loss(y[test_idx], y_prob):.4f}") # 打印系数,看特征影响方向 coef_df = pd.DataFrame({"feature": feature_cols, "coef": model.coef_[0]}) print(coef_df.sort_values("coef", ascending=False))这段代码里,max_iter=1000 是因为标准化之后特征数量不算少,默认100次迭代偶尔不收敛,调大一点更稳妥。predict_proba 返回二维数组,取第二列就是主胜概率。log_loss 是概率预测的对数损失,越小说明概率校准越好,后面比较模型时比准确率更细腻。
跑完你会看到,PLUS_MINUS_roll5_home 这类净胜分滚动均值的系数通常显著高于其他特征,说明近期净胜分是预测比赛结果最强的单一信号。把"基于系数分析特征重要性"写进课程设计报告,就是一块拿得出手的内容。
4.2 树模型上场:随机森林和XGBoost的参数怎么设
基线跑通之后,下一步换更强的模型。比赛预测场景里,随机森林和XGBoost是主流选择。两者都是树模型,能自动处理特征之间的非线性关系,不需要像逻辑回归那样做标准化。代价是模型本身像个黑匣子,你很难直接说出每个特征的作用方向。
随机森林的优势是参数少、不容易过拟合、训练速度快,适合作为第一个升级版模型。XGBoost 的准确率通常比随机森林高零点几到一两个百分点,但参数非常多,调起来耗时,对新手不友好。建议:课程设计用随机森林做主线,XGBoost 作为进阶对比实验;时间紧的话只做随机森林也完全够。
下面这段代码同时跑随机森林和 XGBoost:
from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier # 树模型不需要标准化,直接用原始特征 X_train, X_test = X.iloc[train_idx], X.iloc[test_idx] y_train, y_test = y[train_idx], y[test_idx] rf = RandomForestClassifier( n_estimators=300, max_depth=6, min_samples_leaf=5, random_state=42, n_jobs=-1 ) rf.fit(X_train, y_train) rf_prob = rf.predict_proba(X_test)[:, 1] print(f"随机森林准确率:{accuracy_score(y_test, (rf_prob > 0.5).astype(int)):.4f}") xgb = XGBClassifier( n_estimators=300, max_depth=4, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, random_state=42, eval_metric="logloss" ) xgb.fit(X_train, y_train) xgb_prob = xgb.predict_proba(X_test)[:, 1] print(f"XGBoost准确率:{accuracy_score(y_test, (xgb_prob > 0.5).astype(int)):.4f}")关键参数说明如下表:
| 模型 | 参数 | 作用 | 课程设计推荐值 |
|---|---|---|---|
| 随机森林 | n_estimators | 树的数量 | 200~500 |
| 随机森林 | max_depth | 树的最大深度,防过拟合 | 6~8 |
| 随机森林 | min_samples_leaf | 叶子节点最少样本数 | 5~10 |
| XGBoost | learning_rate | 学习率,越小步长越细 | 0.03~0.1 |
| XGBoost | n_estimators | 迭代轮数 | 与学习率配合,200~500 |
| XGBoost | subsample | 每轮抽样比例 | 0.7~0.9 |
| XGBoost | colsample_bytree | 每棵树使用特征比例 | 0.7~0.9 |
随机森林的 max_depth=6 和 min_samples_leaf=5 是防过拟合的核心参数;如果训练集只有几千场,树的深度超过8就很容易把训练集背下来。XGBoost 的 learning_rate=0.05 配合 n_estimators=300,表示以较小步长迭代300轮;subsample=0.8 每轮抽80%样本,colsample_bytree=0.8 每棵树抽80%特征,这两个参数能明显提升泛化能力。
另外,树模型的超参数设置在一定程度上有玄学成分,不用追求全局最优,关键是先设一组合理的默认值跑通,再围绕防过拟合调深度和样本量。eval_metric="logloss" 是告诉 XGBoost 用对数损失作为内部评估标准。如果你装的是很老的 XGBoost 版本,可能遇到 deprecation 警告,不影响运行;新版直接不用管。
4.3 评估指标怎么选:准确率、对数损失与AUC的分工
课程设计报告里只写一个准确率数字,很单薄。建议至少同时报告三个指标:准确率、对数损失和AUC,它们各自回答不同的问题。
准确率最直观,预测错误的场次占比多少,但缺点是无法反映概率的可信度。比如模型对某场比赛给出51%的胜率,赢了算对、输了算错,但51%和95%的置信度显然不一样。对数损失就能惩罚这种"低置信度错误":模型在错误方向上给出的概率越高,惩罚越重。AUC 则是排序能力的度量,回答"模型给出的概率排序是否合理",和具体阈值无关,对样本不平衡也不太敏感。
三者的组合能帮你判断改进方向。如果准确率还行但 log loss 偏高,说明模型对某些比赛过度自信,可以降低树模型深度;如果 AUC 明显高于0.5,说明特征和标签的关联真实存在,问题出在阈值设置,可以微调判定阈值。
from sklearn.metrics import roc_auc_score, brier_score_loss for name, prob in [("逻辑回归", y_prob), ("随机森林", rf_prob), ("XGBoost", xgb_prob)]: auc = roc_auc_score(y_test, prob) brier = brier_score_loss(y_test, prob) acc = accuracy_score(y_test, (prob > 0.5).astype(int)) print(f"{name}: 准确率={acc:.4f}, AUC={auc:.4f}, Brier={brier:.4f}")Brier 分数是概率预测和真实结果之间的均方误差,越小越好。答辩时一张表格列出三个模型的准确率、AUC、log loss、Brier,就构成了一个完整的评估体系。如果想把 Python数据分析与可视化 也加进去,用 matplotlib 画一条 ROC 曲线放到报告里,能直观展示三个模型在不同阈值下的表现差异。
4.4 分组交叉验证:防止同赛季的比赛同时出现在训练集和验证集
讲完评估指标,必须讲交叉验证的正确姿势。很多同学用 GridSearchCV 调参时发现验证集表现很好,一到时间切分的测试集就掉点。原因在于默认的 KFold 是随机切分的,前面也说过,随机切分在时序数据上等于让模型瞥见了未来。
正确的做法是 TimeSeriesSplit 或 GroupKFold 按赛季分组。TimeSeriesSplit 每次都拿时间靠前的数据做训练、时间靠后的做验证,模拟真实预测场景。GroupKFold 则保证整个赛季的数据在同一折里面,避免同赛季比赛被切到训练和验证两侧。
from sklearn.model_selection import TimeSeriesSplit, cross_val_score from sklearn.ensemble import RandomForestClassifier # 数据必须先按时间升序排序,TimeSeriesSplit 才有效 games_sorted = games.sort_values("GAME_DATE_home").reset_index(drop=True) X_sorted = games_sorted[feature_cols] y_sorted = games_sorted["home_win"] tscv = TimeSeriesSplit(n_splits=5) rf = RandomForestClassifier(n_estimators=200, max_depth=6, random_state=42, n_jobs=-1) scores = cross_val_score(rf, X_sorted, y_sorted, cv=tscv, scoring="accuracy") print(f"TimeSeriesSplit 交叉验证准确率:{scores.mean():.4f} ± {scores.std():.4f}")关键点:cross_val_score 用 TimeSeriesSplit 时不会自动打乱数据,你必须确保传入的 X_sorted 本身按时间升序排好,否则切分失去意义。scoring="accuracy" 可以换成 scoring="roc_auc" 评估排序能力。
如果想用默认切分快速看一眼指标,可以先用 KFold 跑一遍,但报告里一定要写明随机切分在时序数据上的局限。把随机切分和时间切分的对比写进课程设计文档,本身就是很漂亮的实验设计。
5. 避坑指南:NBA预测项目里最常翻车的五个地方
这几个坑是我自己反复踩、也帮同学改这个题目时见得最多的,按出现频率排序,照着排查基本能解决大部分问题。
5.1 现象:拉取数据时频繁报429或连接超时
现象:循环拉五个赛季的数据,拉到第二个赛季就抛 requests.exceptions.HTTPError: 429 Too Many Requests,或者程序直接卡住不动。
原因:nba_api 底层请求的是 NBA 官网接口,官网对单IP的请求频率有限制。课程设计阶段大家常在机房或宿舍网络下跑,一个出口IP可能有多台设备同时请求,更容易触发限流。另一个隐藏原因是某些版本的 nba_api 默认没有设置 User-Agent,被官网的防火墙识别成爬虫直接拒绝。
解决:一是在每次请求之间加 time.sleep(1),把请求间隔控制在一秒以上;二是给请求设置浏览器 UA 和完整的请求头;三是给单次请求设置 timeout 参数,避免卡死。前面2.4节的重试代码可以直接复用。如果学校网络反复出问题,换手机热点试一次,很多时候是网络链路的问题而不是代码的问题。
5.2 现象:训练准确率高达80%以上,一上真实预测就崩
现象:训练集准确率能到85%,时间切分的测试集也能到65%左右,但拿模型去预测下一周的真实比赛,准确率只有50%上下,跟猜差不多。
原因:根子上几乎全是数据泄漏。常见泄漏点有三个:滚动均值没有 shift(1),当天的比赛数据参与了当天特征计算;切分没有按时间,随机切分让模型在训练时看到了未来;第三个最隐蔽——标签构造错误,或者把比赛结束后的全场累计统计(比如球队单场篮板、助攻总数)当成了特征,数据和标签同时来自同一个结果,准确率自然虚高。
解决:逐一排查。滚动均值必须 shift(1);切分必须用时间切分;标签必须按 GAME_ID 配对后生成主队胜负;特征列里不能出现和比赛同一天生成的统计。排查方法很朴素:随机挑一场已知结果的比赛,手工算它的滚动均值特征,和代码计算值对比,看是否严格排除了当天比赛。在报告里写一句"已人工校验特征无泄漏",很显专业。
5.3 现象:做滚动均值或合并时报索引不对齐
现象:跑特征工程时报 ValueError: cannot reindex on an axis with duplicate labels,或者合并出来的结果是全 NaN。
原因:通常是因为 GAME_ID 有重复,或者 rolling 之后 reset_index 的方式不对。一个 GAME_ID 在主客两行里如果格式不一致(一个纯数字一个字符串),merge 时会变成笛卡尔积。另外,groupby().rolling() 返回的是分组级索引,没有正确 reset 的话会把原始 DataFrame 索引打乱,后续一切按索引对齐的操作都会错位。
解决:拿到原始数据后先做三件事:GAME_ID 统一转成 str;检查并去除重复行;确保 GAME_DATE 已转成 datetime。rolling 计算完统一 reset_index(drop=True),再做 merge。标准的预处理开头长这样:
games["GAME_ID"] = games["GAME_ID"].astype(str) games = games.drop_duplicates(subset=["GAME_ID", "TEAM_ID"]) games = games.sort_values(["TEAM_ID", "GAME_DATE"]).reset_index(drop=True) assert games.duplicated(subset=["GAME_ID", "TEAM_ID"]).sum() == 0 print("数据检查通过:无重复记录,索引已重置")5.4 现象:同样的代码换台电脑跑,结果完全不一样
现象:自己电脑上准确率62%,换到舍友电脑或者答辩电脑上跑变成58%甚至更低,或者直接报错。
原因:最常见的原因是库版本不一致。pandas 不同版本的 rolling 行为、sklearn 不同版本的随机数、nba_api 不同版本的字段名,都会导致差异。特别是 nba_api 更新频繁,字段名或构造函数参数名可能变化,老代码在新版本下可能直接报错。另一个原因是数据不同——两次拉取时间不同,数据里包含的场次数不一样,特征统计自然有差异。
解决:写一个 requirements.txt 把核心依赖版本锁死,文档里写明测试环境。答辩前提前去答辩电脑上把环境和依赖装好,不要临场拉数据。数据方面,固定下载日期,把原始CSV随代码一起提交,任何环境跑的都是同一份数据。如果 nba_api 版本过新导致参数报错,快速定位的方式是打印 dir(leaguegamefinder) 看当前支持的参数名,别拿老教程硬套。
5.5 现象:预测概率总往0.5收缩,看不出"观点"
现象:模型输出的概率绝大多数落在0.4到0.6之间,很少出现70%以上的判断,看着不够有观点。
原因:这在NBA预测里是正常现象,不是bug。篮球比赛本身随机性很强,主场优势、近期状态这些信号只能解释比赛结果的一部分方差,模型天然学不到特别强的区分度。NBA单场两队合计得分动辄230分以上,一个球员手感爆发就能改变走势,概率往0.5收缩是信息量的真实反映,不是模型坏了。
解决:不要为了"模型有观点"去强行调阈值或改损失函数。正确做法是接受这个现实,从特征侧找增量——加入球员轮休、伤病名单、球队连胜连败状态,都能小幅拉开概率。还可以建模"分差"而不是"胜负",因为连续量比分立标签包含更多信息。说到底,60%到63%的准确率对这个任务已经是很不错的水平,对比随机猜测的50%基准,这个结论本身就立得住。
6. 进阶验证:用滚动回测与概率校准检验模型真实水平
模型训练完,还差最后一环:用一套能反复执行的验证流程证明模型不是碰运气。课程设计里最常说的一句话是"模型准确率62%",但这个数字怎么来的、在不同时间段是否稳定,很多人说不清楚。这一章讲两个进阶动作:滚动回测和概率校准。
滚动回测的思路是模拟真实的预测节奏。假设现在是某个比赛日,你有截至前一天的所有数据,要预测今天所有比赛的胜负;等比赛结束,把预测和结果对比,记录命中。把时间轴往前推,每周做一次这样的预测,累积一整个赛季,就得到一条真实的准确率曲线。实现上不需要复杂框架,一个 for 循环就能完成。
# 把测试期按周切块,逐周回测 test_games = games[games["GAME_DATE_home"] >= split_date].copy() test_games["week"] = test_games["GAME_DATE_home"].dt.isocalendar().week results = [] for week, group in test_games.groupby("week"): # 截止到本周之前的全部数据作为训练集 train_data = games[games["GAME_DATE_home"] < group["GAME_DATE_home"].min()] if len(train_data) < 200: continue X_tr = train_data[feature_cols] y_tr = train_data["home_win"] # 每周重新训练一次,模拟真实部署节奏 rf = RandomForestClassifier(n_estimators=200, max_depth=6, random_state=42) rf.fit(X_tr, y_tr) prob = rf.predict_proba(group[feature_cols])[:, 1] acc = ((prob > 0.5).astype(int) == group["home_win"].values).mean() results.append({"week": week, "games": len(group), "accuracy": acc}) result_df = pd.DataFrame(results) print(result_df) print(f"整体回测准确率:{result_df['accuracy'].mean():.4f}")这段代码的核心是"每周重训一次",而不是训一次模型测一整年。因为NBA赛季中有交易、伤病、阵容变化,固定模型会随时间推移慢慢失效。回测结果里命中率的波动范围值得关注——如果某几周准确率突然跌到40%以下,去查那几周发生了什么,往往能对应上全明星周末、交易截止日这类特殊赛程事件。
概率校准是第二个值得做的动作。模型输出0.55的概率,到底接近真实命中率还是虚标,需要用校准曲线验证。sklearn 的 calibration_curve 可以直接画:
from sklearn.calibration import calibration_curve import matplotlib.pyplot as plt # 把测试集概率分桶,对比每个桶内的实际命中率 prob_true, prob_pred = calibration_curve(y_test, xgb_prob, n_bins=10) plt.figure(figsize=(6, 6)) plt.plot(prob_pred, prob_true, marker="o", label="XGBoost") plt.plot([0, 1], [0, 1], linestyle="--", label="理想校准") plt.xlabel("预测概率") plt.ylabel("实际命中率") plt.legend() plt.savefig("calibration_curve.png", dpi=150)如果校准曲线明显偏离对角线,说明概率系统性偏高或偏低,这时可以用 CalibratedClassifierCV 做一次事后校准。对课程设计来说,画一张校准曲线并解释它的含义,比多调几个百分点的准确率更能体现你对机器学习模型的理解深度。
我自己做这个项目时养成的习惯是:每个比赛日结束后,把前一天的真实结果回填到数据表,重算滚动均值,重新训练模型,再把当日比赛的预测输出到一个CSV。坚持一个月累积下来,就能画出一条真实的准确率曲线。看到曲线连续下滑,就知道该更新特征或者查查是不是有核心球员伤停了——这种从数据里发现问题的感觉,比调参更有成就感。这个项目做完,你带走的不只是代码,而是一套从需求到可验证结果的完整套路:先跑通最小流程,再逐层加特征、换模型、补评估,每一步都有可验证的输出。以后做别的数据类项目,这条骨架都能直接复用。希望帮到你。
本文还有配套的精品资源,点击获取