论文阅读笔记
论文(KDD 2018):Deep Interest Network for Click-Through Rate Prediction
Abstract
点击率预测是在线广告等行业应用中的一项重要任务。最近基于深度学习的模型,大多遵循类似的 Embedding&MLP 范式。在这些方法中,大规模稀疏输入特征首先被映射到低维嵌入向量中,然后以分组方式转换成定长向量,最后连接到多层感知器(MLP)中,以学习特征之间的非线性关系。这样,无论候选广告是什么,用户特征都会被压缩成一个固定长度的表示向量。固定长度向量的使用将成为一个瓶颈,这给嵌入和 MLP 方法从丰富的历史行为中有效捕捉用户的不同兴趣带来了困难。
在本文中,我们提出了一种新型模型:该模型通过设计一个局部激活单元来自适应地学习用户对某一广告历史行为的兴趣表征。这种表征向量会随不同的广告而变化,从而大大提高了模型的表达能力。此外,我们还开发了两种技术:小批量感知正则化和数据自适应激活函数,它们可以帮助训练具有数亿个参数的工业深度网络。
1 Introduction
在点击付费(CPC)广告系统中,广告按 eCPM(广告每展示一千次预计可以获得的收入)排名,eCPM 是出价与 CTR(点击率)的乘积,而 CTR 需要由系统进行预测。因此,CTR 预测模型的性能直接影响最终收益,在广告系统中起着关键作用。CTR 预测模型受到了研究界和产业界的广泛关注。
目前的方法遵循相似的 Embedding&MLP 范式:首先将大规模稀疏输入特征映射为低维嵌入向量,然后以分组方式转换为定长向量,最后串联起来送入MLP,以学习特征之间的非线性关系。与常用的逻辑回归模型相比,这些深度学习方法可以减少大量的特征工程工作,大大提高模型能力。
然而,Embedding&MLP 方法中维度有限的用户表示向量将成为表达用户不同兴趣的瓶颈。以电电商广告为例,用户在访问网站时,可能会同时对不同种类的商品感兴趣。也就是说,用户的兴趣是多样化的。在进行点击率预测时,用户兴趣通常是从用户行为数据中获取的。Embedding&MLP 方法通过将用户行为的嵌入向量转换为一个固定长度的向量来学习某个用户的所有兴趣表示,该向量位于所有用户表示向量所在的欧几里得空间中。这个固定长度的向量限制了 Embedding&MLP 方法的表达能力。为了使表征足以表达用户的各种兴趣,需要大大扩展固定长度向量的维度。这将大大增加学习参数的大小,并加剧有限数据下的过拟合风险。这对于工业在线系统来说可能是无法承受的。
另一方面,在预测候选广告时,没有必要将某个用户的所有不同兴趣都压缩到同一个向量中,因为只有部分用户的兴趣会影响他/她的行动(点击或不点击)。例如,一名游泳女选手会点击推荐的泳镜,主要是因为她买了泳衣,而不是上周购物清单中的鞋子。受此启发,我们提出了一个新颖的模型:深度兴趣网络(DIN)通过考虑候选广告与用户历史行为的相关性,自适应地计算用户兴趣的表示向量。通过引入局部激活单元,DIN 通过软搜索历史行为的相关部分来关注相关的用户兴趣,并采取加权和池化的方式来获得与候选广告相关的用户兴趣表征。与候选广告相关度较高的行为会获得较高的激活权重,并在用户兴趣表征中占据主导地位。通过这种方法,用户兴趣向量在不同广告中是不同的,提高了模型在有限维度下的表达能力,使 DIN 能够更好地捕捉用户的不同兴趣。
本文贡献如下:
- 指出了使用固定长度的向量来表达用户多样化兴趣的局限性,并设计了一种新颖的深度兴趣网络(DIN),该网络引入了一个局部激活单元,可从给定广告的历史行为中自适应地学习用户兴趣的表示。DIN 可以大大提高模型的表达能力,更好地捕捉用户兴趣的多样性特征。
- 我们开发了两种新型技术来帮助训练工业深度网络:i) 迷你批量感知正则化器,它可以节省参数数量巨大的深度网络正则化的繁重计算,并有助于避免过度拟合;ii) 数据自适应激活函数,它通过考虑输入的分布来概括 PReLU,并显示出良好的性能。
2 Deep Interest Network
2.1 Feature Representation
工业点击率预测任务中的数据多为多组分类形式,例如[weekday=Friday, gender=Female, visited_cate_ids={Bag,Book}, ad_cate_id=Book],通常通过编码将其转化为高维稀疏二元特征:
- $t_i\in \mathbb{R}^{K_i}$ :表示第 $i$ 个特征组
- $K_i$ :表示特征组的维度,即特征组 $i$ 含有 $K_i$ 个不同的 id
- $t_i[j]\in\{0,1\}$ :表示第 $j$ 个元素的取值
- 当 $\text{sum}(t_i)=1$ 时即为 one-hot 编码,否则是 multi-hot 编码
- 一个实例可以表示为 $x=[t_i^{T},\cdots , t_M^{T}]^T$ ,其中 $M$ 为特征组个数
- $\sum_{i=1}^{M}K_i = K$ 表示整个特征空间的维度
2.2 Base Model (Embedding&MLP)
大多模型都使用 Embedding&MLP 范式,我们称之为基线模型。它包含以下部分:
Embedding层。由于输入是高维二进制向量,Embedding层将其转化为低维的稠密表示。对于第 $i$ 个特征组 $t_i$ ,令 $W^i = [w_1^i,\cdots , w_{K_i}^i]\in\mathbb{R}^{D\times K_i}$ 为它的编码字典,其中 $w_j^i \in \mathbb{R}^D$ 为维度 $j$ 的特征向量。
- 如果 $t_i$ 是一个 one-hot 向量且 $t_i[j]=1$ ,那么 $t_i$ 的编码 $e_i=w_j^i$
- {用户: {性别, 年龄}, 商品: {商品id, 商店id, 类别id}, 会话: {搜索id, 时间}}
- 如果 $t_i$ 是一个 multi-hot 向量且当 $j\in \{i_1.i_2,\cdots , i_k\}$ 的时候有 $t_i[j]=1$ ,那么 $t_i$ 的编码表示为 $\{e_{i_1},\cdots ,e_{i_k}\} = \{w_{i_1}^i,\cdots ,w_{i_k}^i\}$
- {用户行为: {历史浏览商品id, 历史浏览店铺id, 历史浏览类别id}}
Pooling层和Concat层。不同用户行为不同,所以 multi-hot 特征的 embedding 在不同实例中维度不同。由于 MLP 只能处理定长输入,所以通常使用一个 pooling 层来得到定长向量:$e_i = \text{pooling}(e_{i_1},\cdots ,e_{i_k})$ 。其中 average pooling 和 sum pooling 是最常用的方法。
MLP。给定聚合后的稠密表示向量,使用一个全连接层自动学习特征之间的关联。
损失。使用负的对数似然函数:
$$\mathcal{L} = – \frac{1}{N} \sum_{(x,y)\in S} (y \log p(x) + (1 – y) \log (1 – p(x)) )$$
其中 $S$ 是大小为 $N$ 的训练集,$x$ 是网络的输入, $y\in \{0,1\}$ 是标签,$p(x)$ 是网络的经过 softmax 之后的预测输出,表示 $x$ 被点击的概率。
2.3 The structure of Deep Interest Network
所有特征当中,用户行为特征非常重要,在电商场景中对用户兴趣建模起关键作用。
基础模型使用固定长度的向量表征用户的兴趣。不论候选广告是什么,这个表示向量都是一样的,从而产生表达瓶颈。为了使其表达能力加强,增大特征维度是一种做法,但是会大大增加模型参数量大小,且在数据有限时容易过拟合。
动机:与展示广告相关的行为在很大程度上促成了点击行为。DIN 通过考虑与候选广告相关的历史行为,自适应地计算用户兴趣的表示向量。这个表示向量随不同广告而变化:
$$v_U(A) = f (v_A, e_1, e_2, \cdots, e_H) = \sum_{j=1}^{H} a(e_j, v_A)e_j = \sum_{j=1}^{H} w_j e_j$$
- $\{e_1, e_2, \cdots, e_H\}$ 为用户的一系列行为特征向量;
- $v_A$ 是广告 $A$ 的特征向量;
- $a(\cdot)$ 是一个 FFN,输出各个特征的权重(将对应的 $e_j$ 与 $v_A$ concat 起来,输入到全连接层)。$a(\cdot)$ 与注意力机制比较类似,但是为了保留用户兴趣的强度,没有 $\sum_i w_i = 1$ 的限制,也就是不会对这个输出做 softmax。
3 Training Techniques
3.1 Mini-batch Aware Regularization
过拟合是训练工业网络的一个关键挑战。对于输入稀疏、参数数以亿计的训练网络,直接应用传统的正则化方法(如 L2 和 L1 正则化)是不切实际的。在基于 SGD 的优化方法中,只有每个小批次中出现的非零稀疏特征参数需要更新,而不需要正则化。然而,当添加 L2 正则化时,它需要计算每个小批次的全部参数的 L2 正则,这将导致极其繁重的计算量,并且在参数扩展到数亿时是不可接受的。
对于整个特征编码空间 $W \in \mathbb{R}^{D\times K}$ ,我们展开起 $L_2$ 正则化项:
$$L_2(W) = \lVert W \rVert_2^2 = \sum_{j=1}^{K} \lVert w_j \rVert_2^2 = \sum_{(x,y)\in S} \sum_{j=1}^{K} \frac{I(x_j \not = 0)}{n_j} \lVert w_j \rVert_2^2$$
其中 $w_j$ 是第 $j$ 个特征向量,$I(x_j \not = 0)$ 表示 $x$ 有特征 $j$ ,$n_j$ 表示所有实例中拥有特征 $j$ 的总数。在进行小批量训练时,上式可以转化为:
$$L_2(W) = \sum_{j=1}^{K} \sum_{m=1}^{B} \sum_{(x,y)\in B_m} \frac{I(x_j \not = 0)}{n_j} \lVert w_j \rVert_2^2$$
其中 $B$ 表示小批量的数目,$B_m$ 表示第 $m$ 个小批量。令 $\alpha_{m_j}=\max_{(x,y)\in B_m}I(x_j \not = 0)$ 表示在小批量 $B_m$ 中是否至少有一个实例有特征 $j$ 。那么总体的 L2 损失可以近似为:
$$L_2(W) \approx \sum_{j=1}^{K} \sum_{m=1}^{B} \frac{\alpha_{m_j}}{n_j} \lVert w_j \rVert_2^2$$
这样就可以在每次梯度下降中,仅计算出现过特征的正则化损失。
$$w_j \leftarrow w_j – \eta \left[ \frac{1}{|B_m|} \sum_{(x,y) \in B_m} \frac{\partial L(p(x), y)}{\partial w_j} + \lambda \frac{\alpha_{m_j}}{n_j} w_j \right]$$
3.2 Data Adaptive Activation Function
PReLU 激活函数如下:
$$f(s) = \begin{cases} s & \text{if } s > 0 \\ \alpha s & \text{if } s \leq 0 \end{cases} = p(s) \cdot s + (1 – p(s)) \cdot \alpha s,$$
其中 $s$ 是输入的单个元素,$p(s) = I(s > 0)$ 为指示函数。PReLU 在值为 0 时有跳跃矫正点,这可能不适合各层输入分布不同的情况。因此设计了一种名为 Dice 的新型数据自适应激活函数:
$$f(s) = p(s) \cdot s + (1 – p(s)) \cdot \alpha s, \quad p(s) = \frac{1}{1 + e^{-\frac{s – \mathbb{E}[s]}{\sqrt{\mathrm{Var}[s] + \epsilon}}}}$$
其中 $\mathbb{E}[s]$ 和 $\mathrm{Var}[s]$ 分别为小批量输入的均值和方差(推理中使用移动平均计算)。Dice 的主要思想是根据输入数据的分布自适应地调整矫正点,其值为输入数据的平均值。此外,Dice 还能控制两个通道之间的平滑切换。
代码与分析
1. din_attn_ori
def din_attn_ori(query, keys, facts, seq_mask, attn_name=""):
batch_size, max_len, dim = facts.get_shape().as_list()
queries = tf.expand_dims(query, 1)
queries = tf.tile(queries, [1, max_len, 1])
bs = tf.shape(facts)[0]
din_all = tf.concat([queries, keys, queries * keys, queries - keys], axis=-1)
din_all_output = modules.DenseTower(name='din_attention_' + attn_name,
output_dims=[40, 20, 1],
initializers=initializers.GlorotNormal(mode='fan_in'),
use_weight_norm = USE_WEIGHT_NORM,
max_shard_num=316
)(din_all)
din_all_output = tf.reshape(din_all_output, [-1, 1, max_len])
scores = din_all_output
mask = tf.equal(seq_mask, tf.ones_like(seq_mask))
key_masks = tf.expand_dims(mask, 1) # [B, 1, T]
scores = scores / (dim ** 0.5)
padding_zeros = tf.ones_like(scores) * (-100000000.0)
scores = tf.where(key_masks, scores, padding_zeros)
scores = tf.nn.softmax(scores, axis=-1)
output = tf.matmul(scores, facts)
output = tf.reduce_sum(output, axis=1)
return output
1.1 函数签名及参数
def din_attn_ori(query, keys, facts, seq_mask, attn_name=""):
query
: 当前查询向量,通常表示当前商品。keys
: 用户历史行为的表示,通常是用户之前的行为序列。facts
: 与 Attention 中value
类似,表示用户历史行为的特征。seq_mask
: 序列掩码,用于标记keys
中的有效部分(1 表示有效,0 表示无效)。attn_name
: 注意力层的名称,用于命名和区分不同的注意力层。
1.2 主要步骤
获取输入的形状信息
batch_size, max_len, dim = facts.get_shape().as_list()
batch_size
: 批次大小。max_len
: 用户行为序列的最大长度。dim
: 每个行为的特征维度。
扩展和复制 query
queries = tf.expand_dims(query, 1)
queries = tf.tile(queries, [1, max_len, 1])
- 将
query
扩展为[batch_size, 1, dim]
。 - 复制
query
使其形状变为[batch_size, max_len, dim]
,与keys
对齐。
构建 din_all
bs = tf.shape(facts)[0]
din_all = tf.concat([queries, keys, queries * keys, queries - keys], axis=-1)
- 将
queries
和keys
及其交互特征(乘积和差值)拼接在一起,形成din_all
,形状为[batch_size, max_len, 4 * dim]
。
通过 MLP 进行特征提取
din_all_output = modules.DenseTower(name='din_attention_' + attn_name,
output_dims=[40, 20, 1],
initializers=initializers.GlorotNormal(mode='fan_in'),
use_weight_norm = USE_WEIGHT_NORM, # False
max_shard_num=316 # 参数分区
)(din_all)
- 使用
DenseTower
模块对din_all
进行特征提取,输出维度依次为 40、20 和 1。 - 输出形状为
[batch_size, max_len, 1]
。
调整输出形状
din_all_output = tf.reshape(din_all_output, [-1, 1, max_len])
scores = din_all_output
- 将
din_all_output
重新调整形状为[batch_size, 1, max_len]
以便后续计算注意力分数。
应用掩码和归一化
mask = tf.equal(seq_mask, tf.ones_like(seq_mask))
key_masks = tf.expand_dims(mask, 1) # [B, 1, T]
scores = scores / (dim ** 0.5)
padding_zeros = tf.ones_like(scores) * (-100000000.0)
scores = tf.where(key_masks, scores, padding_zeros)
scores = tf.nn.softmax(scores, axis=-1)
- 根据
seq_mask
生成key_masks
以标记有效的keys
。 - 对
scores
进行缩放,以避免梯度消失或爆炸。 - 使用一个非常小的值填充无效位置,确保这些位置在 softmax 操作中被忽略。
- 对
scores
应用 softmax 归一化。
计算注意力加权输出
output = tf.matmul(scores, facts) output = tf.reduce_sum(output, axis=1)
- 通过矩阵乘法计算加权和,得到注意力加权后的输出。
- 对输出进行求和,得到最终的输出向量。
返回最终输出
return output
- 返回注意力机制加权后的输出,形状为
[batch_size, dim]
。
2. din_attn
def din_attn(query, keys, seq_mask, facts=None, attn_name=""):
batch_size, max_len, dim = keys.get_shape().as_list()
queries = tf.expand_dims(query, 1)
bs = tf.shape(keys)[0]
if facts is None:
facts = keys
attn_position_embedding = M.get_variable(
name='attn_position_embedding_' + attn_name,
shape=[1, max_len, 32], initializer=initializers.Zeros())
din_dims = output_dims=[40, 40, 20, 1]
din_query_output1 = modules.DenseTower(
name='din_attention_query_' + attn_name,
output_dims=[din_dims[0]],
initializers=initializers.GlorotNormal(mode='fan_in'),
use_weight_norm=USE_WEIGHT_NORM,
max_shard_num=316)(queries)
din_pos_emb_output1 = modules.DenseTower(
name='din_attention_pos_emb_' + attn_name,
output_dims=[din_dims[0]],
initializers=initializers.GlorotNormal(mode='fan_in'),
use_weight_norm=USE_WEIGHT_NORM,
max_shard_num=316)(attn_position_embedding)
din_keys_output1 = modules.DenseTower(
name='din_attention_keys_' + attn_name,
output_dims=[din_dims[0]],
initializers=initializers.GlorotNormal(mode='fan_in'),
use_weight_norm=USE_WEIGHT_NORM,
max_shard_num=316)(keys)
din_all_output1 = tf.nn.relu(din_query_output1 + din_pos_emb_output1 + din_keys_output1)
din_all_output = modules.DenseTower(
name='din_attention_' + attn_name,
output_dims=din_dims[1:],
initializers=initializers.GlorotNormal(mode='fan_in'),
use_weight_norm = USE_WEIGHT_NORM,
max_shard_num=316)(din_all_output1)
scores = din_all_output / (dim ** 0.5) # [B, T, output_dim]
mask = tf.cast(tf.equal(seq_mask, tf.ones_like(seq_mask)), dtype=scores.dtype)
key_masks = tf.expand_dims(mask, -1) # [B, T, 1]
padding_zeros = tf.ones_like(scores) * (-100000000.0)
scores = scores * key_masks + padding_zeros * (1 - key_masks)
scores = tf.nn.sigmoid(scores)
output = facts * scores
output = tf.reduce_sum(output, axis=1)
return output