
简介SoccerPredictor 是一份面向机器学习与体育赛事预测爱好者的足球赛果预测项目源码聚焦英超胜负及双倍机会判定把比赛预测非常规地建模为时间序列分类问题并为每支球队构建神经网络模型同步训练。压缩包共29个文件、约2.41MB其中14个 Python 文件承载数据抓取、特征处理、模型训练、回测与可视化主流程8张 png 为训练曲线和结果图表2个 pickle 保存模型或中间数据另有 css、json、md、txt 等配置与说明文件目录按 data、models、images、assets 等模块拆分。已有1258人学习适合想了解时序分类落地、Keras/TensorFlow 建模与回测评估的中级读者。作者在113天、150场比赛的测试中给出约90%预测精度、33.4% ROI 与1069%利润的参考结果需注意数据集未随包发布读者可先基于附带文件做可视化与回测复现再自行采集数据完成训练并参考说明文档理解整体设计。1. 从一份赛程 CSV 到概率输出SoccerPredictor 想解决什么每个周末联赛开打前把主客队近十场战绩摊在桌面上人肉推演最后猜中的概率往往跟抛硬币差不多——这是我拆 SoccerPredictor 之前最真实的体验。这个项目做的事情其实很克制用 Python 把多年赛果整理成时间序列交给机器学习里的 LSTM 做「主胜 / 平 / 客胜」三分类再用 Dash 搭一个能点选球队、当场出概率的页面。整套东西把「时序数据 → 特征工程 → 神经网络 → Web 演示」这条链路走通了适合两类人手里有几季联赛 CSV 想做点数据活儿的球迷以及想找一个完整时序多分类闭环练手的开发者。它不是一个能替你下注的系统这点先说在前面免得后面期待错位。2. 工程骨架与数据管线从原始赛果到窗口张量把项目拉下来先别急着pip install先看它怎么分层。整个仓库是典型的四层结构数据层负责把散落的赛季 CSV 收敛成一张按时间排序的长表特征层把「球队状态」压成数值列模型层只关心张量形状应用层只负责把张量喂进去再画图。这个边界的价值在于——你换模型的时候不用动特征代码换特征的时候不用动 Dash 页面改一处坏一片是这类小项目最容易踩的坑。2.1 目录结构与四个层次的职责边界目录大概是下面这个样子我按职责加了注释你可以对照着自己那份源码看SoccerPredictor/ ├── data/ │ ├── raw/ # 各联赛各赛季的原始赛果 CSV一个赛季一个文件 │ └── processed/ # 特征工程后的 parquet训练和推理都读这里 ├── src/ │ ├── config.py # 联赛代码、窗口长度、特征列清单、路径常量 │ ├── fetch_data.py # 原始 CSV 的读取与合并不做计算 │ ├── features.py # 滚动状态、Elo 等级分、休息天数、交锋记录 │ ├── dataset.py # 把长表切成 (样本数, 时间步, 特征数) 的张量 │ ├── model.py # 网络结构定义只接收形状参数 │ ├── train.py # 训练循环、回调、保存最佳权重 │ └── evaluate.py # log loss、混淆矩阵、与基线的对比 ├── app/ │ ├── app.py # Dash 入口负责启动服务器 │ ├── layouts.py # 页面布局 │ └── callbacks.py # 下拉框 → 推理 → 概率条 ├── models/ │ └── lstm_v1.keras # 训练产物应用层只读不写 ├── requirements.txt └── README.md几个文件的实际职责跟名字有点出入值得留意。features.py是整条链路里最长的一个文件也是最容易在改造时被改坏的地方dataset.py看起来像数据加载实际上承担了「样本对齐」这个最重的活——它决定了第 t 个样本能看到哪些信息、绝不能看到哪些信息。config.py里通常有个FEATURE_COLS列表训练和推理都从它取列顺序这是保证线上线下一致的关键开关动手改特征前先认准它。我一般会建议在这个阶段先跑一遍python -m src.dataset --dry-run之类的入口项目里通常是直接把dataset.py当脚本跑看它能不能把 raw 目录的 CSV 全读进来、打印出张量形状。形状对不上后面所有报错都是这个的连锁反应。2.2 数据清洗赛果 CSV 里最常见的三种脏数据公开赛果数据看着规整实际打开经常一塌糊涂。日期列是dd/mm/yy还是mm/dd/yy得显式指定否则03/04/2023这种会静悄悄解析错赛季末混进来的补赛、取消的比赛进球列是空值同一场比赛因为多次抓取产生重复行。这三样不处理干净后面所有统计都是错的。import pandas as pd RAW_COLS [date, home_team, away_team, home_goals, away_goals, season] def load_raw(path: str) - pd.DataFrame: df pd.read_csv(path, encodingutf-8) df df[RAW_COLS].copy() # 原始数据里 dd/mm/yy 是常态dayfirst 必须显式开不能靠默认行为 df[date] pd.to_datetime(df[date], dayfirstTrue, errorscoerce) # 补赛、取消场次进球列为空直接丢不要用 0 填充否则等于伪造了一场 0:0 df df.dropna(subset[date, home_goals, away_goals]) df[home_goals] df[home_goals].astype(int) df[away_goals] df[away_goals].astype(int) # 时序项目里排序不是可选步骤是前置条件 df df.sort_values(date) df df.drop_duplicates(subset[date, home_team, away_team], keepfirst) return df.reset_index(dropTrue)这段没什么技巧全是血泪经验。errorscoerce把解析失败的日期变成 NaT配合后面dropna一起用避免整列astype时直接抛异常drop_duplicates的subset用日期加两队名是因为主队名加客队名在同一赛季里可能重复出现主客场对调必须带上日期。最后那个sort_values(date)挪到去重前面更保险我吃过一次亏去重时keepfirst保留的可能是条乱序记录。清洗完之后通常还要补一步「队名归一化」。不同赛季的 CSV 里同一支球队可能叫Man United也可能叫Manchester Utd不统一的话滚动窗口会把它们算成两支球队样本直接被劈成两半。2.3 特征工程把一支球队的「状态」压成几个数滚动窗口那段代码是整个特征层的地基也是最容易写错的地方。原始表是一行一场比赛、同时包含主客两队要做「某队近十场平均进球」就得先把表拆成「球队视角」的长表——每场比赛产生两行一行是主队视角一行是客队视角。import pandas as pd def to_team_view(df: pd.DataFrame) - pd.DataFrame: home df.rename(columns{ home_team: team, away_team: opponent, home_goals: gf, away_goals: ga, }).assign(venueH) away df.rename(columns{ away_team: team, home_team: opponent, away_goals: gf, home_goals: ga, }).assign(venueA) long pd.concat([home, away], ignore_indexTrue) return long.sort_values([team, date]).reset_index(dropTrue) def add_rolling_form(long: pd.DataFrame, window: int 10) - pd.DataFrame: grp long.groupby(team, group_keysFalse) # shift(1) 绝对不能省当轮结果不能出现在当轮特征里 for col, new in [(gf, gf_avg10), (ga, ga_avg10)]: long[new] grp[col].apply(lambda s: s.shift(1).rolling(window, min_periods3).mean()) long[win_rate10] grp[gf].apply( lambda s: (s.shift(1) 0).rolling(window, min_periods3).mean() ) # 休息天数两场比赛间隔太短状态会明显掉 long[rest_days] grp[date].apply(lambda s: s.diff().dt.days).clip(upper30) return longshift(1)是这段代码唯一的重点也是我见过最多人写漏的一行。不加它第 t 行的gf_avg10就包含了第 t 行自己的进球数模型在训练集上能刷出离谱的准确率一上线全军覆没。min_periods3是为了赛季初样本不足时不产生 NaN 行——前三轮确实没有可靠状态宁可给个不完整窗口也不要丢掉新升班马的所有样本。除了滚动状态这个项目还有一类特征值得单独说Elo 等级分。它不是窗口统计而是逐场递推必须按日期顺序循环def update_elo(rh: float, ra: float, goal_diff: int, k: float 20, hfa: float 60): # hfa 把主场优势折成分数并入期望联赛之间切换时不用改公式 exp_h 1 / (1 10 ** ((ra - rh - hfa) / 400)) score_h 1.0 if goal_diff 0 else (0.5 if goal_diff 0 else 0.0) delta k * (score_h - exp_h) return rh delta, ra - deltak控制更新步长20 是个常见起点hfa是主场优势的等效分差取值越大主队期望胜率越高不同联赛差异明显英超和巴甲就不是一个数。这段逻辑不能向量化只能一场一场滚好处是它天然满足因果性坏处是慢——几千场比赛循环下来也就几秒可以接受。最后是休息天数、交锋记录H2H、赛季排名这几列。交锋记录要注意只取「本场之前」的历史按队对聚合然后shift一次做法跟滚动窗口一样。特征列凑齐之后统一放进config.FEATURE_COLS顺序定死后面所有环节都按这个顺序取列。特征列含义出一个常见错误gf_avg10/ga_avg10近十场进球、失球均值漏shift(1)标签泄漏win_rate10近十场胜率平局被算成负场elo_h/elo_a赛前双方等级分用赛后更新的分数rest_days距上一场天数不裁剪休赛期出现 90 天h2h_home_win历史交锋主队胜率混入本场结果2.4 标签与样本构造时间步到底装了什么标签是三分类主胜记 0、平局记 1、客胜记 2用 one-hot 而不是整数标签因为损失函数选了 categorical_crossentropy。样本构造的思路是对每场比赛分别取主队赛前最近 10 场的特征序列和客队赛前最近 10 场的特征序列拼成一个(10, N)的二维矩阵——注意不是拼成(20, N)而是把两队同时间步的特征并到同一个特征维度上让模型在每一步同时看到「主队当时什么状态、客队当时什么状态」。import numpy as np def build_sequences(long: pd.DataFrame, feature_cols: list, steps: int 10): X, y, keys [], [], [] for _, row in long.iterrows(): # 逐场展开按日期顺序 h long[(long[team] row[home_team]) (long[date] row[date])].tail(steps) a long[(long[team] row[away_team]) (long[date] row[date])].tail(steps) if len(h) steps or len(a) steps: continue # 窗口不完整直接跳过不要补零 X.append(np.hstack([h[feature_cols].values, a[feature_cols].values])) y.append({H: 0, D: 1, A: 2}[row[result]]) keys.append((row[date], row[home_team], row[away_team])) return np.asarray(X, dtypefloat32), np.asarray(y), keys这段是全项目最慢的一段iterrows加两次布尔索引几千场比赛跑下来能磨几分钟。真要提速就按球队预先分组、把每队的历史序列缓存成字典再按日期索引切片——不改也行训练是离线的慢一点换代码可读性值。hstack那个地方决定了最终张量的最后一维是2 * len(feature_cols)这个数字必须跟模型输入层对齐也是改造时最容易忘记同步的地方。样本落盘成 parquet 或者 npz 都行dataset.py里读回来直接进模型。3. 模型层拆解Keras LSTM 的三分类头怎么搭模型这块反而是整个项目里最短的一段代码短到容易让人以为没什么可讲。但训练不收敛、验证集 log loss 一直抖、线上预测永远是主胜——这些问题九成出在模型之外的形状对齐和损失设置上而不是网络深度不够。下面按「输入形状 → 网络结构 → 训练循环 → 评估方式」四段拆。3.1 为什么时间步是 10 而不是 38时间步长度在config.py里是一个常量默认 10。这个数字不是拍脑袋一轮联赛十场左右刚好覆盖一个月的比赛密度短于 6 场状态噪声太大长于 15 场又会把赛季初的阵容变化混进窗口里。更重要的是时间步越长能构造出的有效样本越少——每个赛季开头那几轮都得丢掉联赛球队多、赛季短的场景下损失会很明显。输入形状最终是(batch, 10, 2 * n_feats)。这里有个细节主队和客队的特征在最后一维是直接拼接的模型并不知道前一半属于主队。想让它知道可以改成两路输入分别过 LSTM 再合并代价是代码复杂度上升收益在多数联赛数据量下并不明显。项目里选的是简单拼接我倾向于保留这个选择——先跑通再谈结构优化。3.2 网络结构与输出层的选择from tensorflow.keras import layers, Model, optimizers def build_lstm(n_steps: int, n_feats: int, n_classes: int 3) - Model: inp layers.Input(shape(n_steps, n_feats), nameseq) # 第一层保留序列让第二层继续抽时序模式 x layers.LSTM(64, return_sequencesTrue, dropout0.2)(inp) x layers.LSTM(32, dropout0.2)(x) x layers.Dense(64, activationrelu)(x) x layers.Dropout(0.3)(x) out layers.Dense(n_classes, activationsoftmax, nameprobs)(x) model Model(inp, out, namesoccer_lstm) model.compile( optimizeroptimizers.Adam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy], ) return model64 → 32这个配置在几千到几万样本的量级上够用再往上加参数只会加速过拟合。两个dropout参数一个管循环层内部、一个管全连接层前者 0.2、后者 0.3是个偏保守的起点。输出层softmax配categorical_crossentropy输出直接就是三类概率应用层拿到不用再做归一化——这是选 softmax 而不是三个 sigmoid 的主要理由省一步就少一个出错点。值得单独提一句accuracy这个 metric 在三分类问题上参考价值有限。主胜占比通常在 45% 上下一个「永远预测主胜」的模型准确率就有 45%看起来还不错实际上毫无信息量。所以评估阶段必须看 log loss这一点放到 3.4 展开。3.3 训练循环早停、降学习率、类别权重from tensorflow.keras import callbacks from sklearn.utils.class_weight import compute_class_weight import numpy as np cw compute_class_weight(balanced, classesnp.array([0, 1, 2]), yy_tr.argmax(1)) class_weight {i: w for i, w in enumerate(cw)} # 平局样本少给更高的权重 cbs [ callbacks.EarlyStopping(monitorval_loss, patience8, restore_best_weightsTrue), callbacks.ReduceLROnPlateau(monitorval_loss, factor0.5, patience3, min_lr1e-5), callbacks.ModelCheckpoint(models/lstm_v1.keras, monitorval_loss, save_best_onlyTrue), ] history model.fit( X_tr, y_tr, validation_data(X_va, y_va), epochs120, batch_size64, callbackscbs, class_weightclass_weight, verbose2, )compute_class_weight(balanced, ...)算出来平局那一类的权重通常是主胜的两倍多因为平局在真实赛果里占比只有 25% 左右。不加这个权重模型很快学会「不预测平局」这条捷径验证集准确率看着还行混淆矩阵里平局那一列全是零。EarlyStopping的patience8配合ReduceLROnPlateau的patience3是让学习率先降、再等几轮没起色才停顺序反过来的话经常在降学习率前就被砍掉。ModelCheckpoint存成.keras格式而不是.h5这是版本相关的问题后面避坑章节细说。batch_size64在几千样本上大概几十步一轮一个 epoch 秒级120 个 epoch 也就是几分钟的事。3.4 评估不能只看 accuracylog loss 与基线对比from sklearn.metrics import log_loss, confusion_matrix p model.predict(X_te, verbose0) print(acc, (p.argmax(1) y_te.argmax(1)).mean()) print(logloss, log_loss(y_te.argmax(1), p, labels[0, 1, 2])) print(confusion_matrix(y_te.argmax(1), p.argmax(1))) # 基线一训练集的经验频率代表「什么都不学」的水平 base np.tile(y_tr.mean(axis0), (len(y_te), 1)) print(baseline logloss, log_loss(y_te.argmax(1), base, labels[0, 1, 2]))基线这一步是判断模型到底有没有学到东西的唯一办法。经验频率基线的 log loss 大概在 1.05 左右一个真正有效的模型应该压到 0.98 上下如果跟基线持平甚至更高说明模型只是把训练集的先验背下来了。还有一个更强的对照是赛前赔率换算出的隐含概率——那个数字通常比任何业余模型都好把它的 log loss 打出来贴在自己结果旁边能省掉很多自我感动。混淆矩阵也要看。如果平局那一行的召回率接近零即便总体准确率有 50%这个模型在实际使用里也只能当「主胜/非主胜」用。真要压平局思路是把二分类和三分类拆开先判胜负再在「非平」的样本里判主客两段各自调阈值。4. Dash 应用层把模型塞进浏览器模型训练完躺在models/里没人看得见Dash 这层的任务就是让它被点一下就能出结果。这一层代码量不大但它是唯一直接面对使用者的部分出问题的概率反而最高——多半不是 Dash 写错了是推理时的特征跟训练时对不上。4.1 页面结构与共享状态布局就三块一排下拉框选主队、客队和比赛日期中间一个概率条形图下面一行文字显示「样本窗口取自哪十场」。第三块容易被省略但它其实是自查工具——当你发现预测离谱时先看它取的是哪十场八成能定位到数据问题。from dash import Dash, dcc, html from app.callbacks import register_callbacks def create_app(teams: list) - Dash: app Dash(__name__) app.layout html.Div([ html.H3(赛前胜平负概率), dcc.Dropdown(idhome, options[{label: t, value: t} for t in teams]), dcc.Dropdown(idaway, options[{label: t, value: t} for t in teams]), dcc.DatePickerSingle(idmatch-date), dcc.Graph(idprobs), html.Div(idmeta), ]) register_callbacks(app) return app球队列表从data/processed里现成的长表取unique()不要在代码里硬编码——换联赛的时候这一行能省你半小时。模型对象在模块级别加载一次不要写进回调函数里否则每次点下拉框都会重新反序列化一遍权重内存和响应时间都会明显变差。4.2 回调从下拉框到概率条from dash import Input, Output import plotly.graph_objects as go import numpy as np callback( Output(probs, figure), Output(meta, children), Input(home, value), Input(away, value), Input(match-date, date), ) def update_pred(home, away, match_date): if not home or not away or home away or not match_date: return go.Figure(), 请选择两支不同的球队和日期 # 必须复用训练时同一个 build_sequences 的特征逻辑不能另写一份 X, ctx build_inference_sample(home, away, match_date) if X is None: return go.Figure(), 该日期之前的历史样本不足无法构造 10 场窗口 proba MODEL.predict(X[None, ...], verbose0)[0] fig go.Figure(go.Bar(x[主胜, 平局, 客胜], yproba)) fig.update_yaxes(range[0, 1], tickformat.0%) return fig, f窗口起点{ctx[start]}共 {ctx[n]} 场build_inference_sample这个名字是刻意起的它内部必须调用跟训练完全相同的特征函数只是把「取历史窗口」的截止日期换成用户选的日期。很多人图省事在callbacks.py里重写一遍滚动均值的逻辑结果训练用shift(1)、推理忘了shift线上预测系统性偏移。X[None, ...]是给单条样本补上 batch 维verbose0关掉进度条这两句是 Dash 里跑 Keras 的标准写法。proba出来之后建议顺手做个校准检查如果把所有历史比赛的预测概率分桶统计主胜桶的预测均值是 0.5、实际命中率只有 0.42说明模型偏乐观需要温度缩放或者 isotonic 校准。这一步不做也能用但概率数字会误导人。4.3 本地运行与部署python -m venv .venv source .venv/bin/activate pip install -r requirements.txt python -m src.dataset # 生成 processed 数据 python -m src.train # 训练并保存 models/lstm_v1.keras python app/app.py # 默认 8050 端口requirements.txt里 tensorflow 和 keras 最好锁到小版本tensorflow2.16之后 Keras 3 成为默认老的.h5保存和自定义层加载行为都变了。启动顺序不能乱dataset依赖 raw 目录有 CSVtrain依赖 processed 有数据app依赖 models 有权重任何一步缺失都会在最后一步报一个跟根因无关的错。对外部署一般用 gunicorn 起 Dash 的 WSGI 对象注意workers别开太多每个 worker 都会独立加载一份 TensorFlow 和权重内存翻倍很快。两三个 worker 对这类低并发应用足够了。提示第一次跑建议先用单联赛、单赛季的数据把整条链路走通确认形状和文件都对了再把全量数据灌进去。全量跑一次十几分钟调试时非常拖节奏。5. 避坑与排查五个把准确率抬到 80% 的假象这一章是全篇最该先读的部分。下面五条都是我在类似项目上真实撞过的墙现象看着五花八门根因集中在两个地方数据泄漏和训练推理不一致。5.1 数据侧的泄漏准确率 80% 的模型基本都是假的现象训练集准确率 78%验证集 76%一跑真实赛程预测就惨不忍睹跟瞎猜差不多。原因滚动特征里忘了shift(1)第 t 场的特征包含了第 t 场的进球数。更进一步h2h交锋特征如果在聚合时没有排除当轮等于把答案塞进了输入。这类泄漏在时序任务里极其隐蔽因为特征名看着完全正常只有一行shift的差别。解决写一个断言函数在构造完张量之后抽查取第 i 个样本对应的比赛日期检查主队窗口里最后一场比赛的日期必须严格小于该日期。这个检查跑一次就知道有没有漏成本几分钟值。def assert_no_leak(X_meta, matches): for h_team, h_last, match_date in X_meta: assert h_last match_date, f{h_team} 窗口末场 {h_last} 不早于比赛日 {match_date}5.2 切分方式的错位随机 shuffle 是时序项目的自杀操作现象train_test_split(shuffleTrue)一切验证集 log loss 比预期低一大截上线后完全对不上。原因随机切分让模型在训练时见过「未来」的比赛验证集里的样本跟训练集在时间上高度重叠同一支球队同一段时间的状态两边都出现过评估结果虚高。解决按时间切。前几季训练、中间一季验证、最后一季测试或者更严谨地做 walk-forward每个赛季当一次测试集只用它之前的赛季训练最后把各折的指标取平均。walk-forward 的代码多写二十行但给出的数字才可信。def walk_forward(df, seasons): for i in range(2, len(seasons)): train df[df[season].isin(seasons[:i])] test df[df[season] seasons[i]] yield train, test, seasons[i]5.3 类别不平衡模型学会了「永不预测平局」现象混淆矩阵里平局那一列全是零模型输出概率里平局从来不超过 0.2。原因平局样本占比只有 25% 左右交叉熵损失下最省力的策略就是把概率全压在主胜和客胜上。不加权重、不看混淆矩阵这个问题能一直藏着。解决compute_class_weight(balanced)把权重传进fit同时评估阶段强制看每一类的召回率。如果加了权重后模型开始乱猜平局平局召回率上去了但整体 log loss 变差就退一步做两阶段先二分类判平/非平再判主客。5.4 训练与推理的特征顺序不一致现象本地跑训练指标一切正常Dash 页面上同一个对阵每次刷新给的数还不一样或者明显偏向某一方。原因推理路径里手工拼特征列顺序跟FEATURE_COLS不同或者hstack([home, away])在推理时写反了顺序。这种错误不会报错只会让模型看到一堆错位的数字。解决只保留一个特征构造入口训练和推理都调它靠参数控制「截止日期」。另外在模型旁边存一份features.json记录列名和顺序推理时读出来做一次df df[meta[cols]]重排。这一步写完基本杜绝此类问题。5.5 环境与保存格式.h5加载报未知层现象load_model(lstm_v1.h5)抛ValueError: Unknown layer或者加载成功但预测结果跟训练时差很多。原因Keras 3 之后默认保存格式是.keras老.h5在带自定义层或自定义损失时反序列化会出问题跨小版本升级时激活函数默认参数也变过比如某些层的dropout语义。解决统一存.kerasrequirements.txt锁tensorflow2.16.*这类明确小版本应用层加载模型后立刻跑一条固定样本把输出概率打印出来跟训练时的记录比对差超过 1e-3 就说明加载有问题。6. 二次开发换联赛、换模型、加特征该怎么动这个项目最大的价值是骨架清晰改起来不用大动干戈。我把常见的三类改造按涉及文件和验证方式列一下照着走基本不会翻车。改造目标主要改动验证方式换联赛 / 加杯赛config.py的联赛代码、to_team_view的队名映射表重跑dataset看样本数是否合理检查是否有球队名重复换模型结构只动src/model.py保持build_model(name, ...)签名同一份数据跑两版比 log loss 和混淆矩阵加新特征xG、伤停features.py加计算config.FEATURE_COLS加列名训练前后各跑一次 walk-forward看指标是否真的动了换评估口径evaluate.py加校准曲线和分桶统计分十桶看预测均值与实际命中率的偏差换模型这块建议先加一层工厂函数避免在训练脚本里写死类名# src/model.py def build_model(name: str, n_steps: int, n_feats: int, n_classes: int 3): if name lstm: return build_lstm(n_steps, n_feats, n_classes) if name gru: return build_gru(n_steps, n_feats, n_classes) # GRU 参数少三成小数据上常常更好 if name mlp: return build_mlp(n_steps, n_feats, n_classes) # 把时间维展平当强基线用 raise ValueError(funknown model: {name})mlp那个分支别小看把(10, N)直接Flatten再进全连接在很多联赛数据上跟 LSTM 打平甚至更好——样本量不到一万的时候循环结构的优势体现不出来。先拿它当基线再决定要不要为 LSTM 的复杂度买单。换联赛最容易踩的坑是冷启动新联赛历史数据只有一两年min_periods3加上 10 步窗口之后可用样本可能只剩几百条模型直接欠拟合。我一般的处理是先在全量联赛上预训练再用新联赛的数据微调最后两层学习率降到 1e-4EarlyStopping的 patience 也调小。跨联赛的队名冲突也要注意config里那张映射表必须人工过一遍。加特征这件事我的建议是每次只加一组加完必须重跑 walk-forward 对比。同时加三四个特征指标涨了你也不知道是哪个起的作用跌了更不知道回滚哪个。xG 这类数据很多公开源只覆盖主流联赛缺失值怎么填用联赛均值还是用该队历史均值会直接影响结果这个选择要写进配置别藏在代码里。最后是验证方法。别只盯着准确率把三样东西固定成发布前的检查清单walk-forward 各折的 log loss、校准曲线的分桶偏差、以及平局类的召回率。三样都过了再谈上线三样里任意一样退化就回滚。从那以后我每次动特征或切分逻辑都强制先把「窗口末场日期必须早于比赛日期」的断言跑一遍再灌数据训练——这个习惯帮我省掉的返工比我优化过的所有网络结构加起来都多。希望帮到你。本文还有配套的精品资源点击获取