参考

精排(三)序列建模

在上一节中,我们探讨了如何通过各类特征交叉模型,让机器自动学习特征之间复杂的组合关系。无论是二阶交叉的FM、AFM,还是高阶交叉的DCN、xDeepFM,它们的核心目标都是从一个静态的特征集合中挖掘出有价值的信息。然而,这些模型普遍存在一个共同的局限:它们大多将用户的历史行为看作一个无序的"物品袋"(a bag of items),如同用户的兴趣是一个静态的表示。

但用户的兴趣不是静止的,而是具有明显的时序性动态演化特点。一个用户先浏览"鼠标"再浏览"显示器",与先浏览"小说"再浏览"显示器",这两个行为序列背后指向的购买意图截然不同。前者可能是一位正在组装电脑的数码爱好者,而后者可能只是在工作之余的随性浏览。传统的特征交叉模型难以捕捉这种蕴含在行为顺序中的、随时间变化的意图。

因此,本节我们将转换视角,不再将用户历史看作一堆静态特征的集合,而是将其视为一个动态的序列。我们将聚焦于如何对用户的行为序列进行建模,从这个序列中挖掘出用户动态、演化的兴趣。接下来,我们将介绍工业界在序列建模方向上的三个代表性模型:DIN、DIEN和DSIN,看看它们是如何解决这个核心挑战的。

1、DIN:局部激活的注意力机制

在大型电商平台中,用户的兴趣是多样的。一个用户可能在一段时间内,既关注数码产品,又浏览运动装备,还会购买生活用品。在传统的深度学习模型(即Embedding&MLP范式)中,通常的做法是将用户所有的历史行为(如点击过的商品ID)对应的Embedding向量通过池化(Pooling)操作,压缩成一个固定长度的向量来代表该用户。

这个固定长度的用户向量,很快就成为了表达用户多样兴趣的瓶颈。想象一下,无论系统准备向这个用户推荐"跑鞋"还是"手机",代表他的都是同一个向量。这个向量试图"一视同仁"地蕴含该用户所有的兴趣点,这不仅非常困难,而且在面对具体推荐任务时显得不够聚焦。为了增强表达能力而粗暴地增加向量维度,又会带来参数量爆炸和过拟合的风险。

DIN的核心思想:局部激活 (Local Activation)

深度兴趣网络(Deep Interest Network, DIN)的提出者们发现,用户的某一次具体点击行为,通常只由其历史兴趣中的一部分所"激活"。当向一位数码爱好者推荐"机械键盘"时,真正起决定性作用的,很可能是他最近浏览"游戏鼠标"和"显卡"的行为,而不是他上个月购买的"跑鞋"。

基于此,DIN提出了一个观点:用户的兴趣表示不应该是固定的,而应是根据当前的候选广告(Target Ad)不同而动态变化的。

DIN模型架构图(右)及其与基准模型(左)的对比

技术实现:注意力机制

为了实现"局部激活"这一思想,DIN在模型中引入了一个关键模块——局部激活单元(Local Activation Unit),其本质就是注意力机制。如上图右侧所示,DIN不再像基准模型(:numref:din_architecture 左)那样对所有历史行为的Embedding进行简单的池化,而是进行了一次"加权求和"。

这个权重(即注意力分数)的计算,体现了DIN的核心思想。具体来说,对于一个给定的用户U和候选广告A,用户的兴趣表示向量vU(A)\boldsymbol{v}_{U}(A)是这样计算的:

vU(A)=f(vA,e1,e2,,eH)=j=1Ha(ej,vA)ej=j=1Hwjej\boldsymbol{v}_{U}(A)=f(\boldsymbol{v}_{A},\boldsymbol{e}_{1},\boldsymbol{e}_{2},\ldots,\boldsymbol{e}_{H})=\sum_{j=1}^{H}a(\boldsymbol{e}_{j},\boldsymbol{v}_{A})\boldsymbol{e}_{j}=\sum_{j=1}^{H}w_{j}\boldsymbol{e}_{j}

其中:

  • e1,e2,,eH\boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H} 是用户U的历史行为Embedding向量列表。

  • vA\boldsymbol{v}_{A} 是候选广告A的Embedding向量。

  • a(ej,vA)a(\boldsymbol{e}_{j}, \boldsymbol{v}_{A}) 是一个激活单元(通常是一个小型前馈神经网络),它接收历史行为ej\boldsymbol{e}_{j}和候选广告vA\boldsymbol{v}_{A}作为输入,输出一个权重wj\boldsymbol{w}_{j}。这个权重就代表了历史行为ej\boldsymbol{e}_{j}在面对广告vA\boldsymbol{v}_{A}时的"相关性"或"注意力得分"。

通过这个公式,用户的最终兴趣表示vU(A)\boldsymbol{v}_{U}(A)不再是一个固定的向量,而是与候选广告A紧密相关。与广告A越相关的历史行为,会获得越高的权重,从而在最终的兴趣向量中占据主导地位。

一个值得注意的细节是,DIN计算出的注意力权重wj\boldsymbol{w}_{j}没有经过Softmax归一化。这意味着wj\sum \boldsymbol{w}_{j}不一定等于1。这样设计的目的是为了保留用户兴趣的绝对强度。例如,如果一个用户的历史行为大部分都与某个广告高度相关,那么加权和之后的向量模长就会比较大,反之则较小。这种设计使得模型不仅能捕捉兴趣的"方向",还能感知兴趣的"强度"。

代码

  • DIN的注意力机制通过将候选广告与历史行为进行多角度交互来计算权重。
  • 这种设计的关键在于:通过[query, keys, query-keys, query*keys]四种交互方式,模型能够从多个角度衡量历史行为与候选广告的相关性,同时不使用softmax归一化以保留兴趣强度信息。
    din.py 文件:
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
65
66
67
68
69
70
71
72
73
74
75
import tensorflow as tf

from .utils import (
build_input_layer,
build_group_feature_embedding_table_dict,
concat_group_embedding,
get_linear_logits,
add_tensor_func,
parse_din_feature_columns,
concat_func,
)
from .layers import DNNs, DinAttentionLayer, PredictLayer


def build_din_model(feature_columns, model_config):
"""
构建 DIN (深度兴趣网络) 排序模型。

参数:
feature_columns: FeatureColumn 列表
model_config: 包含以下参数的字典:
- dnn_units: list, 隐藏层单元数包括输出大小 (默认 [128, 64, 1])
- linear_logits: bool, 是否添加线性项 (默认 True)

返回:
(model, None, None): 排序模型元组
"""
dnn_units = model_config.get("dnn_units", [128, 64, 1])
use_linear_logits = model_config.get("linear_logits", True)

# 输入和嵌入
input_layer_dict = build_input_layer(feature_columns)
group_embedding_feature_dict = build_group_feature_embedding_table_dict(
feature_columns, input_layer_dict, prefix="embedding/"
)

# 来自 'dnn' 组的基础 DNN 输入
dnn_inputs = concat_group_embedding(group_embedding_feature_dict, "dnn")

# 对序列特征进行 DIN 注意力机制
din_output_list = []
din_feature_list = parse_din_feature_columns(feature_columns)
for k_name, v_name in din_feature_list:
query_feature = group_embedding_feature_dict["din_sequence"][k_name]
key_feature = group_embedding_feature_dict["din_sequence"][v_name]
din_output = DinAttentionLayer(name=v_name + "_din_layer")(
[
query_feature,
key_feature,
]
)
din_output_list.append(din_output)
din_output = concat_func(din_output_list, axis=1, flatten=True)
dnn_inputs = concat_func([dnn_inputs, din_output], axis=-1)

