这是去年底今年初打的比赛,结赛博客却拖到现在。虽然比赛很早就结束了,但是在2019年春季的「高级编程技术」的期末大作业恰巧也是参加一个类Kaggle比赛,于是这个比赛经历就给了我们很大帮助。本文在大作业报告的基础上完成。

比赛介绍

PUBG Finish Placement Prediction 是 Kaggle 在 2018 年底在全球范围启动的一个数据挖掘比赛,至今已经有约 1500 支队伍参赛。

Kaggle 官方使用 PUBG Developer API 抓取了 65000 次 PUBG 游戏竞赛的数据,以每次比赛中的每一个参赛者作为一个样本,一共有超过六百万个样本。对于每一条样本, Kaggle 官方都进行了脱敏处理,将参赛者 ID 和比赛 ID 进行哈希。样本提供了三十个特征,如参赛者在比赛过程中行走的路程等,数据信息部分我们将在 数据探索分析 一节中详细说明。

训练集中的最后一个特征 winPlacePerc,是指该参赛者的比赛排名位次比例,变换进 $[0,1]$ 区间。即,如果参赛者的排名为第一名,winPlacePerc 为 1,排名为最后一名的参赛者的 winPlacePerc 为 0,其他参赛者的 winPlacePerc 按排名均匀散落在 $[0,1]$ 之间。

测试集中的特征和训练集相同,但测试集的 winPlacePerc 是缺失的。我们要做的,就是通过训练集,来对测试集的每一个样本的 winPlacePerc 进行预测。 Kaggle 官方有一份测试集的真实 winPlacePerc,但是不公开,比赛将根据我们预测的 winPlacePerc 和真实值进行比对来得到分数。

分数采用 MAE 衡量,即平均绝对误差。分数越低,代表预测越准确。在我们参加比赛的时候,排名第一的队伍的 MAE 是 0.01385

EDA

Credits to Jed Zhang

探索性数据分析(Exploratory Data Analysis)是指在尽可能少的先验假设下对数据集的基本特征进行探索的过程。从它的名字「探索性」可以看出,我们在拿到数据集后,往往不知道如何下手,因此需要「探索性」地在数据中寻找一些简单规律,通过作图、制表、拟合等方式,尝试从数据中找到一些初步结论。

准备工作

PUBG Finish Placement Prediction 提供了约 445 万行的训练数据集。首先引入所需要的包,并进行一些配置。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns 

读取数据到内存中,然后查看数据集的基本信息。

train = pd.read_csv('train_V2.csv')
train.info()
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 4446966 entries, 0 to 4446965
    Data columns (total 29 columns):
    Id                 object
    groupId            object
    matchId            object
    assists            int64
    boosts             int64
    damageDealt        float64
    DBNOs              int64
    headshotKills      int64
    heals              int64
    killPlace          int64
    killPoints         int64
    kills              int64
    killStreaks        int64
    longestKill        float64
    matchDuration      int64
    matchType          object
    maxPlace           int64
    numGroups          int64
    rankPoints         int64
    revives            int64
    rideDistance       float64
    roadKills          int64
    swimDistance       float64
    teamKills          int64
    vehicleDestroys    int64
    walkDistance       float64
    weaponsAcquired    int64
    winPoints          int64
    winPlacePerc       float64
    dtypes: float64(6), int64(19), object(4)
    memory usage: 983.9+ MB

共有 29 列,Kaggle 的比赛主页上给出了以上列的含义,如下表:

列名称 数据类型 含义
Id 字符串(object) 用户 ID
groupId 字符串(object) 所处小队 ID
matchId 字符串(object) 该场比赛 ID
assists 整数(int64) 助攻数
boosts 整数(int64) 使用能量道具数量
damageDealt 浮点数(float64) 总造成伤害
DBNOs 整数(int64) 击倒敌人数量
headshotKills 整数(int64) 爆头数
heals 整数(int64) 使用治疗药品的数量
killPlace 整数(int64) 本场比赛杀敌排行
killPoints 整数(int64) Elo 杀敌排名
kills 整数(int64) 杀敌数
killStreaks 整数(int64) 连续杀敌数
longestKill 浮点数(float64) 最远杀敌距离
matchDuration 整数(int64) 比赛时长
matchType 字符串(object) 比赛类型(小队人数)
maxPlace 整数(int64) 本局最差名次
numGroups 整数(int64) 小组数量
rankPoints 整数(int64) Elo 排名
revives 整数(int64) 救活队友的次数
rideDistance 浮点数(float64) 驾车距离
roadKills 整数(int64) 驾车杀敌数(如撞死)
swimDistance 浮点数(float64) 游泳距离
teamKills 整数(int64) 杀死队友的次数
vehicleDestroys 整数(int64) 毁坏机动车的数量
walkDistance 浮点数(float64) 步行距离
weaponsAcquired 整数(int64) 收集武器的数量
winPoints 整数(int64) 胜率 Elo 排名
winPlacePerc 浮点数(float64) 百分比排名

我们显示出数据集的前几行,肉眼观察一下数据集的样子,使自己有一个直观的感觉。

train.head()

由于列数过多,这里不做展示,详见代码结果。

相关系数矩阵热力图

