DSIN 深度 Session 兴趣网络介绍及源码剖析

DSIN 深度 Session 兴趣网络介绍及源码剖析

前言(可以忽略~)

本文介绍 DSIN 网络的基本原理,并对源码进行详细分析,从数据预处理,训练数据生成,模型构建等方面对 DSIN 的完整实现进行详细介绍。

(PS:好久好久没有写文章了,罪过罪过,这段时间发生了太多的事情,似梦如幻,2020 年结尾钟声快要敲响之际,平静终于回归了我的内心,过去的事情不再留恋,2021 年开启新的征程。“星空”我望过了,还差的就是脚踏实地。祝新的一年身体健康,万事如意!)

广而告之

可以在微信中搜索 “珍妮的算法之路” 或者 “world4458” 关注我的微信公众号;另外可以看看知乎专栏 PoorMemory-机器学习, 以后文章也会发在知乎专栏中;

文章信息

核心观点

对用户历史行为序列进行建模,阿里有几个非常重要的工作,如 DIN (深度兴趣网络,详见博客 DIN 深度兴趣网络介绍以及源码浅析) 以及 DIEN (深度兴趣进化网络, 博客之后完成, Flag~😂),然而这些模型并没有考虑到用户历史行为序列中的内在结构,即行为序列可以被划分为多个 Sessions,Session 之间可以反映出用户兴趣的变化。下图是将用户行为进行 Session 划分的一个例子:

图片表示用户点击过的商品,图片下面的数字为点击时间,Session 的划分规则是:如果两个行为之间的间隔超过了 30 分钟,就划分一个 Session;(该规则出自 Airbnb 的一篇论文:Real-time personalization using embeddings for
search ranking at airbnb
) 从图中可以清楚的看到,同一个 Session 内的行为非常相似,而 Session 间的兴趣则是多样的。因此,考虑对 Session 进行建模,可以更好的捕获用户动态变化的兴趣。

本文提出的 DSIN 网络主要包含三个核心组成部分:

  • Session 兴趣提取层 (Session Interest Extractor Layer): 将用户的序列行为划分为多个 session 后,使用 self-attention 以及 bias-encoding 对每个 session 进行建模;self-attention 可以捕获 session 内各个行为之间的内在联系,进而提取出每个 session 的用户兴趣;
  • Session 兴趣交互层 (Session Interest Interacting Layer):提取出用户的 session 兴趣后,session 兴趣之间也是存在联系的,采用 Bi-LSTM(双向 LSTM) 来捕获 session 兴趣间的变化和演进。
  • Session 兴趣激活层 (Session Interest Activating Layer):每个 session 兴趣对目标商品的影响不同,采用 Attention 机制刻画目标商品和每个 session 兴趣之间的相关性。

以上是对 DSIN 的概括总结, 下面进行详细的分析.

核心观点解读

DSIN 的网络结构如下图所示:

网络结构图稍显复杂, 我们按照从易到难的顺序进行介绍, 大致可以分为 3 个部分:

  • 左下角部分: 将用户侧和目标商品侧的稀疏特征分别转换为 embedding, 设为 X U ∈ R N u × d model \mathbf{X}^{U} \in \mathbb{R}^{N_{u} \times d_{\text {model}}} XURNu×dmodel X I ∈ R N i × d model \mathbf{X}^{I} \in \mathbb{R}^{N_{i} \times d_{\text {model}}} XIRNi×dmodel (其中 N u N_u Nu 表示用户侧稀疏特征的个数, N i N_i Ni 表示商品侧稀疏特征的个数, d m o d e l d_{model} dmodel 为 embedding size), 并进行 concatenation, 图中使用粉红进行标注;
  • 右下角部分: DSIN 的核心模块, 对用户历史行为进行 session 划分, 采用 self-attention 与 Bi-LSTM 来分别捕获 session 兴趣序列 { I i } i = 1 , … , K \{\mathbf{I}_i\}_{i=1,\ldots,K} {Ii}i=1,,K { h i } i = 1 , … , K \{\mathbf{h}_i\}_{i=1,\ldots,K} {hi}i=1,,K, 并通过 Attention 机制来学习目标商品 (Target Item) 和 session 兴趣间的相关性程度, 再对两个 session 兴趣序列分别进行加权求和, 得到聚合后的 session 兴趣 U I \mathbf{U}^I UI (使用浅黄色表示) 以及 U H \mathbf{U}^H UH (使用蓝色表示);
  • 上半部分: 为经典的 MLP 结构, 输入为用户侧和商品侧的 embedding X U \mathbf{X}^{U} XU, X I \mathbf{X}^{I} XI 以及自适应学习的 session 兴趣 U I \mathbf{U}^I UI, U H \mathbf{U}^H UH 四者进行 concatenation, 经过 MLP 的处理后, 输出为点击率的预估值.

下面对右下角的部分进行拆解分析, 该部分为 DSIN 的核心模块, 主要由:

  • Session Divsion Layer (Session 划分层)
  • Session Interest Extractor Layer (Session 兴趣提取层)
  • Session Interest Interacting Layer (Session 兴趣交互层)
  • Session Interest Activating Layer (Session 兴趣激活层)

四个部分构成. 下面按顺序依次介绍.

Session 划分层 (Session Divsion Layer)

对用户历史行为序列按照时间顺序排序, 如果两个行为之间的间隔超过了 30 分钟,就划分一个 Session; 按该规则将用户行为序列 S \mathbf{S} S 划分为多个 Sessions Q \mathbf{Q} Q, 其中第 k k k 个 Session 定义为 Q k = [ b 1 ; … ; b i ; … ; b T ] ∈ R T × d model \mathbf{Q}_{k}=\left[\mathbf{b}_{1} ; \ldots ; \mathbf{b}_{i} ; \ldots ; \mathbf{b}_{T}\right] \in \mathbb{R}^{T \times d_{\text {model}}} Qk=[b1;;bi;;bT]RT×dmodel, 其中 T T T 为该 Session 中的行为个数, b i \mathbf{b}_i bi 表示该 Session 中用户的第 i i i 个行为.

Session 兴趣提取层 (Session Interest Extractor Layer)

划分完 Session 后, 为了捕获 Session 内各个行为的内在关系, 在每个 Session 内应用 multi-head self-attention 机制. 为了刻画 Session 内行为的顺序关系, 类似于 Transorformer 中的 positional encoding, DSIN 介绍了 Bias Encoding B E ∈ R K × T × d m o d e l \mathbf{BE}\in\mathbb{R}^{K\times T\times d_{model}} BERK×T×dmodel (其中 K K K 为用户 Session 的个数, T T T 为一个 Session 内的行为个数, d m o d e l d_{model} dmodel 为 embedding size), 并与用户 Session 相加:

Q = Q + B E \mathbf{Q} = \mathbf{Q} + \mathbf{BE} Q=Q+BE

其中 B E \mathbf{BE} BE 中的每个元素定义为:

B E ( k , t , c ) = w k K + w t T + w c C \mathbf{B E}_{(k, t, c)}=\mathbf{w}_{k}^{K}+\mathbf{w}_{t}^{T}+\mathbf{w}_{c}^{C} BE(k,t,c)=wkK+wtT+wcC

注意 B E ( k , t , c ) \mathbf{B E}_{(k, t, c)} BE(k,t,c) 是一个元素值, 表示第 k k k 个 Session 内的第 t t t 个行为对应的 embedding, 其第 c c c 个元素所对应的偏置值 (bias). 另外注意 w K ∈ R K , w T ∈ R T , w C ∈ R C \mathbf{w}^{K}\in\mathbb{R}^{K}, \mathbf{w}^{T}\in\mathbb{R}^{T}, \mathbf{w}^{C}\in\mathbb{R}^{C} wKRK,wTRT,wCRC 均为向量.