# DNN 塔
dnn_logits = DNNs(dnn_units, use_bn=True)(dnn_inputs)
if use_linear_logits:
linear_logit = get_linear_logits(input_layer_dict, feature_columns)
dnn_logits = add_tensor_func(
[dnn_logits, linear_logit], name="din_linear_logits"
)

# 输出: 遵循排序约定以确保 (batch,) 标签兼容性
final_logits = tf.keras.layers.Flatten()(dnn_logits)
output = tf.keras.layers.Dense(1, activation="sigmoid", name="din_output")(
final_logits
)
output = tf.keras.layers.Flatten()(output)
model = tf.keras.models.Model(
inputs=list(input_layer_dict.values()), outputs=output
)

return model, None, None

2、DIEN:兴趣的演化建模

DIN成功地捕捉了用户兴趣的"多样性"和"局部激活"特性,但它仍然存在一个局限:它将用户的历史行为看作是一个无序的集合,忽略了行为之间的时序依赖关系。用户的兴趣不仅是多样的,更是在持续演化的。

为了解决这个问题,深度兴趣演化网络(Deep Interest Evolution Network, DIEN) 被提出。DIEN认为,我们不仅要关注哪些历史兴趣是相关的,更要理解这些兴趣是如何一步步演化至今的。

DIEN模型架构图

DIEN的核心思想是,直接对原始、显性的行为序列建模是不够的。行为只是表象,我们更应该关注行为背后那个潜在的、抽象的 "兴趣"状态,并对这个兴趣状态的演化过程进行建模。为此,DIEN设计了一个两阶段结构,如上图所示。

第一阶段:兴趣提取层 (Interest Extractor Layer)

这一层的目标是从原始的行为序列中,抽取出更能代表"潜在兴趣"的兴趣状态序列

DIEN使用门控循环单元(GRU)来按时间顺序处理用户的行为Embedding序列e1,e2,,eT{\boldsymbol{e}_1, \boldsymbol{e}_2, \dots, \boldsymbol{e}_T}。理论上,GRU在t时刻的隐状态ht\boldsymbol{h}_t就捕捉了到该时刻为止的序列信息。但DIEN的作者认为,这样的隐状态还不足以精准地代表"兴趣"。

因此,他们引入了一项关键创新:辅助损失 (Auxiliary Loss)。其核心思想是:用户在tt时刻的兴趣,直接导致了他在t+1t+1时刻的行为。基于此,DIEN增加了一个辅助的监督任务:用tt时刻的兴趣状态ht\boldsymbol{h}_t去预测用户在t+1t+1时刻的真实行为et+1\boldsymbol{e}_{t+1}

具体地,辅助损失LauxL_{aux}定义如下:

Laux=1N(i=1Nt=1Tlogσ(hti,eb[t+1]i)+log(1σ(hti,e^b[t+1]i)))L_{aux}=-\frac{1}{N}\left(\sum_{i=1}^{N}\sum_{t=1}^{T}\log\sigma(\boldsymbol{h}^i_t,\boldsymbol{e}^i_{b[t+1]})+\log(1-\sigma(\boldsymbol{h}^i_t,\boldsymbol{\hat{e}}^i_{b[t+1]}))\right)

其中:

  • hti\boldsymbol{h}^i_t 是用户i在t时刻的兴趣状态(即GRU的隐状态)。

  • eb[t+1]i\boldsymbol{e}^i_{b[t+1]} 是用户i在t+1时刻真实点击的物品Embedding(正样本)。

  • e^b[t+1]i\boldsymbol{\hat{e}}^i_{b[t+1]} 是从物品池中负采样得到的物品Embedding(负样本)。

  • σ()\sigma(\cdot) 是Sigmoid函数,这里用于计算两个向量的点积并映射到(0,1)区间。

这个辅助损失会与模型最终的CTR预测损失LtargetL_{target}加在一起共同优化:L=Ltarget+αLauxL = L_{target} + \alpha L_{aux}。这个额外的监督信号,在每个时间步都对GRU的学习进行指导,使其产出的隐状态ht\boldsymbol{h}_t能够更精准地表达用户的潜在兴趣。

第二阶段:兴趣演化层 (Interest Evolving Layer)

经过第一阶段,我们得到了一个更能代表用户内在兴趣的兴趣状态序列 h1,h2,,hT\boldsymbol{h}_1, \boldsymbol{h}_2, \dots, \boldsymbol{h}_T。第二阶段的目标,就是对这个兴趣序列的演化过程进行建模。

然而,兴趣的演化并不总是平滑的,常常会伴随着兴趣漂移(Interest Drifting)现象,即用户可能在不同的兴趣点之间快速切换。如果用一个标准的GRU来建模这个兴趣序列,不相关的历史兴趣(漂移)可能会干扰对当前主要兴趣演化的判断。

为了解决这个问题,DIEN再次借鉴了DIN的思想,并将其与序列模型融合,设计了带注意力更新门的GRU(AUGRU)。AUGRU的核心是在GRU的更新门(Update Gate)上融入了注意力机制。注意力得分ata_ttt时刻的兴趣状态ht\boldsymbol{h}_t候选广告ea\boldsymbol{e}_a共同决定:

at=exp(htWea)j=1Texp(hjWea)a_t = \frac{\exp(\boldsymbol{h}_t W \boldsymbol{e}_a)}{\sum_{j=1}^T\exp(\boldsymbol{h}_j W \boldsymbol{e}_a)}

然后,这个注意力得分ata_t会去调整(scale)GRU的原始更新门ut\boldsymbol{u}'_t

u~t=atut\boldsymbol{\tilde{u}}'_t = a_t \cdot \boldsymbol{u}'_t

最后,使用这个被注意力调整过的更新门u~t\boldsymbol{\tilde{u}}'_t来更新隐状态:

ht=(1u~t)ht1+u~th~t\boldsymbol{h}_{t}' = (1 - \boldsymbol{\tilde{u}}_t') \circ \boldsymbol{h}_{t-1}' + \boldsymbol{\tilde{u}}_t' \circ \boldsymbol{\tilde{h}}_{t}'

其中\circ表示元素级乘积(element-wise product)。

通过这种方式,AUGRU在兴趣演化的每一步,都会参考当前的候选广告,来判断历史兴趣的相关性。与候选广告越相关的兴趣,其对应的ata_t越大,其信息在更新门中的权重也越大,从而能更顺畅地在序列中传递;反之,不相关的兴趣(漂移)其影响力就会被削弱。这使得模型能够聚焦于与当前推荐任务最相关的兴趣演化路径。

代码

  • 辅助损失的计算通过预测下一个行为来监督兴趣状态的学习。
  • 这种设计确保GRU的隐状态不仅能记录历史信息,还能有效预测未来行为,从而学到更有意义的兴趣表示。
  • AUGRU的核心在于用注意力分数调整GRU的更新门。
  • AUGRU通过注意力分数动态调整更新门,使得与目标广告相关的兴趣能够顺利传递,而不相关的兴趣(漂移)被抑制,从而更精准地捕捉兴趣演化路径。
    dien.py 文件:
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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
"""
DIEN (深度兴趣演化网络) 排序模型实现,用于 FunRec。

在 funrec 中自包含。构建与统一训练/评估管道兼容的单一排序模型(返回 (model, None, None))。

两个关键层:
1) InterestExtractorLayer: 对行为序列使用 GRU,可选辅助损失
2) InterestEvolutionLayer: 双线性注意力 + AIGRU/AGRU/AUGRU
"""

import tensorflow as tf
from tensorflow.keras import layers

from .utils import (
build_input_layer,
build_group_feature_embedding_table_dict,
concat_group_embedding,
get_linear_logits,
add_tensor_func,
concat_func,
parse_dien_feature_columns as _parse_dien_feature_columns,
)
from .layers import DNNs, PredictLayer


