推荐算法

特征交叉系列:Deep&Cross(DCN-V2)理论和实践

2023-09-24  本文已影响0人  xiaogp

关键词:DCNDNN推荐算法

内容摘要


DCN之前FM系列特征交叉思路总结

在之前的推荐算法特征交叉系列中,已经介绍了从FM,FFM,到PNN,DeepFM,NFM,AFM这一系列围绕特征交叉展开的算法,这些算法都是以FM为基石,在FM的基础上优化其二阶交互表达能力的策略,主要有三种形式:

针对第一点,之前章节的代码实践都能证明引入DNN相比于FM有显著提升,DNN弥补了FM的非线性能力隐式高阶交叉能力。针对第二点外积和内外融合相比于内积有提升但是会带来更多参数的学习,详情见特征交叉系列:PNN向量积模型理论和实践,FM和DNN的串联,而第三点不论怎么聚合,隐向量的逐位相乘都保留了下来,因此逐位相乘是FM特征交叉的精髓,是能够让两特征进行交叉表达的核心。

在DCN出来之前,以上算法都是没有改变FM这种范式底层结构,仅仅是对FM的某些细节做调整,而不论怎么调整也只能完成二阶交叉,对于高阶交叉只能寄希望于DNN在FM二阶的基础上能够隐式的学习到,因此对于高阶交叉这系列算法的表达能力是不充分的,引出本节介绍的算法DCN-V2,它彻底摆脱了FM的范式,通过一个递归的设计实现了任意有限阶的显示高阶交叉


DCN-V2模型结构介绍

DCN(Deep & Cross Network,深度交叉网络),有两个版本分别时DCN V1和DCN V2,DCN V1也叫DCN-V发表于2017年,DCN V2也叫DCN-M发表于2020年,两篇论文都来自Google公司,其中DCN-V1存在理论缺陷本节不做介绍,直接进入DCN-V2,两者的核心区别是前者使用向量Vector作为交叉层的学习权重,而后者采用矩阵Matrix,这也是命名DCN-V和DCN-M的由来。
DCN-V2的网络结构如下

DCN-V2

左边Stack和右边Parallel分别是DCN中交叉网络和DNN组合的策略,Stack代表串联,Parallel代表并联,相当于肯定了之前基于FM和DNN并联或者串联的算法,认为这两种形式都可以,具体用那种需要根据数据学习情况而定。
最底层是输入层,采用连续变量和稀疏变量的embedding拼接的方式,拼接的结果为一个向量,该向量作为一个整体进入Cross Network交叉层,交叉层每做一层交叉会输出一个新的向量,该新向量和输入的向量维度相同,与此同时每次交叉操作都需要引入一次原始输入,由于输入的向量维度和输入相同,因此这个操作可以无限递归下去,即Cross Network可以完成任意阶数的交叉操作。
Cross Network输出的结果作为高阶交叉的最终表征,如果是Stack策略则输入给DNN继续学习,如果是Parallel策略则和DNN的输出进行合并,最终映射到标签y上进行损失迭代学习。


DCN-V2的交叉网络为什么可以完成高阶交叉

上面简述了DCN-V2的整体结构,除了Cross Network其他和NFM,DeepFM没啥本质区别,现在开始深入Cross Network部分,它的计算公式如下


交叉计算公式

论文中的可视化表达地更加容易理解

交叉计算公式可视化

前面也提到DCN-V2的交叉部分是个可以无限递归的过程,i表示第i阶(本层),i+1表示第i+1阶(下一层),则下一层交叉的结果等于本层的结果经过一个M矩阵和b向量偏置,和原始输入的逐位相乘,再加上本层的结果,下面对于等式右边的元素,按照从左到右的位置分别讲解下它们在这个操作中的作用

这个计算公式的核心是权重矩阵W,可以举个简单例子侧面证明W的有效性,比如交叉网络只做一层,这个计算公式能不能复现出FM?举例我们有三个特征,经过embedding之后分别有三个向量w1,w2,w3,在FM中会采用类似for循环的方式形成field_num*(field_num-1)对向量两两逐位相乘,分别是w1×w2,w2×w3,w1×w3,而在DCN-V2中输入被拼接在了一起w1||w2||w3,在一层中是X0和X0进行交叉,因此只要W能把第二个X0变换为w2||w3||w1,就可以复现FM的效果,如图