该数据集有 29 个特征之多,我们先来看看这些特征之间的相关性如何。计算所有数值列(类型为整数或浮点数的特征)之间的 Pearson 相关系数(train.corr()),并将其用热力图的形式表示出来。图中相关系数为正的用红色系表示,为负的用蓝色系表示,颜色越深表示相关性越强(正相关或负相关)。

plt.subplots(figsize=(12, 12))
sns.heatmap(train.corr(), square=True, annot=True, fmt='.2f', annot_kws={"size": 8}, cmap = "RdBu_r", center=0)
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_10_0-2019108

注意到相关系数矩阵是对称的,因此我们实际上只需要观察以对角线分割的一半图像即可。我们可以看到,图像的大多数色块颜色很浅(接近白色),说明大多数特征之间几乎不相关,但也有几个深色色块值得我们注意:

  • damageDealtkills 的相关系数为 0.89。杀敌多意味着造成的伤害多;而由于玩家的血量有限,因此造成伤害多可能代表着更多的杀敌。这是意料之中的且容易推断得出的,对我们的数据分析意义不大。
  • winPlacePercwalkDistance 的相关系数为 0.81。步行距离和玩家的百分比排名正相关,且相关性很强。
  • winPlacePercboosts 的相关系数为 0.63。使用能量道具的次数和玩家的百分比排名有着较强的相关性。
  • winPlacePercweaponsAcquired 的相关系数为 0.58。收集的武器数量和玩家的百分比排名有着较强的相关性,但相关性没有上面两条强。
  • 造成伤害、杀敌数、使用治疗药品等因素也与玩家的百分比排名有关。

在观察相关系数热力图时,应该特别注意与 winPlacePerc 相关的特征,因为该比赛的目的是预测 winPlacePerc

探索步行距离

从上面的相关系数矩阵我们发现玩家的百分比排名与步行距离有着很强的相关性,因此我们来更加细致地研究一下。

显示步行距离的描述性统计信息。

train['walkDistance'].describe()
    count    4.446966e+06
    mean     1.154218e+03
    std      1.183497e+03
    min      0.000000e+00
    25%      1.551000e+02
    50%      6.856000e+02
    75%      1.976000e+03
    max      2.578000e+04
    Name: walkDistance, dtype: float64
print('平均步行距离:{:.1f} 米'.format(train['walkDistance'].mean()))
print('99%的玩家的步行距离不超过:{} 米'.format(train['walkDistance'].quantile(0.99)))
print('最大步行距离:{} 米'.format(train['walkDistance'].max()))
平均步行距离:1154.2 米
99%的玩家的步行距离不超过:4396.0 米
最大步行距离:25780.0 米

上面我们显示了「99%的玩家的步行距离不超过」这一数值,这主要是为了排除一些特别大的异常值。DataFrame 的quantile方法用于计算样本的分位数。

接下来,我们舍去位于 99% 分位数以上的异常样本,然后绘制步行距离的直方图,并拟合核密度估计曲线。

df = train.copy()
df = df[df['walkDistance'] < df['walkDistance'].quantile(0.99)]
sns.distplot(df['walkDistance'])
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_16_0-2019108

注意到有些玩家的步行距离为 0,这也是不正常的数据。出现这种情况有两种可能,一是这些玩家跳伞落地后立即死亡(这种可能性不大),二是这些玩家掉线了或者在挂机。我们看看有多少玩家出现了这种情况。

print('步行距离为0的玩家数:{}'.format(len(df[df['walkDistance'] == 0])))
print('占比:{:.2%}'.format(len(df[df['walkDistance'] == 0]) / len(train)))
步行距离为0的玩家数:99603
占比:2.24%

在原始的训练数据集上绘制百分比排名和步行距离的联合分布图。

sns.jointplot(x='winPlacePerc', y='walkDistance', data=train)
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_20_0-2019108

探索驾车距离

在探索了步行距离之后,我们很自然地想到还应该以相同的方法分析其它的距离,如驾车距离和游泳距离。我们先来分析驾车距离特征。

train['rideDistance'].describe()
    count    4.446966e+06
    mean     6.061157e+02
    std      1.498344e+03
    min      0.000000e+00
    25%      0.000000e+00
    50%      0.000000e+00
    75%      1.909750e-01
    max      4.071000e+04
    Name: rideDistance, dtype: float64
print('平均驾车距离:{:.1f} 米'.format(train['rideDistance'].mean()))
print('99%的玩家的驾车距离不超过:{:.1f} 米'.format(train['rideDistance'].quantile(0.99)))
print('最大驾车距离:{} 米'.format(train['rideDistance'].max()))
平均驾车距离:606.1 米
99%的玩家的驾车距离不超过:6966.0 米
最大驾车距离:40710.0 米
df = df.copy()
df = df[df['rideDistance'] < df['rideDistance'].quantile(0.99)]
sns.distplot(df['rideDistance'], color='green')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_24_0-2019108

我们发现相当多的一部分玩家都没有驾驶。那么究竟有多少玩家没有驾驶呢?

print('驾驶距离为0的玩家数:{}'.format(len(df[df['rideDistance'] == 0])))
print('占比:{:.2%}'.format(len(df[df['rideDistance'] == 0]) / len(train)))
驾驶距离为0的玩家数:3295246
占比:74.10%

最后,绘制联合分布图。

sns.jointplot(x='winPlacePerc', y='rideDistance', data=train, color='green')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_28_0-2019108

探索游泳距离

探索方法与上面基本相同。