这里补充一点, 可能一开始看到这里的时候会有些疑惑, 比如 B E \mathbf{BE} BE 这个 Tensor 是如何实现的, 毕竟 w K , w T , w C \mathbf{w}^{K}, \mathbf{w}^{T},\mathbf{w}^{C} wK,wT,wC 是三个维度不相等的向量, 它们无法直接相加. 在代码实现时, w K \mathbf{w}^{K} wK 为大小等于 (K, 1, 1) 的数组, w T \mathbf{w}^{T} wT 为大小等于 (1, T, 1) 的数组, 而 w C \mathbf{w}^{C} wC 为大小等于 (1, 1, C) 的数组, 利用 Broadcast 操作可以将三者给相加, 最终得到大小为 ( K , T , C ) (K, T, C) (K,T,C) 的数组.

用户 Session 与 Bias Encoding 相加后, 得到新的表达 Q ∈ R K × T × d m o d e l \mathbf{Q}\in\mathbb{R}^{K\times T\times d_{model}} QRK×T×dmodel 输入到 Multi-Head Self-Attention 模块中. 设 Head 的个数为 H H H, 那么对于第 k k k 个 Session Q k \mathbf{Q}_k Qk, 其将被划分为 H H H 份, 即 Q k = [ Q k 1 ; … ; Q k h ; … ; Q k H ] \mathbf{Q}_k = \left[\mathbf{Q}_{k 1} ; \ldots ; \mathbf{Q}_{k h} ; \ldots ; \mathbf{Q}_{k H}\right] Qk=[Qk1;;Qkh;;QkH], 其中 Q k h ∈ R T × d h \mathbf{Q}_{k h}\in\mathbb{R}^{T\times d_h} QkhRT×dh Q k \mathbf{Q}_k Qk 的第 h h h 个 Head, 其中 d h = 1 H d m o d e l d_h = \frac{1}{H}d_{model} dh=H1dmodel.

h h h 个 Head 的输出结果可以表示为:

  head   h =  Attention  ( Q k h W Q , Q k h W K , Q k h W V ) = softmax ⁡ ( Q k h W Q W K T Q k h T d model ) Q k h W V \begin{aligned} \textbf{ head }_{h} &=\text { Attention }\left(\mathbf{Q}_{k h} \mathbf{W}^{Q}, \mathbf{Q}_{k h} \mathbf{W}^{K}, \mathbf{Q}_{k h} \mathbf{W}^{V}\right) \\ &=\operatorname{softmax}\left(\frac{\mathbf{Q}_{k h} \mathbf{W}^{Q} \mathbf{W}^{K^{T}} \mathbf{Q}_{k h}^{T}}{\sqrt{d_{\text {model}}}}\right) \mathbf{Q}_{k h} \mathbf{W}^{V} \end{aligned}  head h= Attention (QkhWQ,QkhWK,QkhWV)=softmax(dmodel QkhWQWKTQkhT)QkhWV

其中 W Q , W K , W Q W V \mathbf{W}^{Q}, \mathbf{W}^{K}, \mathbf{W}^{Q} \mathbf{W}^{V} WQ,WK,WQWV 均为线性矩阵. 之后将不同 Head 的输出结果进行 concatenation, 再输入到 feed-forward network 中:

I k Q =   FFN   (   Concat   (   head   1 , … ,   head   H ) W O ) \mathbf{I}_{k}^{Q}=\textbf{ FFN }\left(\textbf{ Concat }\left(\textbf{ head }_{1}, \ldots, \textbf{ head }_{H}\right) \mathbf{W}^{O}\right) IkQ= FFN ( Concat ( head 1,, head H)WO)

其中 W O \mathbf{W}^{O} WO 也为线性矩阵, I k Q ∈ R T × d m o d e l \mathbf{I}_{k}^{Q}\in\mathbb{R}^{T\times d_{model}} IkQRT×dmodel. 之后对该 Session 中的 T T T 个行为做 Average Pooling 操作, 得到该 Session 兴趣对应的 embedding:

I k = Avg ⁡ ( I k Q ) \mathbf{I}_{k}=\operatorname{Avg}\left(\mathbf{I}_{k}^{Q}\right) Ik=Avg(IkQ)

此时 I k ∈ R d m o d e l \mathbf{I}_{k}\in\mathbb{R}^{d_{model}} IkRdmodel.

Session 兴趣交互层 (Session Interest Interacting Layer)

为了进一步捕获 Session 兴趣间的变化和演进, 作者采用了 Bi-LSTM 模型. 其公式化如下:

i t = σ ( W x i I t + W h i h t − 1 + W c i c t − 1 + b i ) f t = σ ( W x f I t + W h f h t − 1 + W c f c t − 1 + b f ) c t = f t c t − 1 + i t tanh ⁡ ( W x c I t + W h c h t − 1 + b c ) o t = σ ( W x o I t + W h o h t − 1 + W c o c t + b o ) h t = o t tanh ⁡ ( c t ) \begin{aligned} \mathbf{i}_{t} &=\sigma\left(\mathbf{W}_{x i} \mathbf{I}_{t}+\mathbf{W}_{h i} \mathbf{h}_{t-1}+\mathbf{W}_{c i} \mathbf{c}_{t-1}+\mathbf{b}_{i}\right) \\ \mathbf{f}_{t} &=\sigma\left(\mathbf{W}_{x f} \mathbf{I}_{t}+\mathbf{W}_{h f} \mathbf{h}_{t-1}+\mathbf{W}_{c f} \mathbf{c}_{t-1}+\mathbf{b}_{f}\right) \\ \mathbf{c}_{t} &=\mathbf{f}_{t} \mathbf{c}_{t-1}+\mathbf{i}_{t} \tanh \left(\mathbf{W}_{x c} \mathbf{I}_{t}+\mathbf{W}_{h c} \mathbf{h}_{t-1}+\mathbf{b}_{c}\right) \\ \mathbf{o}_{t} &=\sigma\left(\mathbf{W}_{x o} \mathbf{I}_{t}+\mathbf{W}_{h o} \mathbf{h}_{t-1}+\mathbf{W}_{c o} \mathbf{c}_{t}+\mathbf{b}_{o}\right) \\ \mathbf{h}_{t} &=\mathbf{o}_{t} \tanh \left(\mathbf{c}_{t}\right) \end{aligned} itftctotht=σ(WxiIt+Whiht1+Wcict1+bi)=σ(WxfIt+Whfht1+Wcfct1+bf)=ftct1+ittanh(WxcIt+Whcht1+bc)=σ(WxoIt+Whoht1+Wcoct+bo)=ottanh(ct)

其中 σ ( ⋅ ) \sigma(\cdot) σ() 为 sigmoid 函数, i , f , o , c \mathbf{i}, \mathbf{f}, \mathbf{o}, \mathbf{c} i,f,o,c 分别为 input gate, forget gate, output gate 以及 cell vectors, 它们的大小和 I t \mathbf{I}_t It 一致. Bi-LSTM 的隐层状态 H \mathbf{H} H 表示为:

H t = h f t → ⊕ h b t ← \mathbf{H}_{t}=\overrightarrow{\mathbf{h}_{f t}} \oplus \overleftarrow{\mathbf{h}_{b t}} Ht=hft hbt

其中 h f t → \overrightarrow{\mathbf{h}_{f t}} hft 表示前向 LSTM 的隐层状态而 h b t ← \overleftarrow{\mathbf{h}_{b t}} hbt 反向 LSTM 的隐层状态.

Session 兴趣激活层 (Session Interest Activating Layer)

在得到 Session 兴趣序列后, 由于每个 session 兴趣对目标商品的影响不同,这里采用 Attention 机制来刻画目标商品和每个 session 兴趣之间的相关性。

前面介绍了使用 Multi-Head Self-Attention 与 Bi-LSTM 分别捕获了 session 兴趣序列 { I i } i = 1 , … , K \{\mathbf{I}_i\}_{i=1,\ldots,K} {Ii}i=1,,K { h i } i = 1 , … , K \{\mathbf{h}_i\}_{i=1,\ldots,K} {hi}i=1,,K, 目标商品分别与两个 Session 兴趣序列进行 Attention 的结果为:

