This page looks best with JavaScript enabled

【AI】腾讯游戏安全竞赛-机器学习 2021 WriteUp

 ·  ☕ 14 min read · 👀... views

虽然AI在客户端安全上的作用还不是非常显著,但我总觉得不学AI迟早要被淘汰。以一个客户端安全人的视角来看,学习AI的只需要学会用即可:也就是不用花大量的时间去研究推敲数学公式和模型以追求极高的准确率,而是能会用一些已有的框架和模型解决一些实际中的问题即可。正巧,发现了2021年的腾讯游戏安全竞赛题非常符合这个要求,数据源非常贴合实际,并且所要解决的问题也是目前游戏安全对抗中非常常见的一个实际问题。同时发现网络上有关腾讯游戏安全技术竞赛的机器学习赛道题解非常少,所以采用这道题来抛砖引玉的分享一下客户端人学习AI并解一道复杂实际问题的过程。

题解参考:https://github.com/librauee/gslab2021

初赛题目

赛题背景

第一人称射击游戏(First Person Shooting,简称FPS游戏)是最为经典的游戏类型之一,也是当下玩家最多,最受欢迎的游戏类型之一。由于射击游戏很多关键逻辑计算放在了客户端,导致普遍安全性不是很高,朝向异常就是其中比较典型外挂功能。朝向异常可帮助作弊玩家实现自动瞄准甚至自动开枪击杀,极大影响游戏的公平竞技性。

比赛规则

请参赛者根据附件中所提供的数据,分析并建立模型,识别正常玩家与恶意玩家

数据说明

瞄准击杀是FPS游戏最常见的玩法。游戏引擎将鼠标移动数据,转化为朝向需要变化的量,确定当前朝向后显示到屏幕,朝向异常外挂通过修改朝向数据使得枪口准心指向目标或者修改鼠标数据间接影响朝向数据来达到类似效果。

我们提供某款FPS游戏的2020.05.09当天全量玩家的瞄准击杀行为数据,以及当天游戏的白用户名单(玩家历史上清白没有处罚记录也没有可疑行为,大概率不作弊)和当天有朝向异常外挂的用户名单(有检测朝向异常外挂进程,大概率作弊),同时提供2020.5.12~2020.5.14部分未标注的2000个账号的瞄准击杀行为数据作为测试集数据,选手需要根据历史数据表现来预测测试集中的账号是否存在朝向异常行为。

案例说明

以训练集文件的玩家 e21e02b0e4057a83fa287cefc22fa683,2020-05-09 10:09:30 为例,可发现deltaX一阶差分序列上会有高频的过0表现(300帧附近),且与前面的序列表现模式区别很大,原因为外挂在生效的时候会直接修改鼠标的移动情况来使得朝向瞄准到目标。外挂除了可以修改鼠标的移动数值外,也可以通过直接修改朝向数值来达到朝向异常瞄准目标的目的。

数据字段含义

训练集和测试集均为玩家完成击杀前600次连续鼠标移动以及对应的朝向数据。

字段名列表 字段类型列表 字段描述列表 备注
log_time log_time STRING 记录入库时间
uin uin STRING 玩家账号
kill_time killtime DOUBLE 玩家击杀的本地时间
time_new time_new DOUBLE 对应帧的精确的本地时间
index index_ BIGINT 帧序号
deltaX deltaX BIGINT 鼠标x轴移动量
deltaY deltaY BIGINT 鼠标y轴移动量
button button_ BIGINT 鼠标按键动作
pitch pitch FLOAT 转换到pi范围 pitch值
yaw yaw FLOAT 转换到pi范围 yaw值
pithch_r pithch_r FLOAT 原始的当前朝向pitch值
yaw_r yaw_r FLOAT 原始的当前朝向yaw值
type type BIGINT 对应帧的事件
weapon_id weapon_id BIGINT 武器id

type标识位定义:值是或计算,如3就是0x1|0x2,表示开枪击中

说明
0x1 开枪
0x2 击中
0x4 击杀

button标识位定义:值是或计算,如10就是0x2|0x8,表示同时松开左右键