train['swimDistance'].describe()
    count    4.446966e+06
    mean     4.509322e+00
    std      3.050220e+01
    min      0.000000e+00
    25%      0.000000e+00
    50%      0.000000e+00
    75%      0.000000e+00
    max      3.823000e+03
    Name: swimDistance, dtype: float64
print('平均游泳距离:{:.1f} 米'.format(train['swimDistance'].mean()))
print('99%的玩家的游泳距离不超过:{:.1f} 米'.format(train['swimDistance'].quantile(0.99)))
print('最大游泳距离:{} 米'.format(train['swimDistance'].max()))
平均游泳距离:4.5 米
99%的玩家的游泳距离不超过:123.0 米
最大游泳距离:3823.0 米

平均游泳距离竟然只有 4 米多?应该是很多玩家都没有下水游泳吧,从直方图验证一下。

df = df.copy()
df = df[df['swimDistance'] < df['swimDistance'].quantile(0.99)]
sns.distplot(df['swimDistance'], color='cyan')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_33_0-2019108

print('游泳距离为0的玩家数:{}'.format(len(df[df['swimDistance'] == 0])))
print('占比:{:.2%}'.format(len(df[df['swimDistance'] == 0]) / len(train)))
    游泳距离为0的玩家数:4092827
    占比:92.04%

确实,几乎所有的玩家都没有游泳。不过,在 PUBG 的几张游戏地图中,有一张是没有水的,也就是说在这张地图中,所有玩家的游泳距离都一定为 0。这张地图一定程度上会影响整个数据集的游泳距离特征。

sns.jointplot(x='winPlacePerc', y='swimDistance', data=train, color='cyan')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_36_0-2019108

下面,我们将游泳距离分为 0m、1~5m、6~20m 和 20+m 几个类别,然后绘制箱线图,观察游泳距离与排名的关系。

swim = train.copy()

swim['swimDistance'] = pd.cut(swim['swimDistance'], [-1, 0, 5, 20, 5286], labels=['0m','1-5m', '6-20m', '20m+'])
sns.boxplot(x="swimDistance", y="winPlacePerc", data=swim)
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_38_0-2019108

从箱线图中可以看出,无论是最大值、最小值、平均值还是 25% 和 75% 分位数,玩家的百分比排名均随着游泳距离的增加而增加,我们可以推断出游泳距离同样会影响玩家的排名(尽管它们的相关系数只有 0.15)。

探索杀敌数和伤害值

玩过 PUBG 的人都知道,杀敌在游戏中是非常重要的。玩家的杀敌数,能够直接反映出该玩家的技术水平,因此能够间接影响他的排名。人们讨论 PUBG 时,也常常以杀敌数作为衡量玩家的重要标准。总之,杀敌数是最重要的特征之一,我们不能忽视它。

print('平均杀敌数:{:.1f}'.format(train['kills'].mean()))
print('99%的玩家的杀敌数不超过:{:.1f}'.format(train['kills'].quantile(0.99)))
print('最大杀敌数:{}'.format(train['kills'].max()))
平均杀敌数:0.9
99%的玩家的杀敌数不超过:7.0
最大杀敌数:72

平均杀敌数还不到 1 人,且绝大多数玩家的杀敌数都不超过 7 人。基于此事实,我们把杀敌数大于等于 8 的数据均归为一组,然后绘制直方图,一次观察玩家杀敌数的分布。

基于杀敌数的数据特点,我们临时地将训练数据集拷贝一份到 df 中,将 df 中 kills 大于 7 的行赋值为字符串'8+',然后使用countplot绘图。

df = train.copy()
df.loc[df['kills'] > df['kills'].quantile(0.99), 'kills'] = '8+'
df['kills'].astype('str').sort_values()
sns.countplot(df['kills'].astype('str').sort_values())
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_43_0-2019108

发现大多数玩家的杀敌数为 0。在 EDA 开始时我们发现杀敌数和伤害值的相关系数高达 0.89,那么我们来看看这些杀敌数为 0 的玩家是否能给敌人带来一点伤害呢?

df = train.copy()
df = df[df['kills'] == 0]  # df中只有kills==0的行
sns.distplot(df['damageDealt'], color='black')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_45_0-2019108

print('杀敌数为0的玩家数:{}'.format(len(df)))  # df中只有kills==0的行
print('占比:{:.2%}'.format(len(df) / len(train)))
print('伤害值为0的玩家数:{}'.format(len(df[df['damageDealt'] == 0])))
print('`伤害值为0`占`杀敌数为0`的比例:{:.2%}'.format(len(df[df['damageDealt'] == 0]) / len(df)))
    杀敌数为0的玩家数:2529722
    占比:56.89%
    伤害值为0的玩家数:1233949
    `伤害值为0`占`杀敌数为0`的比例:48.78%

可见,没有杀敌的玩家和没有造成伤害的玩家都很多,所占比例都接近一半(注意选取的总体不同)。

那么,杀敌数为 0、伤害值为 0 是否能代表这些玩家一定没有获得胜利呢?在 PUBG 中,我们把获得第一名称为「吃鸡」,如果一个玩家在整局游戏中都没有杀死一个敌人,甚至是没有造成任何伤害,那么我们称之为「0 杀吃鸡」或「躺赢」。下面的叙述中可能会用到这些俗称。