a k I = exp ⁡ ( I k W I X I ) ) ∑ k K exp ⁡ ( I k W I X I ) U I = ∑ k a k I I k a k H = exp ⁡ ( H k W H X I ) ) ∑ k K exp ⁡ ( H k W H X I ) U H = ∑ k a k H H k \begin{aligned} a_{k}^{I} &=\frac{\left.\exp \left(\mathbf{I}_{k} \mathbf{W}^{I} \mathbf{X}^{I}\right)\right)}{\sum_{k}^{K} \exp \left(\mathbf{I}_{k} \mathbf{W}^{I} \mathbf{X}^{I}\right)} \\ \mathbf{U}^{I} &=\sum_{k} a_{k}^{I} \mathbf{I}_{k} \\ a_{k}^{H} &=\frac{\left.\exp \left(\mathbf{H}_{k} \mathbf{W}^{H} \mathbf{X}^{I}\right)\right)}{\sum_{k}^{K} \exp \left(\mathbf{H}_{k} \mathbf{W}^{H} \mathbf{X}^{I}\right)} \\ \mathbf{U}^{H} &=\sum_{k} a_{k}^{H} \mathbf{H}_{k} \end{aligned} akIUIakHUH=kKexp(IkWIXI)exp(IkWIXI))=kakIIk=kKexp(HkWHXI)exp(HkWHXI))=kakHHk

其中 X I \mathbf{X}^{I} XI 为目标商品对应的 embedding; 最后, 将 Attention 得到的结果 U I \mathbf{U}^{I} UI, U H \mathbf{U}^{H} UH, 目标商品 embedding X I \mathbf{X}^{I} XI 以及用户侧 embedding X U \mathbf{X}^{U} XU 进行 concatenation 后输入到 MLP 中实现对点击率的预估.

源码分析

下面对 DSIN 的源码进行分析,了解其数据处理,模型构建、训练等实现细节,从而加深对论文核心观点的理解。要解读的源码地址为:https://github.com/shenweichen/DSIN

数据集介绍

DSIN 代码处理的数据集 Ad Display/Click Data on Taobao.com 是阿里巴巴提供的一个淘宝展示广告点击率预估数据集。其主要内容如下:

说明:

  • raw_sample:原始的样本骨架,应该是从展现点击日志中获取的数据,描述了用户与曝光商品(广告)之间的关系,比如是否发生了点击等;
  • ad_feature: 描述了广告的基本信息,比如广告 ID,广告计划 ID,品牌等;
  • user_profile: 描述了用户的基本信息,如用户 ID, 年龄,性别等;
  • raw_behavior_log: 用户的行为日志,描述了用户的历史行为,行为类型主要包含浏览、购买、加购、喜欢(收藏);

数据预处理 – 采样用户

代码地址:https://github.com/shenweichen/DSIN/blob/master/code/0_gen_sampled_data.py

作者贴心地在给代码文件命名时加上了 0_, 1_ 之类的前缀,表明了代码的执行顺序,首先我们需要执行 0_gen_sampled_data.py 对用户进行采样。代码中很多内容是文件的读取,下面我只截取出比较核心的部分:

  1. 采样用户 (详情见注释)

基本信息读取,并对用户进行下采样,历史行为只考虑浏览行为。

## 读取用户信息表和原始样本骨架
user = pd.read_csv('../raw_data/user_profile.csv')
sample = pd.read_csv('../raw_data/raw_sample.csv')

## FRAC 为采样率,对用户进行下采样;原始样本骨架只考虑采样后的用户
if FRAC < 1.0:
    user_sub = user.sample(frac=FRAC, random_state=1024)
else:
    user_sub = user
sample_sub = sample.loc[sample.user.isin(user_sub.userid.unique())]

## 读取用户历史行为,这里只考虑浏览行为(log['btag'] == 'pv', 其中 'pv' 表示浏览)
## 历史行为也只考虑采样后的用户
log = pd.read_csv('../raw_data/behavior_log.csv')
log = log.loc[log['btag'] == 'pv']
userset = user_sub.userid.unique()
log = log.loc[log.user.isin(userset)]

## 读取广告基本信息
ad = pd.read_csv('../raw_data/ad_feature.csv')
ad['brand'] = ad['brand'].fillna(-1)
  1. 用户行为编码

分别对 cate_idbrand 进行编码,注意编码从 1 开始计数,这是因为后续会划分 Session,每个 Session 内的行为个数是不同的,为了让 Session 内的行为数为一固定值,那么行为数不足的 Session 就需要补 0,这些 0 可以表示无行为;因此为了方便区分,可以从 1 开始计数。

from sklearn.preprocessing import LabelEncoder

## 分别对 cate_id 和 brand 进行编码
lbe = LabelEncoder()
unique_cate_id = np.concatenate(
    (ad['cate_id'].unique(), log['cate'].unique()))
lbe.fit(unique_cate_id)
ad['cate_id'] = lbe.transform(ad['cate_id']) + 1
log['cate'] = lbe.transform(log['cate']) + 1

lbe = LabelEncoder()
unique_brand = np.concatenate(
    (ad['brand'].unique(), log['brand'].unique()))
lbe.fit(unique_brand)
ad['brand'] = lbe.transform(ad['brand']) + 1
log['brand'] = lbe.transform(log['brand']) + 1

文件剩余的内容就是将处理后的数据进行保存。OK,现在总结一下该文件的作用:

  • 数据处理, 对用户信息表(user_profile)中的用户进行采样, 并从原始样本骨架(raw_sample)中获取这部分用户对广告的反馈(是否点击等);
  • 之后结合广告基本信息表 (ad_feature) 获取广告的基本信息, 主要是 brand(品牌) 以及 cate_id(商品类目) 信息;
  • 接下来结合用户行为日志 (raw_behavior_log), 获取用户的历史浏览行为(代码只考虑浏览行为, 其他三种行为, 如: 加购、购买、喜欢 暂不考虑), 并对历史行为进行编码,浏览行为用 (cate_id, brand, time_stamp) 来表示.

数据预处理 – Session 划分

代码位置:https://github.com/shenweichen/DSIN/blob/master/code/1_gen_sessions.py

0_gen_sample_data.py 中完成了对用户的采样以及历史行为的预处理,1_gen_sessions.py 文件采用多进程的方式对这部分用户的历史行为进行 Session 划分;

gen_user_hist_sessions 运行入口

函数中我会删去不需要特意分析的代码;

def gen_user_hist_sessions(model, FRAC=0.25):
    """
    在 0_gen_sample_data.py 中完成了对用户的采样, 这会采用多进程的方式对这部分用户的历史行为进行 session 的划分; session 的划分标准是: 如果一个用户的前后两次行为的时间差 > 30min, 那么就划分一个 session; 此外如果一个 session 内的行为数不超过 2 个, 该 session 就不保留了.
    """
	
	## 读取用户历史行为,只保留 0503 ~ 0513 这段时间范围内的数据
    print("gen " + model + " hist sess", FRAC)
    name = '../sampled_data/behavior_log_pv_user_filter_enc_' + str(FRAC) + '.pkl'
    data = pd.read_pickle(name)
    data = data.loc[data.time_stamp >= 1493769600]  # 0503-0513
    # 0504~1493856000
    # 0503 1493769600
	
	## 读取采样后的用户信息
    user = pd.read_pickle('../sampled_data/user_profile_' + str(FRAC) + '.pkl')

	## 用户数量较多,采用多进程的方式来进行处理,
    n_samples = user.shape[0]
    batch_size = 150000
    iters = (n_samples - 1) // batch_size + 1

    for i in range(0, iters):
        target_user = user['userid'].values[i * batch_size:(i + 1) * batch_size]
        sub_data = data.loc[data.user.isin(target_user)]
		
		## 对用户进行分组聚合,将同一个用户的所有行为聚合在一起,然后再对这个
		## 行为列表进行排序以及 Session 划分,这部分逻辑在 gen_session_list_dsin
		## 函数中完成,调用 applyParallel 函数实现多进程处理
        df_grouped = sub_data.groupby('user')
        user_hist_session = applyParallel(
            df_grouped, gen_session_list_dsin, n_jobs=20, backend='multiprocessing')


    print("1_gen " + model + " hist sess done")
