简介:这份资源面向计算机相关专业学生与Python初学者,提供一套可直接参考的NBA比赛结果预测课程设计完整方案,解决从数据采集到模型预测的全流程实现问题。包内共11个文件,以6个CSV数据文件、4个Python脚本和1份docx说明文档为主,压缩包约311KB,CSV涵盖球队赛季统计、赛程与历史比赛结果等原始数据,Python脚本分别负责爬虫抓取与机器学习建模预测,文档则说明项目结构与使用方式。目前已有1462人学习下载,说明该方案在同类课设中具有较高参考价值。读者可据此获得一套已获学期优秀项目评选的完整代码与数据资料,直接用于课程设计答辩或大作业提交,同时理解爬虫采集、特征处理与结果预测的衔接思路,并借助现成数据快速复现实验、对照调试,节省从零搜集数据与搭建框架的时间。
1. 从一份课程设计说起:NBA 比赛数据抓取与机器学习预测到底怎么做
每年一到期末,计算机相关专业的课程设计就会集中爆发,其中「获取 NBA 比赛数据并进行机器学习智能预测」是出现频率极高的选题。原因很直接:数据公开、业务好懂、模型可解释、答辩时老师一听就明白你在干什么。但真正动手的人会发现,卡点根本不在模型,而在数据从哪来、字段怎么对齐、特征怎么构造、预测结果为什么总是接近五五开。
这篇笔记面向正在做 Python 课程设计大作业、或者想用 python 机器学习入门实战练手的同学。我会按一条真实可复现的路径讲:先解决数据获取,再讲特征工程和模型选型,然后落到代码和参数,最后把我在这个方向上踩过的坑摊开说。整套流程用到的库都是 python 安装 sklearn 库、pandas、requests 这类常规依赖,vscode python 环境配置或 pycharm 配置 python 环境都能跑。读完你应该能自己搭出一条从原始比赛记录到胜负预测的完整链路,而不是停在「调了个模型,准确率 0.5」的阶段。
需要先建立一个认知:NBA 胜负预测本质上是一个二分类问题,但它的信噪比很低。常规赛 82 场,主客场、背靠背、伤病、轮休都会让单场结果带很大随机性。所以课程设计里追求的不是 90% 准确率,而是把一套完整的数据处理和机器学习应用流程跑通,并能解释清楚每个环节为什么这么做。这才是老师真正想看的东西。
2. 数据获取:从公开数据源拿到可用的比赛记录
2.1 为什么数据源选择决定了后面一半的工作量
做 NBA 数据获取,常见做法有三类:一是调用公开的统计接口,二是抓取体育数据网站的页面,三是用现成的 CSV 数据集。课程设计里我一般推荐前两种结合,因为老师会看你有没有真实的「获取」动作,直接下载一个 CSV 交上去,工作量说服力不够。
公开统计接口的好处是返回结构化 JSON,字段干净,省去大量解析工作。缺点是部分接口有访问频率限制,连续请求会被限流。抓取网页的好处是数据全,缺点是页面结构会变,今天能跑的解析逻辑下个月可能就翻车。所以稳妥的策略是:优先用接口拿核心比赛数据,用网页抓取补充球队和球员的辅助信息,并且把抓下来的原始数据先落盘存成 CSV,后续所有处理都基于本地文件,避免每次跑代码都重新请求。
这里有个血泪经验:一定要做本地缓存。我见过太多同学调试模型时反复请求数据源,结果 IP 被临时限制,答辩前一天数据拉不下来,只能干瞪眼。落盘之后,数据处理和模型训练就完全离线了,稳定得多。
2.2 用 requests 拉取赛季比赛数据的完整代码
下面这段代码演示从公开接口按赛季拉取比赛记录并保存为 CSV 的最小实现。接口地址和字段名以你实际使用的数据源为准,这里给出的是通用结构。
import requests import pandas as pd import time import os # 请求头,模拟正常浏览器访问,避免被直接拒绝 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" } def fetch_season_games(season: str, save_dir: str = "data") -> pd.DataFrame: """ 按赛季拉取比赛数据 season: 例如 '2023-24' """ os.makedirs(save_dir, exist_ok=True) cache_file = os.path.join(save_dir, f"games_{season}.csv") # 本地已有缓存直接读取,避免重复请求 if os.path.exists(cache_file): print(f"命中缓存: {cache_file}") return pd.read_csv(cache_file) # 这里替换成你实际使用的数据接口 url = "https://example-sports-api.com/games" params = {"season": season, "league": "NBA"} all_records = [] page = 1 while True: params["page"] = page resp = requests.get(url, headers=HEADERS, params=params, timeout=10) if resp.status_code != 200: print(f"请求失败,状态码 {resp.status_code},第 {page} 页") break data = resp.json() records = data.get("games", []) if not records: break all_records.extend(records) page += 1 time.sleep(1.5) # 控制频率,别把人家接口打挂 df = pd.DataFrame(all_records) df.to_csv(cache_file, index=False, encoding="utf-8-sig") print(f"共获取 {len(df)} 条比赛记录,已保存到 {cache_file}") return df if __name__ == "__main__": df = fetch_season_games("2023-24") print(df.head())逻辑说明:函数先检查本地缓存,有就直接读,没有才发请求。分页循环里每请求一页 sleep 1.5 秒,这是控制访问频率的关键参数,设太小容易触发限流,设太大拉一个赛季的数据会很慢,1 到 2 秒是比较平衡的值。timeout 设 10 秒,避免网络卡住时程序一直挂着。保存时用 utf-8-sig 编码,这样用 Excel 打开不会中文乱码,答辩演示时省事。
参数说明:season 决定拉哪个赛季,课程设计建议至少拉 3 个赛季,样本量才够模型学。save_dir 是缓存目录,建议单独建一个 data 文件夹,和代码分开。如果你用的接口需要 API Key,把它放到环境变量里,别硬编码在代码里提交,这是基本习惯。
2.3 抓取网页数据时的解析要点
如果接口拿不到你想要的字段,就得抓网页。用 requests 拿到 HTML 后,用 BeautifulSoup 或 lxml 解析表格。核心是定位到表格元素,逐行提取单元格文本,再转成结构化数据。
from bs4 import BeautifulSoup import requests import pandas as pd def parse_standings(url: str) -> pd.DataFrame: resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding # 防止中文乱码 soup = BeautifulSoup(resp.text, "lxml") table = soup.find("table", class_="standings-table") if table is None: raise ValueError("未找到目标表格,页面结构可能已变化") rows = [] for tr in table.find_all("tr")[1:]: # 跳过表头 cells = [td.get_text(strip=True) for td in tr.find_all("td")] if cells: rows.append(cells) columns = ["team", "wins", "losses", "win_rate", "points_for", "points_against"] df = pd.DataFrame(rows, columns=columns[:len(rows[0])]) return df逻辑说明:resp.apparent_encoding 会自动推断编码,比手动指定 gbk 或 utf-8 更省心。find 表格时用 class 定位,如果页面改版导致找不到,会抛异常而不是静默返回空数据,这样你能第一时间发现解析失效。跳过第一行是因为那是表头,单独定义列名更可控。
参数说明:columns 列表要和实际表格列数对齐,列数不匹配时用切片兜底。抓取频率同样要控制,网页抓取比接口更容易被封,建议每次请求间隔 2 秒以上,并且只抓必要的页面。
3. 特征工程:把原始比赛记录变成模型能吃的输入
3.1 哪些特征真正影响胜负
拿到原始数据后,直接扔给模型是不行的。原始记录里通常有比赛日期、主客队、比分,这些要转成模型能理解的数值特征。我一般会构造这几类:
第一类是球队近期状态,比如最近 5 场、10 场的胜率,这是强特征。第二类是主客场因素,NBA 主场胜率长期在 55% 到 60% 之间,这个信号必须保留。第三类是休息天数,背靠背比赛对球队影响明显,用比赛间隔天数表示。第四类是历史交手记录,两队本赛季之前的交锋结果。第五类是进攻防守效率,用场均得分、场均失分、净胜分来刻画。
这里要提醒一个常见误区:不要用比赛当天的比分去构造特征,那叫数据泄露。比如你想预测 A 队对 B 队的胜负,就不能把这场比赛的得分放进特征里。所有特征必须只用这场比赛之前的信息计算。这个坑非常隐蔽,很多同学模型准确率虚高到 0.9,一查就是泄露了。
3.2 构造滚动胜率和休息天数的代码实现
import pandas as pd def build_features(df: pd.DataFrame) -> pd.DataFrame: """ 输入: 含 date, home_team, away_team, home_score, away_score 的比赛记录 输出: 带特征和标签的数据集 """ df = df.copy() df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) # 标签: 主队是否获胜 df["home_win"] = (df["home_score"] > df["away_score"]).astype(int) # 记录每支球队上一场比赛日期,用于算休息天数 last_game_date = {} home_rest, away_rest = [], [] for _, row in df.iterrows(): h, a, d = row["home_team"], row["away_team"], row["date"] home_rest.append((d - last_game_date[h]).days if h in last_game_date else 3) away_rest.append((d - last_game_date[a]).days if a in last_game_date else 3) last_game_date[h] = d last_game_date[a] = d df["home_rest"] = home_rest df["away_rest"] = away_rest # 滚动胜率: 每支球队最近 10 场的胜率 team_history = {} home_winrate, away_winrate = [], [] for _, row in df.iterrows(): h, a = row["home_team"], row["away_team"] home_winrate.append(_recent_winrate(team_history.get(h, []), 10)) away_winrate.append(_recent_winrate(team_history.get(a, []), 10)) # 更新历史,注意用本场结果更新,供后续比赛使用 team_history.setdefault(h, []).append(row["home_win"]) team_history.setdefault(a, []).append(1 - row["home_win"]) df["home_winrate_10"] = home_winrate df["away_winrate_10"] = away_winrate df["winrate_diff"] = df["home_winrate_10"] - df["away_winrate_10"] df["rest_diff"] = df["home_rest"] - df["away_rest"] return df def _recent_winrate(history: list, n: int) -> float: recent = history[-n:] if not recent: return 0.5 # 无历史时给中性值 return sum(recent) / len(recent)逻辑说明:整个函数按时间顺序遍历比赛,每场比赛只使用此前累积的历史信息来构造特征,这样就不会泄露未来数据。滚动胜率用列表保存每队逐场的胜负序列,取最近 n 场求平均。休息天数用字典记录每队上一场比赛日期,相减得到间隔。
参数说明:滚动窗口 n 取 10 是经验值,取太小噪声大,取太大反应迟钝,5 到 15 之间都可以试。无历史时胜率给 0.5、休息天数给 3,这是中性填充,避免赛季初样本全是缺失值。winrate_diff 和 rest_diff 是差值特征,比单独两个特征更能让模型捕捉相对优势。
3.3 特征标准化和数据集划分
构造完特征后,把数值特征和标签分开,用 train_test_split 划分训练集和测试集。注意时间序列数据不能随机打乱,要按时间切分,否则又是变相泄露。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler feature_cols = ["home_winrate_10", "away_winrate_10", "winrate_diff", "home_rest", "away_rest", "rest_diff"] X = df[feature_cols].values y = df["home_win"].values # 按时间顺序切分,前 80% 训练,后 20% 测试 split = int(len(X) * 0.8) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 注意用训练集的参数逻辑说明:StandardScaler 在训练集上 fit,在测试集上只 transform,这是标准做法。如果对全体数据 fit 再切分,测试集的均值方差信息就泄露到训练里了。按时间切分而不是随机切分,是因为预测未来比赛才是真实场景。
参数说明:split 比例 0.8 是常见选择,样本少时可以调到 0.7。feature_cols 要和前面构造的列名严格一致,多一个少一个都会报错。
4. 模型训练与评估:从逻辑回归到梯度提升的选型对比
4.1 为什么先跑逻辑回归再上复杂模型
很多同学一上来就用 XGBoost 或神经网络,结果调参调到崩溃,还不一定比逻辑回归好。我的建议是先跑逻辑回归作为基线,它训练快、可解释、不容易过拟合,能让你快速判断特征到底有没有用。如果逻辑回归准确率只有 0.5,说明特征有问题,换再复杂的模型也救不回来。
基线跑通后,再试随机森林和梯度提升。这两个在表格数据上表现通常最好,而且 sklearn 里都有现成实现,不需要额外装库。课程设计里对比三到四个模型,画个对比表,工作量和技术深度都够了。
4.2 多模型训练与对比的完整代码
from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.metrics import accuracy_score, roc_auc_score, classification_report models = { "LogisticRegression": LogisticRegression(max_iter=1000, C=1.0), "RandomForest": RandomForestClassifier(n_estimators=200, max_depth=6, min_samples_leaf=10, random_state=42), "GradientBoosting": GradientBoostingClassifier(n_estimators=150, learning_rate=0.05, max_depth=3, random_state=42), } results = [] for name, model in models.items(): model.fit(X_train, y_train) pred = model.predict(X_test) prob = model.predict_proba(X_test)[:, 1] acc = accuracy_score(y_test, pred) auc = roc_auc_score(y_test, prob) results.append({"model": name, "accuracy": round(acc, 4), "auc": round(auc, 4)}) print(f"\n===== {name} =====") print(classification_report(y_test, pred, target_names=["客胜", "主胜"])) import pandas as pd print(pd.DataFrame(results))逻辑说明:循环训练三个模型,统一用准确率和 AUC 评估。AUC 比准确率更能反映模型区分能力,尤其当主客胜样本不均衡时。classification_report 输出精确率、召回率、F1,答辩时能讲出更多细节。
参数说明:逻辑回归的 C 是正则强度,越小正则越强,1.0 是默认起点。随机森林 n_estimators 是树的数量,200 棵通常够用,max_depth 限制深度防过拟合,min_samples_leaf 设 10 让叶子节点至少 10 个样本,避免学噪声。梯度提升 learning_rate 设 0.05 配合 150 棵树,是慢学习率的稳健组合,调大学习率虽然收敛快但容易过拟合。
4.3 评估指标怎么看才不被误导
准确率 0.6 在 NBA 预测里已经算不错了,因为主场优势本身就贡献了约 0.58 的基线。也就是说,如果你无脑预测主队全胜,准确率就有 0.58 左右。所以你的模型必须明显超过这个基线才有意义。我一般会先算一个「全预测主胜」的基线准确率,作为对照。
AUC 在 0.6 到 0.65 之间说明模型有一定区分能力,超过 0.7 就要警惕是不是数据泄露了。如果 AUC 高得离谱,先回去检查特征构造有没有用到未来信息。这个自查习惯能帮你避开答辩时被老师一句话问倒的尴尬。
5. 避坑与排查:课程设计里最容易翻车的五个地方
5.1 现象:模型准确率异常高,超过 0.85
原因:几乎可以确定是数据泄露。最常见的是特征里混入了比赛结果相关的字段,比如用本场得分算了净胜分,或者标准化时对全体数据 fit。
解决:逐列检查特征,确认每个特征都只用比赛之前的信息。把特征构造函数按时间顺序重写,确保更新历史发生在特征提取之后。标准化严格在训练集 fit。
5.2 现象:抓取数据时程序中途报错退出,只拿到一部分数据
原因:网络请求没有异常处理,某一次请求超时或返回非 200 就整个崩掉。或者分页逻辑没写终止条件,死循环。
解决:给每个请求包 try except,失败时重试最多 3 次,仍失败就记录页码跳过。分页循环要有明确的终止条件,比如返回空列表或达到最大页数。已经拿到的数据先存盘,别等全部拉完才保存。
5.3 现象:训练时报错「Input contains NaN」或「could not convert string to float」
原因:原始数据里有缺失值或字符串列没处理干净。比如某场比赛日期缺失,或者球队名里混了空格。
解决:在特征工程前先做一轮数据清洗,用 df.isnull().sum() 看每列缺失情况,数值列用中位数或 0 填充,字符串列 strip 去空格。特征列确保全是数值类型,用 df[feature_cols].dtypes 检查。
5.4 现象:换了赛季数据后模型效果大幅下降
原因:不同赛季的球队、赛制、数据字段可能有变化,特征分布漂移。或者新赛季数据里有些球队是新增的,历史胜率全是默认值。
解决:跨赛季训练时,把赛季作为特征之一,或者至少保证训练集覆盖多个赛季。新球队的历史用联盟平均值填充,而不是 0.5 这种拍脑袋的值。评估时按赛季分别看效果,别只看总体。
5.5 现象:predict_proba 输出的概率全是 0.5 附近
原因:特征区分度不够,或者模型欠拟合。也可能是特征标准化后数值范围被压缩,模型学不到东西。
解决:先看特征和标签的相关性,用 df.corr() 检查。如果所有特征相关性都接近 0,说明特征构造有问题,回去重新设计。模型方面可以适当增加树的数量或深度,但优先解决特征问题。
6. 进阶技巧:用时间序列交叉验证和特征重要性把模型调到位
走到这一步,你已经有一条能跑的链路了。但课程设计想拿高分,还得在验证方法和调优上多走一步。我一般会用时间序列交叉验证替代单次切分,因为单次切分的结果波动很大,换个随机种子准确率可能差好几个点,答辩时被问到「你这个结果稳定吗」就不好回答。
时间序列交叉验证的思路是:把数据按时间分成 K 折,每次用前 k 折训练、第 k+1 折验证,逐步向前滚动。sklearn 里用 TimeSeriesSplit 就能做。
from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import GradientBoostingClassifier from sklearn.metrics import accuracy_score import numpy as np tscv = TimeSeriesSplit(n_splits=5) model = GradientBoostingClassifier(n_estimators=150, learning_rate=0.05, max_depth=3, random_state=42) scores = [] for train_idx, val_idx in tscv.split(X): X_tr, X_val = X[train_idx], X[val_idx] y_tr, y_val = y[train_idx], y[val_idx] model.fit(X_tr, y_tr) pred = model.predict(X_val) scores.append(accuracy_score(y_val, pred)) print(f"各折准确率: {[round(s, 4) for s in scores]}") print(f"平均准确率: {np.mean(scores):.4f},标准差: {np.std(scores):.4f}")逻辑说明:TimeSeriesSplit 保证每次验证集都在训练集之后,符合真实预测场景。n_splits=5 表示切 5 折,数据量少时可以降到 3。输出各折分数和标准差,标准差小说明模型稳定,这比单次的高准确率更有说服力。
参数说明:n_splits 越大每折验证集越小,评估越不稳定,5 是常用值。如果数据只有一两个赛季,建议用 3 折。
另一个进阶点是看特征重要性,搞清楚模型到底靠什么做判断。随机森林和梯度提升都有 feature_importances_ 属性。
import pandas as pd model.fit(X_train, y_train) importance = pd.DataFrame({ "feature": feature_cols, "importance": model.feature_importances_ }).sort_values("importance", ascending=False) print(importance)如果 winrate_diff 重要性最高,说明近期状态差异是主要信号,这符合直觉,答辩时能讲出业务含义。如果某个你没想到的特征排第一,就要警惕是不是泄露了。特征重要性既是调优工具,也是自查工具。
最后说个我自己的习惯:每次改完特征或参数,都把结果记到一个表格里,包括特征组合、模型、参数、准确率、AUC。课程设计报告里把这表格一放,老师能清楚看到你的迭代过程,比只放一个最终结果有说服力得多。这个方向不难,难的是把每个环节做扎实、讲清楚,希望帮到你。
本文还有配套的精品资源,点击获取