# 0杀吃鸡(杀敌数为0,但获得第一名)
num = len(df[df['winPlacePerc'] == 1.0 ])  # df中只有kills==0的行
print('0杀吃鸡的玩家数:{}'.format(num))
print('占比:{:.2%}'.format(num / len(train)))
    0杀吃鸡的玩家数:16666
    占比:0.37%
# 伤害值为0,但获得第一名
num = len(df[(df['damageDealt']==0) & (df['winPlacePerc']==1.0)])  # df中只有kills==0的行
print('伤害值为0,但获得第一名的玩家数:{}'.format(num))
print('占比:{:.2%}'.format(num / len(train)))
    伤害值为0,但获得第一名的玩家数:4709
    占比:0.11%

绘制联合分布图。

sns.jointplot(x='winPlacePerc', y='kills', data=train, color='black')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_51_0-2019108

显然,杀敌数和排名正相关。根据游戏特点,我们将杀敌数分为 5 个类别,然后绘制箱线图。

kills = train.copy()
kills['killsCategories'] = pd.cut(kills['kills'], [-1, 0, 2, 5, 10, 60], labels=['0_kills','1-2_kills', '3-5_kills', '6-10_kills', '10+_kills'])
sns.boxplot(x='killsCategories', y='winPlacePerc', data=kills)
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_53_0-2019108

探索能量道具和治疗药品

我们从相关系数矩阵可以看出道具与排名也具有一定的相关性,我们现在来探索这些特征。

# boosts(能量道具)
print('平均使用能量道具数:{:.1f} 个'.format(train['boosts'].mean()))
print('99%的玩家使用能量道具不超过:{:.1f} 个'.format(train['boosts'].quantile(0.99)))
print('最大使用能量道具数:{} 个'.format(train['boosts'].max()))
    平均使用能量道具数:1.1 个
    99%的玩家使用能量道具不超过:7.0 个
    最大使用能量道具数:33 个
# heals(治疗药品)
print('平均使用治疗药品数:{:.1f} 个'.format(train['heals'].mean()))
print('99%的玩家使用治疗药品不超过:{:.1f} 个'.format(train['heals'].quantile(0.99)))
print('最大使用治疗药品数:{} 个'.format(train['heals'].max()))
    平均使用治疗药品数:1.4 个
    99%的玩家使用治疗药品不超过:12.0 个
    最大使用治疗药品数:80 个
# boosts(能量道具)
sns.jointplot(x='winPlacePerc', y='heals', data=train, color='blue')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_57_0-2019108

# heals(治疗药品)
sns.jointplot(x='winPlacePerc', y='boosts', data=train, color='red')
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_58_0-2019108

df = train.copy()
df = df[df['heals'] < df['heals'].quantile(0.99)]
df = df[df['boosts'] < df['boosts'].quantile(0.99)]
sns.pointplot(x='heals', y='winPlacePerc', data=df, color='red')
sns.pointplot(x='boosts', y='winPlacePerc', data=df, color='blue')
plt.xlabel('boosts or heals')
plt.grid()
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_59_0-2019108

探索组队人数

在 PUBG 中,玩家可以选择一人一队(solo)、两人一队(duos)或四人一对(squads)。此外,游戏还分为 FPP (First Person Perspective) 和非 FPP 模式,这里我们不做考虑,仅考虑组队人数。

首先观察 matchType 列都有哪些比赛类型。

train['matchType'].value_counts()
    squad-fpp           1756186
    duo-fpp              996691
    squad                626526
    solo-fpp             536762
    duo                  313591
    solo                 181943
    normal-squad-fpp      17174
    crashfpp               6287
    normal-duo-fpp         5489
    flaretpp               2505
    normal-solo-fpp        1682
    flarefpp                718
    normal-squad            516
    crashtpp                371
    normal-solo             326
    normal-duo              199
    Name: matchType, dtype: int64
solos = train[train['matchType'].isin(['solo', 'solo-fpp'])]
duos = train[train['matchType'].isin(['duo', 'duo-fpp'])]
squads = train[train['matchType'].isin(['squad', 'squad-fpp', 'normal-squad-fpp'])]
print('Solo: {}, {:.2%}'.format(len(solos), len(solos)/len(train)))
print('Duos: {}, {:.2%}'.format(len(duos), len(duos)/len(train)))
print('Squads: {}, {:.2%}'.format(len(squads), len(squads)/len(train)))
    Solo: 718705, 16.16%
    Duos: 1310282, 29.46%
    Squads: 2399886, 53.97%

考虑到一个队伍中玩家水平可能参差不齐,我们希望观察组队人数不同时,玩家排名与杀敌数之间的关系。

sns.pointplot(x='kills', y='winPlacePerc', data=solos, color='black')
sns.pointplot(x='kills', y='winPlacePerc', data=duos, color='#CC0000')
sns.pointplot(x='kills', y='winPlacePerc', data=squads, color='#3399FF')
plt.grid()
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_65_0-2019108

观察上图得知,在 solo 和 duo 比赛中,杀敌数和排名呈现出明显的正相关关系;而在 squad 比赛中,杀敌数和排名的关系较弱,但总体趋势仍然是杀敌数越多,排名越靠前,不过波动比 solos 和 duos 更大。

探索组队游戏中特有的特征