gen_session_list_dsin 实现划分 Session 的逻辑

根据用户的历史行为来划分 session, 按照前后两次行为的时间间隔是否超过 30min 来进行 session 的划分, 另外只保留行为数超过 2 个的 sessions.

def gen_session_list_dsin(uid, t):
    """
    根据用户的历史行为来划分 session, 按照前后两次行为的时间间隔是否超过 30min 来进行 session 的划分, 另外只保留行为数超过 2 个的 sessions
    """
    ## 对用户行为序列 t 按时间从小到大排序,也就是说,近期的行为排在后面,而较老的行为排在前面
    t.sort_values('time_stamp', inplace=True, ascending=True)
    last_time = 1483574401  # pd.to_datetime("2017-01-05 00:00:01")
    session_list = []
    session = []
    ## row[0] 为样本在表格中的序号, row[1] 为 pandas.Series, 里面的内容包括
    ## (user, time_stamp, cate, brand)
    ## 下面的逻辑是计算前后两个行为之间的时间误差,如果 delta > 30min, 那么就划分一个 Session;此外,如果该 Session 中的行为数不多于 2 个,那么就丢弃该 Session.
    for row in t.iterrows():
        time_stamp = row[1]['time_stamp']
        delta = time_stamp - last_time  ## 计算和上一个行为的时间误差
        cate_id = row[1]['cate']
        brand = row[1]['brand']
        if delta > 30 * 60:  # Session begin when current behavior and the last behavior are separated by more than 30 minutes.
            if len(session) > 2:  # Only use sessions that have >2 behaviors
                session_list.append(session[:])
            session = []

        session.append((cate_id, brand, time_stamp))
        last_time = time_stamp
    if len(session) > 2:
        session_list.append(session[:])
    return uid, session_list

函数最后返回 user_id 以及 session_list, 行为使用 (cate_id, brand, timestamp) 来表示,那么 session_list 可以表示为:

session_list = [
	[(c11, b11, t11), (c12, b12, t12), ...],
	[(c21, b21, t21), (c22, b22, t22), ...],
	.......
]

产生模型训练数据

代码位置:https://github.com/shenweichen/DSIN/blob/master/code/2_gen_dsin_input.py

之后运行 2_gen_dsin_input.py 来产生模型所需要的训练数据。这部分代码相对比较复杂,下面将代码拆分,对各个部分进行分析。

  1. 读取 1_gen_session.py 产生的用户 session 文件,并统一保存到 user_hist_session 字典中。
user_hist_session = {}
FILE_NUM = len(
    list(filter(lambda x: x.startswith('user_hist_session_' + str(FRAC) + '_dsin_'),
                os.listdir('../sampled_data/'))))

print('total', FILE_NUM, 'files')

for i in range(FILE_NUM):
    """
    在 1_gen_sessions.py 中, 划分完 session 后, 最终使用字典来保留 {user_id: session_list}, 即每个用户对应的 session 序列;
    这里将所有用户的 session 序列统一保存到 user_hist_session 中
    """
    user_hist_session_ = pd.read_pickle(
        '../sampled_data/user_hist_session_' + str(FRAC) + '_dsin_' + str(i) + '.pkl')  # 19,34
    user_hist_session.update(user_hist_session_)
    del user_hist_session_
  1. 获取采样后的用户, 保存到 sample_sub
sample_sub = pd.read_pickle(
        '../sampled_data/raw_sample_' + str(FRAC) + '.pkl')
  1. sample_sub 中的所有用户的 session 汇总,保存到 sess_input_dict 中,每个用户只保留 DSIN_SESS_COUNT = 5 (定义在 config.py 文件中)个 Session. 另外使用 sess_input_length_dict 保存每个 session 的真实行为个数,因为在后面的处理过程中,会存在将 session 截断或补 0 的操作,使所有 Session 的长度统一为 DSIN_SESS_MAX_LEN = 10.
sess_input_dict = {}
sess_input_length_dict = {}
for i in range(SESS_COUNT):
    sess_input_dict['sess_' + str(i)] = {'cate_id': [], 'brand': []}
    sess_input_length_dict['sess_' + str(i)] = []

sess_length_list = []
for row in tqdm(sample_sub[['user', 'time_stamp']].iterrows()):
    sess_input_dict_, sess_input_length_dict_, sess_length = gen_sess_feature_dsin(
        row)
    for i in range(SESS_COUNT):
        sess_name = 'sess_' + str(i)
        sess_input_dict[sess_name]['cate_id'].append(
            sess_input_dict_[sess_name]['cate_id'])
        sess_input_dict[sess_name]['brand'].append(
            sess_input_dict_[sess_name]['brand'])
        sess_input_length_dict[sess_name].append(
            sess_input_length_dict_[sess_name])
    sess_length_list.append(sess_length)

其中 gen_sess_feature_dsin 函数得到每个用户的 SESS_COUNT = 5 个 Sessions,并保存在 sess_input_dict_ 中,而 sess_input_dict 用于汇总所有用户的 Sessions。最后得到的 sess_input_dict 形式如下:

## c: cate_id, b: brand
## 假设用户数为 k 个, 每个用户保留 5 个 Session。
## 由于 Session 内的行为数不固定,所以 m,n,q 不一定相等
sess_input_dict = {
	'sess_0': {
		'cate_id': [[c11, c12, ..., c1m], ## sess_0 中用户1 有 m 个行为
					[c21, c22, ..., c2n], ## 用户 2 有 n 个行为
					...,
					[ck1, ck2, ..., ckq]], ## 用户 k 有 q 个行为
		'brand'  : [[b11, b12, ..., b1m],
					[b21, b22, ..., b2n],
					...,
					[bk1, bk2, ..., bkq]],
	},
	'sess_1': {
		......
	},
	....., 
	'sess_5': {
		.....
	},
}

下面再详细介绍 gen_sess_feature_dsin 函数:

其内容主要是从一个用户的 session_list 中保留最近的 5 个 Session,同时要保证 Session 内的行为时间不超过用户和广告发生交互的时间 (否则就不叫历史行为了…)