说明
0x0001 Left button changed to down.
0x0002 Left button changed to up.
0x0004 Right button changed to down.
0x0008 Right button changed to up.
0x0010 Middle button changed to down.
0x0020 Middle button changed to up.

结果评价

针对测试集账号预测结果,朝向异常用户标识为1正常用户为0,计算得分score=4PR/(P+3R)*100%,其中P为测试集预测结果整体的准确度,R表示测试集预测结果被判为朝向异常的玩家在真实朝向异常玩家中的覆盖度。

题目分析

可以看出来这是一个非常实际的问题,从赛题角度来看,难点主要有以下几个

  1. 样本不纯:即黑样本未必开挂,白样本未必不开挂
  2. 数据量太大了:5E+条数据,一个csv接近70G,这不是一般个人电脑能够承受的,必须要做一些预处理(如果你的电脑不一般那可以直接忽略这个问题)

数据处理与特征提取

首先第一个问题应该是思考数据特征应该是什么样的。在这样一个问题中,我们暂不考虑代练、账号出借等情况,一个账号的游戏行为应该是相对固定的,这个行为绑定于游戏账号(uin)。如果这个用户朝向异常了,异常的那次击杀行为,会相对于他正常时候的游戏行为会有明显的差异。所以玩家行为绑定游戏账号(uin),数据特征关联玩家行为,即我们需要提取的是每个用户的游戏行为数据特征。

第二个问题就是这个数据集实在是太大了,哪怕只是选择一些列的数据来加载,这个加载时间也是不可承受的。我的2024版32G运存联想Y7000P+sn990固态使用pd.read_csv加载数据集的部分有效列需要11个小时。

经过多次加载发现,随着加载的进行,pd.read_csv读取chunk的速度会越来越慢,初速度大约是20w+行每秒,但是到后期就只剩几千行每秒了,并且从性能管理器看运存没有占满的情况下,这个读取速度也是在不断降低的。

基于这个情况,如果将数据处理拆分成多个批次,以列为批次划分并不是一个好的选择,应该降低每个批次行的数量。同时,我们需要提取的是每个用户的游戏行为数据特征,这个特征的计算每个用户相对独立,并不需要和大盘比较。所以,我们可以分批提取uin来计算,最后再做整合。

笔者这边的做法是将训练集以uin尾号拆分成奇数和偶数两个csv(可以以实际情况拆分成更多的csv),分别提取特征后再整合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
DATA_PATH = "data/训练集玩家瞄准击杀数据1.txt"
PART1_PATH = "data/训练集玩家瞄准击杀数据1(奇).txt"
PART2_PATH = "data/训练集玩家瞄准击杀数据1(偶).txt"

fp1 = open(PART1_PATH, 'w')
fp2 = open(PART2_PATH, 'w')

with open(DATA_PATH, 'r') as fp:
    cnt = 0
    flush_cnt = 0
    while True:
        line = fp.readline()
        if not line:
            break
        cnt += 1

        l = line.split('|')
        if int(l[1][-1], 16) % 2 == 1:
            fp1.write(line)
        else:
            fp2.write(line)

        if cnt % 1000000 == 0:
            fp1.flush()
            fp2.flush()
            flush_cnt += 1
            print(f"{flush_cnt}/518")

Ok,下面是开始预处理数据。先加载训练数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import multiprocessing, warnings, math, gc
import pandas as pd
from tqdm import tqdm
from gensim.models import Word2Vec
warnings.filterwarnings('ignore')

DATA_PATH = "data/训练集玩家瞄准击杀数据1(奇).txt"
WHITE_LIST = "data/训练集white_uin.txt"
BLACK_LIST = "data/训练集black_uin.txt"

dtype={
    1:'category',
    2:"float32",
    4:"uint16",
    5:"int32",
    6:"int32",
    7:"uint32",
    8:"float32",
    9:"float32",
    10:"float32",
    11:"float32",
    12:"uint8",
    13:"uint64",
}
col_names = ['uin', 'kill_time', 'index', 'deltaX', 'deltaY', 'button',
             'pitch', 'yaw','pitch_r', 'yaw_r', 'type', 'weapon_id']

