基于机器学习的英雄联盟Ban/Pick胜率预测:从特征工程到实时推理
简介LeaguePredictor 是一套面向电子竞技数据分析初学者与机器学习实践者的 Python 项目源码聚焦英雄联盟比赛胜率预测这一具体场景帮助读者理解如何把分类模型落地到真实赛事数据上。压缩包共 24 个文件以 22 个 py 脚本为主体辅以 gitignore 与 txt 说明文件整体约 31KB涵盖数据预处理、特征工程、模型训练与评估等模块目录按 src、game、scripts 等分层组织便于按流程阅读。项目围绕 Pandas 处理结构化比赛数据、Numpy 数值计算与 Scikit-learn 分类模型展开涉及逻辑回归、随机森林等算法的选型与调参思路并配有游戏数据生成、分类器测试与训练等脚本方便读者对照运行与二次修改。目前已有 235 人学习下载适合想通过完整小项目掌握监督学习流程、积累特征工程与模型评估经验的开发者参考。1. 从 Ban/Pick 阶段判断胜负LeaguePredictor 能给你什么排位赛里最让人上头的瞬间不是操作失误而是阵容选完那一刻你心里已经隐约觉得“这把没了”。上单杰斯、打野盲僧、中单劫、下路德莱文加锤石——对面反手掏出石头人、蔚、加里奥、薇恩加璐璐你还没进游戏就知道团战会被冲烂。问题是这种直觉能不能量化LeaguePredictor 就是干这个的它用 Python 把英雄联盟对局数据喂给机器学习模型在 Ban/Pick 结束的瞬间输出一个胜率预测值。适合两类人一是想拿真实对局数据练手分类模型的 Python 开发者二是想从数据角度理解阵容克制关系的硬核玩家。它不教你操作只回答一个问题——这套阵容数据上到底几几开。2. 数据管道与特征工程把 Ban/Pick 变成模型能吃的数字2.1 原始对局数据长什么样LeaguePredictor 的输入通常来自公开对局 API 或社区爬取的对局记录每条样本对应一局完整比赛。原始字段一般包括双方各五个位置的英雄 ID、比赛结果蓝方胜/红方胜、对局时长、版本号、段位分布。这里有个容易翻车的点——很多人直接拿英雄 ID 当数值特征扔进模型结果模型学到的是“ID 越大越容易赢”这种毫无意义的规律。英雄 ID 是类别标识不是连续数值必须做编码。常见做法是 One-Hot 编码但英雄数量在 160 以上双方十个位置展开后维度会爆炸。更实用的方案是 Embedding 或者按位置分组后做目标编码Target Encoding统计每个英雄在某个位置上的历史胜率用胜率作为特征值。我一般会先按位置拆成五个子表上单对位上单、打野对打野这样模型能学到“同位置英雄对抗”的关系而不是把十个英雄混在一起。import pandas as pd # 假设 raw_df 包含 blue_top, red_top, blue_jungle, red_jungle ... 等位置列 # 以及 result 列1 表示蓝方胜0 表示红方胜 positions [top, jungle, mid, adc, support] def build_position_winrate(df, positions): 按位置统计每个英雄的历史胜率作为目标编码特征 winrate_map {} for pos in positions: # 蓝方该位置英雄的胜率 blue df.groupby(fblue_{pos})[result].mean() # 红方该位置英雄的胜率注意红方胜率是 result 取反 red df.groupby(fred_{pos})[result].apply(lambda x: 1 - x.mean()) # 合并同一英雄在蓝红两方的表现 combined pd.concat([blue, red]).groupby(level0).mean() winrate_map[pos] combined return winrate_map winrate_map build_position_winrate(raw_df, positions) # 把胜率映射回每一行生成特征列 for pos in positions: raw_df[fblue_{pos}_wr] raw_df[fblue_{pos}].map(winrate_map[pos]) raw_df[fred_{pos}_wr] raw_df[fred_{pos}].map(winrate_map[pos])这段代码的逻辑是对每个位置分别计算英雄在蓝方和红方时的胜率再取平均得到一个与阵营无关的英雄强度基准。参数说明result列必须是 0/1 二值蓝方胜为 1groupby后如果某个英雄样本太少比如低于 30 场胜率波动会很大建议设一个最小场次阈值低于阈值的用全局平均胜率兜底。这一步做完每个位置对抗就变成了两个胜率数值的对比模型能直接学到“我方上单胜率 52% 对敌方上单胜率 48%”这种信号。2.2 特征工程里最容易被忽略的三类信号除了英雄本身的胜率还有三类特征对预测精度影响很大但新手经常漏掉。第一是阵容协同某些英雄组合在一起胜率会显著高于各自单独胜率之和比如加里奥加青钢影的进场体系。做法是统计英雄对的共现胜率但组合爆炸问题严重常见做法是只保留出现频率前 200 的英雄对组合其余归入“其他”。第二是对位克制同一个位置两个英雄的历史对位胜率这个数据比全局胜率更有针对性。第三是版本权重不同版本英雄强度变化很大训练时必须把版本号作为特征或者按版本分片训练否则模型会把上个版本的强势英雄当成这版本的答案。# 对位克制特征统计同位置英雄 A 对英雄 B 的历史胜率 def build_matchup_features(df, pos): 生成 blue_{pos} 对 red_{pos} 的对位胜率特征 matchup df.groupby([fblue_{pos}, fred_{pos}])[result].mean() # 同时统计反向对位用于红方视角 reverse df.groupby([fred_{pos}, fblue_{pos}])[result].apply( lambda x: 1 - x.mean() ) combined pd.concat([matchup, reverse]).groupby(level[0, 1]).mean() return combined matchup_top build_matchup_features(raw_df, top) # 映射时用 merge 而不是 map因为是对 (英雄A, 英雄B) 的联合索引 raw_df raw_df.merge( matchup_top.rename(top_matchup_wr), left_on[blue_top, red_top], right_indexTrue, howleft )这里的关键参数是howleft保证即使某个对位组合在历史数据里没出现过原始行也不会丢失缺失值后续用该位置的平均对位胜率填充。对位特征比对全局胜率更能捕捉克制关系比如提莫对位慎的胜率可能远高于提莫的全局胜率。但要注意样本量冷门对位可能只有几场记录这种特征噪声极大建议对样本数少于 20 的对位组合直接置为缺失。2.3 训练集/验证集切分的时间陷阱很多人做对局预测时直接随机切分训练集和验证集这是典型的翻车操作。对局数据有时间顺序同一个版本、同一批玩家在不同时间段的行为模式不同。随机切分会导致验证集里混入了“未来”的对局信息模型在验证集上表现虚高上线后直接打脸。正确做法是按时间切分用较早的对局训练用较晚的对局验证。如果数据量足够还可以做滚动窗口验证比如用第 1 到第 8 周训练、第 9 周验证然后窗口后移。# 按时间切分假设 df 有 game_date 列 df df.sort_values(game_date) split_idx int(len(df) * 0.8) train_df df.iloc[:split_idx] valid_df df.iloc[split_idx:] # 检查两个集合的版本分布是否一致 print(train_df[patch].value_counts(normalizeTrue).head()) print(valid_df[patch].value_counts(normalizeTrue).head())如果验证集的版本分布在训练集里几乎没出现过那验证结果参考价值很低。这时候要么扩大训练数据覆盖更多版本要么把版本特征去掉、只保留与版本无关的通用特征。我一般会强制检查这一步版本分布差异过大就重新调整切分点。3. 模型选型与训练为什么树模型比神经网络更稳3.1 从逻辑回归到梯度提升树的取舍LeaguePredictor 这类任务本质是二分类蓝方胜或红方胜。可选模型很多但实际落地时我优先用梯度提升树LightGBM 或 XGBoost而不是深度学习。原因很直接对局数据的特征维度不算高几十到几百维样本量通常在几万到几十万之间树模型在这个量级上训练快、调参少、可解释性强。逻辑回归可以作为基线但它假设特征与对数几率线性相关而英雄胜率和对位克制对胜负的影响显然不是线性的——一个英雄胜率从 48% 提到 50% 的影响和从 52% 提到 54% 的影响可能完全不同。神经网络不是不能用但它需要更多数据、更长训练时间而且调参空间大对新手不友好。如果你只是想快速拿到一个可用的预测器LightGBM 是性价比最高的选择。它的num_leaves和learning_rate是两个最关键的参数num_leaves控制模型复杂度默认 31数据量小就调低到 15 左右防止过拟合learning_rate默认 0.1想更稳可以降到 0.05 但需要增加n_estimators。import lightgbm as lgb from sklearn.metrics import roc_auc_score, accuracy_score feature_cols [c for c in train_df.columns if c.endswith(_wr) or c.endswith(_matchup_wr)] X_train, y_train train_df[feature_cols], train_df[result] X_valid, y_valid valid_df[feature_cols], valid_df[result] model lgb.LGBMClassifier( n_estimators500, learning_rate0.05, num_leaves15, max_depth6, min_child_samples30, subsample0.8, colsample_bytree0.8, random_state42 ) model.fit( X_train, y_train, eval_set[(X_valid, y_valid)], eval_metricauc, callbacks[lgb.early_stopping(50)] ) pred_prob model.predict_proba(X_valid)[:, 1] print(AUC:, roc_auc_score(y_valid, pred_prob)) print(Accuracy:, accuracy_score(y_valid, (pred_prob 0.5).astype(int)))参数说明min_child_samples30表示每个叶子至少 30 个样本防止模型记住冷门组合subsample和colsample_bytree都是 0.8增加随机性来抑制过拟合early_stopping(50)表示验证集 AUC 连续 50 轮不提升就停止训练。AUC 比准确率更适合评估这类预测器因为胜负样本大致均衡但并非严格 50/50AUC 能反映模型在不同阈值下的排序能力。如果 AUC 只有 0.55 左右说明特征信息量不足优先回去补对位和协同特征而不是换更复杂的模型。3.2 类别不平衡与概率校准对局数据里蓝方胜率通常略高于红方但不会极端失衡所以类别不平衡不是主要矛盾。真正需要注意的是概率校准树模型输出的概率往往偏向 0 或 1而实际胜率分布更平滑。如果你想把预测值当作“胜率”来用比如 62% 胜率就需要做校准。常见做法是 Platt Scaling 或 Isotonic Regression用验证集拟合一个映射函数把模型原始输出转成校准后的概率。from sklearn.calibration import CalibratedClassifierCV calibrated CalibratedClassifierCV(model, methodisotonic, cvprefit) calibrated.fit(X_valid, y_valid) calibrated_prob calibrated.predict_proba(X_valid)[:, 1]cvprefit表示用已经训练好的模型不再重新交叉验证。Isotonic 适合验证集样本量较大的情况几千条以上样本少就用methodsigmoid。校准后你会发现原本模型说 70% 胜率的对局校准后可能只有 60%但这 60% 更接近真实频率。对于想拿预测值做决策的场景校准这一步不能省。3.3 特征重要性怎么看才不误导LightGBM 自带特征重要性输出但默认的split重要性会偏向高基数特征容易误导。我更习惯看gain重要性它反映特征在分裂时带来的信息增益总和。看重要性时不要只盯着第一名而是看前十个特征里有多少是对位特征、多少是协同特征、多少是全局胜率。如果全局胜率霸榜而协同特征全部垫底说明模型没学到阵容配合可能需要对协同特征做更强的表达。importance pd.DataFrame({ feature: feature_cols, gain: model.booster_.feature_importance(importance_typegain) }).sort_values(gain, ascendingFalse) print(importance.head(15))如果发现某个位置的特征重要性异常低比如辅助位的对位胜率几乎没贡献先别急着删——可能是辅助位英雄池深、对位样本稀疏导致特征噪声大。这时候可以退一步把辅助位特征从“具体英雄对位”改成“辅助类型对位”硬辅/软辅/法辅降低稀疏性。4. 避坑与排查预测器上线前必须过的五道坎4.1 现象验证集 AUC 0.75实际预测感觉像抛硬币原因通常是数据泄漏。最常见的是在特征工程阶段用了全量数据计算英雄胜率然后又把同一批数据切分训练验证。英雄胜率里已经包含了验证集对局的结果信息模型在验证集上自然表现好但面对真正的新对局就失效。解决方法是所有统计类特征胜率、对位胜率、协同胜率都只在训练集上计算然后映射到验证集验证集里出现训练集没见过的英雄组合时用全局均值兜底。4.2 现象某个版本更新后预测准确率骤降原因通常是版本特征处理不当。如果模型把版本号当作数值特征版本从 13.1 到 13.2 的变化会被理解为“增加了 0.1”但实际英雄强度调整是跳跃式的。解决方法是把版本号当作类别特征或者干脆按版本分片训练多个模型。更稳妥的做法是维护一个版本权重表每次版本更新后重新计算英雄胜率并更新特征映射而不是让模型去猜版本变化的影响。4.3 现象冷门英雄组合的预测概率极端偏高或偏低原因是样本稀疏导致目标编码不可靠。一个英雄只出场 5 次赢了 4 次胜率 80%但这个数字没有统计意义。解决方法是在目标编码时加平滑用贝叶斯平滑或简单加权把冷门英雄的胜率往全局均值拉。常见做法是smoothed_wr (n * wr m * global_wr) / (n m)其中n是该英雄样本数m是平滑参数一般取 20 到 50。这样样本越少胜率越接近全局均值避免模型被小样本带偏。4.4 现象训练时 AUC 很高但校准曲线严重偏离对角线原因是模型过拟合或者概率输出未校准。先检查训练集和验证集的 AUC 差距如果训练集 AUC 0.95、验证集 0.65那是过拟合需要降低模型复杂度减少num_leaves、增加min_child_samples。如果两者差距不大但校准曲线偏离那就是概率校准问题按 3.2 节的方法做 Isotonic 或 Sigmoid 校准。注意校准必须用独立的校准集不能再用训练集。4.5 现象预测器对蓝方有系统性偏好原因是训练数据里蓝方胜率本身偏高模型学到了“蓝方优势”这个先验。如果这是真实情况那没问题但如果你希望预测器对双方公平就需要做阵营平衡训练时对红方胜的样本上采样或者把蓝方特征和红方特征做对称交换增强。具体做法是把每条样本的蓝红特征对调、标签取反生成一条镜像样本这样模型学到的胜负关系与阵营无关。5. 把预测器接进实时选人界面一个可复用的推理封装5.1 用字典映射替代 DataFrame 拼接训练时用 DataFrame 很方便但实时预测时每来一个 Ban/Pick 就拼一次 DataFrame开销大且容易出错。更实用的做法是把特征映射逻辑封装成一个纯函数输入是十个英雄 ID 和位置输出是特征向量。这个函数内部只做字典查找和简单运算不依赖 pandas。class DraftPredictor: def __init__(self, model, winrate_map, matchup_map, global_wr0.5, smooth_m30): self.model model self.winrate_map winrate_map # {pos: {champion_id: winrate}} self.matchup_map matchup_map # {pos: {(blue_champ, red_champ): winrate}} self.global_wr global_wr self.smooth_m smooth_m def _smooth(self, wr, n): 贝叶斯平滑n 为样本数 return (n * wr self.smooth_m * self.global_wr) / (n self.smooth_m) def _get_winrate(self, pos, champ_id): stats self.winrate_map.get(pos, {}).get(champ_id) if stats is None: return self.global_wr wr, n stats return self._smooth(wr, n) def _get_matchup(self, pos, blue_champ, red_champ): stats self.matchup_map.get(pos, {}).get((blue_champ, red_champ)) if stats is None: return self.global_wr wr, n stats return self._smooth(wr, n) def predict(self, blue_team, red_team): blue_team/red_team: {pos: champion_id} features [] for pos in [top, jungle, mid, adc, support]: b, r blue_team[pos], red_team[pos] features.append(self._get_winrate(pos, b)) features.append(self._get_winrate(pos, r)) features.append(self._get_matchup(pos, b, r)) prob self.model.predict_proba([features])[0, 1] return prob这个封装的关键在于winrate_map和matchup_map在初始化时从训练集统计结果加载预测时只做 O(1) 查找。_smooth方法保证冷门英雄不会给出极端值。predict返回蓝方胜率红方胜率就是1 - prob。实际接入选人界面时每次 Ban/Pick 变化后调用一次predict延迟在毫秒级完全跟得上手速。5.2 用历史对局做回测验证封装好之后别急着上线先拿一批历史对局做回测。回测的逻辑是按时间顺序遍历对局每局只用该局之前的数据训练模型然后预测该局结果。这样模拟真实使用场景能暴露数据泄漏和版本漂移问题。def backtest(df, min_train_size5000, step500): results [] for i in range(min_train_size, len(df), step): train df.iloc[:i] test df.iloc[i:istep] # 在 train 上重新统计特征并训练模型 model, winrate_map, matchup_map train_pipeline(train) predictor DraftPredictor(model, winrate_map, matchup_map) for _, row in test.iterrows(): blue_team {pos: row[fblue_{pos}] for pos in positions} red_team {pos: row[fred_{pos}] for pos in positions} prob predictor.predict(blue_team, red_team) results.append((prob, row[result])) # 计算整体 AUC probs, labels zip(*results) return roc_auc_score(labels, probs)回测的step参数控制每次前移的对局数设太小计算量大设太大则验证点稀疏。我一般设 500既能覆盖版本变化又不至于跑太久。回测 AUC 如果比单次验证低很多说明模型对时间漂移敏感需要加入更多版本相关特征或者缩短训练窗口。5.3 一个容易被忽略的细节英雄 ID 对齐不同数据源的英雄 ID 可能不一致比如官方 API 用数字 ID社区数据用英文名或中文名。训练时用一套 ID预测时传入另一套模型会直接查不到映射全部返回全局均值预测结果毫无区分度。我踩过一次这个坑训练用数字 ID前端传的是英雄英文名结果预测器对任何阵容都输出 50%排查了半天才发现是 ID 没对齐。从那以后我每次接入新数据源都强制走一遍 ID 映射校验把所有英雄 ID 打印出来和训练集比对确认一一对应才继续。希望帮到你。本文还有配套的精品资源点击获取