KuaiRand 跨域多场景精排项目面试复习:从 DIN 到 PSRG/PCRG
这份笔记用来复习 KuaiRand-27K 多场景精排项目。面试讲这个项目时,我不想从模块名开始堆,而是先把问题讲清楚:用户历史来自多个 tab,当前预测发生在某个 tab,不同 tab 背后的数据分布并不一样。
模型改造围绕这件事展开:共享底层 embedding 保留跨场景共性,再用当前场景上下文重新解释历史行为,并抽取当前候选相关的多兴趣。
项目一句话
这是一个基于 KuaiRand-27K 曝光日志的 pointwise 短视频精排任务。每一次曝光是一条样本,模型输入包括候选视频、用户画像、当前 tab/time 上下文,以及用户此前最多 50 条历史行为,输出是用户是否会对候选视频产生长观看。
代码主线:
DIN baseline
-> ADS: PSRG + PCRG
-> TransformerFusion
-> MBCNet
-> PPNet
我面试时会把它讲成:
先用 DIN 建一个候选相关兴趣 baseline。
再处理多 tab 带来的两个问题:
1. 历史行为在不同当前场景下语义会变。
2. 单 query 很难表达用户多兴趣。
PSRG 负责重解释历史。
PCRG 负责用候选和场景生成多个 query 抽取多兴趣。
后面的 TransformerFusion、MBCNet、PPNet 分别补兴趣交互、特征交叉和个性化调制。
场景和域不要混
场景是业务入口,比如推荐、关注、附近、商城。域不是一个字段,而是一类样本的整体分布。
按 tab 切开后,每个 tab 的用户、内容、曝光机制和反馈规律都可能不同:
P(用户特征, 视频特征, 历史行为, 反馈 | 关注页)
!=
P(用户特征, 视频特征, 历史行为, 反馈 | 附近页)
所以我会这样区分:
| 概念 | 项目里的含义 |
|---|---|
| 场景 | tab 或业务入口 |
| 域 | 这个入口下形成的数据分布 |
| 跨场景 | 用户历史可能来自多个 tab |
| 跨域建模 | 不同 tab 分布不同,既要共享信息,又要做场景适配 |
例子:
历史行为:
关注页看过美妆博主
推荐页看过粉底测评
当前请求:
商城页预测用户是否长观看粉底商品视频
同一个历史行为在不同当前 tab 下含义不一样。关注页里的美妆行为可能偏作者关系,商城页下可能更像商品兴趣。
数据和标签口径
主任务是长观看预测,label 是 label_long_view。点击、点赞和播放比例没有被做成多任务 label,而是用于历史筛选或候选统计特征。
代码里样本 label 来自 long_view:
# preprocess/step3_generate_samples_v2.py:62
sample = {
'label_long_view': int(row['long_view']),
'user_id_raw': int(user_id),
'sample_time_ms': int(row['time_ms']),
'meta_is_rand': int(row.get('is_rand', 0)),
'meta_log_source': str(row.get('log_source', 'UNKNOWN')),
}
历史序列的入池条件是点击或长观看:
# preprocess/step3_generate_samples_v2.py:142
if row['is_click'] == 1 or row['long_view'] == 1:
curr_time = row['time_ms']
prev_time = history[-1]['time'] if history else curr_time
delta_t = curr_time - prev_time
play_ratio = min(row['play_time_ms'] / max(row['duration_ms'], 1), 3.0)
候选统计特征只用 pre 期日志算,避免把测试期反馈泄漏进去:
# preprocess/step2_compute_stats.py:43
stats = log_pre.groupby('video_id').agg(
pre_show_cnt=('video_id', 'count'),
pre_ctr=('is_click', 'mean'),
pre_lv_rate=('long_view', 'mean'),
pre_like_rate=('is_like', 'mean'),
pre_play_ratio=('play_ratio', 'mean'),
).reset_index()
这一点面试里容易被追问。我的说法会比较收敛:当前模型是单目标 BCE 长观看预测,其他反馈信号主要进历史序列和统计特征。
DIN baseline
Baseline 选 DIN,是因为精排要回答的是“当前候选和用户历史是否相关”。DIN 用候选 item 作为 query,对历史 item 做 target attention。
候选和历史共用同一套 video/author embedding 表:
# src/models/din.py:945
def _build_cand_item_repr(self, batch: Dict[str, torch.Tensor]) -> torch.Tensor:
cand_vid_emb = self.video_id_emb(batch[self.cand_video_col])
cand_aid_emb = self.author_id_emb(batch[self.cand_author_col])
cand_repr = torch.cat([cand_vid_emb, cand_aid_emb], dim=-1)
# src/models/din.py:957
def _build_hist_item_repr(self, batch: Dict[str, torch.Tensor]) -> torch.Tensor:
hist_vid_emb = self.video_id_emb(batch["hist_video_id"])
hist_aid_emb = self.author_id_emb(batch["hist_author_id"])
hist_repr = torch.cat([hist_vid_emb, hist_aid_emb], dim=-1)
DIN attention 的交互输入是 [q, h, q-h, q*h]:
# src/models/modules/target_attention_dnn.py:125
q = query.unsqueeze(1).expand(-1, num_tokens, -1)
att_input = torch.cat([q, keys, q - keys, q * keys], dim=-1)
att_scores = self.mlp(att_input).squeeze(-1)
att_weights, all_pad = masked_softmax(att_scores, mask, dim=-1)
user_interest = torch.bmm(att_weights.unsqueeze(1), keys).squeeze(1)
DIN 够适合做 baseline,但在这个项目里有三个明显缺口:
- 历史 item 表示是静态的,不知道当前请求在哪个 tab。
- 单 query 最后只能聚合出一个兴趣向量,多兴趣会被压平。
- head 是 concat + MLP,显式特征交叉不够直接。
为什么不为每个 tab 学一套 embedding
这个项目没有给每个 tab 单独训练 video/author embedding。这样做会让数据被切碎,低频视频和作者更稀疏,也会破坏候选和历史共用的向量空间。
共享 embedding 解决的是共性复用:
同一个视频 / 作者
在候选侧、历史侧、不同 tab 样本里
都落在同一个基础表示空间
但共享 embedding 不解决“当前域下如何解释历史”。同一条历史行为,在关注页和商城页里的预测语义可能不同。PSRG/PCRG 做的是共享表示之上的场景适配。
d_ctx
d_ctx 是当前请求的域上下文。它不是单独一个 tab id,而是把 tab/time 等上下文 embedding 拼起来,再过一个轻量 MLP:
# src/models/modules/domain_context.py:71
def forward(self, ctx_concat: torch.Tensor) -> torch.Tensor:
d_ctx = self.encoder(ctx_concat)
d_ctx = self.layernorm(d_ctx)
return d_ctx
主配置里 d_ctx 用了 tab、hour_of_day、day_of_week,输出 48 维。后面 PSRG 和 PCRG 都用同一份 d_ctx。
PSRG:把历史放到当前域里解释
PSRG 处理 key/value 侧,也就是历史序列表示。它做的不是“换一套 embedding”,而是在共享 embedding 上加当前场景条件。
当前代码是 PSRG-lite,不是完整 HyperNetwork。默认是 gated residual:
# src/models/modules/psrg.py:95
gate = torch.sigmoid(self.gate_mlp(merged)).reshape(B, L, D)
delta = self.delta_mlp(merged).reshape(B, L, D)
hist_out = hist_repr + gate * delta
hist_out = self.layernorm(hist_out)
这比生成完整 private matrix 稳很多,也够表达“同一历史行为在不同当前场景下语义不同”。
被问“为什么历史 embedding 在不同域下应该不一样”时,我不会说这是数学上先验成立的结论。它是一个建模假设,需要用证据支撑:
- 业务上,同一视频在关注 tab 和商城 tab 里表达的偏好不同。
- 数据上,同一 item 的点击、长观看、播放比例分布可能随 tab 分裂。
- 消融上,可以保留重映射层容量但置零或打乱
d_ctx,看增益是否缩水。 - 表征上,可以看同一历史 item 在不同
d_ctx下的 PSRG 输出是否被推开。
这套回答比直接说“应该不一样”更稳。
PCRG:用候选和场景抽多兴趣
PCRG 处理 query 侧。它不是提前存几组用户兴趣,而是根据当前候选和当前域动态生成多个 query:
# src/models/modules/pcrg.py:168
q_input = torch.cat([cand_item_repr, d_ctx], dim=-1)
q = self.query_gen(q_input).reshape(B, self.num_queries, self.query_dim)
q_item = self.query_to_item(q)
每个 query 分别 attend 历史序列,得到多个 interest token:
# src/models/modules/pcrg.py:173
scores = self._score_queries_with_hist(q_item, hist_repr)
mask_3d = hist_mask.unsqueeze(1).expand(-1, self.num_queries, -1)
attn, all_pad = _masked_softmax(scores, mask_3d, dim=-1)
z = torch.einsum("bgl,bld->bgd", attn, hist_repr)
这里要分清:
| 名称 | 含义 |
|---|---|
| query | 候选和场景生成的检索视角 |
| interest token | query attend 历史之后得到的兴趣向量 |
和 MIND、ComiRec 相比,PCRG 更贴近精排。MIND/ComiRec 的多兴趣偏用户级静态表达;PCRG 的 query 从一开始就带候选和当前场景,是 candidate-aware + domain-aware。
和 STAR / PEPNet 的关系
PSRG/PCRG 和 STAR、PEPNet 是同一类思想:共享主干,再做条件化调制。
区别在作用位置:
| 方法 | 主要作用位置 |
|---|---|
| STAR | 网络权重或 tower 的域特定组合 |
| PEPNet | embedding、tower 或 head 的门控 / 缩放 |
| PSRG | 历史序列每个位置的表示重映射 |
| PCRG | 多兴趣 query 生成 |
我不会把 PSRG 说成替代 STAR。更准确的说法是:把 domain-conditioning 这个原则下沉到了序列兴趣抽取层。
TransformerFusion
PCRG 得到多个 interest token 后,它们之间还没有交互。TransformerFusion 默认不是直接处理 50 条历史,而是处理 PCRG 输出的少量兴趣 token。
先 self-attention:
# src/models/modules/transformer_fusion.py:237
if self.n_layers > 0:
padding_mask = token_mask <= 0
for layer in self.encoder_layers:
x = layer(x, padding_mask=padding_mask)
再用候选做 target attention:
# src/models/modules/transformer_fusion.py:242
if self.use_target_attention:
u_target, attn_debug = self.target_attention(
query=q,
keys=x,
mask=token_mask,
return_debug=True,
)
这一层的分工比较清楚:
PCRG:从历史里取出多个兴趣
TransformerFusion:让这些兴趣之间先交互
TargetAttention:再让当前候选选择最相关的兴趣组合
DIN target attention 关注的是候选和历史是否相关;self-attention 关注的是兴趣 token 之间的关系,比如相似、互补、冲突。
MBCNet / FGC
前面几层主要处理序列兴趣。最后打分还要看候选、用户画像、上下文、候选统计特征。原来的 concat + MLP 能学隐式交叉,但没有明确结构。
MBCNet 的 head 有三条路:
| 分支 | 作用 |
|---|---|
| FGC | 按语义 group 做局部显式交叉 |
| Low-rank Cross | 用低秩方式做全局显式交叉 |
| Deep | 保留 MLP 的高阶非线性表达 |
特征分组来自 head input 的语义切片:
# src/models/modules/feature_slices.py:22
DEFAULT_MBCNET_GROUPS: List[str] = [
"user_interest",
"cand_repr",
"user_profile_sparse_embs",
"user_dense",
"context_embs",
"candidate_side_embs",
]
FGC 组内交叉有 cross1、bilinear、gated:
# src/models/modules/mbcnet.py:86
if self.mode == "cross1":
self.param = nn.Linear(self.group_dim, 1, bias=True)
elif self.mode in {"bilinear", "gated"}:
self.param = nn.Linear(self.group_dim, self.group_dim, bias=True)
如果去掉 FGC,只保留 Low-rank Cross 和 Deep,可以把它解释成全局低秩显式交叉 + DNN 隐式交叉。
PPNet
当前仓库 README 的最后一步是 PPNet。它不改变前面的兴趣抽取,而是在 head 侧做轻量个性化调制。
p_ctx 来自场景、用户属性和活跃度代理:
# src/models/modules/personal_context.py:159
if context_embs is not None and context_embs.shape[-1] > 0:
parts.append(context_embs)
默认是 group-wise FiLM:
# src/models/modules/ppnet.py:179
params = self.out_proj(self.backbone(p_ctx))
gamma, beta = torch.chunk(params, 2, dim=-1)
x_norm_before = x.norm(dim=-1).mean()
x_mod = self._apply_group_film(x=x, gamma=gamma, beta=beta)
直觉上,关注页可能更重作者关系,商城页可能更重商品属性;高活跃用户和低活跃用户对历史兴趣的依赖也不同。PPNet 就是把这类条件带到最终 head。
指标
这个项目能稳定讲 AUC 和 GAUC。
全局 AUC 是所有曝光样本放在一起算。GAUC 是按用户分组,每个用户内部算 AUC,再按样本数加权。只有单边标签的用户会被跳过:
# src/metrics/metrics.py:230
if n_pos == 0 or n_neg == 0:
continue
严格的 request-level NDCG 不能算,因为数据里没有同一次请求下的候选列表分组。现在有的是曝光样本级候选、预测分数、label 和 user_id。
README 里的 full track 结果:
| 阶段 | test_standard GAUC | test_random GAUC | random 增量 |
|---|---|---|---|
| DIN baseline | 0.6761 | 0.5525 | - |
| +ADS | 0.6854 | 0.5698 | +0.0173 |
| +Transformer | 0.6880 | 0.5745 | +0.0047 |
| +MBCNet | 0.6905 | 0.5775 | +0.0030 |
| +PPNet | 0.6918 | 0.5815 | +0.0040 |
这里要注意口径:0.6918 是 standard,不是 random。random 最终是 0.5815,从 baseline 的 0.5525 绝对提升 0.0290。
面试里容易被追问的点
PSRG 是不是生成完整 private weight?
不是。当前实现是 PSRG-lite,用共享 MLP 生成 gate/delta 或 FiLM 参数,对每个历史位置做调制,不生成完整动态矩阵。
PCRG 生成的是 query 还是 interest token?
先生成 query,再用 query attend 历史,得到 interest token。query 是检索视角,interest token 是检索结果。
为什么不直接对历史做 self-attention?
可以做,但默认路径先用 PCRG 把 50 条历史压成少量候选相关兴趣,再在这些兴趣 token 上做 self-attention。计算更省,也更容易解释。
MBCNet 是不是只处理非序列特征?
不是。MBCNet 处理最终 head input,里面包含上游得到的 user_interest,也包含候选、用户画像、上下文和候选侧属性。
怎么证明不是参数变多带来的提升?
要看消融。比如保留 PSRG 重映射层容量但置零或打乱 d_ctx,看增益是否消失;PCRG 可以看 G=1 和 G>1 的差异。
复习时按这条线讲
业务现象:
用户历史跨 tab,当前请求在某一个 tab。
建模问题:
不同 tab 是不同数据域,既要共享跨场景共性,也要适配当前目标域。
DIN:
用候选对历史做 target attention,建立精排 baseline。
共享 embedding:
复用视频和作者的跨场景基础表示。
d_ctx:
编码当前 tab/time 目标域。
PSRG:
把历史行为映射到当前目标域下重新解释。
PCRG:
由候选和当前域生成多个 query,抽取多兴趣。
TransformerFusion:
让多个兴趣 token 交互,再由候选做 target attention。
MBCNet / PPNet:
加强 head 侧特征交叉和个性化调制。
评估:
BCE 训练,AUC 和用户级 GAUC 评估,重点看 test_random GAUC。
这套项目最重要的不是“用了多少模块”,而是每一步都对应 DIN 在多场景精排里的一个缺口:历史不随场景变、单 query 压平兴趣、兴趣之间缺少交互、head 交叉不够明确、不同用户和场景下特征权重不同。