DBNOs 特征指击倒敌人数量,所谓「击倒」,是指玩家受到伤害使其生命值减为 0 后失去攻击和步行等能力,只能匍匐在地上等待队友的救援。玩家可以救援被击倒但为被杀死的队友,救援次数由 revives 特征给出。此外,在组队游戏中还有「助攻」,是指当玩家对某一敌人造成一定伤害后,该敌人被其他队友击杀,助攻次数由 assists 特征给出。

DBNOsrevivesassists 都是在组队游戏中才有的特征,因为单人游戏没有队友。因此,一下讨论均在 duos 和 squads 上面进行。

sns.pointplot(x='DBNOs',y='winPlacePerc',data=duos,color='#CC0000')
sns.pointplot(x='DBNOs',y='winPlacePerc',data=squads,color='#3399FF')
sns.pointplot(x='revives',y='winPlacePerc',data=duos,color='#660000')
sns.pointplot(x='revives',y='winPlacePerc',data=squads,color='#000066')
sns.pointplot(x='assists',y='winPlacePerc',data=duos,color='#FF6666')
sns.pointplot(x='assists',y='winPlacePerc',data=squads,color='#CCE5FF')
plt.xlabel('DBNOs or revives or assists')
plt.grid()
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_67_0-2019108

再探相关系数矩阵

在 EDA 开始时,我们已经绘制了相关系数矩阵的热力图,现在,我们从中挑选 5 个和玩家百分比排名正相关程度最强的特征,并绘制这 5 个特征的热力图。(注意下图采用的配色方案与之前不同)

cols = train.corr().nlargest(5, 'winPlacePerc')['winPlacePerc'].index
cm = train[cols].corr()
sns.heatmap(cm, annot=True, square=True, fmt='.2f', annot_kws={'size': 10}, yticklabels=cols.values, xticklabels=cols.values)
plt.show()

Kaggle - PUBG-Kaggle-PUBG-output_70_0-2019108

特征工程

Credits to Shawn Ye

在上面的函数中我们进行了特征工程部分的工作。我们在原有的特征基础上又加入了一些新的特征,用来提高我们最后模型训练结果的准确性。

首先,我们引入了 totalDistance 总距离这一新特征,是由驾驶载具、游泳距离和步行距离三者相加而得到的玩家总行驶距离,提取出这一特征首先从直觉上是合理的,其次,由数据分析部分可知,往往游戏总距离更长的人在游戏中的生存时间更长,也就相对有更高的排名百分比。

我们引入的第二个特征是 killStreakrate 连续杀敌率,是由来连续杀敌数和杀敌总数之比得到的。healthitems 是使用治疗药品和加速道具的数量总和。killPlace_over_maxPlace 是本局杀敌排行和本局最差名次之比,这个比例越高,该参赛者往往也有更高的排名百分比。headshotKills_over_kills 是爆头杀敌数和杀敌人数之比,这个比值越高也往往意味着该选手有更高的游戏技巧,能在最后的游戏排名百分比中占据较高的位置。
distance_over_weapons 是平均每获得一把武器行走的距离,walkDistance_over_heals 是平均每使用一份治疗药品行走的距离,walkDistance_over_kills 是每击杀一个敌人行走的距离,这三个新的特征具有一定程度的相似性,都是通过比值来反映一定的游戏者的操控技巧,从而影响其最后的游戏排名百分比。

最后提取的两个特征,skillteamwork,一个通过爆头击杀数加驾车杀敌数获得,另一个通过助攻数和救起队友次数相加获得,这两个新特征分别反映了游戏者的个人操作技术和团队协作技术,这两个特征都是能从一定程度上反应出该游戏者最后在本局游戏的排名百分比。

在特征工程部份,除了构造一些新特征来提高模型预测的准确度以外,还由于 playground 是一个以小组为单位的游戏,所以我们通过分组统计和聚合来提取并观察整个小组在游戏中的表现情况,也会对模型预测的准确率有一定的帮助。

print('get group mean features')
agg=df.groupby(['matchId','groupId'])[features].agg('mean')
agg_rank=agg.groupby('matchId')[features].rank(pct=True).reset_index()

例如上面这段代码,我们先按比赛 ID 和团队 ID 进行分组,分别计算每一列特征的平均值,用一个小组的特征的平均值反应整个小组最后在比赛的排名百分比,从而在预测单个玩家时能通过其团队表现来提高预测准确率,这是一种可行且高效的分组统计预测方案。下面的几组数据处理也是按照这样的方法,分别求出不同比赛中不同团队不同特征的最小值、最大值、平均值以及不同场次的比赛中玩家的平均水平等参数,从给出的数据中提取出更多对预测结果有帮助的数据,从而进行进一步的分析。

模型介绍

我们一共尝试了两个决策树模型和两个神经网络模型,分别是 XGBoost、LightGBM、MLP 和一个我们自己写的多层感知网络。

决策树模型

在介绍我们使用的 XGBoost 和 LightGBM 之前,我们先了解决策树模型。

和「数据结构与算法」课程中提到的决策树类似,一个决策树模型以每一个特征作为一个树的节点,根据特征的值来判断进入哪个分支,经过对一些特征的考察(考虑到剪枝,不一定是所有的特征),最终到达一个叶节点,即最终的决策结果。但是先考察哪些特征,后考察哪些特征,则需要通过信息论的方法来决定。

在信息论中,我们使用 Information Entropy 来表示这一特征划分以后的纯度,以多分类问题为例,如果集合 $D$ 共有 $k$ 类样本,第 $i$ 类样本所占的比例为 $p_{\textit{i}}$ ,那么 Information Entropy