class InterestExtractorLayer(tf.keras.layers.Layer):
"""
DIEN 模型的兴趣提取层。

该层使用带辅助损失的 GRU 从行为序列中提取用户兴趣。
辅助损失通过使用下一个行为来监督当前兴趣状态,帮助 GRU 隐藏状态更好地表示用户兴趣。

参数:
hidden_units (int): GRU 中的隐藏单元数 (默认: 128)
use_auxiliary_loss (bool): 是否使用辅助损失 (默认: True)
auxiliary_loss_weight (float): 辅助损失的权重 (默认: 0.1)
dropout_rate (float): 正则化的 dropout 率 (默认: 0.0)
"""

def __init__(
self,
hidden_units=128,
use_auxiliary_loss=True,
auxiliary_loss_weight=0.1,
dropout_rate=0.0,
**kwargs,
):
super(InterestExtractorLayer, self).__init__(**kwargs)
self.hidden_units = hidden_units
self.use_auxiliary_loss = use_auxiliary_loss
self.auxiliary_loss_weight = auxiliary_loss_weight
self.dropout_rate = dropout_rate

# 兴趣提取 GRU
self.interest_gru = tf.keras.layers.GRU(
units=hidden_units,
return_sequences=True,
return_state=False,
name="interest_extractor_gru",
)

# 辅助损失组件
if self.use_auxiliary_loss:
# 辅助损失预测的 MLP
self.auxiliary_mlp = tf.keras.Sequential(
[
tf.keras.layers.Dense(64, activation="relu", name="aux_dense_1"),
tf.keras.layers.Dropout(dropout_rate),
tf.keras.layers.Dense(32, activation="relu", name="aux_dense_2"),
tf.keras.layers.Dropout(dropout_rate),
tf.keras.layers.Dense(1, activation="sigmoid", name="aux_output"),
],
name="auxiliary_mlp",
)
else:
self.auxiliary_mlp = None

# Dropout 层
if dropout_rate > 0:
self.dropout = tf.keras.layers.Dropout(dropout_rate)
else:
self.dropout = None

def build(self, input_shape):
"""构建方法,如果需要则初始化辅助 MLP。"""
super(InterestExtractorLayer, self).build(input_shape)

# 如果存在辅助 MLP 则构建它
if self.auxiliary_mlp is not None:
# 辅助 MLP 输入形状: [batch_size, seq_len-1, hidden_units + embedding_dim]
# embedding_dim 从 input_shape 确定
if isinstance(input_shape, list) and len(input_shape) > 0:
embedding_dim = input_shape[0][-1] # 从行为嵌入获取嵌入维度
else:
embedding_dim = 8 # 默认嵌入维度

aux_input_shape = (None, self.hidden_units + embedding_dim)
self.auxiliary_mlp.build(aux_input_shape)

def call(self, inputs, training=None, mask=None):
"""
兴趣提取层的前向传播。

参数:
inputs: 包含 [behavior_embeddings, neg_behavior_embeddings (可选)] 的列表
- behavior_embeddings: 形状为 [batch_size, seq_len, embedding_dim] 的张量
- neg_behavior_embeddings: 形状为 [batch_size, seq_len, embedding_dim] 的张量 (用于辅助损失)
training: 训练模式标志
mask: 序列的填充掩码

返回:
interest_states: 形状为 [batch_size, seq_len, hidden_units] 的张量
表示每个时间步用户兴趣的隐藏状态
"""
behavior_embeddings = inputs[0] # 形状: [batch_size, seq_len, embedding_dim]

# 使用 GRU 提取兴趣
# 形状: [batch_size, seq_len, hidden_units]
interest_states = self.interest_gru(
behavior_embeddings, mask=mask, training=training
)

# 如果启用则应用 dropout
if self.dropout is not None:
interest_states = self.dropout(interest_states, training=training)

# 如果启用且在训练模式下计算辅助损失
if (
self.use_auxiliary_loss
and training
and len(inputs) > 1
and self.auxiliary_mlp is not None
):
neg_behavior_embeddings = inputs[
1
] # 形状: [batch_size, seq_len, embedding_dim]
aux_loss = self._compute_auxiliary_loss(
interest_states, behavior_embeddings, neg_behavior_embeddings, mask
)
# 将辅助损失添加到层损失中
self.add_loss(self.auxiliary_loss_weight * aux_loss)

return interest_states

def _compute_auxiliary_loss(
self, interest_states, pos_behaviors, neg_behaviors, mask
):
"""
计算兴趣提取的辅助损失。

辅助损失使用下一个行为来监督当前兴趣状态:
- 正样本: 当前兴趣应该预测下一个正行为
- 负样本: 当前兴趣不应该预测下一个负行为

参数:
interest_states: 形状为 [batch_size, seq_len, hidden_units] 的张量
pos_behaviors: 形状为 [batch_size, seq_len, embedding_dim] 的张量
neg_behaviors: 形状为 [batch_size, seq_len, embedding_dim] 的张量
mask: 填充掩码

返回:
aux_loss: 标量辅助损失
"""
# 获取兴趣和下一个行为(兴趣排除最后一个时间步)
# 形状: [batch_size, seq_len-1, hidden_units]
current_interests = interest_states[:, :-1, :]
# 形状: [batch_size, seq_len-1, embedding_dim]
next_pos_behaviors = pos_behaviors[:, 1:, :]
next_neg_behaviors = neg_behaviors[:, 1:, :]

# 连接兴趣和行为嵌入作为 MLP 输入
# 形状: [batch_size, seq_len-1, hidden_units + embedding_dim]
pos_input = tf.concat([current_interests, next_pos_behaviors], axis=-1)
neg_input = tf.concat([current_interests, next_neg_behaviors], axis=-1)

# 预测概率
# 形状: [batch_size, seq_len-1, 1]
pos_probs = self.auxiliary_mlp(pos_input)
neg_probs = self.auxiliary_mlp(neg_input)

# 计算二元交叉熵损失
pos_loss = -tf.math.log(pos_probs + 1e-8) # 形状: [batch_size, seq_len-1, 1]
neg_loss = -tf.math.log(
1 - neg_probs + 1e-8
) # 形状: [batch_size, seq_len-1, 1]

# 应用掩码(排除填充位置)
if mask is not None:
# 为 seq_len-1 调整掩码
loss_mask = mask[:, 1:] # 形状: [batch_size, seq_len-1]
loss_mask = tf.expand_dims(
loss_mask, axis=-1
) # 形状: [batch_size, seq_len-1, 1]
loss_mask = tf.cast(loss_mask, tf.float32)

pos_loss = pos_loss * loss_mask
neg_loss = neg_loss * loss_mask

# 平均损失
aux_loss = tf.reduce_mean(pos_loss + neg_loss)

return aux_loss


class InterestEvolutionLayer(tf.keras.layers.Layer):
"""
DIEN 模型的兴趣演化层。

该层使用双线性注意力结合 GRU 变体来建模用户兴趣随时间的演化。支持三种演化类型:
1. AIGRU: 基于注意力的输入 GRU
2. AGRU: 基于注意力的 GRU
3. AUGRU: 基于注意力的更新 GRU(推荐)

注意力机制遵循原始 DIEN 论文使用双线性形式:
a_t = exp(h_t * W * e_a) / sum(exp(h_j * W * e_a))

参数:
hidden_units (int): 演化 GRU 中的隐藏单元数 (默认: 128)
evolution_type (str): 演化机制类型 ('AIGRU', 'AGRU', 'AUGRU') (默认: 'AUGRU')
dropout_rate (float): Dropout 率 (默认: 0.0)
"""