def gen_sess_feature_dsin(row):
	"""
	row 中保存着一个用户的 ID 以及对广告进行反馈的时间 time_stamp,
	因此在下面的处理中,主要目的是将 time_stamp 之前的行为保留,
	同时对每个用户只保留最近的 5 (DSIN_SESS_COUNT)个 Session
	"""
    sess_count = DSIN_SESS_COUNT  ## 5
    sess_max_len = DSIN_SESS_MAX_LEN  ## 10,该函数没有用到这个变量
    sess_input_dict = {}
    sess_input_length_dict = {}
    for i in range(sess_count):
        sess_input_dict['sess_' + str(i)] = {'cate_id': [], 'brand': []}
        sess_input_length_dict['sess_' + str(i)] = 0
    sess_length = 0
    user, time_stamp = row[1]['user'], row[1]['time_stamp'] ## time_stamp 是用户对广告的反馈时间, 历史行为的时间应该要小于这个时间
    # 边界情况处理
    if user not in user_hist_session:
        for i in range(sess_count):
            sess_input_dict['sess_' + str(i)]['cate_id'] = [0]
            sess_input_dict['sess_' + str(i)]['brand'] = [0]
            sess_input_length_dict['sess_' + str(i)] = 0
        sess_length = 0
    else: # 核心逻辑
    	## 先确定 sess_0 的结果,再确定 sess_1 ~ sess_4 的结果
        valid_sess_count = 0
        last_sess_idx = len(user_hist_session[user]) - 1
        for i in reversed(range(len(user_hist_session[user]))): ## 从最新的 session 开始处理
            cur_sess = user_hist_session[user][i]  ## 用户 user 的第 i 个 session, [(cate_id, brand, timestamp), ....]
            if cur_sess[0][2] < time_stamp: ## cur_sess[0][2] 表示第一个行为的行为时间, 需要小于 time_stamp
                in_sess_count = 1
                for j in range(1, len(cur_sess)): ## 这个session中其他行为的时间也应该小于 time_stamp, in_sess_count 统计这个 session 内小于 time_stamp 的行为数
                    if cur_sess[j][2] < time_stamp:
                        in_sess_count += 1
                if in_sess_count > 2:
                    ## 取该 session 中最近的 sess_max_len(10个) 个行为, 如果 session 内的行为个数(此外还要满足时间<time_stamp这个条件)少于 10 个,
                    ## 那么 index 范围为 [0, in_sess_count];
                    ## 如果 session 中行为数较多, 那么取时间最新的, 行为用 (cate_id, brand, timestamp) 表示, e[0] 表示 cate_id, e[1] 表示 brand
                    sess_input_dict['sess_0']['cate_id'] = [e[0] for e in cur_sess[max(0,
                                                                                       in_sess_count - sess_max_len):in_sess_count]]
                    sess_input_dict['sess_0']['brand'] = [e[1] for e in
                                                          cur_sess[max(0, in_sess_count - sess_max_len):in_sess_count]]
                    sess_input_length_dict['sess_0'] = min(
                        sess_max_len, in_sess_count)
                    last_sess_idx = i
                    valid_sess_count += 1
                    break
        ## 上一段代码得到最新的 session 作为 sess_0, 下面依次获取 sess_1 ~ sess_4
        for i in range(1, sess_count):
            if last_sess_idx - i >= 0: ## 一个 session 内至少有两个行为, 在 1_gen_sessions.py 中有这样的设定
                cur_sess = user_hist_session[user][last_sess_idx - i]
                ## 这里获取 session 内行为的代码使用简便的 cur_sess[-sess_max_len:], 而上一段代码使用复杂的 
                ## cur_sess[max(0, in_sess_count - sess_max_len):in_sess_count], 是因为第一个 session 要考虑
                ## 行为的时间不能超过 time_stamp, 但这里的 session 中所有行为的时间都小于 time_stamp, 所以直接用 cur_sess[-sess_max_len:]
                ## 处理即可
                sess_input_dict['sess_' + str(i)]['cate_id'] = [e[0]
                                                                for e in cur_sess[-sess_max_len:]]
                sess_input_dict['sess_' + str(i)]['brand'] = [e[1]
                                                              for e in cur_sess[-sess_max_len:]]
                sess_input_length_dict['sess_' +
                                       str(i)] = min(sess_max_len, len(cur_sess))
                valid_sess_count += 1
            else:  ## 如果用户的 session 个数比较少, 默认用 0 表示
                sess_input_dict['sess_' + str(i)]['cate_id'] = [0]
                sess_input_dict['sess_' + str(i)]['brand'] = [0]
                sess_input_length_dict['sess_' + str(i)] = 0

        sess_length = valid_sess_count  ## valid_sess_count 记录有效的 session 长度
    ## sess_input_length_dict 用于记录每个 session 真实的行为长度
    return sess_input_dict, sess_input_length_dict, sess_length

由于 gen_sess_feature_dsin 函数只处理一个用户的 session_list, 所以其返回结果 sess_input_dict 形如:

sess_input_dict = {
	'sess_0': {
		'cate_id': [c1, c2, ..., cm],
		'brand'  : [b1, b2, ..., bm],
	},
	'sess_1': {
		......
	},
	....., 
	'sess_5': {
		.....
	},
}
  1. 继续介绍主逻辑:下一步读取用户信息以及广告信息,并和原始样本骨架表(raw_sample) 三表进行关联:
user = pd.read_pickle('../sampled_data/user_profile_' + str(FRAC) + '.pkl')
ad = pd.read_pickle('../sampled_data/ad_feature_enc_' + str(FRAC) + '.pkl')
user = user.fillna(-1)
user.rename(
    columns={'new_user_class_level ': 'new_user_class_level'}, inplace=True)

sample_sub = pd.read_pickle(
    '../sampled_data/raw_sample_' + str(FRAC) + '.pkl')
sample_sub.rename(columns={'user': 'userid'}, inplace=True)

## sample_sub 描述了用户和广告的关联, 要得到 user_id 本身的信息以及 ad 的信息, 需要用
## sample_sub 去关联 user 和 ad 两个表
data = pd.merge(sample_sub, user, how='left', on='userid', )
data = pd.merge(data, ad, how='left', on='adgroup_id')

关联后的结果保存到 data 变量中.

  1. 将稀疏特征和稠密特征都转化为 ID,并记录各个特征空间的大小:
## 这里 sparse_features 大小为 13
sparse_features = ['userid', 'adgroup_id', 'pid', 'cms_segid', 'cms_group_id', 'final_gender_code', 'age_level',
                       'pvalue_level', 'shopping_level', 'occupation', 'new_user_class_level', 'campaign_id',
                       'customer']

dense_features = ['price']

## 转换为 id; 
for feat in tqdm(sparse_features):
    lbe = LabelEncoder()  # or Hash
    data[feat] = lbe.fit_transform(data[feat])
mms = StandardScaler()
data[dense_features] = mms.fit_transform(data[dense_features])

## 记录特征空间的大小; SingleFeat 就是一个 namedtuple, 用于记录特征的基本信息
## sparse_feature_list 大小为 13 + 2 = 15
sparse_feature_list = [SingleFeat(feat, data[feat].nunique(
) + 1) for feat in sparse_features + ['cate_id', 'brand']]
dense_feature_list = [SingleFeat(feat, 1) for feat in dense_features]

加上 cate_idbrand 的话,总共 15 个稀疏特征,以及 1 个稠密特征。注意代码中使用 sparse_feature_listdense_feature_list 记录了各个稀疏和稠密特征的个数,用于后续构建 Embedding Layer (设置 Embedding Layer 的大小)以及 Hash 等。

  1. 将所有用户各个 Session 中的行为个数统一限制为 DSIN_SESS_MAX_LEN (10个),如果行为个数不足 10 个,那么进行补零操作:
from tensorflow.python.keras.preprocessing.sequence import pad_sequences

sess_feature = ['cate_id', 'brand']
sess_input = []
sess_input_length = []
## 使用 pad_sequences 对所有 session 进行补 0 操作, 使得所有 session 的长度均为 DSIN_SESS_MAX_LEN
## sess_input 为一个大小为 SESS_COUNT * len(sess_feature) 的 list, 里面的元素为 shape 为 (number_of_user, DSIN_SESS_MAX_LEN) 的 numpy 数组. 
## SESS_COUNT=5, sess_feature = [cateid, brand]
for i in tqdm(range(SESS_COUNT)):
    sess_name = 'sess_' + str(i)
    for feat in sess_feature:
        sess_input.append(pad_sequences(
            sess_input_dict[sess_name][feat], maxlen=DSIN_SESS_MAX_LEN, padding='post'))
    sess_input_length.append(sess_input_length_dict[sess_name])
  1. 构造 DSIN 的输入数据:
## model_input 中每个元素为 shape=(number_of_user,) 的 numpy 数组, model_input 大小为 len(sparse_feature_list) + len(dense_feature_list);
    ## sess_input 为一个大小为 SESS_COUNT * len(sess_feature) 的 list, 里面的元素为 shape 为 (number_of_user, DSIN_SESS_MAX_LEN) 的 numpy 数组
    ## np.array(sess_length_list) 为 shape=(number_of_user,) 的 numpy 数组 
model_input = [data[feat.name].values for feat in sparse_feature_list] + \
              [data[feat.name].values for feat in dense_feature_list]
sess_lists = sess_input + [np.array(sess_length_list)]
model_input += sess_lists

model_input 是一个 list,里面保存着各种 numpy 数组:(注: 图中的 N N N 应该等于 sample_sub 的行数)

model_input 的大小为 27,其中包括 15 个稀疏特征,1 个稠密特征,5 个 Session,每个 Session 内用 cate_idbrand 表示行为,因此有 5 x 2 个元素,最后再加上 1 个数组表示每个 Session 内真实的行为长度,因此总大小是 15 + 1 + 5 x 2 + 1 = 27.

最后将输入数据,label 以及特征空间大小保存下来,用于后续训练模型。