chunks = []
for chunk in tqdm(pd.read_csv(DATA_PATH, sep="\|", header=None, usecols=dtype.keys(), dtype=dtype, chunksize=5000)): 
    chunks.append(chunk)
df = pd.concat(chunks, axis=0)      # 所有chunks纵向合并
del chunks

df.columns = col_names
df['uin'] = df['uin'].astype('category')
df.info()

加载标签数据

1
2
3
4
5
6
7
8
9
black = pd.read_csv(BLACK_LIST)
white = pd.read_csv(WHITE_LIST)
black.columns = ["uin"]
white.columns = ["uin"]
intersection = list(set(black["uin"].to_list()) & set(white["uin"].to_list()))
black["label"] = 1
white["label"] = 0
label_df = pd.concat([black, white], axis=0)                    # 为所有uin打标签
label_df = label_df[~label_df['uin'].isin(intersection)]        # 删掉历史白玩家和当日可疑玩家的交集

加载标签数据的时候有一个很关键的点,也就是会出现一个uin既出现在white_list中也出现在black_list中。也就是说,这个uin在历史上是一个白玩家,但是今天他出现了朝向异常行为。这也就是我们前面所说的该题存在样本不纯的问题:黑样本未必开挂,白样本未必不开挂。对于这一部分我的处理是删除white_list和black_list的交集,也就是既不信任这部分uin的白玩家身份也不认可今天他们的朝向异常是作弊行为。

然后剔除训练数据中没有标签的账号数据,再对训练数据以uin、kill_time、index执行排序

1
2
3
4
5
6
7
8
pos_uin = list(label_df[label_df['label'] == 1]['uin'])
neg_uin = list(label_df[label_df['label'] == 0]['uin'])
sample_uin = pos_uin + neg_uin
df = df[df['uin'].isin(sample_uin)]
df.info()

df.sort_values(['uin', 'kill_time', 'index'], inplace=True)
gc.collect()        # 显式垃圾回收

预处理好了训练集和标签数据,接下来要开始提取每个uin的数据特征。我并不是一个专业的AI人,也没有专门的修过统计学、数据分析和数据科学,只能模板式的提取我认为重要的数据的一些基本数据特征:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def get_angle_feature_df(field):
    agg_list = []
    for uin in tqdm(set(df['uin'])):
        df_uin = df[df['uin'] == uin][['uin', 'kill_time', field]]
        df_temp = df_uin.groupby(['kill_time'])[field].agg([        # 针对每个uin分组每次击杀
        (f'{field}_max', 'max'),                                    # 该次击杀角度最大值
        (f'{field}_min', 'min'),                                    # 该次击杀角度最小值
        (f'{field}_mean', 'mean'),                                  # 该次击杀角度均值
        (f'{field}_std', 'std'),                                    # 该次击杀角度标准差
        (f'{field}_skew', 'skew'),                                  # 该次击杀角度偏差

        (f'{field}_range_diff_max', lambda x: x.diff().max()),      # 该次击杀角度差值最大值
        (f'{field}_range_diff_min', lambda x: x.diff().min()),      # 该次击杀角度差值最小值
        (f'{field}_range_diff_mean', lambda x: x.diff().mean()),    # 该次击杀角度差值均值
        (f'{field}_range_diff_std', lambda x: x.diff().std()),      # 该次击杀角度差值标准差
        (f'{field}_range_diff_skew', lambda x: x.diff().skew()),    # 该次击杀角度差值偏差
            ]).reset_index()
        df_temp['uin'] = uin
        # print(df_temp)
        agg_list.append(df_temp)
    
    return pd.concat(agg_list, axis=0)


# pitch数据特征
df_temp = get_angle_feature_df("pitch")
label_df = pd.merge(label_df, df_temp, on=["uin"], how="left")
del df["pitch"]

# pitch_r数据特征
df_temp = get_angle_feature_df("pitch_r")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")
del df["pitch_r"]

