1. 为什么选这道题:赛事背景与赛题拆解
飞桨学习赛是百度AI Studio平台上很经典的一类入门实战赛事,我这次报的是“英雄联盟大师预测”。说白了,赛题给了一批英雄联盟玩家的对局统计特征,要求参赛者构建模型,预测玩家最可能达到的段位。段位标签里包含大师这个高段位,所以赛题叫“英雄联盟大师预测”。
我选这道题,主要基于三个判断:第一,这是一道典型的表格数据分类问题,特征全部是数值型统计量,和很多工业场景里的用户画像、风险评分任务同构,做一遍能沉淀一套可复用的方法论;第二,数据集规模不大,不像CV、NLP赛道那样动辄几十个G的数据和昂贵的训练资源,普通笔记本的CPU也能跑通全流程;第三,段位预测天然带多分类、样本不均衡、特征相关性高等特点,很适合用来练手深度学习框架下的调优基本功。
先说赛题的具体目标。训练集里每行是一个玩家的行为快照,列包含类似击杀数、死亡数、助攻数、总场次、胜场、常用位置、每局平均经济等在内的统计指标。标签是玩家的当前段位,从黑铁到王者共9个级别。这里的难点在于:特征本身是高度冗余的(比如击杀数和场均击杀存在强线性关系),段位分布又严重不平衡,王者、大师这种头部段位样本占比可能不到1%。如果直接用朴素的多分类交叉熵训练,模型很容易把所有样本都预测成占比最高的“黄金”“铂金”,得到一个看似很高、实则无意义的准确率。
一句话概括我在这道题里的解题主线:先用EDA把数据分布和特征关系摸清楚,再围绕游戏机制设计出有区分度的人工特征,最后落到飞桨框架里用MLP类模型做多分类,并在验证集上反复验证每一处改动是否真的有效。下面我把每个环节的细节和踩过的坑展开讲。
2. 拿到数据第一件事:数据初探与预处理细节
2.1 先别急着训练,把数据读进来看5分钟
很多新手拿到CSV后第一反应是直接丢给模型训练,这是最容易走弯路的地方。我习惯先做一轮快速探查,主要看三样东西:字段类型是否合理、缺失值多不多、标签分布是否极度倾斜。
import pandas as pd train_df = pd.read_csv("train.csv") test_df = pd.read_csv("test.csv") print(train_df.shape, test_df.shape) print(train_df.dtypes.value_counts()) print(train_df.isnull().sum().sort_values(ascending=False).head(10)) print(train_df["rank"].value_counts(normalize=True))我这次跑出来的结果有几个值得注意的点:
- 特征列有四十多个,绝大多数是float64,部分是int64,没有object型的离散文本列,但有一个“most_played_position”字段是典型的低频类别特征,后面需要单独处理;
- 缺失值整体不多,集中在三四个特征里,缺失比例在1%到5%之间,处理策略不需要太复杂;
- 标签分布确认是长尾形态,“黄金”“铂金”两个段位占了将近一半样本,“大师”“王者”合计不足2%。
2.2 缺失值处理:均值填充未必是最优解
缺失值处理看似简单,实则会直接影响最终效果。我的原则是:数值型连续特征用中位数填充,而不是均值。原因是这些统计量大多呈右偏分布,个别“大号”玩家的击杀数可能远超中位数,均值会被极端值拉高,用均值填充相当于给缺失样本注入了偏高的信号。
num_cols = train_df.select_dtypes(include=["float64", "int64"]).columns for col in num_cols: med = train_df[col].median() train_df[col] = train_df[col].fillna(med) test_df[col] = test_df[col].fillna(med)对于“most_played_position”这种低频类别特征,我采用众数填充,然后做序数编码或独热编码。不要小看这个字段,在英雄联盟里,打野和中单的游走节奏、参团率差异非常大,后续特征工程里它经常和KDA、击杀参与率产生有意义的交互。
2.3 标签编码的正确姿势
段位是有序的,0到8分别对应黑铁到王者。这里有两种编码思路:一是当作无序类别做普通Softmax多分类,二是利用段位的天然顺序做回归或有序分类。我实测下来,直接回归段位等级的效果并不理想,因为段位之间的“距离”并不均匀——钻石到大师的跨度远大于白银到黄金的跨度。所以最终方案还是多分类,但我会在损失函数上做文章,这部分放到模型章节细说。
提示:标签编码时一定要保证训练集和测试集使用同一套映射表,不要在测试集上重新fit,否则极容易在提交阶段踩到“类别对不上”的坑。
3. 特征工程:从通用统计量到带机制的特征设计
3.1 原始特征里的信息密度其实很低
赛题给的特征虽然数量不少,但信息密度并不高。比如“总场次”“胜场”和“胜率”三个特征,本质上就是同一个信息的三种表达;又比如“总击杀”“总死亡”“总助攻”和“总场次”组合起来,才能算出真正有意义的KDA。直接把这些原始列喂给模型,模型要花很多层才能隐式学到这些比值关系,不如我们直接替它算好。
我第一版特征工程的主线是“机制驱动”,也就是围绕英雄联盟的游戏逻辑构造特征,而不是盲目堆砌。英雄联盟胜负的核心变量有三类:个人操作(击杀、死亡、助攻)、资源获取能力(补刀、经济、等级)、团队协作能力(参团率、视野得分)。所以我的特征设计也按这三类展开。
3.2 亲手算出来的核心特征组
第一组是个体效率特征,包括KDA、(击杀+助攻)/死亡、(击杀+助攻)/场次等。这里有个小技巧:KDA公式里的死亡数要加一个极小值平滑项,防止出现除以0的inf。
eps = 1e-5 train_df["kda"] = (train_df["kills"] + train_df["assists"]) / (train_df["deaths"] + eps) train_df["kill_participation"] = (train_df["kills"] + train_df["assists"]) / (train_df["team_total_kills"] + eps)第二组是位置与风格特征,做法是把“最常玩位置”进行独热编码后,再与KDA、场均经济等指标做交叉。比如中单玩家的KDA与段位的相关性,明显强于辅助玩家的KDA与段位的相关性,如果不做交叉,模型很难捕捉这种条件关系。
第三组是稳定性和成长性特征,比如场次等级变动率、胜率与KDA的比值。用胜率除以KDA可以刻画“混子型玩家”——胜率高但KDA低的玩家,说明他更偏团队型打法;反过来KDA高但胜率低,说明是“独狼型”玩家。这类特征对段位预测的区分度非常可观。
3.3 特征筛选:不是越多越好
特征工程做完后,维度大概到了一百多个。我不建议直接把所有特征丢进模型,因为部分交叉特征之间相关性极高,既拖慢训练速度,也增加过拟合风险。我习惯用两步筛选:第一步看相关系数矩阵,把相关系数超过0.95的特征合并或剔除;第二步用LightGBM的feature_importance做一轮快速初筛,保留重要性排序前80%的特征,再落到飞桨模型里训练。
这样做的逻辑很简单:不同模型对特征的敏感度不同,树模型擅长处理非线性且对特征尺度不敏感,而深度学习模型对方差大的特征更敏感。先用树模型筛一轮,再用网络训练,可以在减少维度的同时保留绝大多数有效信号。
4. 飞桨模型搭建:从基准MLP到调优路径
4.1 飞桨的建模方式和我为什么选MLP
飞桨(PaddlePaddle)在AI Studio上支持动态图模式,API设计和PyTorch非常接近,上手成本很低。这道题的特征全部是结构化表格数据,没有空间结构也没有时序依赖,所以不需要上CNN、RNN、Transformer这类重型结构,一个精心调过的多层感知机就能达到相当好的效果。
我第一版基准模型用的是三层MLP:输入层维度接特征数量,中间两层每层256个神经元,激活函数用ReLU,每个全连接层后接Dropout,输出层用Softmax输出9个段位的概率。评估指标以准确率为主,同时参考Macro F1,避免被头部段位的大样本主导。
import paddle import paddle.nn as nn class RankModel(nn.Layer): def __init__(self, feat_dim, num_classes=9): super().__init__() self.fc1 = nn.Linear(feat_dim, 256) self.fc2 = nn.Linear(256, 256) self.fc3 = nn.Linear(256, 64) self.out = nn.Linear(64, num_classes) self.drop = nn.Dropout(0.3) self.relu = nn.ReLU() def forward(self, x): x = self.relu(self.fc1(x)) x = self.drop(x) x = self.relu(self.fc2(x)) x = self.drop(x) x = self.relu(self.fc3(x)) return self.out(x)4.2 损失函数的针对性设计:加权交叉熵
前面提到,段位分布严重不均衡,直接用nn.CrossEntropyLoss训练,模型会偏向预测大类别。针对这个赛题,我用了带类别权重的交叉熵损失。权重怎么算?采用“1 / 类别频率开根号”的方式做平滑,避免权重被头部段位压得过小。
import numpy as np label_counts = train_df["rank"].value_counts().sort_index().values weights = 1.0 / np.sqrt(label_counts) weights = weights / weights.sum() * len(weights) weight_tensor = paddle.to_tensor(weights, dtype="float32") criterion = nn.CrossEntropyLoss(weight=weight_tensor)这里有个关键点:类别权重的调整要适度。权重如果拉得过大,模型会把预测结果推往小众段位,整体准确率下降,Macro F1却可能提升。比赛评价指标如果只看准确率,权重就不宜过猛;如果同时关注F1,则需要做一个折中。我在实际调参中是用验证集同时看两个指标,找到一个平衡点。
4.3 训练配置与监控方法
优化器我选Adam,初始学习率设3e-4,配合CosineAnnealingDecay做学习率衰减。批次大小64,训练轮数50轮。每轮结束后用验证集评估一次准确率,并保存验证集指标最优的那版模型,而不是最后一轮模型——深度学习训练后期容易过拟合,最后一轮往往不是泛化最好的点。
scheduler = paddle.optimizer.lr.CosineAnnealingDecay( learning_rate=3e-4, T_max=50, verbose=True) optimizer = paddle.optimizer.Adam( parameters=model.parameters(), learning_rate=scheduler)训练日志里我除了看loss,还会打印准确率、Macro F1、以及每个段位的召回率。只看总loss容易产生“明明在下降但效果没变化”的错觉,细分到每个类别的召回率能帮助定位模型是否放弃了某些段位。
5. 踩坑实录:训练阶段最耗时间的三个问题
5.1 特征拼接没对齐,训练集和测试集维度不一致
这是我这次比赛踩的第一个坑。计数类特征在构造时,如果训练集和测试集分开处理,某些特征的值域范围不同,导致fillna时填充值不一致,甚至拼接后列数都对不上。最后训练时报了个“shape mismatch”的错,定位半天才发现是预处理阶段的问题。
后来我把特征工程封装成了一个函数,先对训练集和测试集做concat处理,整体算填充值、整体做编码,再按原索引切分回两部分。这个习惯现在已经成为我所有表格赛事的标准流程。特征工程必须保证训练和推理走同一套逻辑,否则线上分数和本地验证结果完全是两回事。
5.2 Dropout过大导致验证集指标震荡
第二版模型为了压制过拟合,我把Dropout提到了0.5,结果训练loss下降变慢,验证集指标出现明显的震荡。原因是表格数据本身不是超大容量模型,Dropout过大相当于每轮都在训练一个残缺的子网络,在样本量不够大的时候,反而损害了模型的稳定性。
我把Dropout从0.5调回0.3,同时把隐藏层从256缩减到128,验证集指标立刻稳定下来。这里想说明一个原则:正则化手段不是加得越多越好,而是要在“模型容量”和“数据量”之间找平衡。飞桨里还有其它正则化工具,比如Weight Decay和Label Smoothing,我最后只用了轻量Weight Decay,没有再叠加Label Smoothing,避免改动过多导致无法判断哪项改动起效。
5.3 验证集划分不当导致复现结果虚高
另一个隐蔽的坑是验证集划分。赛题数据按玩家分组,特征里没有显式的时间戳,但不同行之间可能存在同一个玩家的重复采样。如果随机切分,同一个玩家的相似对局会同时出现在训练集和验证集里,造成验证集分数虚高。
我处理的办法是按玩家ID分组划分。如果赛题没有提供玩家ID,就退而求其次用特征聚类先找出可能重复的样本,把同一类的样本放进同一个折里,再分别划分训练和验证。这一步做完后,验证集准确率比随机划分低了大概两个百分点,但线下分数和线上提交结果的一致性明显提高。
提示:在任何表格类比赛中,划分验证集前先确认样本之间是否存在分组相关,是决定你调参方向是否可信的关键一环。宁可线下分低一点,也要保证验证集能真实反映线上情况。
6. 复现要点与我的赛后复盘
6.1 完整流程串起来是什么样
最后把整个流程按可复现的方式整理一遍。第一步,读入数据,检查字段类型、缺失值和标签分布;第二步,缺失值用中位数/众数填充,类别特征做序数编码;第三步,围绕游戏机制构造三组特征:个体效率、位置风格、稳定性成长性,再做相关性筛选和树模型初筛;第四步,按玩家维度做分组划分,拆出验证集;第五步,搭建飞桨MLP模型,使用加权交叉熵损失,Adam优化器加余弦退火,训练50轮并保存验证集最优模型;第六步,在测试集上推理,按官方格式输出提交文件。
这套流程跑下来,我的最终成绩大约在0.912的准确率,Macro F1在0.74左右。作为对照,完全不做特征工程、直接用原始特征训同样结构的模型,准确率只有0.87左右,说明人工特征在表格竞赛里的增益依旧非常可观。后续再提升的空间主要在两个方向:一是用LightGBM和MLP做模型加权融合,树模型和神经网络在表格数据上的错误模式差异较大,融合后通常还能再涨一到两个点;二是针对小众段位做更精细的上采样或数据增强,比如用SMOTE合成了一些大师和王者样本,但效果提升有限,需要谨慎控制比例避免引入噪声。
6.2 我在这道题上悟到的几点经验
第一,表格类任务里“特征机制”比“模型结构”重要得多。我试过把MLP换成更深的残差网络,准确率几乎没有变化,但特征工程的两个小改动就可能带来两三个点的提升。原因在于,神经网络在表格数据里很难凭空学到特征之间的显式比值关系,把这些关系直接喂给模型,相当于替模型降低了拟合难度。
第二,样本不均衡问题的处理要跟随评价指标。如果评价指标是准确率,模型偏向多数类其实是合理的;如果评价指标是Macro F1,就必须通过类别权重或重采样来平衡。赛前先搞清楚评价指标,能省掉大量无用功。
第三,飞桨的动态图模式对调试非常友好,所有的中间张量都可以直接打印和观察。我在调损失函数时就用这个特性打印过每一批样本的预测概率分布,直观看到了模型初期把概率全部堆在“黄金”段位上的现象,然后才针对性引入类别权重。
最后再分享一个小技巧:每次改动特征或者模型结构后,固定随机种子,在同一个验证集上跑至少三遍取平均值,避免因为随机初始化导致的波动误判改动效果。这个习惯能帮你节省大量无效调参时间,尤其是在数据量不算大的学习赛里,单次验证结果的可信度真的很有限。