DCN第一层复现FM

因此只要设计一个矩阵W实现如图所示的把w1||w2||w3转化为w2||w3||w1即可,这个W当然很容易就可以设计出来,W的每一行都设计成一个onehot向量即可,只在红线的开始的那个位置和W矩阵相乘的对应位置处设置为1,其他位置全部为0即可。因此在DCN的第一层,W矩阵可以完全恢复出FM的效果,相当于在DCN-V2的第一层完成了二阶交叉,而往后的每一层都在之前基础上再做一次二阶交叉,因此DCN-V2可以通过这种递归的方式实现任意高阶的交叉。
本质上,DCN-V2采用了一个wx+b操作对每层的表征x做一个变换,使得它更好地和一阶输入x0进行哈达马积,而哈达马积就是在做特征交叉


再回过头来看DCN的高阶交叉思路

在明白交叉层的公式之后,我们回过头来猜测一下作者这么设计的脑回路,毕竟DCN彻底从FM这种field_num*(field_num-1)的for循环中跳出来了,非常具有创新性。
我们能不能把所有特征嵌入向量拼接在一起看成一个整体,让这个整体自己去做各种复杂的交叉操作,而不是采用FM这个手动两两的策略。对这个整体的输入,我们能不能要设计一个网络能对原始输入做任意高阶交叉,能不能设计一个变换函数,使得n+1阶交叉从n阶表达直接变换出来,最好能满足以下两个条件:

如果这两个条件能够满足,就可以写一个for循环把原始输入无限变换再变换到任意高阶交叉,这是多么丝滑的体验。这种思路在深度学习中并不少见,图神经网络GNN也是设计了一个固定的信息传播聚合的公式,使得GNN可以让中心节点聚合任意跳的邻居节点信息作为自身的表征。
前文也提到DCN是将输入的向量拼接成成一个整体,而FM系是做半三角的循环拿到两两向量作为一对,紧接着引出DCN和FM的交叉性质的不同,即DCN是采用bit-wise交叉,而FM系列都是vector-wise的交叉,所谓bit-wise就是输入中不再区分不同特征field的概念,所有embeding的每个元素当成一个特征单元和其他任意元素单元进行交叉,即同一个field下会内部元素进行交叉,而vector-wise是一个field的向量和另一个field的向量进行交叉,field内部元素之间不进行交叉。


DCN-V2在PyTorch下的实践

本次实践的数据集和上一篇特征交叉系列:完全理解FM因子分解机原理和代码实战一致,采用用户的购买记录流水作为训练数据,用户侧特征是年龄,性别,会员年限等离散特征,商品侧特征采用商品的二级类目,产地,品牌三个离散特征,随机构造负样本,一共有10个特征域,全部是离散特征,对于枚举值过多的特征采用hash分箱,得到一共72个特征。
DCN-V2的PyTorch代码实现如下

class Embedding(nn.Module):
    def __init__(self, feat_num, emb_num):
        super(Embedding, self).__init__()
        self.embedding = nn.Embedding(feat_num, emb_num)
        nn.init.xavier_normal_(self.embedding.weight.data)

    def forward(self, x):
        # [None, filed_num] => [None, filed_num, emb_num] => [None, filed_num * emb_num]
        return self.embedding(x).flatten(1)


class DNN(nn.Module):
    def __init__(self, input_num, hidden_nums, dropout=0.1):
        super(DNN, self).__init__()
        layers = []
        input_num = input_num
        for hidden_num in hidden_nums:
            layers.append(nn.Linear(input_num, hidden_num))
            layers.append(nn.BatchNorm1d(hidden_num))
            layers.append(nn.ReLU())
            layers.append(nn.Dropout(p=dropout))
            input_num = hidden_num
        self.mlp = nn.Sequential(*layers)
        for layer in self.mlp:
            if isinstance(layer, nn.Linear):
                nn.init.xavier_normal_(layer.weight.data)

    def forward(self, x):
        return self.mlp(x)