def __init__(
self, hidden_units=128, evolution_type="AUGRU", dropout_rate=0.0, **kwargs
):
super(InterestEvolutionLayer, self).__init__(**kwargs)
self.hidden_units = hidden_units
self.evolution_type = evolution_type
self.dropout_rate = dropout_rate

# 验证演化类型
if evolution_type not in ["AIGRU", "AGRU", "AUGRU"]:
raise ValueError(
f"evolution_type 必须是 ['AIGRU', 'AGRU', 'AUGRU'] 之一,得到 {evolution_type}"
)

# 如原始 DIEN 论文中的双线性注意力机制
# 注意力分数: h_t * W * e_a,其中 W 是双线性权重矩阵
self.bilinear_weight = None # 将在 build 方法中初始化

# 演化 GRU(基于 evolution_type 的不同实现)
if evolution_type == "AIGRU":
self.evolution_gru = tf.keras.layers.GRU(
units=hidden_units,
return_sequences=False,
return_state=False,
name="evolution_gru",
)
elif evolution_type == "AGRU":
# 自定义 AGRU 实现
self.evolution_gru = self._build_agru()
elif evolution_type == "AUGRU":
# 自定义 AUGRU 实现
self.evolution_gru = self._build_augru()

# Dropout 层
if dropout_rate > 0:
self.dropout = tf.keras.layers.Dropout(dropout_rate)
else:
self.dropout = None

def build(self, input_shape):
"""构建方法,初始化双线性权重矩阵。"""
super(InterestEvolutionLayer, self).build(input_shape)

# 初始化注意力计算的双线性权重矩阵
# 形状: [hidden_units, embedding_dim]
# 这将用于: h_t * W * e_a
if isinstance(input_shape, list) and len(input_shape) > 1:
# input_shape[0] 是 interest_states 形状,input_shape[1] 是 target_item_embedding 形状
embedding_dim = (
input_shape[1][-1] if len(input_shape[1]) > 1 else input_shape[1][0]
)
else:
embedding_dim = 8 # 默认嵌入维度

self.bilinear_weight = self.add_weight(
name="bilinear_attention_weight",
shape=(self.hidden_units, embedding_dim),
initializer="glorot_uniform",
trainable=True,
)

def _build_agru(self):
"""构建自定义 AGRU 单元。"""
return AGRULayer(self.hidden_units, name="agru_evolution")

def _build_augru(self):
"""构建自定义 AUGRU 单元。"""
return AUGRULayer(self.hidden_units, name="augru_evolution")

def call(self, inputs, training=None, mask=None):
"""
兴趣演化层的前向传播。

参数:
inputs: 包含 [interest_states, target_item_embedding] 的列表
- interest_states: 形状为 [batch_size, seq_len, hidden_units] 的张量
- target_item_embedding: 形状为 [batch_size, embedding_dim] 的张量
training: 训练模式标志
mask: 序列的填充掩码

返回:
final_interest: 形状为 [batch_size, hidden_units] 的张量
最终演化的兴趣表示
"""
interest_states = inputs[0] # 形状: [batch_size, seq_len, hidden_units]
target_item_embedding = inputs[1] # 形状: [batch_size, embedding_dim]

# 计算每个兴趣状态与目标物品之间的注意力分数
# 形状: [batch_size, seq_len, 1]
attention_scores = self._compute_attention_scores(
interest_states, target_item_embedding
)

# 基于演化类型应用注意力机制
if self.evolution_type == "AIGRU":
# 基于注意力的输入 GRU: 将注意力分数与输入相乘
# 形状: [batch_size, seq_len, hidden_units]
attended_interests = interest_states * attention_scores
# 形状: [batch_size, hidden_units]
final_interest = self.evolution_gru(
attended_interests, mask=mask, training=training
)

elif self.evolution_type in ["AGRU", "AUGRU"]:
# 使用内部集成注意力的自定义 GRU 实现
# 形状: [batch_size, hidden_units]
final_interest = self.evolution_gru(
[interest_states, attention_scores], mask=mask, training=training
)

# 如果启用则应用 dropout
if self.dropout is not None:
final_interest = self.dropout(final_interest, training=training)

return final_interest

def _compute_attention_scores(self, interest_states, target_item_embedding):
"""
使用双线性形式计算兴趣状态与目标物品之间的注意力分数。

遵循原始 DIEN 论文: a_t = exp(h_t * W * e_a) / sum(exp(h_j * W * e_a))

参数:
interest_states: 形状为 [batch_size, seq_len, hidden_units] 的张量
target_item_embedding: 形状为 [batch_size, 1, embedding_dim] 或 [batch_size, embedding_dim] 的张量

返回:
attention_scores: 形状为 [batch_size, seq_len, 1] 的张量
"""
# 确保 target_item_embedding 具有正确的形状 [batch_size, embedding_dim]
if len(target_item_embedding.shape) == 3:
# 形状: [batch_size, 1, embedding_dim] -> [batch_size, embedding_dim]
target_item_embedding = tf.squeeze(target_item_embedding, axis=1)

# 双线性注意力计算: h_t * W * e_a
# 步骤 1: h_t * W -> [batch_size, seq_len, embedding_dim]
h_W = tf.tensordot(interest_states, self.bilinear_weight, axes=[[2], [0]])

# 步骤 2: (h_t * W) * e_a -> [batch_size, seq_len]
# 将目标嵌入扩展为 [batch_size, 1, embedding_dim] 以进行广播
target_expanded = tf.expand_dims(target_item_embedding, axis=1)

# 逐元素乘法并在嵌入维度上求和
# 形状: [batch_size, seq_len, embedding_dim] * [batch_size, 1, embedding_dim] -> [batch_size, seq_len, embedding_dim]
attention_scores = tf.reduce_sum(
h_W * target_expanded, axis=2
) # [batch_size, seq_len]

# 应用 softmax 来归一化注意力分数
# 形状: [batch_size, seq_len]
attention_scores = tf.nn.softmax(attention_scores, axis=1)

# 扩展为 [batch_size, seq_len, 1] 以与模型其余部分保持一致
attention_scores = tf.expand_dims(attention_scores, axis=2)

return attention_scores


class AGRULayer(tf.keras.layers.Layer):
"""
基于注意力的 GRU 层。

在 AGRU 中,注意力分数直接替换更新门值。
这是一种简化的方法,但可能会失去一些表示能力。

参数:
units (int): 隐藏单元数
"""

def __init__(self, units, **kwargs):
super(AGRULayer, self).__init__(**kwargs)
self.units = units

# GRU 单元组件(在 init 中构建它们)
self.dense_input_reset = tf.keras.layers.Dense(units, name="input_reset")
self.dense_hidden_reset = tf.keras.layers.Dense(units, name="hidden_reset")
self.dense_input_candidate = tf.keras.layers.Dense(
units, name="input_candidate"
)
self.dense_hidden_candidate = tf.keras.layers.Dense(
units, name="hidden_candidate"
)

def call(self, inputs, mask=None, training=None):
"""
AGRU 的前向传播。

参数:
inputs: [interest_states, attention_scores] 的列表
- interest_states: 形状为 [batch_size, seq_len, units] 的张量
- attention_scores: 形状为 [batch_size, seq_len, 1] 的张量
mask: 填充掩码
training: 训练模式标志

返回:
final_state: 形状为 [batch_size, units] 的张量
"""
interest_states, attention_scores = inputs
batch_size = tf.shape(interest_states)[0]
seq_len = tf.shape(interest_states)[1]

# 初始化隐藏状态
# 形状: [batch_size, units]
hidden_state = tf.zeros([batch_size, self.units])

