请点击此处查看本环境基本用法.

Please click here for more detailed instructions.

飞桨常规赛:听闻 - 10月第8名方案

( T i p s : 该 部 分 主 要 为 介 绍 思 路 和 基 线 修 改 指 南 , 实 战 部 分 在 后 半 部 分 单 独 列 出 ) \color{red}{(Tips:该部分主要为介绍思路和基线修改指南,实战部分在后半部分单独列出)} Tips线

赛题介绍

广告欺诈是数字营销需要面临的重要挑战之一,点击会欺诈浪费广告主大量金钱,同时对点击数据会产生误导作用。本次比赛提供了约50万次点击数据。特别注意:我们对数据进行了模拟生成,对某些特征含义进行了隐藏,并进行了脱敏处理。

MarTech技术已经被广泛应用于商业广告分析与挖掘中,在搜索广告,信息流广告,营销预测,反欺诈发现,商品购买预测,智能创意生成中有广泛的应用。

请预测用户的点击行为是否为正常点击,还是作弊行为。点击欺诈预测适用于各种信息流广告投放,banner广告投放,以及 百度网盟平台,帮助商家鉴别点击欺诈,锁定精准真实用户。

点击跳转至赛题页面
( T i p s : 机 器 学 习 框 架 方 面 只 允 许 使 用 飞 桨 深 度 学 习 框 架 哦   ) \color{red}{(Tips:机器学习框架方面只允许使用飞桨深度学习框架哦~)} Tips使 

基线介绍

运行方式

本次基线基于飞桨PaddlePaddle2.0版本,若本地运行则可能需要额外安装pandas模块。

本地运行

下载左侧文件夹中的”本地运行方案“,解压到本地后修改为英文目录后运行run_model.py即可开始训练,修改该文件的IS_INFER参数即可生成推理结果,推理结果文件为result.csv。(推荐在右上角“文件”->“导出Notebook到py”,这样可以保证代码是最新版本)

AI Studio (Notebook)运行

依次运行下方的cell即可,若运行时修改了cell,推荐在右上角重启执行器后再以此运行,避免因内存未清空而产生报错。
Tips:若修改了左侧文件夹中数据,也需要重启执行器后才会加载新文件。

设计思想

执行流程
  1. 配置预处理数据方案(选手可以自行设计,默认提供用于Embedding的数据转化和归一化两种方案)
  2. 生成Embedding所需要的index字典(若选手不使用Embedding则可以跳过该步骤)
  3. 检查数据是否可以正确读取(可省略,若选手自行修改了Reader部分,务必检查能否读取后再进行下一步操作)
  4. 开始训练
  5. 执行预测并产生结果文件
技术方案

在本次赛题中,虽然赛题数据与以往的线性回归任务比较相似,但更像是一个二分类任务(判断欺诈、非欺诈)。 接下来将介绍技术方案中的一些细节问题以及新增method流程。

数据处理方案

我们希望选手在做预处理之前首先判断19个字段属性是什么,然后再决定处理方案。
基 线 默 认 提 供 了 两 种 常 见 的 预 处 理 方 案 ( n o r m 、 E m b e d d i n g ) , 这 些 可 在 “ 数 据 预 处 理 − 配 置 ” c e l l 中 进 行 配 置 \color{red}{基线默认提供了两种常见的预处理方案(norm、Embedding),这些可在“数据预处理-配置”cell中进行配置 } 线normEmbeddingcell
Tips:虽然赛题页面有字段说明,但字段类型仍需选手自行判断。

新增预处理方案

比赛数据集字段过多,为了减轻预处理代码的工作量特编写此方案。
新增步骤如下:

  1. 可以模仿baseline_tools.py中的Data2IdNorm来实现预处理class
    以归一化至0~1处理为例:
    假设某字段含有1,2,3,4共计4个值,如果想缩放到0~1之间,可以使用(当前值 - 最小值) / (最大值 - 最小值)方式计算出权重大小。
    本示例为了方便起见,直接通过(1 / 最大值)来计算权重,此处可能有一定调优空间。
