简介:LeaguePredictor 是一套面向电子竞技数据分析初学者与机器学习实践者的 Python 项目源码,聚焦英雄联盟比赛胜率预测这一具体场景,帮助读者理解如何把分类模型落地到真实赛事数据上。压缩包共 24 个文件,以 22 个 py 脚本为主体,覆盖数据生成、特征结构处理、分类器训练与测试等模块,另含 1 个 gitignore 与 1 个 txt 说明文件,整体约 31KB,体量轻便、便于本地运行与二次修改。项目围绕数据预处理、特征工程、模型选择、训练评估与部署更新五个环节展开,涉及 Pandas、Numpy、Scikit-learn 等常用库,并给出逻辑回归、随机森林等模型的对比思路。目前已有 235 人学习下载,适合想通过完整小项目熟悉监督学习流程、积累特征构造与模型调参经验的读者参考。
1. 从一局排位说起:这个预测胜率的项目到底能干什么
你有没有过这种体验:选人阶段刚结束,看一眼双方阵容,心里就大概有数了——这把是碾压还是坐牢。但“大概有数”终究是玄学,能不能用代码把这个直觉量化出来?LeaguePredictor 就是干这个的:输入双方阵容、段位、近期战绩等特征,输出一个 0 到 1 之间的胜率预测值。它不是那种“赢了就是模型准、输了就是队友菜”的玩具,而是一条完整的机器学习流水线,从数据清洗、特征工程到模型训练和推理接口都给你搭好了。
这个项目适合两类人:一类是想拿一个真实场景练手 Python 机器学习的新手,另一类是打算把它接进自己工具链、做赛前分析或直播辅助的开发者。技术栈上,核心是 Python + pandas + scikit-learn,数据层用 SQLite 或 CSV 落盘,推理部分封装成一个可调用的函数,方便你后面接 Flask 或 FastAPI 做服务。整份代码没有花哨的深度学习框架,全是能跑、能读、能改的工程代码,这一点对二次开发特别友好。
2. 项目骨架拆解:数据从哪来、模型往哪走
2.1 目录结构与模块职责
拿到源码先别急着跑,花两分钟把目录扫一遍,后面改代码会顺很多。这个项目的结构是典型的“数据-特征-模型-接口”四层分离,常见做法是长这样:
LeaguePredictor/ ├── data/ │ ├── raw/ # 原始对局数据,CSV 或 JSON │ └── processed/ # 清洗后的特征表 ├── src/ │ ├── data_loader.py # 读取原始数据,做基础校验 │ ├── feature_engineer.py # 特征构造与拼接 │ ├── train.py # 训练入口,输出模型文件 │ ├── predict.py # 推理入口,加载模型出胜率 │ └── utils.py # 通用工具,比如路径管理、日志 ├── models/ │ └── lgbm_model.pkl # 训练好的模型持久化文件 ├── config.yaml # 路径、超参数、特征开关 └── requirements.txtdata_loader.py只负责把原始数据读进来,不做复杂变换;feature_engineer.py才是真正干活的地方,把英雄、位置、段位这些原始字段转成模型能吃的数值;train.py和predict.py一进一出,边界清晰。这种拆法的好处是,你想换模型只动train.py,想加特征只动feature_engineer.py,不会牵一发动全身。
2.2 数据加载与清洗的关键代码
原始对局数据通常来自公开数据集或自己抓的接口,字段包括双方五个位置的英雄 ID、段位、胜负标签等。下面这段是data_loader.py里的核心逻辑:
import pandas as pd from pathlib import Path def load_raw_data(data_dir: str) -> pd.DataFrame: """读取 raw 目录下所有 CSV,合并成一张大表""" data_path = Path(data_dir) # 常见做法是按赛季或月份分文件,这里统一 glob 进来 files = sorted(data_path.glob("*.csv")) if not files: raise FileNotFoundError(f"在 {data_dir} 下没找到任何 CSV 文件") frames = [] for f in files: df = pd.read_csv(f) # 只保留有胜负标签的完整对局,缺标签的丢掉 df = df.dropna(subset=["win"]) frames.append(df) merged = pd.concat(frames, ignore_index=True) # 去重,防止同一局被重复采集 merged = merged.drop_duplicates(subset=["match_id"]) return merged这里有几个参数值得注意:dropna(subset=["win"])只检查标签列,不会因为某个英雄字段缺失就把整局丢掉;drop_duplicates的subset指定match_id,避免同一场比赛因为采集时间不同被算两次。如果你拿到的数据没有match_id,常见做法是用双方十个英雄 ID 加时间戳拼一个哈希出来当唯一键。
2.3 特征工程:把阵容变成模型能读的数字
这是整个项目最需要动脑子的地方。英雄 ID 本身是类别变量,直接扔给模型会引入错误的序关系,所以要做编码。同时,双方阵容之间存在克制关系,单看一边不够,得构造交叉特征。
import pandas as pd from sklearn.preprocessing import OneHotEncoder def build_features(df: pd.DataFrame, encoder: OneHotEncoder = None): """把原始对局表转成特征矩阵,返回特征和编码器""" # 双方各五个位置的英雄 ID blue_cols = [f"blue_hero_{i}" for i in range(1, 6)] red_cols = [f"red_hero_{i}" for i in range(1, 6)] # 把两边英雄拼成一个列表列,方便统一编码 df["blue_team"] = df[blue_cols].astype(str).agg(",".join, axis=1) df["red_team"] = df[red_cols].astype(str).agg(",".join, axis=1) # 段位做有序编码,青铜到王者映射成 0-8 rank_map = {"Iron": 0, "Bronze": 1, "Silver": 2, "Gold": 3, "Platinum": 4, "Diamond": 5, "Master": 6, "Grandmaster": 7, "Challenger": 8} df["blue_rank"] = df["blue_rank"].map(rank_map).fillna(3) df["red_rank"] = df["red_rank"].map(rank_map).fillna(3) df["rank_diff"] = df["blue_rank"] - df["red_rank"] # 英雄组合做 one-hot,fit 和 transform 分开,方便推理时复用 if encoder is None: encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False) hero_matrix = encoder.fit_transform(df[["blue_team", "red_team"]]) else: hero_matrix = encoder.transform(df[["blue_team", "red_team"]]) feature_df = pd.DataFrame(hero_matrix) feature_df["rank_diff"] = df["rank_diff"].values return feature_df, encoderhandle_unknown="ignore"这个参数很关键:推理时如果遇到训练集里没出现过的英雄组合,编码器不会报错,而是输出全零向量,保证服务不崩。rank_diff是我自己加的一个特征,段位差往往比绝对段位更有信息量,这个在实战里比单纯堆段位数值效果好不少。
3. 训练与推理:模型怎么练、胜率怎么算
3.1 训练脚本的完整流程
train.py是整个项目的入口,它把前面几步串起来,最后落一个模型文件。下面这段是主干逻辑:
import joblib import pandas as pd from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score from lightgbm import LGBMClassifier from src.data_loader import load_raw_data from src.feature_engineer import build_features def main(): # 1. 读数据 raw = load_raw_data("data/raw") # 2. 构造特征 X, encoder = build_features(raw) y = raw["win"].astype(int) # 3. 切分,stratify 保证正负样本比例一致 X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 4. 训练,参数先给一组保守值 model = LGBMClassifier( n_estimators=300, learning_rate=0.05, max_depth=6, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model.fit(X_train, y_train) # 5. 验证,看 AUC 而不是准确率 pred_prob = model.predict_proba(X_val)[:, 1] auc = roc_auc_score(y_val, pred_prob) print(f"验证集 AUC: {auc:.4f}") # 6. 持久化模型和编码器,推理时两个都要用 joblib.dump({"model": model, "encoder": encoder}, "models/lgbm_model.pkl") if __name__ == "__main__": main()为什么用 AUC 而不是准确率?因为胜负标签大致是 1:1,准确率看着高不代表模型有区分度,AUC 更能反映排序能力。stratify=y是为了防止切分后某一类样本过少导致验证结果抖动。模型选 LightGBM 而不是逻辑回归,是因为英雄组合的 one-hot 维度很高,树模型对稀疏特征的处理更稳。
3.2 推理接口:给一个阵容,返回胜率
predict.py要做的事情和训练相反:加载模型和编码器,把用户输入的阵容转成同样的特征格式,然后输出概率。
import joblib import pandas as pd from src.feature_engineer import build_features class WinPredictor: def __init__(self, model_path: str = "models/lgbm_model.pkl"): bundle = joblib.load(model_path) self.model = bundle["model"] self.encoder = bundle["encoder"] def predict(self, blue_heroes: list, red_heroes: list, blue_rank: str = "Gold", red_rank: str = "Gold") -> float: """输入双方英雄 ID 列表和段位,返回蓝方胜率""" row = {} for i, h in enumerate(blue_heroes, start=1): row[f"blue_hero_{i}"] = h for i, h in enumerate(red_heroes, start=1): row[f"red_hero_{i}"] = h row["blue_rank"] = blue_rank row["red_rank"] = red_rank row["win"] = 0 # 占位,build_features 不读这一列 df = pd.DataFrame([row]) X, _ = build_features(df, encoder=self.encoder) prob = self.model.predict_proba(X)[0, 1] return float(prob)注意build_features在推理时传入了训练好的encoder,这样英雄编码的维度才能和训练时对齐。如果你忘了传,编码器会重新 fit,维度对不上,模型直接报错。这个坑我在第一次接服务的时候踩过,排查了半天才发现是编码器没复用。
3.3 配置与依赖管理
config.yaml里放路径和超参数,好处是换数据集不用改代码:
data: raw_dir: "data/raw" processed_dir: "data/processed" model: n_estimators: 300 learning_rate: 0.05 max_depth: 6 test_size: 0.2 random_state: 42requirements.txt里核心依赖就四个:pandas、scikit-learn、lightgbm、joblib。版本上建议锁一下,LightGBM 不同版本对sparse_output参数的支持有差异,不锁容易在别人机器上翻车。
4. 避坑与排查:跑不起来时先看这几条
4.1 编码器维度不匹配
现象:推理时报ValueError: X has n features, but LGBMClassifier is expecting m features。
原因:推理时没有复用训练阶段的OneHotEncoder,而是重新 fit 了一个,导致英雄组合的编码维度变了。
解决:把 encoder 和 model 一起用joblib.dump存成一个字典,推理时一起加载。上面WinPredictor的写法就是标准做法,别偷懒只存模型。
4.2 英雄 ID 类型不一致
现象:训练时英雄 ID 是整数,推理时传进来的是字符串,编码后特征全对不上。
原因:CSV 读取时 pandas 可能把英雄 ID 推断成 int,而接口传参是 str。
解决:在build_features里统一astype(str),训练和推理走同一条路径。这个改动很小,但能省掉大量“为什么线上线下结果不一样”的排查时间。
4.3 数据泄漏导致 AUC 虚高
现象:验证集 AUC 冲到 0.95 以上,上线后预测一塌糊涂。
原因:特征里混入了对局结束后的信息,比如击杀数、经济差。这些字段在选人阶段根本拿不到。
解决:严格区分“赛前特征”和“赛后特征”,build_features里只保留选人阶段能获取的字段。我一般会在特征表里加一列available_at,标注每个特征在什么时间点可用,训练前过滤一遍。
4.4 样本不均衡被忽略
现象:模型永远预测蓝方赢,因为蓝方胜率略高于 50%。
原因:没有做分层切分,也没有在训练时调整类别权重。
解决:train_test_split加stratify=y,LightGBM 里加class_weight="balanced"。如果蓝红胜率差距很大,还可以对少数类做上采样,但要注意别在验证集上做。
4.5 模型文件路径写死
现象:换台机器跑,报FileNotFoundError: models/lgbm_model.pkl。
原因:代码里用了相对路径,工作目录一变就找不到。
解决:用pathlib.Path(__file__).parent拼绝对路径,或者统一从config.yaml读。这个属于工程习惯问题,早改早省心。
5. 二次开发:换模型、加特征、接服务
5.1 换模型:从 LightGBM 切到 XGBoost 或神经网络
项目现在的训练入口是写死的 LightGBM,但结构上留了口子。你只需要在train.py里把模型实例换掉,特征部分完全不用动:
from xgboost import XGBClassifier model = XGBClassifier( n_estimators=300, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, use_label_encoder=False, eval_metric="logloss", random_state=42 )如果想上神经网络,把build_features的输出接一个StandardScaler,再搭个几层的 MLP 就行。但要注意,树模型对 one-hot 稀疏特征天然友好,神经网络反而需要更细致的归一化和嵌入层设计,别指望换个模型就无脑涨点。
5.2 加特征:从“阵容胜率”到“阵容+选手”胜率
现在的特征只有英雄和段位,信息量有限。一个自然的扩展是加入选手近期战绩:
def add_player_features(df, player_stats): """把选手近 20 局的 KDA、胜率拼进来""" for side in ["blue", "red"]: for i in range(1, 6): pid_col = f"{side}_player_{i}" df = df.merge( player_stats, left_on=pid_col, right_on="player_id", how="left", suffixes=("", f"_{side}_{i}") ) return df合并之后记得处理缺失值,新选手没有历史数据是常态,用段位均值填充比直接丢样本更合理。特征变多之后,建议跑一次特征重要性排序,把贡献低的字段砍掉,不然训练时间涨了、效果没动,纯属给自己找事。
5.3 接服务:用 FastAPI 包一层
推理类直接调用太原始,包一个 HTTP 接口才能被其他系统用:
from fastapi import FastAPI from pydantic import BaseModel from src.predict import WinPredictor app = FastAPI() predictor = WinPredictor() class MatchInput(BaseModel): blue_heroes: list red_heroes: list blue_rank: str = "Gold" red_rank: str = "Gold" @app.post("/predict") def predict_win_rate(match: MatchInput): prob = predictor.predict( match.blue_heroes, match.red_heroes, match.blue_rank, match.red_rank ) return {"blue_win_rate": round(prob, 4)}启动命令是uvicorn main:app --host 0.0.0.0 --port 8000。注意WinPredictor要在模块加载时初始化一次,别每次请求都重新加载模型,那样延迟会高得没法用。
5.4 验证改造效果:别只看 AUC
改完模型或特征后,除了 AUC,我还会看两个指标:一是校准曲线,看预测概率和实际胜率是否吻合;二是分段的准确率,比如预测胜率在 0.6 到 0.7 区间的对局,实际胜率是不是也在这个范围。只看 AUC 容易忽略概率校准问题,而胜率预测恰恰对校准很敏感。
从那以后我每次改完特征或换模型,都强制走一遍“AUC + 校准 + 分段”三件套,不再单看一个数字就下结论。希望这个项目的拆解能帮到你,拿到源码后先跑通训练,再按自己的数据改特征,最后接上服务,整条链路走一遍,比读十篇教程都管用。
本文还有配套的精品资源,点击获取