if not os.path.exists('../model_input/'):
        os.mkdir('../model_input/')

pd.to_pickle(model_input, '../model_input/dsin_input_' +
             str(FRAC) + '_' + str(SESS_COUNT) + '.pkl')
pd.to_pickle(data['clk'].values, '../model_input/dsin_label_' +
             str(FRAC) + '_' + str(SESS_COUNT) + '.pkl')
## 将稀疏特征和稠密特征的空间大小使用字典保存
pd.to_pickle({'sparse': sparse_feature_list, 'dense': dense_feature_list},
             '../model_input/dsin_fd_' + str(FRAC) + '_' + str(SESS_COUNT) + '.pkl')
print("gen dsin input done")

介绍完数据预处理的部分,下面介绍模型训练代码。

模型训练代码介绍

代码位于:https://github.com/shenweichen/DSIN/blob/master/code/train_dsin.py

数据预处理中得到的 model_input 包含了所有的数据,因此首先需要划分训练集和测试集:

SESS_COUNT = DSIN_SESS_COUNT  ## 5, 每个用户的 Session 个数
SESS_MAX_LEN = DSIN_SESS_MAX_LEN  ## 10,每个 Session 中的行为个数

fd = pd.read_pickle('../model_input/dsin_fd_' +
                    str(FRAC) + '_' + str(SESS_COUNT) + '.pkl')
model_input = pd.read_pickle(
    '../model_input/dsin_input_' + str(FRAC) + '_' + str(SESS_COUNT) + '.pkl')
label = pd.read_pickle('../model_input/dsin_label_' +
                       str(FRAC) + '_' + str(SESS_COUNT) + '.pkl')

sample_sub = pd.read_pickle(
    '../sampled_data/raw_sample_' + str(FRAC) + '.pkl')
## 增加 idx 列,表示样本的索引,用于后续训练集和测试集的划分
sample_sub['idx'] = list(range(sample_sub.shape[0]))

## 按时间,将用户与广告发生交互的时间 < 1494633600 的样本当作训练集,
## 其他作为测试集,首先获取到这些样本的索引
train_idx = sample_sub.loc[sample_sub.time_stamp <
                           1494633600, 'idx'].values
test_idx = sample_sub.loc[sample_sub.time_stamp >=
                          1494633600, 'idx'].values

得到索引后,再划分模型的输入数据和 label 等:

## 输入也进行划分, 下面两行代码中的 i 均为 numpy 数组
train_input = [i[train_idx] for i in model_input]
test_input = [i[test_idx] for i in model_input]

train_label = label[train_idx]
test_label = label[test_idx]

模型训练和预估, loss 采用 binary_crossentropy, 优化方法选择 adagrad. 重点在 DSIN 模型。

sess_count = SESS_COUNT
sess_len_max = SESS_MAX_LEN
BATCH_SIZE = 4096

sess_feature = ['cate_id', 'brand']
TEST_BATCH_SIZE = 2 ** 14

## DSIN 的构建依赖 deepctr 库
model = DSIN(fd, sess_feature, embedding_size=4, sess_max_count=sess_count,
             sess_len_max=sess_len_max, dnn_hidden_units=(200, 80), att_head_num=8,
             att_embedding_size=1, bias_encoding=False)

model.compile('adagrad', 'binary_crossentropy',
              metrics=['binary_crossentropy', ])

hist_ = model.fit(train_input, train_label, batch_size=BATCH_SIZE,
                  epochs=1, initial_epoch=0, verbose=1, )

pred_ans = model.predict(test_input, TEST_BATCH_SIZE)

print()
print("test LogLoss", round(log_loss(test_label, pred_ans), 4), "test AUC",
      round(roc_auc_score(test_label, pred_ans), 4))

下面详细介绍 DSIN 模型。

DSIN 模型代码介绍

代码位置:https://github.com/shenweichen/DSIN/blob/master/code/models/dsin.py

DSIN 的构建依赖 deepctr 库,注意一开始运行代码之前,使用 pip install -r requirements.txt 安装必要的依赖项,deepctr 库最新版本很多接口发生了变化,所以最好是安装 requirements.txt 中指定的版本。

作者给输入参数添加了详细的说明,我再介绍一下:

def DSIN(feature_dim_dict, sess_feature_list, embedding_size=8, sess_max_count=5, sess_len_max=10, bias_encoding=False,
         att_embedding_size=1, att_head_num=8, dnn_hidden_units=(200, 80), dnn_activation='sigmoid', dnn_dropout=0,
         dnn_use_bn=False, l2_reg_dnn=0, l2_reg_embedding=1e-6, init_std=0.0001, seed=1024, task='binary',
         ):
    """Instantiates the Deep Session Interest Network architecture.

    :param feature_dim_dict: 类似 {'sparse':{'field_1':4,'field_2':3,'field_3':2},'dense':[]} 这样的字典,指明了每个特征空间的大小
    :param sess_feature_list: 传入的是 ['cate_id', 'brand'], 用这些表示用户行为
    :param embedding_size: 稀疏特征 embedding size 的大小
    :param sess_max_count: 每个用户最大的 Session 个数,为 5
    :param sess_len_max: 每个 Session 中的行为个数,为 10
    :param bias_encoding: bool 值,是否使用 bias encoding
    :param att_embedding_size: the embedding size of each attention head
    :param att_head_num: attention head 的个数
    :param dnn_hidden_units: list, dnn 网络每一隐层的节点个数
    :param dnn_activation: dnn 中每一层的激活函数
    :param dnn_dropout: float in [0,1), the probability we will drop out a given DNN coordinate.
    :param dnn_use_bn: bool. Whether use BatchNormalization before activation or not in deep net
    :param l2_reg_dnn: float. L2 regularizer strength applied to DNN
    :param l2_reg_embedding: float. L2 regularizer strength applied to embedding vector
    :param init_std: float,to use as the initialize std of embedding vector
    :param seed: 随机种子
    :param task: str, ``"binary"`` for  binary logloss or  ``"regression"`` for regression loss
    :return: A Keras model instance.
    """

train_dsin.py 文件中传入到该函数的参数中,需要注意的有:

sess_feature=['cate_id', 'brand'] 
embedding_size=4
sess_max_count=sess_count  ## 5
sess_len_max=sess_len_max  ## 10
att_head_num=8
att_embedding_size=1

注意特征的 embedding size 为 4.

代码首先进行数值判断,要保证 Multi-Head Self-Attention 的输出结果 embedding 的维度和输入是相同的。首先,每个稀疏特征都会被映射为大小等于 embedding_size 的向量,而一个行为使用 (cate_id, brand) 来表示,该行为对应的 embedding 大小是这两个稀疏特征对应的 embedding 向量进行 concatenation 得到的,即行为的 embedding 大小为 2 * embedding_size (len(sess_feature_list) == 2)

if (att_embedding_size * att_head_num != len(sess_feature_list) * embedding_size):
        raise ValueError(
            "len(session_feature_lsit) * embedding_size must equal to att_embedding_size * att_head_num ,got %d * %d != %d *%d" % (
            len(sess_feature_list), embedding_size, att_embedding_size, att_head_num))

获取模型的输入:

## sparse_input: sparse_input 是大小为 15 的字典, key 有 userid, group_id 等特征, 而元素均为 shape=[B, 1] 的 tensor,表示每个特征的 id
## user_behavior_input_dict 为大小为 sess_max_count 的字典,key 为 sess_0 ~ sess_1, 每个 session 包含 brand/cid 两种类型的序列
## user_sess_length 表示真实 Session 的长度,虽然每个用户最后都划分了 5 个 Session,
## 但并不是每个用户都有 5 个 Session
sparse_input, dense_input, user_behavior_input_dict, _, user_sess_length = get_input(
    feature_dim_dict, sess_feature_list, sess_max_count, sess_len_max)