$$
Ent(D)=-\sum_{i}^{|k|} p_{i} log_{2} p_{i}
$$

如果我们需要知道使用那个特征用以划分以后,对纯度的提升最大,就需要计算 Information Gain

$$
Gain(D, a)=\operatorname{Ent}(D)-\sum_{v=1}^{V} \frac{\left|D^{v}\right|}{|D|} \operatorname{Ent}\left(D^{v}\right)
$$

但是有些特征不能认为是样本的本质特征,比如标号,但是它们的 information gain 会很高,因此需要使用 Gain Ratio 来替代 Information Gain

$$
Gain_Ratio(D, a)=\frac{\operatorname{Gain}(D, a)}{\operatorname{IV}(a)}
$$

这里的 ${\operatorname{IV}(a)}$ 是用以衡量这个特征可能取值的范围有多大

$$
IV(a)=-\sum_{v=1}^{V} \frac{\left|D^{v}\right|}{|D|} \log _{2} \frac{\left|D^{v}\right|}{|D|}
$$

如果有一些特征计算得到了相同的 Information Gain Ratio,我们可以进一步计算 Gini Value

$$
Gini(D)=1-\sum_{k=1}^{|\mathcal{Y |}} p_{k}^{2}
$$

Gini Value 最终用于计算 Gini Index
$$
Gini_Index(D, a)=\sum_{v=1}^{V} \frac{\left|D^{v}\right|}{|D|} Gini\left(D^{v}\right)
$$

这样,我们已经有了一套完备的特征选择体系来用构建出决策树。

XGBoost

XGBoost 在 2016 年由华盛顿大学的陈天奇在 XGBoost: A Scalable Tree Boosting System 中提出,此模型被验证在各类数据挖掘比赛上表现优异。XGBoost 的优化目标函数分为两部分,其中前面一项表示在训练数据上的损失情况,后一项表示模型的复杂度以及在未知数据上的稳定性。

$$
\mathcal{L}(\phi)=\sum_{i} l\left(\hat{y_{i}}, y_{i}\right)+\sum_{k} \Omega\left(f_{k}\right)
$$

由于陈天奇团队已经将 XGBoost 封装在了一个包里,我们可以直接调取模型,并设置参数。

def runXGB(train_X, train_y, val_X, val_y, test_X):
    regressor = xgb.XGBRegressor(
        objective='reg:linear',
        colsample_bytree=0.2,
        learning_rate=0.05,
        max_depth=3,
        alpha=0.4,
        n_estimators=128
    )
    regressor.fit(train_X, train_y)
    pred = regressor.predict(test_X)
    return pred, regressor

这里设置了一组比较典型的参数:使用线性回归模型,每棵树随机采样的列数的占比为 0.2,学习率 0.05,最大树深度为 3,alpha 值为 0.4,树的个数为 128。

LightGBM

XGBoost 的问题在于训练起来实在是太慢了(虽然相比之前的GBDT模型已经快了很多)。因此我们又尝试了微软亚洲研究院和北京大学在 2017 年在 LightGBM: A Highly Efficient Gradient BoostingDecision Tree 中提出的 LightGBM 决策树模型,它因为使用了 Gradient-based One-Side Sampling(GOSS) 和 Exclusive Feature Bundling(EFB) 这两个技巧,在几乎不损失预测效果的情况下比 XGBoost 快了一大截。同样,LightGBM 也已经拥有完整封装的包,可以直接调取。在参数的选择上,我们也没有特意去调,大多数都选择了默认参数。

def runLGB(train_X, train_y, val_X, val_y, test_X):
    params = {
        "objetive": "regression",
        "metric": "mae",
        "n_estimators": 20000,
        "early_stopping_rounds": 200,
        "num_leaves": 32,
        "learning_rate": 0.05,
        "bagging_fraction": 0.7,
        "bagging_seed": 0,
        "num_threads": 4,
        "colsample_bytree": 0.7
    }
    lgb_train = lgb.Dataset(train_X, label=train_y)
    lgb_val = lgb.Dataset(val_X, label=val_y)
    regressor = lgb.train(params,
                      lgb_train, valid_sets=[lgb_train, lgb_val],
                      early_stopping_rounds=200,
                      verbose_eval=1000
                      )
    pred = regressor.predict(test_X, num_iteration=regressor.best_iteration)
    return pred, regressor

网络模型

MLP

这里使用 MLP 名称是为了和下面提到的多层感知网络区分开,实际上两者差别并不大。这里调用 Ultimate 库中的 MLP 模型以及设置了一组典型参数。

mlp = MLP(
    layer_size=[train_X.shape[1], hidden_size, hidden_size, hidden_size, 1],
    activation='a2m2l',
    op='fc',
    rate_init=0.06,
    leaky=(5 ** 0.5 - 3) / 2,
    bias_rate=[],
    regularization=1,
    importance_mul=0.0001,
    output_shrink=0.1,
    output_range=[-1, 1],
    loss_type='mae',
    verbose=1,
    importance_out=True,
    rate_decay=0.8,

    epoch_decay=epoch_decay,
    epoch_train=epoch_train,
)

多层感知网络