# yaw_r数据特征
df_temp = get_angle_feature_df("yaw_r")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")

# yaw数据特征
df_temp = get_angle_feature_df("yaw")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")

df['yaw_difference'] = df['yaw'] - df['yaw_r']
df_temp = get_angle_feature_df("yaw_difference")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")
del df["yaw"]
del df["yaw_r"]
del df['yaw_difference']

# deltaX数据特征
df_temp = get_angle_feature_df("deltaX")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")

# deltaY数据特征
df_temp = get_angle_feature_df("deltaY")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")

# deltaXY数据特征
df['deltaXY'] = df.apply(lambda x: math.sqrt(x['deltaX'] ** 2 + x['deltaY'] ** 2), axis=1)
df_temp = get_angle_feature_df("deltaXY")
label_df = pd.merge(label_df, df_temp, on=["uin", "kill_time"], how="left")
del df['deltaX']
del df['deltaY']
del df['deltaXY']

计算每个uin的击杀次数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 每个uin的击杀次数
df_temp = df.groupby('uin')['kill_time'].nunique().to_frame()
df_temp.rename(columns={'kill_time':'kill_count'}, inplace=True)
label_df = pd.merge(label_df, df_temp, on=["uin"], how="left")

# 分组每个uin的每次击杀 获取武器id唯一值
agg_list = []
for uin in tqdm(set(df["uin"])):
    df_uin = df[df["uin"] == uin][["kill_time", "weapon_id"]]
    df_temp = df_uin.groupby(['kill_time'])['weapon_id'].agg([
        ('weapon_id_nunique', 'nunique'),           # 获取武器id唯一值
        ])
    df_temp['uin'] = uin
    df_temp = pd.merge(df_temp, df_uin.drop_duplicates('kill_time'), on=['kill_time'], how='left')
    agg_list.append(df_temp)
label_df = pd.merge(label_df, pd.concat(agg_list, axis=0), on=['uin', 'kill_time'], how='left')
del agg_list
del df["weapon_id"]


# 分组每个uin的每次击杀 获取鼠标按键动作唯一值
agg_list = []
for uin in tqdm(set(df["uin"])):
    df_uin = df[df["uin"] == uin][["kill_time", "button"]]
    df_temp = df_uin.groupby(['kill_time'])['button'].agg([
        ('button_nunique', 'nunique'),           # 获取button唯一值
        ])
    df_temp['uin'] = uin
    df_temp = pd.merge(df_temp, df_uin.drop_duplicates('kill_time'), on=['kill_time'], how='left')
    agg_list.append(df_temp)
label_df = pd.merge(label_df, pd.concat(agg_list, axis=0), on=['uin', 'kill_time'], how='left')
del agg_list


# 分组每个uin的每次击杀 获取帧事件的唯一值
agg_list = []
for uin in tqdm(set(df['uin'])):
    df_uin = df[df['uin'] == uin][['kill_time', 'type', 'index']]
    df_uin.reset_index(drop=True, inplace=True)    
    df_temp = df_uin.groupby(['kill_time'])['type'].agg([
        ('type_nunique', 'nunique'),     
        ])
    df_temp['uin'] = uin
    # print(df_temp)
    agg_list.append(df_temp)
label_df = pd.merge(label_df, pd.concat(agg_list, axis=0), on=['uin', 'kill_time'], how='left')
del agg_list