"""
-- 中途插入 --
其中 get_input 定义如下,我把代码直接贴在这里,方便阅读
"""
from tensorflow.python.keras.layers import Input
def get_input(feature_dim_dict, seq_feature_list, sess_max_count, seq_max_len):
    sparse_input, dense_input = create_singlefeat_inputdict(feature_dim_dict)
    user_behavior_input = {}
    for idx in range(sess_max_count):
        sess_input = OrderedDict()
        for i, feat in enumerate(seq_feature_list):
            sess_input[feat] = Input(
                shape=(seq_max_len,), name='seq_' + str(idx) + str(i) + '-' + feat)

        user_behavior_input["sess_" + str(idx)] = sess_input

    user_behavior_length = {"sess_" + str(idx): Input(shape=(1,), name='seq_length' + str(idx)) for idx in
                            range(sess_max_count)}
    user_sess_length = Input(shape=(1,), name='sess_length')

    return sparse_input, dense_input, user_behavior_input, user_behavior_length, user_sess_length

上面代码获取的输入(Input) 有 sparse_input, dense_input, user_behavior_input_dict, user_sess_length, 为了保证我们思维的连续性,我们先暂时略过 DSIN 函数中间部分的细节,直接跳到该函数的末尾部分,看看模型完整的输入是如何构建的,这样可以和前面讲过的model_input (数据预处理那一节)结合起来:

"""
我们直接跳到 DSIN 函数最后的部分,看看模型完整的输入

get_inputs_list 函数大概的作用是将 dict/OrderedDict 转换成 list 输出
"""
sess_input_list = []
for i in range(sess_max_count):
    sess_name = "sess_" + str(i)
    sess_input_list.extend(get_inputs_list(
        [user_behavior_input_dict[sess_name]]))

model_input_list = get_inputs_list([sparse_input, dense_input]) + sess_input_list + [
    user_sess_length]

model = Model(inputs=model_input_list, outputs=output)

return model

我们可以看到 model_input_list 这个变量,它包含的内容刚好是 [sparse_input, dense_input] + sess_input_list + [user_sess_length], 而如果将它们都展开来看的话,正是:

## sparse_input 大小为 15
sparse_input = [
	Input(shape=(1,), name='user_id', dtype),
	Input(shape=(1,), name='adgroup_id', dtype),
	......,
	Input(shape=(1,), name='cate_id', dtype),
	Input(shape=(1,), name='brand', dtype),
]
## dense_input 大小为 1
dense_input = [
	Input(shape=(1,), name='price', dtype),
]
## sess_input_list 大小为 len(sess_feature_list) * SESS_COUNT = 2 * 5 = 10
## seq_max_len == 10
sess_input_list = [
	Input(shape=(seq_max_len,), name='seq_00-cate_id'),
	Input(shape=(seq_max_len,), name='seq_01-brand'),
	......,
	Input(shape=(seq_max_len,), name='seq_40-cate_id'),
	Input(shape=(seq_max_len,), name='seq_41-brand'),
]
## user_sess_length 大小为 1,表示真实 Session 的长度
user_sess_length = [
	Input(shape=(1,), name='sess_length')
]

如果把上面的所有 Input 按顺序来看,正好可以和下图对应!

model_input 中的数据就通过 Input 输入到模型中了。

下面我们回到模型代码。获取输入数据后,由于输入的是各种特征对应的 ID 值,首先要将它们转换为稠密的 embedding 向量:

from tensorflow.python.keras.layers import (Concatenate, Dense, Embedding,
                                            Flatten, Input)
## sparse_embedding_dict 保存每个稀疏特征对应的 Embedding Layer
## 后面可以使用 embedding_lookup 查找每个特征对应的 embedding
sparse_embedding_dict = {feat.name: Embedding(feat.dimension, embedding_size,
                                              embeddings_initializer=RandomNormal(
                                                      mean=0.0, stddev=init_std, seed=seed),
                                              embeddings_regularizer=l2(
                                                  l2_reg_embedding),
                                              name='sparse_emb_' +
                                                   str(i) + '-' + feat.name,
                                              mask_zero=(feat.name in sess_feature_list)) for i, feat in
                         enumerate(feature_dim_dict["sparse"])}

获取目标商品对应的 embedding:

"""
+ query_emb_list 为大小为 2 (等于 len(sess_feature_list)) 的 list, 保存两个 tensor, 
+ target item 对应的 cate_id 和 brand
+ feature_dim_dict['sparse'] 记录了每个 field 的空间大小,
                   用于对这个 field 下的特征值进行 Hash, 
+ sparse_embedding_dict 存储了每个 field 对应的 embedding layer, 
                   用于将稀疏特征映射为稠密向量;
+ sparse_input 则保存了每个特征的取值 
"""
query_emb_list = get_embedding_vec_list(sparse_embedding_dict, sparse_input, feature_dim_dict["sparse"],
                                        sess_feature_list, sess_feature_list)

"""
-- 中途插入 --
get_embedding_vec_list 定义如下,用于获取特征对应的 embedding, 相当于在 Embedding Layer
做 embedding_lookup, 如果指定了 return_feat_list,那么将只会获取 return_feat_list 中特征对应的 embedding
"""
def get_embedding_vec_list(embedding_dict, input_dict, sparse_fg_list,return_feat_list=(),mask_feat_list=()):
    embedding_vec_list = []
    for fg in sparse_fg_list:
        feat_name = fg.name
        if len(return_feat_list) == 0  or feat_name in return_feat_list:
            if fg.hash_flag:
            	## 做 hash 的时候,if mask_zero = True,0 or 0.0 will be set to 0,other value will be set in range[1,num_buckets)
                lookup_idx = Hash(fg.dimension,mask_zero=(feat_name in mask_feat_list))(input_dict[feat_name])
            else:
                lookup_idx = input_dict[feat_name]

            embedding_vec_list.append(embedding_dict[feat_name](lookup_idx))

    return embedding_vec_list

query_emb_list 是一个 list,保存着两个大小为 [B, 1, 4] 的 Tensor (embedding_size = 4), 之后将特征进行 concatenation

query_emb = concat_fun(query_emb_list)  ## [B, 1, 8]

之后再获取输入到 DNN 中的特征, 仍然是调用 get_embedding_vec_list 实现:

"""
sparse_embedding_dict 保存 embedding layer, sparse_input 保存特征, 
feature_dim_dict["sparse"] 保存特征空间大小
传入 mask_feat_list 是想将这些特征中的 0 值直接映射为 0 向量;
"""
deep_input_emb_list = get_embedding_vec_list(sparse_embedding_dict, sparse_input, feature_dim_dict["sparse"],
                                             mask_feat_list=sess_feature_list)
deep_input_emb = concat_fun(deep_input_emb_list)  ## [B, 1, 60], 15 个稀疏特征, emb_dim=4
deep_input_emb = Flatten()(NoMask()(deep_input_emb)) ## [B, 60]

获取到 deep_input_emb 后,相当于完成了模型示意图中的左下角部分:

下一步实现 Session Division Layer, 用于将用户行为划分为 Session, 并加上 Bias-Encoding. 但由于在制作数据集的时候已经对行为划分了 Session, 所以在这一步主要内容是将行为转换为 embedding:

"""
tr_input: list, 长度等于 sess_max_count, 每个元素为 [B, 10, 8] 大小的 Tensor, 
10 表示 max_session_len, tr_input 的前缀 tr_ 表示 transformer,说明该变量是 Transformer 的输入
"""
tr_input = sess_interest_division(sparse_embedding_dict, user_behavior_input_dict, feature_dim_dict['sparse'],
                                  sess_feature_list, sess_max_count, bias_encoding=bias_encoding)