为了尝试达到更好的效果,我们也尝试使用 PyTorch 来实现一个网络模型,这里使用多层感知网络是因为比较直观,易于理解。我们的网络模型也非常简单,一共只有五层,中间每个隐层都拥有 128 个神经元,使用 ReLU 作为激活函数,且每一层之间都是全连接的。

class MLPModel(nn.Module):
    def __init__(self, num_features):
        super().__init__()
        self.model = nn.Sequential(
            weight_norm(nn.Linear(num_features, 128)),
            nn.ReLU(),
            weight_norm(nn.Linear(128, 128)),
            nn.ReLU(),
            weight_norm(nn.Linear(128, 128)),
            nn.ReLU(),
            weight_norm(nn.Linear(128, 128)),
            nn.ReLU(),
            weight_norm(nn.Linear(128, 1)),
        )
        for m in self.model:
            if isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight_v)
                nn.init.kaiming_normal_(m.weight_g)
                nn.init.constant_(m.bias, 0)

    def forward(self, input_tensor):
        return torch.clamp(self.model(input_tensor), 0, 1)

模型训练

XGBoost

先使用 XGBoost 训练是因为 XGBoost 已经有封装地非常易用的包,可以直接输入 DataFrame 进行训练,不需要进行预处理。

pred, model = runXGB(train_X, train_y, val_X, val_y, test_X)

LightGBM

与 XGBoost 不同的是,LightGBM 要求训练数据首先进行一些预处理,将数据标准化。

scaler.transform(train_X)
scaler.transform(test_X)
np.clip(test_X, out=test_X, a_min=-1, a_max=1)
train_y = train_y * 2 - 1

然后划分一下训练集和验证集,这里选择训练集和验证集的比例为 $0.8:0.2$。

train_index = round(int(train_x.shape[0]*0.8))
dev_X = train_X[:train_index]
val_X = train_X[train_index:]
dev_y = train_y[:train_index]
val_y = train_y[train_index:]

完成数据的标准化以后,可以通过调用 模型介绍 部分编写的函数进行训练。

pred, model = runLGB(dev_X, dev_y, val_X, val_y, test_X)
Training until validation scores don't improve for 200 rounds.
[1000]	training's l1: 0.0588639	valid_1's l1: 0.0601643
[2000]	training's l1: 0.0568477	valid_1's l1: 0.0593014
[3000]	training's l1: 0.0553922	valid_1's l1: 0.0588932
[4000]	training's l1: 0.0541782	valid_1's l1: 0.058668
[5000]	training's l1: 0.0531071	valid_1's l1: 0.0585234
[6000]	training's l1: 0.0520963	valid_1's l1: 0.0583954
[7000]	training's l1: 0.0511978	valid_1's l1: 0.0583113
[8000]	training's l1: 0.0503353	valid_1's l1: 0.0582485
[9000]	training's l1: 0.0495292	valid_1's l1: 0.0582005
[10000]	training's l1: 0.0487677	valid_1's l1: 0.0581655
[11000]	training's l1: 0.0480622	valid_1's l1: 0.0581429
[12000]	training's l1: 0.0473635	valid_1's l1: 0.0581145
[13000]	training's l1: 0.0466782	valid_1's l1: 0.0580828
Early stopping, best iteration is:
[13348]	training's l1: 0.0464543	valid_1's l1: 0.058076

由于我们前面已经设置了 early_stopping_rounds=200 ,因此模型在第 13348 次迭代时,发现已经有超过 200 次迭代没有带来效果的提升,就停止了训练。如此设置是为了避免过拟合。

MLP

MLP 的数据预处理方式与上面相同。

mlp.fit(train_X, train_y)
100.00% |====================>| 0ms    rate: 0.00135108   loss: 0.00667117  
time elapsed: 3h26m
<ultimate.mlp.MLP at 0x7f2445f02048>

这里需要指出的是,模型在验证集上的 $loss$ 达到了 $0.00667117$,这是一个非常好的表现了。但是实际在测试集上表现却查了很多,我们初步判断还是过拟合了。

多层感知网络

为了方便模型的训练,我们调用了 helperbot 库,这个是一个个人开发者编写的类,用于简化模型的编写。

from helperbot.bot import BaseBot
from helperbot.lr_scheduler import TriangularLR
from helperbot.weight_decay import WeightDecayOptimizerWrapper

bot = PUBGBot(
    model,
    train_loader,
    val_loader,
    optimizer=wrapped_optimizer,
    avg_window=int(batches_per_epoch / 10),
)

然后进行训练。

bot.train(
    n_steps,
    log_interval=int(batches_per_epoch / 10),
    snapshot_interval=int(batches_per_epoch / 10 * 5),
    early_stopping_cnt=10,
    scheduler=scheduler,
)
Val loss: 0.02644326

后处理

在我们做好上面的工作,跑出了一组结果以后,后处理的步骤也十分重要。后处理主要是修正一些特殊值或者离群值,往往对分数能有一点点提升。这里我们尝试了三种后处理方式,其中第一种带来了一些略微的提升,第二种带来了较大的提升,第三种则没有带来提升,反而让分数下降了。

第一种后处理的方式,逻辑上十分简单:模型跑出来的预测结果,有某几个样本是超出 $[0,1]$ 这个范围的,与题意不符。那么我们就把这些样本的值拉回到这个值域中。即如果一个样本的 winPlacePerc > 1,就让他等于一,同理对于 winPlacePerc < 0 的样本值我们将它置零。