# 逐步处理序列
for t in range(seq_len):
current_input = interest_states[:, t, :] # 形状: [batch_size, units]
current_attention = attention_scores[:, t, 0] # 形状: [batch_size]

# 使用注意力作为更新门的自定义 GRU 步骤
# 使用标准 GRU 计算重置门和候选状态
reset_gate = tf.nn.sigmoid(
self.dense_input_reset(current_input)
+ self.dense_hidden_reset(hidden_state)
)

candidate_state = tf.nn.tanh(
self.dense_input_candidate(current_input)
+ self.dense_hidden_candidate(reset_gate * hidden_state)
)

# 使用注意力分数作为更新门
# 形状: [batch_size, units]
update_gate = tf.expand_dims(current_attention, axis=1)
update_gate = tf.tile(update_gate, [1, self.units])

# 更新隐藏状态
hidden_state = (
1 - update_gate
) * hidden_state + update_gate * candidate_state

return hidden_state


class AUGRULayer(tf.keras.layers.Layer):
"""
基于注意力的更新 GRU 层。

AUGRU 保持更新门的多维性质,同时通过注意力分数对其进行缩放。
这是 DIEN 中推荐的方法。

参数:
units (int): 隐藏单元数
"""

def __init__(self, units, **kwargs):
super(AUGRULayer, self).__init__(**kwargs)
self.units = units

# GRU 单元权重
self.dense_input_update = tf.keras.layers.Dense(units, name="input_update")
self.dense_hidden_update = tf.keras.layers.Dense(units, name="hidden_update")
self.dense_input_reset = tf.keras.layers.Dense(units, name="input_reset")
self.dense_hidden_reset = tf.keras.layers.Dense(units, name="hidden_reset")
self.dense_input_candidate = tf.keras.layers.Dense(
units, name="input_candidate"
)
self.dense_hidden_candidate = tf.keras.layers.Dense(
units, name="hidden_candidate"
)

def call(self, inputs, mask=None, training=None):
"""
AUGRU 的前向传播。

参数:
inputs: [interest_states, attention_scores] 的列表
- interest_states: 形状为 [batch_size, seq_len, units] 的张量
- attention_scores: 形状为 [batch_size, seq_len, 1] 的张量
mask: 填充掩码
training: 训练模式标志

返回:
final_state: 形状为 [batch_size, units] 的张量
"""
interest_states, attention_scores = inputs
batch_size = tf.shape(interest_states)[0]
seq_len = tf.shape(interest_states)[1]

# 初始化隐藏状态
# 形状: [batch_size, units]
hidden_state = tf.zeros([batch_size, self.units])

# 逐步处理序列
for t in range(seq_len):
current_input = interest_states[:, t, :] # 形状: [batch_size, units]
current_attention = attention_scores[:, t, 0] # 形状: [batch_size]

# 标准 GRU 计算
# 更新门: 形状 [batch_size, units]
update_gate = tf.nn.sigmoid(
self.dense_input_update(current_input)
+ self.dense_hidden_update(hidden_state)
)

# 重置门: 形状 [batch_size, units]
reset_gate = tf.nn.sigmoid(
self.dense_input_reset(current_input)
+ self.dense_hidden_reset(hidden_state)
)

# 候选状态: 形状 [batch_size, units]
candidate_state = tf.nn.tanh(
self.dense_input_candidate(current_input)
+ self.dense_hidden_candidate(reset_gate * hidden_state)
)

# 基于注意力的更新门缩放
# 形状: [batch_size, units]
attention_expanded = tf.expand_dims(current_attention, axis=1)
attention_expanded = tf.tile(attention_expanded, [1, self.units])
attention_update_gate = attention_expanded * update_gate

# 使用注意力缩放的更新门更新隐藏状态
hidden_state = (
1 - attention_update_gate
) * hidden_state + attention_update_gate * candidate_state

return hidden_state


def build_dien_model(feature_columns, model_config):
"""
构建 DIEN (深度兴趣演化网络) 模型。

DIEN 通过两个关键组件捕获用户兴趣演化:
1. 兴趣提取层: 从行为序列中提取兴趣
2. 兴趣演化层: 使用双线性注意力建模兴趣随时间的演化

参数:
feature_columns: 特征列规范列表
dnn_units: 最终 DNN 层的隐藏单元列表 (默认: [256, 128, 64, 1])
interest_hidden_units: 兴趣提取的隐藏单元 (默认: 128)
evolution_type: 演化机制类型 ('AIGRU', 'AGRU', 'AUGRU') (默认: 'AUGRU')
use_auxiliary_loss: 是否使用辅助损失 (默认: True)
auxiliary_loss_weight: 辅助损失的权重 (默认: 0.1)
dropout_rate: 正则化的 dropout 率 (默认: 0.0)
linear_logits: 是否包含线性 logits (默认: True)

返回:
(model, None, None): 统一接口的排序模型元组
"""
# 解析配置
dnn_units = model_config.get("dnn_units", [200, 80, 1])
interest_hidden_units = model_config.get("interest_hidden_units", 64)
evolution_type = model_config.get("evolution_type", "AUGRU")
use_auxiliary_loss = model_config.get("use_auxiliary_loss", True)
auxiliary_loss_weight = model_config.get("auxiliary_loss_weight", 0.1)
dropout_rate = model_config.get("dropout_rate", 0.1)
use_linear_logits = model_config.get("linear_logits", True)
# 构建输入层
input_layer_dict = build_input_layer(feature_columns)

# 构建嵌入层
group_embedding_feature_dict = build_group_feature_embedding_table_dict(
feature_columns, input_layer_dict, prefix="embedding/"
)

# 获取 DNN 输入(非序列特征)
dnn_inputs = concat_group_embedding(group_embedding_feature_dict, "dnn")

# 处理 DIEN 序列特征
dien_feature_list = _parse_dien_feature_columns(feature_columns)

if len(dien_feature_list) == 0:
raise ValueError(
"未找到 DIEN 序列特征。请在 combiner 中添加包含 'dien' 的序列特征。"
)

# 处理每个 DIEN 特征对(query, key)
dien_outputs = []
for target_feature, sequence_feature in dien_feature_list:
# 获取目标物品嵌入(query)
# 形状: [batch_size, embedding_dim]
target_embedding = group_embedding_feature_dict["dien_sequence"][target_feature]

# 获取行为序列嵌入(keys)
# 形状: [batch_size, seq_len, embedding_dim]
behavior_embeddings = group_embedding_feature_dict["dien_sequence"][
sequence_feature
]

# 为辅助损失生成负样本(简化方法)
if use_auxiliary_loss:
# 通过打乱行为嵌入创建负样本
# 形状: [batch_size, seq_len, embedding_dim]
neg_behavior_embeddings = tf.random.shuffle(behavior_embeddings)
interest_extractor_inputs = [behavior_embeddings, neg_behavior_embeddings]
else:
interest_extractor_inputs = [behavior_embeddings]

# 兴趣提取层
# 从行为序列中提取用户兴趣
# 形状: [batch_size, seq_len, interest_hidden_units]
interest_extractor = InterestExtractorLayer(
hidden_units=interest_hidden_units,
use_auxiliary_loss=use_auxiliary_loss,
auxiliary_loss_weight=auxiliary_loss_weight,
dropout_rate=dropout_rate,
name=f"{sequence_feature}_interest_extractor",
)
interest_states = interest_extractor(interest_extractor_inputs)

# 兴趣演化层
# 基于目标物品使用双线性注意力建模兴趣演化
# 形状: [batch_size, interest_hidden_units]
interest_evolution = InterestEvolutionLayer(
hidden_units=interest_hidden_units,
evolution_type=evolution_type,
dropout_rate=dropout_rate,
name=f"{sequence_feature}_interest_evolution",
)
evolved_interest = interest_evolution([interest_states, target_embedding])