""""
-- 中途插入 --
sess_interest_division 函数的定义如下:
"""
def sess_interest_division(sparse_embedding_dict, user_behavior_input_dict, sparse_fg_list, sess_feture_list,
                           sess_max_count,
                           bias_encoding=True):
    tr_input = []
    """
    使用 get_embedding_vec_list 获取每个行为对应的 embedding,
    行为使用 (cate_id, brand) 表示,而 cate_id 和 brand 对应的 Tensor 大小
    均为 [B, 10], 因此 get_embedding_vec_list 得到两个大小为 [B, 10, 4] 的 Tensor,
    使用 concat_fun 进行 concat,得到 keys_emb shape=[B, 10, 8]
    tr_input 的长度为 sess_max_count=5
    """
    for i in range(sess_max_count):
        sess_name = "sess_" + str(i)
        keys_emb_list = get_embedding_vec_list(sparse_embedding_dict, user_behavior_input_dict[sess_name],
                                               sparse_fg_list, sess_feture_list, sess_feture_list)
        keys_emb = concat_fun(keys_emb_list)  ## [B, 10, 8]
        tr_input.append(keys_emb)
    ## 加上 BiasEncoding
    if bias_encoding:
        tr_input = BiasEncoding(sess_max_count)(tr_input)
    return tr_input

上面代码中用到了论文中介绍的 BiasEncoding,单独介绍一下, 其核心代码段如下:

class BiasEncoding(Layer):
    def __init__(self, sess_max_count, seed=1024, **kwargs):
        self.sess_max_count = sess_max_count
        self.seed = seed
        super(BiasEncoding, self).__init__(**kwargs)

    def build(self, input_shape):
        # Create a trainable weight variable for this layer.

        if self.sess_max_count == 1:
            embed_size = input_shape[2].value
            seq_len_max = input_shape[1].value
        else:
            embed_size = input_shape[0][2].value
            seq_len_max = input_shape[0][1].value
		
		"""
		sess_bias_embedding: [sess_max_count, 1, 1]
		seq_bias_embedding: [1, seq_len_max, 1]
		item_bias_embedding: [1, 1, embed_size]
		注意它们的 shape
		"""
        self.sess_bias_embedding = self.add_weight('sess_bias_embedding', shape=(self.sess_max_count, 1, 1),
                                                   initializer=TruncatedNormal(
                                                       mean=0.0, stddev=0.0001, seed=self.seed))
        self.seq_bias_embedding = self.add_weight('seq_bias_embedding', shape=(1, seq_len_max, 1),
                                                  initializer=TruncatedNormal(
                                                      mean=0.0, stddev=0.0001, seed=self.seed))
        self.item_bias_embedding = self.add_weight('item_bias_embedding', shape=(1, 1, embed_size),
                                                   initializer=TruncatedNormal(
                                                       mean=0.0, stddev=0.0001, seed=self.seed))

	def call(self, inputs, mask=None):
        """
        :param concated_embeds_value: None * field_size * embedding_size
        :return: None*1
        sess_bias_embedding + seq_bias_embedding + item_bias_embedding 
        三者通过 Broadcast 进行相加
        """
        transformer_out = []
        for i in range(self.sess_max_count):
            transformer_out.append(
                inputs[i] + self.item_bias_embedding + self.seq_bias_embedding + self.sess_bias_embedding[i])
        return transformer_out

得到经过 BiasEncoding 处理后的 Session 输入后,相当于实现了模型示意图中的如下部分:

下一步要将其输入到 Multi-Head Self-Attention 中,以学习 Session 内各行为的内在关系, 并学习出对应的 Session 兴趣,这一步相当于实现了 Session Interest Extractor Layer

Self_Attention = Transformer(att_embedding_size, att_head_num, dropout_rate=0, use_layer_norm=False,
                                 use_positional_encoding=(not bias_encoding), seed=seed, supports_masking=True,
                                 blinding=True)
"""
tr_input 为 list,大小为 5,里面的元素为大小等于 [B, 10, 8] 的 Tensor
Self-Attention 的输出 sess_fea 大小为 [B, 5, 8], 5 为 sess_max_count

提前做个说明,Transformer 中完成每个 Session 的 Multi-Head Self-Attention 后,结果大小
应该是 out=[B, 10, 8], 但最后输出时会做 reduce_mean(out, axis=1, keep_dims=True), 
用于生成 Session 兴趣对应的 embedding,大小为 [B, 1, 8], 由于总共有 sess_max_count=5 个 Session,所以最终的输出 sess_fea 大小为 [B, 5, 8]
"""
sess_fea = sess_interest_extractor(
    tr_input, sess_max_count, Self_Attention)  ## [B, 5, 8]

"""
-- 中途插入 --
sess_interest_extractor 函数的定义如下
"""
def sess_interest_extractor(tr_input, sess_max_count, TR):
	"""
	tr_input 为 list, 大小为 5,里面的元素大小为 [B, 10, 8]
	sess_max_count=5
	TR 即 Transformer,实现 Multi-Head Self-Attention
	"""
    tr_out = []
    for i in range(sess_max_count):
        tr_out.append(TR(
            [tr_input[i], tr_input[i]]))
    sess_fea = concat_fun(tr_out, axis=1)
    return sess_fea

Transformer 的核心代码就不贴了,太长了,知道它输入输出大小即可。上述代码完成了模型结构图中如下部分:

下一步使用 Bi-LSTM 学习 Session 兴趣之间的演进,完成了 Session Interest Interacting Layer 的实现:

"""
输入 sess_fea 大小为 [B, 5, 8]
输出 lstm_outputs 大小也为 [B, 5, 8]
"""
lstm_outputs = BiLSTM(len(sess_feature_list) * embedding_size,
                      layers=2, res_layers=0, dropout_rate=0.2, )(sess_fea)

相当于实现了模型示意图中的:

在得到 Session 兴趣序列后, 由于每个 session 兴趣对目标商品的影响不同,这里采用 Attention 机制来刻画目标商品和每个 session 兴趣之间的相关性,即实现 Session Interest Activating Layer 层:

"""
注意输入为 [query_emb, sess_fea, user_sess_length], 其中 query_emb 为目标商品对应的 embedding,大小为 [B, 8], 而 sess_fea 表示用户兴趣,大小为 [B, 5, 8], user_sess_length 大小为 [B, 1], 表示用户真实的 Session 的长度,在做 Attention 时,用作 Mask,以计算权重系数。

AttentionSequencePoolingLayer 即 DIN 网络中目标商品和历史行为的 Attention,关于 DIN 网络的介绍可以查看: https://zhuanlan.zhihu.com/p/338050940
输出结果 interest_attention_layer 的 shape=[B, 1, 8]
"""
interest_attention_layer: [B, 1, 8], user_sess_length: [B, 1]
interest_attention_layer = AttentionSequencePoolingLayer(att_hidden_units=(64, 16), weight_normalization=True,
                                                         supports_masking=False)(
                                    [query_emb, sess_fea, user_sess_length])

"""
同理,这里时 query_emb 和 lstm_outputs 继续 Attention,
lstm_outputs 大小为 [B, 5, 8]
输出结果 lstm_attention_layer 大小为 [B, 1, 8]
"""
lstm_attention_layer = AttentionSequencePoolingLayer(att_hidden_units=(64, 16), weight_normalization=True)(
                               [query_emb, lstm_outputs, user_sess_length])

以上步骤相当于实现了模型示意图中的:

最后,将输入特征都给 Concat 起来:

## deep_input_emb: [B, 76] 60 + 8 + 8
deep_input_emb = Concatenate()(
    [deep_input_emb, Flatten()(interest_attention_layer), Flatten()(lstm_attention_layer)])

## dense_input.values(): [B, 1], 表示 price
## 此时 deep_input_emb: [B, 77]
if len(dense_input) > 0:  ## 如果存在稠密特征的话
    deep_input_emb = Concatenate()(
        [deep_input_emb] + list(dense_input.values()))

相当于实现了:

将 Concat 起来的向量输入到 DNN 中,实现对点击率的预估:

## output: [B, 80], dnn_hidden_units: [200, 80]
output = DNN(dnn_hidden_units, dnn_activation, l2_reg_dnn,
             dnn_dropout, dnn_use_bn, seed)(deep_input_emb)
output = Dense(1, use_bias=False, activation=None)(output)
## output: [B, 1]
output = PredictionLayer(task)(output)

即实现:

至此,DSIN 的代码介绍完毕了。

总结

文章内容有点多,前前后后写了很久,每天挤点时间不容易,冬日应该冬眠的 🤣🤣🤣 后面再分析下 DIEN 的代码,Flag 还是要立的

评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值