class CrossCell(nn.Module):
    """一个交叉单元"""

    def __init__(self, input_num):
        super(CrossCell, self).__init__()
        self.w = nn.Parameter(torch.randn(input_num, input_num))
        self.b = nn.Parameter(torch.randn(input_num, 1))
        nn.init.xavier_normal_(self.w.data)

    def forward(self, x0, xi):
        # [None, emb_num] => [None, emb_num, 1]
        xi = xi.unsqueeze(2)
        x0 = x0.unsqueeze(2)
        # [input_num, input_num] * [None, emb_num, 1] => [None, emb_num, 1] => [None, emb_num, 1] => [None, emb_num, 1]
        xii = (torch.matmul(self.w, xi) + self.b) * x0 + xi
        return xii.squeeze(2)


class CrossNet(nn.Module):
    def __init__(self, order_num, input_num):
        super(CrossNet, self).__init__()
        self.order = order_num
        self.cell_list = nn.ModuleList([CrossCell(input_num) for i in range(order_num)])

    def forward(self, x0):
        xi = x0
        for i in range(self.order):
            xi = self.cell_list[i](x0=x0, xi=xi)
        return xi


class DCN(nn.Module):
    def __init__(self, field_num, feat_dim, emb_num, order_num, dropout=0.1, method='parallel',
                 hidden_nums=(128, 64, 32)):
        super(DCN, self).__init__()
        input_num = field_num * emb_num
        self.embedding = Embedding(feat_num=feat_dim, emb_num=emb_num)
        self.dnn = DNN(input_num=input_num, hidden_nums=hidden_nums, dropout=dropout)
        self.cross_net = CrossNet(order_num=order_num, input_num=input_num)
        if method not in ('parallel', 'stacked'):
            raise ValueError('unknown combine type: ' + method)
        self.method = method
        linear_dim = hidden_nums[-1]
        if self.method == 'parallel':
            linear_dim = linear_dim + input_num
        self.linear = nn.Linear(linear_dim, 1)
        nn.init.xavier_normal_(self.linear.weight.data)

    def forward(self, x):
        emb = self.embedding(x)  # [None, field * emb_num]
        cross_out = self.cross_net(emb)  # [None, input_num]
        if self.method == 'parallel':
            dnn_out = self.dnn(emb)  # [None, input_num]
            out = torch.concat([cross_out, dnn_out], dim=1)
        else:
            out = self.dnn(cross_out)  # [None, input_num]
        out = self.linear(out)
        return torch.sigmoid(out).squeeze(dim=1)

其中CrossCell子模块是一层交叉层,在CrossNet中指定阶数创建多个CrossCell的列表。在DCN主模块中设定了stacked,parallel两种策略。
本例全部是离散分箱变量,所有有值的特征都是1,因此只要输入有值位置的索引即可,一条输入例如

>>> train_data[0]
Out[120]: (tensor([ 2, 10, 14, 18, 34, 39, 47, 51, 58, 64]), tensor(0))

其中x的长度10代表10个特征域,每个域的值是特征的全局位置索引,从0到71,一共72个特征。


DCN-V2调参效果对比

对阶数(order_num)和融合策略(method)这两个参数进行调参,分别尝试1~4层交叉层,stacked和parallel两种策略,采用10次验证集AUC不上升作为早停条件,验证集的平均AUC如下

DCN调参AUC 并行parallel 串行stacked
1层交叉(2阶) 0.6331 0.6325
2层交叉(3阶) 0.6334 0.6320
3层交叉(4阶) 0.6348 0.6344
4层交叉(5阶) 0.6330 0.6326

结论是不论parallel还是stacked,在当前数据集下DCN使用3层交叉达到最优AUC,其中parallel略高一点,再往上对叠交叉层AUC会下降。
再对比一下之前文章中实践的FM,FFM,PNN等一系列算法,验证集AUC和参数规模如下

算法 AUC 参数量
FM 0.6274 361
FFM 0.6317 2953
IPNN 0.6322 15553
OPNN 0.6326 27073
PNN* 0.6342 29953
DeepFM 0.6322 12746
NFM 0.6329 10186
DCN-parallel-3 0.6348 110017
DCN-stacked-3 0.6344 109857
image.png

DCN都取得了目前为止的SOTA结果,其次是PNN*,发现随着模型参数量的增大,模型的预测效果也随着提升,DCN的参数规模原高于其他FM系的算法,在本例中即使只用1层交叉DCN的参数规模也能达到5万(是FM参数量的100倍以上)。从结果看,引入高阶交叉的DCN-V2确实比只有二阶交叉的FM系高出一筹,但是模型复杂度也大幅提升。

上一篇下一篇

猜你喜欢

热点阅读