简介:SoccerPredictor 是一份面向机器学习与体育数据爱好者的足球赛果预测项目源码,核心思路是把英超比赛胜负预测建模为时间序列分类问题,并为每支球队分别构建神经网络模型同步训练,适合具备 Python 与 TensorFlow/Keras 基础、想研究时序分类与体育预测落地的读者参考。压缩包共 29 个文件,约 2.41MB,以 14 个 py 脚本为主体,辅以 8 张 png 图表、2 个 pickle 模型或数据文件,以及 css、json、md 等配置与说明文档,目录中可看到训练器、回测器、可视化等模块划分。已有 1258 人学习。作者在测试期内取得约 90% 预测精度、33.4% ROI 与 1069% 利润的回测结果,并附有可视化与回测脚本,便于读者理解特征构造、模型训练与策略评估的完整链路;需注意作者未公开数据集,实际训练需自行组装数据,现有文件主要用于演示与回测。
1. 足球赛果预测到底能不能落地:一份时间序列机器学习资源拆解
足球赛果预测这件事,圈里一直有两种极端声音:一种说赔率面前人人平等,模型都是玄学;另一种把 LSTM 当万能钥匙,喂几场历史比分就指望稳定输出。我拆过不少同类资源,SoccerPredictor 这份属于少见的「把话说全」的类型——它不承诺胜率,而是把从原始赛果数据到特征工程、时间序列建模、再到 Dash 可视化面板的整条链路摊开给你看。技术栈是 Python + TensorFlow/Keras 的 LSTM 加 Dash,核心解决的是「历史比赛数据怎么变成可训练的时间序列样本」以及「预测结果怎么用交互面板呈现」这两个具体问题。适合已经会写 Python、想拿真实场景练手时间序列建模的从业者,也适合做体育数据分析、想理解特征构造边界的人。它不负责帮你赢钱,负责让你把一套可复现的预测流水线跑通。
2. 数据到样本:时间序列建模前的特征工程怎么做
时间序列预测足球赛果,第一个翻车点从来不是模型结构,而是样本怎么切。原始数据通常是一张按日期排列的比赛表,每行有主队、客队、比分、联赛、赛季等字段。直接把这堆字段丢进 LSTM,模型学到的只会是「主队名字的 one-hot 编码」,跟比赛结果没有因果关系。合格的从业者会先把「一场比赛」转成「两支球队各自的历史状态向量」,再按时间窗口滑窗取样。
2.1 从赛果表构造滚动特征
常见做法是为每支球队维护一份滚动统计:近 N 场的进球数、失球数、胜平负分布、主客场分别统计。这里的关键是「只用比赛日之前的数据」,否则就是典型的数据泄漏。下面这段是我一般会先写的特征构造骨架,逻辑是按日期排序后逐场回填,确保每场比赛的特征只来自它之前的记录。
import pandas as pd def build_rolling_features(df, window=5): # df 需包含: date, home_team, away_team, home_goals, away_goals df = df.sort_values("date").reset_index(drop=True) team_history = {} # 每队维护一个历史记录列表 rows = [] for _, match in df.iterrows(): home, away = match["home_team"], match["away_team"] h_hist = team_history.get(home, []) a_hist = team_history.get(away, []) # 只取最近 window 场,不足则用已有数据 h_recent = h_hist[-window:] a_recent = a_hist[-window:] rows.append({ "date": match["date"], "home_team": home, "away_team": away, "home_avg_goals": sum(x["gf"] for x in h_recent) / max(len(h_recent), 1), "home_avg_conceded": sum(x["ga"] for x in h_recent) / max(len(h_recent), 1), "away_avg_goals": sum(x["gf"] for x in a_recent) / max(len(a_recent), 1), "away_avg_conceded": sum(x["ga"] for x in a_recent) / max(len(a_recent), 1), "label": 1 if match["home_goals"] > match["away_goals"] else (0 if match["home_goals"] == match["away_goals"] else -1) }) # 比赛结束后才更新历史,保证时序正确 team_history.setdefault(home, []).append( {"gf": match["home_goals"], "ga": match["away_goals"]}) team_history.setdefault(away, []).append( {"gf": match["away_goals"], "ga": match["home_goals"]}) return pd.DataFrame(rows)这段代码里window是最重要的参数,取 5 意味着用近 5 场状态描述球队当前水平。取太小噪声大,取太大则反应迟钝,赛季中换帅、伤病潮这类突变会被平滑掉。label用 1/0/-1 表示主胜/平/客胜,是三分类的常见编码。注意team_history的更新放在 append 之后,这是防泄漏的关键一行,很多人图省事在循环开头就更新,结果特征里混进了本场比分,模型在训练集上漂亮得离谱,上线就崩。
2.2 滑窗序列与标签对齐
有了逐场特征表,下一步是把它转成 LSTM 要的三维张量(samples, timesteps, features)。这里的坑在于「一场比赛对应一个序列」还是「一支球队对应一个序列」。SoccerPredictor 这类资源通常采用前者:对每场比赛,取主队最近 T 场的特征序列和客队最近 T 场的特征序列,拼接或分别送入网络。我一般会先做对齐检查,确认序列长度和标签一一对应。
import numpy as np def make_sequences(feature_df, timesteps=5): X, y = [], [] feature_cols = ["home_avg_goals", "home_avg_conceded", "away_avg_goals", "away_avg_conceded"] values = feature_df[feature_cols].values labels = feature_df["label"].values for i in range(timesteps, len(feature_df)): X.append(values[i - timesteps:i]) # 过去 timesteps 场 y.append(labels[i]) # 当前场结果 return np.array(X), np.array(y)timesteps和前面的window不是一回事:window控制单场特征里包含多少历史,timesteps控制送进 LSTM 的序列长度。两个参数都调大,样本量会骤减,小联赛数据本来就少,很容易训不动。我的经验是先把window=5、timesteps=5跑通,再单独调其中一个,别一次改两个,否则出问题根本分不清是谁的锅。
3. LSTM 网络搭建:Keras 里的层数、维度与训练参数
模型部分这份资源用的是 Keras 的 Sequential 接口搭 LSTM,结构不复杂,但每个参数背后都有取舍。很多人上来就堆两层 LSTM、每层 128 单元,结果在小数据集上过拟合到训练准确率 90%、验证集 50%,还以为是数据问题。实际上足球赛果本身随机性极高,平局尤其难预测,模型容量必须和数据量匹配。
3.1 网络结构与输出层选择
三分类任务,输出层用 3 个神经元的 softmax,损失函数用 sparse_categorical_crossentropy,标签保持整数编码即可,省去 one-hot 转换。中间层我一般从单层 LSTM 起步,单元数 32 到 64,后面接一个 Dropout 压过拟合。下面是一个能直接跑的基线结构。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam def build_lstm_model(timesteps, n_features, n_classes=3): model = Sequential([ LSTM(64, input_shape=(timesteps, n_features), return_sequences=False), # 单层,不返回序列 Dropout(0.3), # 抑制过拟合 Dense(32, activation="relu"), Dense(n_classes, activation="softmax") ]) model.compile( optimizer=Adam(learning_rate=1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"] ) return modelreturn_sequences=False表示只取最后一个时间步的隐状态,适合「一个序列出一个标签」的场景;如果你要堆第二层 LSTM,这一项必须改成 True,否则维度对不上,这是新手最常见的报错来源。Dropout(0.3)是我在体育数据上比较稳的取值,0.5 往往欠拟合,0.1 又压不住过拟合。学习率 1e-3 是 Adam 的默认档,数据量小的时候可以降到 5e-4,训练曲线会平滑一些。
3.2 训练循环与早停策略
训练阶段必须留验证集,而且验证集要按时间切,不能随机打乱。随机切会让「未来比赛」混进训练集,验证指标虚高,这是时间序列任务的血泪经验。常见做法是前 80% 时间做训练、后 20% 做验证,再配 EarlyStopping 防止空转。
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks = [ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), ModelCheckpoint("best_soccer_lstm.keras", monitor="val_loss", save_best_only=True) ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=callbacks, verbose=1 )patience=10表示验证损失连续 10 轮不降就停,restore_best_weights=True保证拿到的是最优轮次的权重而不是最后一轮。batch_size=32在几千条样本的规模下比较合适,太小梯度噪声大,太大又容易卡在局部最优。训练完别只看 accuracy,三分类里「全猜主胜」在主场优势明显的联赛也能到 45% 左右,要看混淆矩阵,确认模型是不是真的学到了平局和客胜的区分能力。
4. Dash 面板落地:把预测结果做成可交互的展示层
模型训完只是半成品,SoccerPredictor 的另一半价值在 Dash 面板:把历史赛果、预测概率、球队状态做成可点选、可筛选的界面。对做数据分析的人来说,这一步决定了成果能不能被非技术同事看懂。Dash 的核心是 layout 加 callback,前者定义页面结构,后者处理交互逻辑。
4.1 页面布局与下拉筛选
一个够用的面板至少要有联赛/球队下拉框、日期区间选择、预测结果表格和概率条形图。布局用html.Div嵌套dcc组件,别一上来就追求花哨,先把数据流打通。
from dash import Dash, dcc, html, Input, Output import plotly.express as px app = Dash(__name__) app.layout = html.Div([ html.H2("足球赛果预测面板"), dcc.Dropdown( id="team-select", options=[{"label": t, "value": t} for t in team_list], value=team_list[0], clearable=False ), dcc.Graph(id="prob-bar"), html.Div(id="match-table") ]) @app.callback( Output("prob-bar", "figure"), Output("match-table", "children"), Input("team-select", "value") ) def update_panel(team): subset = pred_df[pred_df["home_team"] == team].tail(10) fig = px.bar(subset, x="date", y=["prob_home", "prob_draw", "prob_away"], barmode="group", title=f"{team} 近 10 场预测概率") table = subset[["date", "away_team", "prob_home", "prob_draw", "prob_away"]] \ .to_dict("records") return fig, tableInput和Output的 id 必须和 layout 里组件 id 严格对应,写错一个字母页面就白屏,而且 Dash 的报错信息不算友好,这是调试时最耗时的点。barmode="group"让三类概率并排显示,比堆叠图更容易看出模型对平局的犹豫程度。表格部分我一般只展示近 10 场,数据一多浏览器渲染会明显变慢。
4.2 预测结果回写与缓存
面板每次切换球队都重新推理一遍模型,体验会很差。常见做法是离线批量推理,把概率写回 DataFrame 或 SQLite,面板只做查询。如果坚持在线推理,至少用functools.lru_cache把模型加载和固定输入的输出缓存住。
from functools import lru_cache import numpy as np @lru_cache(maxsize=1) def load_model_once(path="best_soccer_lstm.keras"): from tensorflow.keras.models import load_model return load_model(path) def predict_proba(sequence): model = load_model_once() seq = np.expand_dims(sequence, axis=0) # 补 batch 维 return model.predict(seq, verbose=0)[0]maxsize=1是因为模型对象很大,缓存多份没意义。np.expand_dims补 batch 维度这一步漏掉的话,Keras 会报输入维度不匹配,报错信息里全是 shape 数字,第一次看容易懵。离线批量推理更适合这份资源的定位——预测本身不需要实时,面板要的是响应快。
5. 避坑与排查:这份资源跑不起来时先看这几条
5.1 常见问题排查清单
现象:训练 loss 一路下降,验证 loss 从第二轮就开始上升。原因多半是样本切分没按时间,或者特征里混进了未来信息。解决:检查build_rolling_features里历史更新是否在 append 之后,确认train_test_split没有用shuffle=True,时间序列必须按日期切。
现象:模型预测全是主胜,混淆矩阵里平局和客胜一列全空。原因是类别不平衡加特征区分度不足。主场优势在多数联赛真实存在,模型偷懒全猜主胜也能拿到不低的准确率。解决:给损失函数加class_weight,或者先做特征筛选,把滚动进球差、近期胜率这类有区分度的字段保留,去掉球队名之类的类别编码。
现象:Dash 页面能打开但图表空白,控制台报 callback 相关错误。原因是Input/Output的组件 id 拼写不一致,或者 callback 返回值数量和Output数量对不上。解决:逐个核对 id,确认函数返回的是元组且长度匹配,调试时可以先只保留一个 Output 跑通再加。
现象:加载模型时报版本不兼容或未知层错误。原因是 Keras 保存格式在不同 TensorFlow 版本间有差异。解决:统一训练和推理环境的 TensorFlow 版本,优先用.keras格式而不是旧的.h5,跨环境部署时把版本号写进依赖文件。
现象:小联赛数据训练时样本量不足,模型完全不收敛。原因是window和timesteps设得太大,滑窗后样本被吃掉大半。解决:先把两个参数都降到 3,确认能跑通再逐步加,同时考虑合并多个赛季数据或只保留出场次数足够的球队。
6. 进阶验证:用回测和基线对比判断模型值不值得留
模型训出来准确率 52% 到底算好算坏,单看数字没有意义,得跟基线比。我习惯做两件事:一是构造「全猜主胜」和「按历史频率随机猜」两个基线,二是按赛季做滚动回测,看模型在不同时间段的表现是否稳定。下面这个回测骨架可以直接套。
import numpy as np from sklearn.metrics import accuracy_score, log_loss def backtest_by_season(model, df, feature_cols, timesteps=5): results = [] for season, group in df.groupby("season"): X, y = make_sequences(group, timesteps) if len(X) == 0: continue proba = model.predict(X, verbose=0) pred = np.argmax(proba, axis=1) baseline = np.full_like(y, 0) # 全猜主胜(编码 0 对应主胜时) results.append({ "season": season, "model_acc": accuracy_score(y, pred), "baseline_acc": accuracy_score(y, baseline), "model_logloss": log_loss(y, proba) }) return pd.DataFrame(results)log_loss比 accuracy 更能反映概率质量,模型就算猜对多,概率给得离谱也会被惩罚。回测结果里如果某个赛季模型明显输给基线,别急着调参,先看那个赛季是不是有异常赛程或数据缺失。我一般要求模型在多数赛季的 log_loss 低于「按训练集类别频率输出固定概率」的基线,达不到就说明特征没提供增量信息,这时候加层数、调学习率都是白费。
从那以后我每次拿到这类预测资源,都强制先跑一遍基线对比再决定要不要投入时间调模型,因为一个跑不赢「全猜主胜」的 LSTM,结构再漂亮也只是个昂贵的随机数生成器。希望这套拆解能帮到你,把这份资源真正跑成自己手里的东西。
本文还有配套的精品资源,点击获取