鼠标按键和帧事件是连串的行为序列,我参考了网上的一份题解(https://github1s.com/librauee/gslab2021)做了词向量转化,即将每一个动作序号看作一个单词建立向量空间。这一部分我其实并不太明白为什么要这么做,因为事件序号不是单词,不能借用已有的语言模型去分析关系。所以这一部分的处理应该是用序列模型才对。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def get_w2v_features(target, type):
    global df

    # 分组每个uin的每次击杀,串联事件
    agg_list = []
    for uin in tqdm(set(df['uin'])):
        df_uin = df[df['uin'] == uin][['kill_time', target]]
        df_uin.reset_index(drop=True, inplace=True)
        df_uin[target] = df_uin[target].astype('str')
        df_temp = df_uin.groupby('kill_time', as_index=False)[target].agg({'list':(lambda x: list(x))}).reset_index(drop=True)
        agg_list.append(df_temp)
    df_bag = pd.concat(agg_list, axis=0)
    doc_list = list(df_bag['list'].values)
    del df_bag
    del agg_list

    # 将串联起来的事件视为文本 做词向量转化
    w2v = Word2Vec(doc_list, vector_size=10, window=3, min_count=1, workers=multiprocessing.cpu_count())
    vocab_keys = list(w2v.wv.key_to_index.keys())       # 获取文本中词的索引(所有词的全集)
    w2v_array = []
    for v in vocab_keys:                        # 对于每一种事件类型(词),生成对应的词向量
        w2v_array.append(list(w2v.wv[v]))

    # 封装事件对应词向量的矩阵
    df_w2v = pd.DataFrame()
    df_w2v['vocab_keys'] = vocab_keys
    df_w2v = pd.concat([df_w2v, pd.DataFrame(w2v_array)], axis=1)
    df_w2v.columns = [target] + ['w2v_%s_%d'%(target, x) for x in range(w2v.vector_size)]
    df_w2v[target] = df_w2v[target].astype(type)

    # 词向量数据写回总表
    df = pd.merge(df, df_w2v, on=target, how='left')

    # 分组每个uin的每次击杀,计算各个w2v向量均值
    agg_list = []
    for uin in tqdm(set(df['uin'])):
        df_uin = df[df['uin'] == uin][['kill_time'] +['w2v_%s_%d'%(target, x) for x in range(w2v.vector_size)]]
        df_uin.reset_index(drop=True, inplace=True)
        df_uin_killtime = df_uin.drop_duplicates('kill_time')[['kill_time']]
        df_uin_killtime["uin"] = uin

        # 计算该次击杀的所有帧的各个w2v向量均值
        for i in range(w2v.vector_size):
            d = df_uin.groupby(["kill_time"])['w2v_%s_%d'%(target, i)].agg([
                (f'{target}_w2v_mean_{i}', 'mean'),     
                ])
            df_uin_killtime = pd.merge(df_uin_killtime, d, on='kill_time', how='left')
        agg_list.append(df_uin_killtime)
    
    df.drop(columns=df_w2v.columns)
    return pd.concat(agg_list, axis=0)

w2v_features = get_w2v_features("type", "uint8")
label_df = pd.merge(label_df, w2v_features, on=['uin', 'kill_time'], how='left')
w2v_features = get_w2v_features("button", "uint32")
label_df = pd.merge(label_df, w2v_features, on=['uin', 'kill_time'], how='left')

最后计算每个uin的各次击杀时间关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 分组每个uin  获取击杀时间数据特征
df_uin_killtime_feature = label_df.groupby('uin')['kill_time'].agg([
        ('kill_time_max', 'max'),                               # 最后一次击杀时间
        ('kill_time_min', 'min'),                               # 第一次击杀时间
        ('kill_time_range_max', lambda x: x.diff().max()),      # 击杀时间最大间隔
        ('kill_time_range_min', lambda x: x.diff().min()),      # 击杀时间最小间隔
        ('kill_time_range_mean', lambda x: x.diff().mean()),    # 击杀时间间隔均值
        ('kill_time_range_std', lambda x: x.diff().std()),      # 击杀时间间隔标准差
        ('kill_time_range_skew', lambda x: x.diff().skew()),    # 击杀时间间隔偏差
        ])
label_df = pd.merge(label_df, df_uin_killtime_feature, on='uin', how='left')
label_df['kill_time_diff'] = label_df['kill_time_max'] - label_df['kill_time_min']
label_df['kill_time_ratio'] = label_df['kill_time_diff'] / label_df['kill_count']
del df_uin_killtime_feature

最后删除空行保存数据

1
2
3
4
5
label_df.dropna(axis=0, subset=['kill_time'], inplace=True)   # 删除kill_time为空的行
print(label_df.head())
print(label_df.columns)
label_df.to_csv("data/train(奇).csv")
label_df.to_pickle("data/train(奇).pkl")

将uin最后一位是偶数的训练数据集也跑一遍,得到两个pickel,将其拼接得到最终的训练集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import pandas as pd
from tqdm import tqdm

DATA1_PATH = "data/train(奇).pkl"     
DATA2_PATH = "data/train(偶).pkl"

df1 = pd.read_pickle(DATA1_PATH)
df2 = pd.read_pickle(DATA2_PATH)

df = pd.concat([df1, df2], ignore_index=True)
del df['uin_last_int']
df.to_csv("data/train.csv")
df.to_pickle("data/train.pkl")

测试集的处理同理,因为题目给出了“测试集玩家名单.txt”和“测试集玩家瞄准击杀数据.txt”,我们需要使用训练好的模型去预测这2000个账号的标签并提交,所以我们和前面的步骤一致通过测试集玩家瞄准击杀数据来提取数据特征,但比训练集会缺少label字段,这个字段需要我们用后续训练好的模型来预测。

模型训练

首先,在这里我要排除使用torch。原因非常简单,主要有两点:

  1. 我并不是一个专业的AI人, 我不能够对于这样一个复杂问题快速分析设计网络,搭建出一套高准确度的模型。我寻求的是一种简单的、高下限的解决方法
  2. 在实际的工业环境中,每天的工作任务繁重,哪怕是一位非常专业的AI人使用torch解决一个复杂的实际问题也需要对这个问题有非常深刻的分析和理解,这在很多情况下并不具备这样的时间条件来分析。所以在实际工业环境中,通常大家也会选择一些已有的框架模型来解决具体的问题,而非使用torch自己搭建一套模型。

然后看一下这道题目,首先可以确定这是一个分类问题,即根据训练集的数据来对测试集中的uin进行分类预测。现在非常主流的用于解决回归和分类问题的算法是梯度提升机器(Gradient Boosting Machine,GBM),其通过不断迭代,以损失函数的负梯度方向训练出一个弱学习器的序列,然后将它们组合起来构成一个强大的模型。现在非常流行的梯度提升库有XGBoost和LightGBM。网上对于他们两者的对比有很多,总而言之是,在通常情况下,XGBoost会比LightGBM的准确度高一点点,但耗费的时间相差数倍。故笔者采用LightGBM解此题。

接下来再回到这道题目上,我们现在拥有两套数据,即训练集和测试集。测试集中的数据不带有label,我们需要使用训练好的模型预测测试集中的数据然后提交,故我们不能使用测试集来自测模型,需要我们对训练集中的数据拆分出验证集来评估。现在比较流行的拆分验证集的方式有K折交叉验证。

K折交叉验证:将含有N个样本的数据集,分成K份,每份含有N/K个样本。选择其中一份作为验证集,另外K−1份作为训练集,从而得到K种情况。在每种情况中,用训练集训练模型,用验证集测试模型,交叉验证重复K次,平均K次的结果作为模型最终的泛化误差。K的取值一般在2-10之间,10折交叉验证是最常用的。K折交叉验证的优势在于,同时重复运用随机产生的子样本进行训练和验证。需要注意的是,训练集和验证集必须从完整的数据集中均匀采样,以减少训练集、验证集与原数据集之间的偏差。

在Sklearn中,我们通常会使用KFold、StratifiedKFold、GroupKFold三种方法来做K折。kfold交叉验证,直接随机的将数据划分为k折,这个方法用的较少;Stratified它会根据数据集的分布来划分,使得划分后的数据集的目标比例和原始数据集近似,也就是构造训练集和测试集分布相同的交叉验证集;GroupKFold 会保证同一个group的数据不会同时出现在训练集和测试集上。

我们分别使用StratifiedKFold和GroupKFold方法来讲一下本题的一个大坑点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 加载训练集
df = pd.read_pickle("data/train.pkl")
df.info()
y = df['label']
X_train = df.copy()
features = X_train.columns
features = features.drop(['uin','label', 'kill_count'])

KF = StratifiedKFold(n_splits=10, random_state=2020, shuffle=True)
params = {
          'objective':'binary',
          'metric':'binary_error', 
          'learning_rate':0.05, 
          'subsample':0.8, 
          'subsample_freq':3, 
          'colsample_btree':0.8,
          'num_iterations': 10000, 
          'silent':True
}

oof_lgb = np.zeros(len(X_train))

# K折交叉验证
for fold_, (trn_idx, val_idx) in enumerate(KF.split(X_train.values, y.values)):
    trn_data = lgb.Dataset(X_train.iloc[trn_idx][features],label=y.iloc[trn_idx])    
    val_data = lgb.Dataset(X_train.iloc[val_idx][features],label=y.iloc[val_idx])
    clf = lgb.train(
        params,
        trn_data,
        num_boost_round=10000,
        valid_sets = [trn_data, val_data],
    )
    # 记录每一折的测试集概率
    oof_lgb[val_idx] = clf.predict(X_train.iloc[val_idx][features], num_iteration=clf.best_iteration)


# 计算得分
def calc_score(label, prob_list, t):
    predicit_label = [1 if i >= t else 0 for i in prob_list]
    AUC = roc_auc_score(label, prob_list)
    F1 = f1_score(label, predicit_label)
    P = precision_score(label, predicit_label)
    R = recall_score(label, predicit_label)
    oof_score = 4 * P * R / (P + 3 * R)
    print("AUC score: {}".format(AUC))
    print("F1 score: {}".format(F1))
    print("Precision score: {}".format(P))
    print("Recall score: {}".format(R))
    print(oof_score)


calc_score(y, oof_lgb, 0.5)

非常简单非常模板化的LGB训练代码,通过StratifiedKFold将训练集做10折交叉验证,最终得到得分:

AUC score: 0.9975759601954487
F1 score: 0.9600627914492821
Precision score: 0.9990391082924954
Recall score: 0.9240135087095628
0.9791632331128012

嗯…如果这个准确率是真实的,这世上应该没有外挂了。很显然是哪里出现了问题。我们替换 KF = GroupKFold(n_splits=10) 来做K折重新跑一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
KF = GroupKFold(n_splits=10)        # 以uin来对训练集做分组K折
for fold_, (trn_idx, val_idx) in enumerate(KF.split(X_train.values, y.values, X_train['uin'])):
    trn_data = lgb.Dataset(X_train.iloc[trn_idx][features],label=y.iloc[trn_idx])    
    val_data = lgb.Dataset(X_train.iloc[val_idx][features],label=y.iloc[val_idx])
    clf = lgb.train(
        params,
        trn_data,
        num_boost_round=10000,
        valid_sets = [trn_data, val_data],
    )
    oof_lgb[val_idx] = clf.predict(X_train.iloc[val_idx][features], num_iteration=clf.best_iteration)

得分如下

AUC score: 0.8856661436082619
F1 score: 0.5263868848432751
Precision score: 0.85142516635217
Recall score: 0.38095449697831496
0.6505662954601505

这个分数看起来对劲了,那么为什么会造成这样的区别呢?我们回过头来看这个数据集,数据集中记录了每个uin每次击杀的数据特征,每次击杀(每个kill_time)记录一行。也就是每个uin在整个数据集中可能出现多行,对应多次击杀。如果我们使用StratifiedKFold,它很可能会将uin的多次击杀同时分配到训练集和验证集中,即这个uin的部分击杀被训练而部分击杀被验证。如果我们假定每一位玩家相对于其自身(无论该玩家是否是作弊玩家),每次击杀的数据特征都是相似的,那么这样的分组方式就会导致验证集的泄露。即考试前做了差不多的原题。从而导致过拟合使准确率虚高。我们将每位uin的数据行为相似的因素考虑进去后,我们就必须对uin分组,保证同一个uin不可以既出现在训练集中又出现在验证集中。这就是GroupKFold的作用。

Share on

Qfrost
WRITTEN BY
Qfrost
CTFer, Anti-Cheater, LLVM Committer