第一种后处理方式实现起来也非常简明。

df_test["winPlacePerc"] = pred
df_test.loc[df_test.winPlacePerc < 0, "winPlacePerc"] = 0
df_test.loc[df_test.winPlacePerc > 1, "winPlacePerc"] = 1

第二种后处理的方式略复杂。我们在 特征工程 的部分有涉及到按组聚合的思想,那么我们在后处理的时候,也先按组聚合,然后给每一个组排序,每一个样本就多了一个特征:样本所在组的排名。再将这个特征进行变换,由组排名变换为组所在位次比例(和 winPlacePerc 类似)。有了组排名,再加上之前已经有的组内排名,我们就可以根据「排名第一的组中排名第一的人全局位次也是第一」和「排名最后的组中排名最后的人全局位次也是最后」两条规则来处理一些边界情况。

第二种后处理的实现涉及的操作就比较多。

df_test_group = df_test.groupby(["matchId", "groupId"]).first().reset_index()
df_test_group["rank"] = df_test_group.groupby(
    ["matchId"])["winPlacePerc"].rank()
df_test_group = df_test_group.merge(
    df_test_group.groupby("matchId")["rank"].max().to_frame(
        "max_rank").reset_index(),
    on="matchId", how="left")

df_test_group["adjusted_perc"] = (
    df_test_group["rank"] - 1) / (df_test_group["numGroups"] - 1)

df_test = df_test.merge(df_test_group[["adjusted_perc", "matchId", "groupId"]], on=[
    "matchId", "groupId"], how="left")
df_test["winPlacePerc"] = df_test["adjusted_perc"]

df_test.loc[df_test.maxPlace == 0, "winPlacePerc"] = 0
df_test.loc[df_test.maxPlace == 1, "winPlacePerc"] = 1

前面两种后处理的方式都是只涉及了 0 和 1 这两个边界,第三种后处理方式就涉及到内部。我们的思考是:由于一场比赛的参赛者数量是离散的,那么他们的位次比例一定是落在某些离散的点上。比如一场比赛有 100 个参赛者,那么他们的位次比例就是在数轴上 $[0,1]$ 之间上落下 100 个点,其中 0 和 1 这两个点一定是有的,那么 100 个落下的点就会把 $[0,1]$ 分割成 99 个区间。也就是说,一场 100 个参赛者的比赛,参赛者最后的位次比例一定是集合 ${1, 1-\dfrac{1}{99}, 1-\dfrac{2}{99}, 1-\dfrac{3}{99}, 1-\dfrac{4}{99}, \dots, 0}$ 中的某个取值。那么我们再看后处理的时候,只需要比对样本最后预测得到的 winPlacePerc 和上面的这个集合,找到集合中与 winPlacePerc 最近的值用来替换即可。

第三种后处理的方式,思路是没有问题的,但是最后跑出来分数却掉了很多。可能是因为预测本身就不够精确,这样后处理反而把误差放大了。

模型融合

平均融合

平均融合得思想就是对多个模型的预测结果取平均值,作为融合后的预测值,

$$
\hat{y}=\frac{\hat{y_1}+\hat{y_2}}{2}
$$

加权融合

加权融合根据单模型的分数($s_1$、$s_2$) 通过指数变换的得到权重,然后做加权平均。

$$
\hat{y}=\frac{\hat{y_1}\times e^{-s_1}+\hat{y_2}\times e^{-s_2}}{e^{-s_1}+e^{-s_2}}
$$

结果

用平均绝对误差(MAE)来衡量模型的分数。
$$
\textit{MAE}=\frac{1}{m}\left|y-\hat{y} \right|_1
$$
结果如下。

模型 后处理 融合 分数(MAE)
XGBoost 第二种 0.02365
LightGBM 第二种 0.02181
MLP 第一种 0.02443
MLP 第二种 0.02169
多层感知网络 第二种 0.02113
LightGBM + MLP 第二种 平均融合 0.02145
LightGBM + 多层感知网络 第二种 平均融合 0.02098
XGBoost + MLP 第二种 平均融合 0.02216
XGBoost + 多层感知网络 第二种 平均融合 0.02174
LightGBM + MLP 第二种 加权融合 0.02145
LightGBM + 多层感知网络 第二种 加权融合 0.02098
XGBoost + MLP 第二种 加权融合 0.02216
XGBoost + 多层感知网络 第二种 加权融合 0.02173

可见,在四个模型中,表现较好的是 LightGBM + 多层感知网络。首先值得思考的是:LightGBM 由于使用了一些 trick 来加快训练速度,预测的精度应该是不如 XGBoost 的,然而在这四个模型中,XGBoost 模型反而成了最拖后腿的一个。这里的解释是,由于 XGBoost 在训练的时候剪枝的积极性不如 LightGBM,因此我们不得不将一些参数设置为较低的标准,否则训练的开销会让我们难以承受。

在我们跑出的结果中,模型融合能带来一些提升,但是提升并不大,这是因为我们在特征的处理上没有办法做到很细致。以及为了保证融合时模型的差异性,在融合的时候,我们每次都选择一个决策树模型和一个网络模型进行融合。

支付宝扫码打赏 微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

扫描二维码,分享此文章

Yzstr Andy's Picture
Yzstr Andy

School of Data and Computer Science, SUN YAT-SEN UNIVERSITY