dien_outputs.append(evolved_interest)

# 连接所有 DIEN 输出
if len(dien_outputs) > 1:
dien_output = concat_func(dien_outputs, axis=1, flatten=True)
else:
dien_output = dien_outputs[0]

# 与其他 DNN 输入结合
# 形状: [batch_size, dnn_dim + interest_hidden_units]
final_dnn_inputs = concat_func([dnn_inputs, dien_output], axis=-1)

# 最终 DNN 层
dnn_logits = DNNs(dnn_units, use_bn=True, dropout_rate=dropout_rate)(
final_dnn_inputs
)

# 可选线性项
if use_linear_logits:
linear_logit = get_linear_logits(input_layer_dict, feature_columns)
dnn_logits = add_tensor_func(
[dnn_logits, linear_logit], name="dien_linear_logits"
)

# 输出: 确保与排序评估器兼容的 rank-1 预测
final_logits = tf.keras.layers.Flatten()(dnn_logits)
output = tf.keras.layers.Dense(1, activation="sigmoid", name="dien_output")(
final_logits
)
output = tf.keras.layers.Flatten()(output)

# 创建模型
model = tf.keras.models.Model(
inputs=list(input_layer_dict.values()), outputs=output
)

return model, None, None

3、DSIN:从行为序列到会话序列

从DIN到DIEN,我们看到了模型对用户兴趣的理解从"静态相关"走向了"动态演化"。然而,它们都将用户的行为看作一条连续的序列。但现实中,用户的行为模式更多是间断性的。用户通常在一个会话(Session) 内拥有一个明确且集中的意图,而在不同会话之间,兴趣点可能发生巨大转变。

用户行为的会话结构示例

如上图所示,一个用户可能在一个会话里集中浏览各种裤子,而在下一个会话则专注于戒指。这种会话内同质、会话间异质的现象非常普遍。如果直接用一个RNN模型处理这种"断层"明显的长序列,模型需要花费很大力气去学习这种兴趣的突变,效果并不理想。

深度会话兴趣网络(Deep Session Interest Network, DSIN) 基于这一观察,提出我们应该将"会话"作为分析用户行为的基本单元,并采用一种分层的思想来建模。

DSIN模型架构图

DSIN的技术实现:分层建模

DSIN的架构如上图所示,其建模过程可以清晰地分为几个层次:

  1. 会话划分层 (Session Division Layer):这是模型的第一步,也是DSIN的基础。它根据行为发生的时间间隔(例如,如果两个行为间隔超过30分钟),将原始的、连续的用户行为长序列S\mathbf{S},切分成多个独立的会话短序列Q=[Q1,Q2,...,QK]\mathbf{Q} = [\mathbf{Q}_1, \mathbf{Q}_2, ..., \mathbf{Q}_K]

  2. 会话兴趣提取层 (Session Interest Extractor Layer):这一层的目标是为每一个会话Qk\mathbf{Q}_k提取出一个核心的兴趣向量。DSIN认为,一个会话内的行为虽然意图集中,但彼此之间的重要性也不同。因此,它没有使用简单的池化,而是采用了自注意力机制(Self−Attention)(与Transformer的核心思想一致)。自注意力网络能够捕捉该会话内部所有行为之间的内在关联,并聚合最重要的信息,最终为每个会话Qk\mathbf{Q}_k生成一个浓缩的兴趣向量Ik\mathbf{I}_k

  3. 会话兴趣交互层 (Session Interest Interacting Layer):经过上一步,我们得到了一个更高层次的序列——会话兴趣向量的序列I1,I2,...,IK\mathbf{I}_1, \mathbf{I}_2, ..., \mathbf{I}_K。这个序列反映了用户兴趣在更长的时间尺度上的演变。DSIN使用一个 双向长短期记忆网络(Bi-LSTM) 来对这个会话序列进行建模,从而捕捉不同会话之间的演进和依赖关系。Bi-LSTM的输出是一个包含了上下文信息的会话兴趣序列[H1,H2,...,HK][\mathbf{H}_1, \mathbf{H}_2, ..., \mathbf{H}_K]

  4. 会话兴趣激活层 (Session Interest Activating Layer):最后一步与DIN的思想一脉相承。模型会根据当前的候选广告XI\mathbf{X}_I,使用注意力机制来计算每个会话兴趣的重要性,并进行加权求和,得到最终的用户兴趣表示。DSIN分别对会话兴趣提取层和交互层的输出都进行了激活:

UI=k=1KakIIkUH=k=1KakHHk\mathbf{U}^{I} = \sum_{k=1}^{K} a_{k}^{I} \mathbf{I}_{k} \quad \text{和} \quad \mathbf{U}^{H} = \sum_{k=1}^{K} a_{k}^{H} \mathbf{H}_{k}

其中,akIa_{k}^{I}akHa_{k}^{H}是根据候选广告计算出的注意力权重。最终,将这两个激活后的向量UI\mathbf{U}^{I}UH\mathbf{U}^{H}拼接,得到用户的最终兴趣表示。

DSIN通过引入"会话"这一更符合用户实际行为模式的中间单元,将复杂的长序列建模问题分解为"会话内信息聚合"(通过自注意力)和"会话间信息传递"(通过Bi-LSTM)两个更清晰的子问题。这种分层建模思想,使得模型能够对用户兴趣进行更精细的刻画。

本节介绍了序列建模的三个关键模型:DIN通过注意力机制解决用户兴趣多样性问题,DIEN进一步建模兴趣的时序演化过程,DSIN则引入会话概念进行分层建模。这些模型体现了序列建模的核心思想:动态性(根据任务调整兴趣表示)、序列性(利用时间顺序信息)和聚焦性(针对任务筛选相关信息)。随着技术发展,未来的序列建模方法将结合更多先进技术来更好地理解用户动态需求。

代码

  • DSIN的三个核心组件实现了分层的会话建模。
  • 这种设计体现了“分层建模”的思想:先用自注意力在会话内聚合,再用BiLSTM在会话间传递,最后用注意力激活相关会话,从而实现对用户行为的细粒度刻画。
  • 本节介绍了序列建模的三个关键模型:DIN通过注意力机制解决用户兴趣多样性问题,DIEN进一步建模兴趣的时序演化过程,DSIN则引入会话概念进行分层建模。这些模型体现了序列建模的核心思想:动态性(根据任务调整兴趣表示)、序列性(利用时间顺序信息)和聚焦性(针对任务筛选相关信息)。随着技术发展,未来的序列建模方法将结合更多先进技术来更好地理解用户动态需求。
    dsin.py 文件:
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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
import tensorflow as tf
from tensorflow.keras import layers
from .utils import (
build_input_layer,
build_group_feature_embedding_table_dict,
concat_group_embedding,
concat_func,
)
from .layers import DNNs, PredictLayer


# 在 build_dsin_model 函数之前添加 BiasEncoding 类
class BiasEncoding(tf.keras.layers.Layer):
"""
DSIN 模型的偏置编码层。

该层为会话嵌入添加三种类型的偏置:
1. 会话偏置:每个会话不同(捕获会话级特征)
2. 位置偏置:会话内每个位置不同(捕获时间模式)
3. Item偏置:每个嵌入维度不同(捕获特征级偏置)

公式:BE(k,t,c) = w_k^K + w_t^T + w_c^C
其中:
- k:会话索引
- t:会话内位置索引
- c:嵌入维度索引
"""

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

def build(self, input_shape):
# 从输入形状获取嵌入大小
# input_shape: [batch_size, sess_max_count, sess_max_len, embedding_dim]
if len(input_shape) == 4:
embedding_dim = input_shape[-1]
else:
raise ValueError(f"期望4维输入形状,得到 {input_shape}")