# ===== Data2IdNorm类 ===== 新增预处理方案 =====
class Data2IdNorm:
    def __init__(self, norm_weight):
        self.norm_weight = norm_weight	# 获取当前字段的归一化权重
    def transform_data(self, sample, shape=None, d_type="float32"):
        sample *= self.norm_weight	# sample为当前index下对应字段的数据,该数据与归一化权重相乘即可进行归一化
        sample = value2numpy(sample, shape, d_type)	# 将该数据转化为numpy的array对象,value2numpy在baseline_tools.py中已经提供
        return sample
    def get_method(self):
        return self.transform_data # 返回预处理方法

新增预处理方案后,可以在Reader类中找到下方代码段,通过添加elif来注册xxx流程。

# ===== Reader类 ===== 预处理方案注册 =====
for col in self.cols:
    if TAGS[col] == "emb":
        self.methods[col] = Data2IdEmb(dict_path=emb_dict_path, dict_name=col).get_method()
    elif TAGS[col] == "xxx":
        self.methods[col] = Data2IdNorm(norm_weight=0.0001).get_method() # 此处假设权重为0.0001
    else:
        raise Exception(str(TAGS) + "是未知的预处理方案,请选手在此位置使用elif注册")

在TAGS中更改对应预处理策略

TAGS = {'android_id': "emb",
        'apptype': "emb",
        'carrier': "xxx", 	# 对应elif TAGS[col] == "xxx"中的xxx
        ...
backbone修改方案

如果在网络部分有特殊需求,可以在SampleNet类中注册网络结构

# ===== 网络结构注册 =====
if tag_method == "emb":
    hidden_layer = nn.LayerList([nn.Embedding(size=[size_dict[tag], EMB_SIZE]),
                                    nn.Linear(input_dim=EMB_SIZE, output_dim=EMB_LINEAR_SIZE)])
    out_layer_input_size += EMB_LINEAR_SIZE
elif tag_method == "xxx": 	
# 注册xxx网络结构,也可以多种预处理方案使用同一种网络结构,例如:elif tag_method in ["xxx", "yyy"]:
    hidden_layer = nn.LayerList(
        [nn.Linear(input_dim=1, output_dim=1, act="tanh") for _ in range(LINEAR_LAYERS_NUM)])
    out_layer_input_size += 1
elif tag_method is None:
    continue
else:
    raise Exception(str(tag_method) + "为未注册的处理方案,请在SampleNet类中进行定义处理流程")
网络结构

通过分析数据集得知,数据集内有较多字段为离散值,并非连续值,若直接放入Linear层可能效果会很差,举例如下:
假设A字段值0 1 2分别代表老王、老李和老张,B字段值0 1 2分别表示0 1 2个苹果的三种价格
我们可以说0个苹果是0元,而2个苹果是2元,Linear层也可以推出1个苹果为1元的结论
但我们能否推出老王是0,老张是2,那么老李一定就是1吗?
显然不能,因为老李与老张和老王并没有直接关系,3也可能是老李,1024也可能是老李。
同理数据集中的设备名字段有“安卓“、”IOS“、”None”三种情况,也不适合当做连续值来处理。
当然我们可以尝试用Embedding操作来做出一组特征向量,输入的虽然是0 1 2这种的索引值,但其会映射出一个shape为N的特征,这个特征可以通过训练来尽可能代表对应人物的关系。
在基线中,我们对若干离散值字段使用了Embedding(Emb)操作来获取其特征向量,随后传入Linear层进行特征提取。当然该方案并不是最优解,选手可以自行判断哪些字段适合做Emb,或者使用比Emb更有效的方案来做处理。
对于连续值,我们则可以用一个Linear层轻松推理出1个苹果的价格,那么对于本次赛题,提升Linear层的size还是其层数由选手自行决定。
同样,网络部分的损失函数(激励函数)也可以由选手自行选择,默认隐藏层为tanh

最终我们将这些特征通concat方法链接在一起,通过softmax激活函数来完成多分类任务。当然选手也可以使用sigmoid激活函数来完成二分类任务,但并不是很推荐这样做。
做softmax时需要注意variable的shape,为了一次性解决该细节问题,基线中使用flatten来转化为2维形式(batch size,feature size),这样就可放心做softmax了。

默认提供的网络组件解析
nn.LayerList会生成一个layer列表,这里我们以"norm"方法对应的组件为例:
nn.Linear为创建一个Linear(线性层),其文档可以在飞桨官方网站中搜索得到,此处不提供解析。
... for _ in range(LINEAR_LAYERS_NUM) 表示循环多少次,1次代表循环一层。
该循环等同于:

tmp = []
for i in range(循环次数):
    tmp.append(nn.Linear(...))

主要结构为

# ===== 网络结构注册 =====
if tag_method == "emb":
    hidden_layer = nn.LayerList([nn.Embedding(size=[size_dict[tag], EMB_SIZE]),
                                    nn.Linear(input_dim=EMB_SIZE, output_dim=EMB_LINEAR_SIZE)])
    out_layer_input_size += EMB_LINEAR_SIZE
elif tag_method == "norm":
    hidden_layer = nn.LayerList(
        [nn.Linear(input_dim=1, output_dim=1, act="tanh") for _ in range(LINEAR_LAYERS_NUM)])
    out_layer_input_size += 1
执行预测

为了简化推理流程,只需要选手将训练脚本的IS_INFER打开,并且设置好CHECK_POINT_ID(每轮训练都会保存一个“数字.pdparams”的参数文件,可以填写该数字作为ID)
运行完毕后会打印出结果文件的路径,选手可以在赛题的报名页面点击提交结果来上传该文件。

( 以 下 为 实 战 部 分 ) \color{red}{(以下为实战部分)}

环境配置

目前飞桨(PaddlePaddle)正式版仍为1.8.4,以下代码均为2.0RC0测试版本,本地安装需要指定版本号进安装。

数据预处理 - 配置部分

根据赛题说明给出的字段说明,结合数据处理结果的id数量,
判断出哪些可能为干扰因素,哪些为被误删除的有效因素,进行调整并结合数据处理方式即可有效提升准确率\n
作为一个刚刚入门的小白,我的思路是在最容易且有效的地方进行优化,即可产生明显效果。

import os

import pandas as pd
import numpy as np
from paddle.io import Dataset
from baseline_tools import *

DATA_RATIO = 0.9  # 训练集和验证集比例

# None表示不使用,“emb”为Embedding预处理方案,选手可自由选择使用字段以及预处理方案
TAGS = {'android_id': None,  # 样本无规律,为干扰因素
        'apptype': "emb",
        'carrier': "emb",
        'dev_height': "emb",  # 屏幕宽高和ppi可以有效筛选
        'dev_ppi': "emb",
        'dev_width': "emb",
        'lan': "emb",
        'media_id': "emb",
        'ntt': "emb",
        'os': "emb",
        'osv': 'emb',
        'package': "emb",
        'sid': None,
        'timestamp': "norm",
        'version': "emb",
        'fea_hash': 'emb',
        'location': "emb",
        'fea1_hash': 'emb',
        'cus_type': 'emb'}

# 归一化权重设置
NORM_WEIGHT = {'timestamp': 6.40986e-13}

数据预处理 - 生成Embedding所需数据

TRAIN_PATH = "train.csv"
SAVE_PATH = "emb_dicts"
df = pd.read_csv(TRAIN_PATH, index_col=0)

pack = dict()
for tag, tag_method in TAGS.items():
    if tag_method != "emb":
        continue
    data = df.loc[:, tag]
    dict_size = make_dict_file(data, SAVE_PATH, dict_name=tag)
    pack[tag] = dict_size + 1  # +1是为了增加字典中不存在的情况,提供一个默认值

with open(os.path.join(SAVE_PATH, "size.dict"), "w", encoding="utf-8") as f:
    f.write(str(pack))

print("全部生成完毕")

apptype 字典生成完毕,共 89 个id
carrier 字典生成完毕,共 5 个id
dev_height 字典生成完毕,共 798 个id
dev_ppi 字典生成完毕,共 92 个id
dev_width 字典生成完毕,共 346 个id
lan 字典生成完毕,共 22 个id
media_id 字典生成完毕,共 284 个id
ntt 字典生成完毕,共 8 个id
os 字典生成完毕,共 2 个id
osv 字典生成完毕,共 155 个id
package 字典生成完毕,共 1950 个id
version 字典生成完毕,共 22 个id
fea_hash 字典生成完毕,共 402980 个id
location 字典生成完毕,共 332 个id
fea1_hash 字典生成完毕,共 4959 个id
cus_type 字典生成完毕,共 58 个id
全部生成完毕

数据预处理 - 定义数据读取器以及预处理流程

def get_size_dict(dict_path="./emb_dicts/size.dict"):
    """
    获取Embedding推荐大小
    :param dict_path: 由run_make_emb_dict.py生成的size.dict
    :return: 推荐大小字典{key: num}
    """
    with open(dict_path, "r", encoding="utf-8") as f:
        try:
            size_dict = eval(f.read())
        except Exception as e:
            print("size_dict打开失败,请检查", dict_path, "文件是否正常,报错信息如下:\n", e)
        return size_dict


class Reader(Dataset):
    def __init__(self,
                 is_infer: bool = False,
                 is_val: bool = False,
                 use_mini_train: bool = False,
                 emb_dict_path="./emb_dicts"):

        """
        数据读取类
        :param is_infer: 是否为预测Reader
        :param is_val: 是否为验证Reader
        :param use_mini_train:使用Mini数据集
        :param emb_dict_path: emb字典路径
        """
        super().__init__()
        # 选择文件名
        train_name = "mini_train" if use_mini_train else "train"
        file_name = "test" if is_infer else train_name
        # 根据文件名读取对应csv文件
        df = pd.read_csv(file_name + ".csv")
        # 划分数据集
        if is_infer:
            self.df = df.reset_index()
        else:
            start_index = 0 if not is_val else int(len(df) * DATA_RATIO)
            end_index = int(len(df) * DATA_RATIO) if not is_val else len(df)
            self.df = df.loc[start_index:end_index].reset_index()
        # 数据预处理
        self.cols = [tag for tag, tag_method in TAGS.items() if tag_method is not None]
        self.methods = dict()
        for col in self.cols:
            # ===== 预处理方法注册 =====
            if TAGS[col] == "emb":
                self.methods[col] = Data2IdEmb(dict_path=emb_dict_path, dict_name=col).get_method()
            elif TAGS[col] == "norm":
                self.methods[col] = Data2IdNorm(norm_weight=NORM_WEIGHT[col]).get_method()
            else:
                raise Exception(str(TAGS) + "是未知的预处理方案,请选手在此位置使用elif注册")

        # 设置FLAG负责控制__getitem__的pack是否包含label
        self.add_label = not is_infer
        # 设置FLAG负责控制数据集划分情况
        self.is_val = is_val

    def __getitem__(self, index):
        """
        获取sample
        :param index: sample_id
        :return: sample
        """
        # 因为本次数据集的字段非常多,这里就使用一个列表来"收纳"这些数据
        pack = []
        # 遍历指定数量的字段
        for col in self.cols:
            sample = self.df.loc[index, col]
            sample = self.methods[col](sample)
            pack.append(sample)

        # 如果不是预测,则添加标签数据
        if self.add_label:
            tag_data = self.df.loc[index, "label"]
            tag_data = np.array(tag_data).astype("int64")
            pack.append(tag_data)
            return pack
        else:
            return pack

    def __len__(self):
        return len(self.df)
        

数据预处理 - 检查数据是否可以正常读取(可选)

默认只检查训练,infer和test可以在val_reader = Reader(此处设置)中参考刚刚定义的Reader来配置

# 用于独立测试数据读取是否正常 推荐在本地IDE中下断点进行测试
print("检查数据ing...")
val_reader = Reader()
print(len(val_reader))
for data_id, data in enumerate(val_reader):
    for i in range(len(data)):
        if data_id == 0:
            print("第", i, "个字段 值为:", data[i])
        else:
            break
    if data_id % 1000 == 0:
        print("第", data_id, "条数据可正常读取 正在检查中", end="\r")
    if data_id == len(val_reader) - 1: 
        print("数据检查完毕")
        break
检查数据ing...

训练&推理配置

import os

import numpy as np
import pandas as pd
import paddle
import paddle.nn as nn
import paddle.tensor as tensor
from paddle.static import InputSpec
from paddle.metric import Accuracy

# 模型保存与加载文件夹
SAVE_DIR = "./output/"

# 部分训练超参数
EPOCHS = 1  # 训练多少个循环
TRAIN_BATCH_SIZE = 1  # mini_batch 大小
EMB_SIZE = 100  # Embedding特征大小
EMB_LINEAR_SIZE = 16  # Embedding后接Linear层神经元数量
LINEAR_LAYERS_NUM = 1  # 归一化方案的Linear层数量
LEARNING_RATE = 0.01  # 学习率

# 配置训练环境
USE_MINI_DATA = True  # 默认使用小数据集,此方法可加快模型产出速度,但可能会影响准确率

# 组网
class SampleNet(paddle.nn.Layer):
    def __init__(self, tag_dict: dict, size_dict: dict):
        # 继承Model
        super().__init__()
        # 新建一个隐藏层列表,用于存储各字段隐藏层对象
        self.hidden_layers_list = []
        # 定义一个用于记录输出层的输入大小变量,经过一个emb的网络结构就增加该结构的output_dim,以此类推
        out_layer_input_size = 0
        # 遍历每个字段以及其处理方式
        for tag, tag_method in tag_dict.items():
            # ===== 网络结构方法注册 =====
            # Embedding方法注册
            if tag_method == "emb":
                hidden_layer = nn.LayerList([nn.Embedding(num_embeddings=size_dict[tag],
                                                          embedding_dim=EMB_SIZE),
                                             nn.Linear(in_features=EMB_SIZE, out_features=EMB_LINEAR_SIZE)])
                out_layer_input_size += EMB_LINEAR_SIZE
            # 归一化方法注册
            elif tag_method == "norm":
                hidden_layer = nn.LayerList(
                    [nn.Linear(in_features=1, out_features=1) for _ in range(LINEAR_LAYERS_NUM)])
                out_layer_input_size += 1
            # 如果对应方法为None,那么跳过该字段
            elif tag_method is None:
                continue
            # 若出现没有注册的方法,提示报错
            else:
                raise Exception(str(tag_method) + "为未知的处理方案,请在SampleNet类中用elif注册处理流程")
            self.hidden_layers_list.append(hidden_layer)
        # 定义输出层,因为是二分类任务,激活函数以及损失方案可以由选手自己发挥,此处为sigmoid激活函数
        # Tips: 若使用sigmoid激活函数,需要修改output_dim和损失函数,推荐理解原理后再尝试修改
        self.out_layers = nn.Linear(in_features=out_layer_input_size,
                                    out_features=2)

    # 前向推理部分 `*input_data`的`*`表示传入任一数量的变量
    def forward(self, *input_data):
        layer_list = []  # 用于存储各字段特征结果
        for sample_data, hidden_layers in zip(input_data, self.hidden_layers_list):
            tmp = sample_data
            for hidden_layer in hidden_layers:
                tmp = hidden_layer(tmp)
            layer_list.append(tensor.flatten(tmp, start_axis=1))  # flatten是因为原始shape为[batch size, 1 , *n], 需要变换为[bs, n]
        # 对所有字段的特征合并
        layers = tensor.concat(layer_list, axis=1)
        # 把特征放入用于输出层的网络
        result = self.out_layers(layers)
        # result = paddle.nn.functional.softmax(result)
        # 返回分类结果
        return result


# 定义网络输入
inputs = []
for tag_name, tag_m in TAGS.items():
    d_type = "float32"
    if tag_m == "emb":
        d_type = "int64"
    if tag_m is None:
        continue
    inputs.append(InputSpec(shape=[-1, 1], dtype=d_type, name=tag_name))

执行训练

# 定义Label
labels = [InputSpec([-1, 1], 'int64', name='label')]
# 实例化SampleNet
model = paddle.Model(SampleNet(TAGS, get_size_dict()), inputs=inputs, labels=labels)
# 获取训练集和测试集数据读取器
train_reader = Reader(use_mini_train=USE_MINI_DATA)
val_reader = Reader(use_mini_train=USE_MINI_DATA, is_val=True)
# 定义优化器
optimizer = paddle.optimizer.SGD(learning_rate=LEARNING_RATE, parameters=model.parameters())
# 模型训练配置
model.prepare(optimizer, paddle.nn.loss.CrossEntropyLoss(), Accuracy())
# 开始训练
model.fit(train_data=train_reader,  # 训练集数据
            eval_data=val_reader,  # 交叉验证集数据
            batch_size=TRAIN_BATCH_SIZE,  # Batch size大小
            epochs=EPOCHS,  # 训练轮数
            log_freq=1000,  # 日志打印间隔
            save_dir=SAVE_DIR)  # checkpoint保存路径

执行推理

# 推理部分
CHECK_POINT_ID = "final"  # 如没有训练完毕,可以挑选SAVE_DIR路径的下的中的checkpoint文件名(不包含拓展名哦),例如"1"
TEST_BATCH_SIZE = 32  # 若因内存/显存发生报错,请优先调整为1
RESULT_FILE = "./result2.csv"  # 推理文件保存位置

# 实例化SampleNet
model = paddle.Model(SampleNet(TAGS, get_size_dict()), inputs=inputs)
# 获取推理Reader并读取参数进行推理
infer_reader = Reader(is_infer=True)
model.load(os.path.join(SAVE_DIR, CHECK_POINT_ID))
# 开始推理
model.prepare()
infer_output = model.predict(infer_reader, TEST_BATCH_SIZE)
# 获取原始表中的字段并添加推理结果
result_df = infer_reader.df.loc[:, "sid"]
pack = []
for batch_out in infer_output[0]:
    for sample in batch_out:
        pack.append(np.argmax(sample))
# 保存csv文件
result_df = pd.DataFrame({"sid": np.array(result_df, dtype="int64"), "label": pack})
result_df.to_csv(RESULT_FILE, index=False)
print("结果文件保存至:", RESULT_FILE)

写在最后

本次比赛可调优空间非常大,选手不限于从以下方面来进行调优,如果尝试后发现效果并不理想,可以在基线项目的评论区中和大家一起讨论~

此外,目前飞桨常规系列赛事已经开放基线模板,如您愿意贡献比赛数据集、基线系统,欢迎发送邮件至zhanghongji@baidu.com,我们会将这类贡献计入飞桨开发者技术专家认证(PPDE)积分并给予开发者适当奖励。

数据处理

  1. 归一化方案 - 直接拉伸是最佳方式吗?
  2. 离散值与连续值 - 哪种方式更适合处理这些方式?是否有较为通用的方法可以尝试?
  3. 字段选择 - 字段真的是越多越好吗?
  4. 数据集划分比例 - 训练集、验证集、测试集应该怎样划分?训练集中包含验证集的Embedding字段是否合理?

首层网络选择

  1. Embedding还是Linear、Conv?- 如果使用卷积应该怎样处理shape?
  2. 多字段合并输入还是分开输入?- 分开输入效果一定好吗?哪些字段更适合合并输入?

网络(Backbone)部分搭建

  1. 隐层大小选择 - 宽度和层数
  2. 尝试复杂网络构建 - 要不要尝试一下简单的注意力机制?
  3. 选择更合适的激活函数
  4. 尝试正则化、dropout等方式避免过拟合
  5. 尝试非Xavier初始化方案

模型(Model)搭建以及训练相关

  1. 选择学习率等超参数
  2. 选择合适的损失函数
  3. 尝试不同的优化器
  4. 尝试使用学习率调度器
  5. 避免脏数据干扰(用深度学习的方式更优更方便)

提交相关

  1. 测试集表现最好的模型一定是最优秀的吗?
  2. 用准确率来衡量二分类模型的能力是最好选择吗?

简要概述

  1. 通过官方基线逐步优化
  2. 从根源优化,逐步向外拓展
  3. 小白第一次试水,希望能在比赛中有所收获呀

代码审查

如选手成绩靠前并收到官方邮件通知代码审查,请参考该链接进行项目上传操作
快捷命令:!zip -rP [此处添加审查邮件中的Key值] [邮件中的UID值].zip /home/aistudio/

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