# 会话偏置:每个会话不同
# 形状:[sess_max_count, 1, 1]
self.sess_bias_embedding = self.add_weight(
"sess_bias_embedding",
shape=(self.sess_max_count, 1, 1),
initializer=tf.keras.initializers.TruncatedNormal(
mean=0.0, stddev=0.0001, seed=self.seed
),
trainable=True,
)

# 位置偏置:会话内每个位置不同
# 形状:[1, sess_max_len, 1]
self.seq_bias_embedding = self.add_weight(
"seq_bias_embedding",
shape=(1, self.sess_max_len, 1),
initializer=tf.keras.initializers.TruncatedNormal(
mean=0.0, stddev=0.0001, seed=self.seed
),
trainable=True,
)

# Item偏置:每个嵌入维度不同
# 形状:[1, 1, embedding_dim]
self.item_bias_embedding = self.add_weight(
"item_bias_embedding",
shape=(1, 1, embedding_dim),
initializer=tf.keras.initializers.TruncatedNormal(
mean=0.0, stddev=0.0001, seed=self.seed
),
trainable=True,
)

super(BiasEncoding, self).build(input_shape)

def call(self, inputs, **kwargs):
"""
对会话嵌入应用偏置编码。

参数:
inputs:会话嵌入张量
形状:[batch_size, sess_max_count, sess_max_len, embedding_dim]

返回:
偏置编码后的会话嵌入,形状相同
"""
# 将所有三种偏置添加到输入中
# 广播将处理维度对齐
# 形状:[batch_size, sess_max_count, sess_max_len, embedding_dim]
encoded_inputs = (
inputs
+ self.sess_bias_embedding # 会话偏置
+ self.seq_bias_embedding # 位置偏置
+ self.item_bias_embedding
) # Item偏置

return encoded_inputs

def compute_output_shape(self, input_shape):
return input_shape

def get_config(self):
config = {
"sess_max_count": self.sess_max_count,
"sess_max_len": self.sess_max_len,
"seed": self.seed,
}
base_config = super(BiasEncoding, self).get_config()
return dict(list(base_config.items()) + list(config.items()))


def build_dsin_model(feature_columns, model_config):
"""
构建深度会话兴趣网络 (DSIN) 模型用于 CTR 预测。

DSIN 引入会话概念来更好地建模用户行为序列。
关键组件:
1. 会话划分:基于时间间隔将用户行为序列划分为会话
2. 偏置编码:应用会话、位置和Item偏置(如果启用)
3. 会话兴趣提取:使用多头注意力提取会话级兴趣
4. 会话兴趣交互:使用双向 LSTM 建模会话间交互
5. 会话兴趣激活:应用注意力激活相关的会话兴趣

参数:
feature_columns:特征列定义列表
model_config:包含以下参数的字典:
- session_feature_list:list,例如 ['video_id']
- sess_max_count:int
- sess_max_len:int
- bias_encoding:bool
- att_embedding_size:int
- att_head_num:int
- dnn_units:list
- dropout_rate:float
- l2_reg:float

返回:
(model, None, None):排序模型元组
"""

session_feature_list = model_config.get("session_feature_list", ["video_id"])
sess_max_count = model_config.get("sess_max_count", 5)
sess_max_len = model_config.get("sess_max_len", 10)
bias_encoding = model_config.get("bias_encoding", True)
att_embedding_size = model_config.get("att_embedding_size", 8)
att_head_num = model_config.get("att_head_num", 2)
dnn_units = model_config.get("dnn_units", [128, 64, 1])
dropout_rate = model_config.get("dropout_rate", 0.2)
try:
l2_reg = float(model_config.get("l2_reg", 1e-6))
except Exception:
l2_reg = 0.000001

# 为所有特征构建输入层
# 形状:input_layer_dict 包含所有输入张量
input_layer_dict = build_input_layer(feature_columns)

# 为所有特征构建嵌入表
# 形状:group_embedding_feature_dict 包含按用途分组的嵌入
group_embedding_feature_dict = build_group_feature_embedding_table_dict(
feature_columns, input_layer_dict, prefix="embedding/"
)

# 获取常规 DNN 输入(非会话特征)
# 形状:[batch_size, total_embedding_dim]
dnn_inputs = concat_group_embedding(group_embedding_feature_dict, "dnn")

# 使用 DSIN 组件处理会话特征
session_embeddings = []

# 查找用于会话处理的 video_id 嵌入
video_id_embedding_layer = None
for fc in feature_columns:
if fc.name == "video_id":
video_id_embedding_layer = tf.keras.layers.Embedding(
fc.vocab_size,
fc.emb_dim,
name="video_id_session_embedding",
mask_zero=True,
embeddings_regularizer=tf.keras.regularizers.l2(l2_reg),
)
break

# 提取会话嵌入
session_inputs_found = False
for sess_idx in range(sess_max_count):
for feature_name in session_feature_list:
session_key = f"sess_{sess_idx}_{feature_name}"

if session_key in input_layer_dict:
session_inputs_found = True
# 获取会话输入张量
# 形状:[batch_size, sess_max_len]
session_input = input_layer_dict[session_key]

# 获取此会话的嵌入
# 形状:[batch_size, sess_max_len, embedding_dim]
session_emb = video_id_embedding_layer(session_input)
session_embeddings.append(session_emb)

# 堆叠会话嵌入
# 形状:[batch_size, sess_max_count, sess_max_len, embedding_dim]
session_embeddings_stack = tf.stack(session_embeddings, axis=1)

# 如果启用,应用偏置编码
if bias_encoding:
bias_encoder = BiasEncoding(sess_max_count, sess_max_len, seed=1024)
session_embeddings_stack = bias_encoder(session_embeddings_stack)

# DSIN 组件 1:会话兴趣提取
session_interests = apply_session_interest_extractor(
session_embeddings_stack,
sess_max_count,
sess_max_len,
att_embedding_size,
att_head_num,
dropout_rate,
)

# DSIN 组件 2:会话兴趣交互
session_interactions = apply_session_interest_interaction(
session_interests,
sess_max_count,
att_embedding_size * att_head_num,
dropout_rate,
)

# DSIN 组件 3:会话兴趣激活
# 获取用于激活的目标Item嵌入
target_item_embedding = get_target_item_embedding_by_prefix(
group_embedding_feature_dict, "embedding/video_id"
)

# 基于目标Item激活会话兴趣和交互
activated_session_interests = apply_attention_activation(
session_interests, target_item_embedding, name_suffix="interests"
)
activated_session_interactions = apply_attention_activation(
session_interactions, target_item_embedding, name_suffix="interactions"
)

# 组合所有特征进行最终预测
all_features = [dnn_inputs]

# 如果可用,添加会话特征
if session_inputs_found:
all_features.extend(
[activated_session_interests, activated_session_interactions]
)

# 连接所有特征
# 形状:[batch_size, total_feature_dim]
final_inputs = concat_func(all_features, axis=-1)

# 应用 DNN 层
# 形状:[batch_size, 1](最终预测)
dnn_logits = DNNs(dnn_units, use_bn=True, dropout_rate=dropout_rate)(final_inputs)

# 最终预测层
# 形状:[batch_size, 1] 带 sigmoid 激活
output = PredictLayer(name="dsin_output")(dnn_logits)

# 创建模型
model = tf.keras.models.Model(
inputs=list(input_layer_dict.values()), outputs=output
)

return model, None, None


def apply_session_interest_extractor(
session_embeddings,
sess_max_count,
sess_max_len,
att_embedding_size,
att_head_num,
dropout_rate,
):
"""
使用多头自注意力应用会话兴趣提取器。

这是 DSIN 的核心组件,用于提取会话级兴趣。
对于每个会话,我们应用多头自注意力来捕获
该会话内Item之间的关系。

教育说明:
- 多头注意力允许模型同时关注会话的不同方面
- 自注意力意味着会话中的每个Item都关注同一会话中的所有其他Item
- 这捕获了会话内的依赖关系和兴趣
- 偏置编码可能已应用于输入,以提供位置和会话级信息

参数:
session_embeddings:堆叠的会话嵌入(可能已偏置编码)
形状:[batch_size, sess_max_count, sess_max_len, embedding_dim]
sess_max_count:每个用户的最大会话数
sess_max_len:最大会话长度
att_embedding_size:每个注意力头的嵌入大小
att_head_num:注意力头数
dropout_rate:正则化的 dropout 率

返回:
tf.Tensor:会话兴趣张量
形状:[batch_size, sess_max_count, att_embedding_size * att_head_num]
"""
d_model = att_embedding_size * att_head_num
session_interests = []

for sess_idx in range(sess_max_count):
# 获取此会话的会话嵌入
# 形状:[batch_size, sess_max_len, embedding_dim]
session_emb = session_embeddings[:, sess_idx, :, :]

# 在此会话内应用多头自注意力
# 这捕获同一会话中Item之间的关系
# 形状:[batch_size, sess_max_len, d_model]
attention_output = tf.keras.layers.MultiHeadAttention(
num_heads=att_head_num,
key_dim=att_embedding_size,
dropout=dropout_rate,
name=f"session_attention_{sess_idx}",
)(session_emb, session_emb)

# 应用层归一化以提高训练稳定性
attention_output = tf.keras.layers.LayerNormalization(
name=f"session_attention_norm_{sess_idx}"
)(attention_output)

# 应用平均池化获取会话级表示
# 这将会话中的所有Item聚合为单一表示
# 形状:[batch_size, d_model]
session_interest = tf.reduce_mean(
attention_output, axis=1, name=f"session_pooling_{sess_idx}"
)
session_interests.append(session_interest)

# 堆叠所有会话兴趣
# 形状:[batch_size, sess_max_count, d_model]
session_interests = tf.stack(
session_interests, axis=1, name="session_interests_stack"
)

return session_interests


def apply_session_interest_interaction(
session_interests, sess_max_count, d_model, dropout_rate
):
"""
使用双向 LSTM 应用会话兴趣交互。

该组件建模会话之间的时间关系。
直觉是会话按时间排序,后续会话
可能受到早期会话的影响。

教育说明:
- 双向 LSTM 在前向和后向方向处理会话
- 这捕获了过去会话如何影响当前会话以及
未来会话如何为当前会话提供上下文
- 输出捕获会话间依赖关系

参数:
session_interests:会话兴趣张量
形状:[batch_size, sess_max_count, d_model]
sess_max_count:最大会话数
d_model:模型维度
dropout_rate:正则化的 dropout 率

返回:
tf.Tensor:会话交互输出
形状:[batch_size, sess_max_count, d_model * 2]
"""

# 应用双向 LSTM 建模会话间的时间依赖关系
# 前向方向:过去会话如何影响当前会话
# 后向方向:未来会话如何为当前会话提供上下文
# 形状:[batch_size, sess_max_count, d_model * 2]
session_interactions = tf.keras.layers.Bidirectional(
tf.keras.layers.LSTM(
d_model // 2, # 每个方向的单元数为一半,以保持输出大小一致
return_sequences=True,
dropout=dropout_rate,
recurrent_dropout=dropout_rate,
name="session_interaction_lstm_forward",
),
name="session_interaction_lstm",
backward_layer=tf.keras.layers.LSTM(
d_model // 2,
return_sequences=True,
dropout=dropout_rate,
recurrent_dropout=dropout_rate,
go_backwards=True,
name="session_interaction_lstm_backward",
),
)(session_interests)

# 应用层归一化以提高训练稳定性
session_interactions = tf.keras.layers.LayerNormalization(
name="session_interaction_norm"
)(session_interactions)

return session_interactions


def apply_attention_activation(session_features, target_item_embedding, name_suffix=""):
"""
基于目标Item对会话特征应用注意力激活。

该组件基于会话特征与目标Item的相关性来激活会话特征。
直觉是并非所有会话对预测用户对特定Item的兴趣都同等相关。

教育说明:
- 注意力机制允许模型关注最相关的会话
- 目标Item嵌入为我们试图预测的内容提供上下文
- 这创建了会话特征的加权组合

参数:
session_features:会话特征张量
形状:[batch_size, sess_max_count, feature_dim]
target_item_embedding:目标Item嵌入
形状:[batch_size, embedding_dim]
name_suffix:层名称后缀以确保唯一性

返回:
tf.Tensor:激活的会话特征
形状:[batch_size, feature_dim]
"""

# 获取维度
batch_size = tf.shape(session_features)[0]
sess_max_count = tf.shape(session_features)[1]
feature_dim = tf.shape(session_features)[2]

# 扩展目标Item嵌入以匹配会话维度
# 形状:[batch_size, 1, embedding_dim]
target_expanded = tf.expand_dims(target_item_embedding, axis=1)

# 为每个会话重复目标嵌入
# 形状:[batch_size, sess_max_count, embedding_dim]
target_repeated = tf.tile(target_expanded, [1, sess_max_count, 1])

# 将会话特征与目标Item嵌入连接
# 这为计算注意力分数提供上下文
# 形状:[batch_size, sess_max_count, feature_dim + embedding_dim]
combined_features = tf.concat([session_features, target_repeated], axis=-1)

# 使用密集层计算注意力分数
# 形状:[batch_size, sess_max_count, 1]
attention_scores = tf.keras.layers.Dense(
1, activation="tanh", name=f"attention_score_{name_suffix}"
)(combined_features)

# 应用 softmax 获取注意力权重
# 这确保权重在会话间的和为 1
# 形状:[batch_size, sess_max_count, 1]
attention_weights = tf.nn.softmax(
attention_scores, axis=1, name=f"attention_weights_{name_suffix}"
)

# 将注意力权重应用于会话特征
# 这创建了会话特征的加权组合
# 形状:[batch_size, feature_dim]
activated_features = tf.reduce_sum(
session_features * attention_weights,
axis=1,
name=f"activated_features_{name_suffix}",
)
return activated_features


def get_target_item_embedding_by_prefix(group_embedding_feature_dict, prefix):
"""
获取用于注意力激活的目标Item嵌入。

该函数从特征嵌入中提取目标Item嵌入。
目标Item是我们试图预测用户兴趣的Item。

参数:
group_embedding_feature_dict:分组嵌入特征字典

返回:
tf.Tensor:目标Item嵌入
形状:[batch_size, embedding_dim]
"""
dnn_embeddings = group_embedding_feature_dict.get("dnn", [])
# 查找以 'embedding/video_id' 开头的嵌入
target_embedding = None
for embedding in dnn_embeddings:
if embedding.name.startswith(prefix):
target_embedding = embedding
break

if len(target_embedding.shape) > 2:
# 如果嵌入有额外维度,取平均值
target_embedding = tf.reduce_mean(target_embedding, axis=1)

return target_embedding

性能对比

1
2
3
4
5
6
7
8
9
+--------+--------+--------+------------+
| 模型 | auc | gauc | val_user |
+========+========+========+============+
| din | 0.5687 | 0.5507 | 928 |
+--------+--------+--------+------------+
| dien | 0.5796 | 0.5568 | 928 |
+--------+--------+--------+------------+
| dsin | 0.5616 | 0.5577 | 99 |
+--------+--------+--------+------------+