一个统一序列建模与非序列特征交叉的工业级推荐模型 baseline,值得每一位推荐系统工程师精读。

KDD Cup 2026 的推荐赛道瞄准了一个长期被忽视的结构性问题:特征交叉模型和序列模型各走各的,谁来把它们统一?
腾讯给出的 baseline------PCVRHyFormer,用约 1800 行高质量 Python 代码给出了一个答案。本文逐文件拆解这个 baseline,从数据管道到模型架构到训练策略,力求说清每一处设计意图。
一、赛题背景:两条平行线的交汇
推荐系统在过去二十年沿着两条几乎平行的路线演进:
| 路线 | 代表工作 | 核心能力 |
|---|---|---|
| 特征交叉 | FM → DeepFM → DCN → xDeepFM | 高维多域类别特征的显式/隐式交叉 |
| 序列建模 | DIN → DIEN → SIM → HSTU | 用户行为序列的时序动态捕捉 |
两条线各有所长,但在工业系统中一直分开部署、独立优化,带来四个结构性问题:
- 跨范式交互浅:特征交叉和序列建模之间只有简单的 embedding 拼接
- 优化目标不一致:两边各自优化子任务,缺乏端到端的统一 loss
- 扩展性差:序列长度和模型规模增长时,碎片化架构越来越低效
- 工程复杂度高:两套模型意味着两套特征工程、两套在线推理链路
这道赛题的核心命题是:能不能设计一个统一的 tokenization 方案和一个同质的、可堆叠的 backbone,在一个模型里同时搞定序列行为建模和非序列多域特征?
PCVRHyFormer 就是这个命题的 baseline 答案。
二、项目总览
tencent2026/
├── dataset.py # 高性能 Parquet 数据管道(763行)
├── model.py # PCVRHyFormer 模型架构(1715行)
├── train.py # 训练入口(363行)
├── trainer.py # 训练循环 + 评估(495行)
├── utils.py # 工具函数(289行)
├── ns_groups.json # 特征分组配置
├── run.sh # 启动脚本
└── tencent-uni-rec-challenge-2026.md # 赛题说明
六个 Python 文件,总计约 1800 行,没有冗余,没有过度抽象。
三、数据管道:dataset.py 的精工细作
数据来自腾讯广告平台真实日志,Parquet 格式,flat column layout。数据集包含 120 列:5 列 ID/Label、46 列 user int、10 列 user dense、14 列 item int、45 列序列特征(4 个行为域)。
3.1 性能优化清单
PCVRParquetDataset 继承 IterableDataset,实现了以下优化:
预分配缓冲区
python
# 不是每次 _convert_batch 都 np.zeros + np.stack
self._buf_user_int = np.zeros((B, user_int_dim), dtype=np.int64)
self._buf_user_dense = np.zeros((B, user_dense_dim), dtype=np.float32)
self._buf_seq[domain] = np.zeros((B, n_feats, max_len), dtype=np.int64)
每次 _convert_batch 直接写入预分配的缓冲区,然后 .copy() 出给 PyTorch。zero-copy 读取 + 批量 copy out,避免碎片化内存分配。
文件系统共享策略
python
torch.multiprocessing.set_sharing_strategy('file_system')
多 DataLoader worker 场景下,默认的 /dev/shm 共享会被大量 tensor 撑爆。切换到文件系统共享是工业实践的标配。
Row Group 级数据划分
python
# 训练/验证按 Row Group 切分,而非按文件切分
rg_info = [(file, rg_idx, num_rows) for ...]
train_dataset = PCVRParquetDataset(..., row_group_range=(0, n_train_rgs))
valid_dataset = PCVRParquetDataset(..., row_group_range=(n_train_rgs, total_rgs))
这比按文件切分更精细,因为 Parquet 的 Row Group 是独立的读取单元。
Fused 序列填充
python
# 多个 side-info 列在一次遍历中同时填充到 3D buffer
for c, (offs, vals, vs, ci) in enumerate(col_data):
for i in range(B):
s, e = int(offs[i]), int(offs[i + 1])
ul = min(e - s, max_len)
out[i, c, :ul] = vals[s:s + ul]
Per-column 填充需要遍历每列每行,fused 后每行只遍历一次。
3.2 FeatureSchema:扁平化特征定位
所有特征被 flatten 成一维张量。FeatureSchema 类通过 (feature_id, offset, length) 三元组维护每个特征在张量中的位置:
user_int_feats: [fid_1][fid_15][fid_48][fid_49]...[fid_109]
↑ ↑ ↑ ↑ ↑
offset=0 len=1 offset=1 len=1 offset=45 len=1
这种设计使得 Embedding 层可以用一个统一的查找表实现,而不需要 per-feature 的独立 EmbeddingBag。
3.3 时间分桶:行为时序信号
python
BUCKET_BOUNDARIES = np.array([
5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, # 秒级
120, 180, 240, 300, 360, 420, 480, 540, 600, # 分钟级
900, 1200, 1500, 1800, ..., 3600, # 半小时到1小时
5400, 7200, ..., 86400, # 小时到天
172800, 259200, ..., 31536000, # 天到年
], dtype=np.int64)
64 个边界产生 65 个桶(0=padding,1~65=有效桶)。将 当前时间戳 - 行为时间戳 的差值通过 np.searchsorted 离散化,作为一个独立的时序特征嵌入。
这个设计的妙处:把连续的时序差变成了离散 token,可以用 Embedding 学习,避免了手工设计衰减函数,也避免了连续值直接入网的数值稳定性问题。
四、模型架构:PCVRHyFormer 的完整拆解
这是整个 baseline 的灵魂,1715 行代码构建了一个统一序列和非序列特征的 Transformer 变体。
4.1 整体数据流
┌─────────────────────────┐
│ ModelInput │
│ user_int_feats [B,Ud] │
│ user_dense_feats[B,Dd] │
│ item_int_feats [B,Id] │
│ item_dense_feats[B,0] │
│ seq_a/b/c/d [B,S,L] │
│ seq_lens, time_buckets │
└───────────┬─────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ NS Tokenizer │ │ Dense Proj │ │ Seq Embedding │
│ user: [B,Nu] │ │ user: [B,1] │ │ + TimeEmb │
│ item: [B,Ni] │ │ item: [B,1] │ │ seq_a/b/c/d │
└──────┬───────┘ └──────┬───────┘ └────────┬─────────┘
│ │ │
└───────────────────┼─────────────────────┘
│
NS tokens [B, num_ns, D]
Seq tokens [B, L_i, D]
│
┌─────────▼──────────┐
│ MultiSeqQueryGen │
│ 每个序列独立生成 │
│ Nq 个 query token │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ MultiSeqHyFormer │
│ Block × N │
│ │
│ For each seq i: │
│ 1. SeqEncoder │
│ 2. CrossAttn │
│ 3. Token Concat │
│ 4. RankMixer │
│ 5. Split │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Output Projection │
│ Classifier │
│ → [B, 1] logit │
└────────────────────┘
4.2 RoPE:位置的优雅编码
python
class RotaryEmbedding(nn.Module):
def __init__(self, dim, max_seq_len=2048, base=10000.0):
# 预计算 inv_freq,一次性构建 cos/sin 缓存
inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
t = torch.arange(max_seq_len, dtype=inv_freq.dtype)
freqs = torch.outer(t, inv_freq)
emb = torch.cat([freqs, freqs], dim=-1)
self.cos_cached = emb.cos().unsqueeze(0)
self.sin_cached = emb.sin().unsqueeze(0)
实现细节值得注意:
- 一次性预计算 :
max_seq_len范围内全部缓存,forward 只做切片 - 兼容 torch.compile:不在 forward 中做动态 padding 或扩展
- Q/K 独立编码:KV 侧用序列位置,Q 侧在 LongerEncoder 中可以指定独立的位置索引
RoPE 的核心运算只有几行:
python
def rotate_half(x):
x1, x2 = x[..., :D//2], x[..., D//2:]
return torch.cat([-x2, x1], dim=-1)
def apply_rope(x, cos, sin):
return x * cos + rotate_half(x) * sin
4.3 三种序列编码器的设计智慧
| 编码器 | 结构 | 适用场景 | 延迟特征 |
|---|---|---|---|
| SwiGLUEncoder | LN → SwiGLU → Dropout → Residual | 轻量级,无注意力 | 最低 |
| TransformerEncoder | Pre-LN Self-Attn + RoPE + FFN | 标准选择 | 中等 |
| LongerEncoder | Cross-Attn(top_k, all) / Self-Attn | 长序列压缩 | 可控 |
LongerEncoder 是最体现工程巧思的组件。面对可变长度的序列,它做了自适应路由:
if L > top_k:
# Cross-Attention 模式
Q = 最近 top_k 个 token(带独立位置编码)
K, V = 全部序列 token
→ 输出 (B, top_k, D)
else:
# Self-Attention 模式
Q = K = V = 全部 token + causal mask
→ 输出 (B, L, D)
关键实现------_gather_top_k 从每个样本中提取最新有效 token:
python
valid_len = (~key_padding_mask).sum(dim=1)
actual_k = torch.clamp(valid_len, max=top_k)
start_pos = valid_len - actual_k
indices = start_pos.unsqueeze(1) + torch.arange(top_k)
top_k_tokens = torch.gather(x, dim=1, index=indices)
这个设计确保了:无论输入序列多长,经过 LongerEncoder 后输出维度恒定(top_k),推理延迟可控。这对满足赛题的延迟硬约束至关重要。
4.4 Query 生成:MultiSeqQueryGenerator
python
class MultiSeqQueryGenerator(nn.Module):
def forward(self, ns_tokens, seq_tokens_list, seq_padding_masks):
ns_flat = ns_tokens.view(B, -1) # 所有 NS token flatten
for i in range(num_sequences):
# 序列池化(mask-aware mean pooling)
valid_mask = ~seq_padding_masks[i].unsqueeze(-1).float()
seq_pooled = (seq_i * valid_mask).sum(dim=1) / valid_mask.sum(dim=1).clamp(min=1)
# 全局信息 = NS + 序列池化
global_info = torch.cat([ns_flat, seq_pooled], dim=-1)
# 每个序列用独立的 FFN 组生成 Nq 个 query token
queries = [ffn(global_info) for ffn in self.query_ffns_per_seq[i]]
q_tokens_list.append(torch.stack(queries, dim=1))
核心思路:每个序列的 query 既包含全局非序列特征(NS tokens),也包含该序列自身的池化信息。这保证了 query 是"序列感知"的,而不仅仅是通用查询。
4.5 RankMixerBlock:零参数的跨 Token 信息混合
这是整个模型最具创新性的组件。
python
def token_mixing(self, Q):
# Q: (B, T, D), 其中 T = Nq*S + Nns
B, T, D = Q.shape
d_sub = D // T # 要求 D 能被 T 整除
# Step 1: 将 D 维切成 T 段
Q_split = Q.view(B, T, T, d_sub)
# Step 2: 交换 token 轴和子空间轴
Q_rewired = Q_split.transpose(1, 2)
# Step 3: 展平
Q_hat = Q_rewired.view(B, T, D)
return Q_hat
直觉理解:
- 把每个 token 的 D 维表示切成 T 等份
- 第 i 个 token 的第 j 份被"路由"到第 j 个 token 的第 i 份
- 等价于一种无参数的全连接信息交换
整个 RankMixerBlock 由 Token Mixing + Per-Token FFN 组成:
Q_hat = token_mixing(Q) # 跨 token 信息重排
Q_e = FFN(LN(Q_hat)) # 每个 token 独立升维/降维
Q_boost = Q + Q_e # 残差连接
三种模式:
full:Token Mixing + FFN(需要D % T == 0)ffn_only:只用共享 FFN,无 token mixingnone:恒等映射
这个设计的工程价值:在延迟硬约束下提供了一种极轻量的跨 token 交互。矩阵乘法的计算量是 O(T²D),而 token mixing 只是 reshape + transpose,几乎是零开销。
4.6 MultiSeqHyFormerBlock:可堆叠的核心 Block
python
class MultiSeqHyFormerBlock(nn.Module):
def __init__(self, d_model, num_heads, num_queries, num_ns,
num_sequences, seq_encoder_type, ...):
# 每个序列独立的编码器
self.seq_encoders = [create_sequence_encoder(...) for _ in range(S)]
# 每个序列独立的交叉注意力
self.cross_attns = [CrossAttention(...) for _ in range(S)]
# 共享的 Token Mixer
self.mixer = RankMixerBlock(d_model, n_total=Nq*S + Nns)
def forward(self, q_list, ns_tokens, seq_list, seq_masks, ...):
# Step 1: 独立序列演化
for i in range(S):
seq_i, mask_i = self.seq_encoders[i](seq_list[i], seq_masks[i])
# Step 2: 独立 Query 解码
for i in range(S):
decoded_q_i = self.cross_attns[i](q_list[i], seq_i, mask_i)
# Step 3: 拼接所有 decoded Q + NS
combined = torch.cat(decoded_qs + [ns_tokens], dim=1)
# Step 4: Query Boosting(跨 token 信息混合)
boosted = self.mixer(combined)
# Step 5: 拆分回各序列的 Q 和 NS
next_q_list = [boosted[:, i*Nq:(i+1)*Nq] for i in range(S)]
next_ns = boosted[:, S*Nq:]
return next_q_list, next_ns, next_seqs, next_masks
每一层 block 是同质的 ------同样的结构,同样的输入输出 shape。这意味着 block 可以随意堆叠,实现深度 scaling。同时每层的 seq_encoders 和 cross_attns 有独立参数,不同层可以学到不同粒度的特征交互。
4.7 NS Tokenizer 的两种哲学
GroupNSTokenizer:语义分组投影
特征1 → Emb → ┐
特征2 → Emb → ├→ Concat → Linear → 1 个 NS token
特征3 → Emb → ┘
NS token 数量 = 分组数量(固定),每个 token 对应一个语义组。
RankMixerNSTokenizer:等分拼接投影
所有特征 → 全部 Emb → 大 Concat → 等分成 T 段 → 每段 Linear → T 个 NS token
NS token 数量自由可调,不绑定分组语义。这是 RankMixer 论文的做法。
对于多值特征(如用户历史点击的 article ID 列表),两种 tokenizer 都用 mask-aware mean pooling:
python
vals = int_feats[:, offset:offset+length]
emb_all = emb_layer(vals) # (B, length, emb_dim)
mask = (vals != 0).float().unsqueeze(-1)
count = mask.sum(dim=1).clamp(min=1)
fid_emb = (emb_all * mask).sum(dim=1) / count # (B, emb_dim)
五、训练策略:工业级的调优实践
5.1 双优化器设计
python
# Embedding 参数 → Adagrad(适合稀疏梯度)
sparse_optimizer = Adagrad(sparse_params, lr=0.05)
# 其他参数 → AdamW(适合密集梯度)
dense_optimizer = AdamW(dense_params, lr=1e-4, betas=(0.9, 0.98))
这是推荐模型训练的经典做法:
- Embedding 表的梯度极度稀疏(每次 batch 只更新少量行),Adagrad 的自适应学习率让低频特征学得更多
- Transformer 的线性层梯度密集,AdamW 的动量机制更稳定
参数分组方式也很巧妙:
python
def get_sparse_params(self):
# 直接遍历所有 nn.Embedding 模块,按 data_ptr 去重
sparse_params = set()
for module in self.modules():
if isinstance(module, nn.Embedding):
sparse_params.add(module.weight.data_ptr())
return [p for p in self.parameters() if p.data_ptr() in sparse_params]
不依赖参数名称,而是通过 isinstance(module, nn.Embedding) 自动发现,自动适配任意模型结构。
5.2 高基数 Embedding 冷重启(MultiEpoch 策略)
python
if epoch >= reinit_sparse_after_epoch:
# 1. 快照旧 Adagrad 状态(按 data_ptr 索引)
old_state = {p.data_ptr(): optimizer.state[p] for ...}
# 2. 重新初始化高基数 Embedding 权重
reinit_ptrs = model.reinit_high_cardinality_params(threshold)
# 3. 重建 Adagrad 优化器
sparse_optimizer = Adagrad(sparse_params, ...)
# 4. 恢复低基数 Embedding 的 optimizer state
for p in sparse_params:
if p.data_ptr() not in reinit_ptrs and p.data_ptr() in old_state:
optimizer.state[p] = old_state[p.data_ptr()]
参考了快手 MultiEpoch 论文(https://arxiv.org/pdf/2305.19531):高基数 ID embedding 容易在多个 epoch 后过拟合,每轮重新初始化 + 重置优化器状态是一种有效的正则化。
5.3 三种阈值控制 Embedding 行为
| 参数 | 默认值 | 作用 |
|---|---|---|
emb_skip_threshold |
0 | 构建时跳过超高基数特征的 Embedding,用零向量代替 |
seq_id_threshold |
10000 | 判断序列特征是否为 ID 特征(施加 2× dropout) |
reinit_cardinality_threshold |
0 | 决定哪些 Embedding 在 epoch 后被冷重启 |
三者独立控制不同阶段的行为:构建时(跳过)、训练时(dropout)、epoch 间(重启)。
5.4 Checkpoint 自包含设计
每次保存的 checkpoint 目录包含:
global_step2500.layer=2.head=4.hidden=64.best_model/
├── model.pt # 模型权重
├── schema.json # 特征布局
├── ns_groups.json # 特征分组
└── train_config.json # 完整训练超参
这使得 checkpoint 可以随时迁移到不同的评估环境,不需要额外携带 schema 文件和配置文件。
六、ns_groups.json:特征分组的领域知识
将 46 个 user int 特征按语义分组,是本 baseline 中唯一一处注入领域知识的配置:
json
"user_ns_groups": {
"U1": [1, 15], // 基础画像
"U2": [48, 49, 89, 90, 91], // 行为统计(与 dense 对齐)
"U3": [80], // 关键单特征
"U4": [51, 52, 53, 54, 86], // 兴趣标签
"U5": [82, 92, 93], // 上下文特征
"U6": [50, 60, 94-109], // 多值 ID 组(16 个特征)
"U7": [3, 4, 55-59, 62-66] // 行为+统计混合
}
分组逻辑基于特征 ID 的语义相关性,而非简单的按列号等分。这是特征工程经验的外化------将相关知识沉淀为 JSON 配置而非硬编码。
七、延迟约束下的架构取舍
赛题要求每个提交必须通过推理延迟硬约束,超时直接无效。PCVRHyFormer 在架构层面做了多项针对性的取舍:
- LongerEncoder 的 top_k 压缩:序列无论多长,输出固定维度
- RankMixer 零参数 token mixing:相比标准 attention 的 O(T²d),mixing 是 O(Td)
- SwiGLUEncoder 选项:完全放弃 attention,只在 FFN 层面交互
- 可控的 sequence encoder 类型:不同序列域可以选不同 encoder,精度和延迟 trade-off 更细粒度
- num_hyformer_blocks 参数:block 数量可自由调节,直接控制模型深度和推理延迟
所有这些选择都通过单一 CLI 参数暴露,方便做 ablation study。
八、代码质量印象
几个值得学习的工程实践:
预分配 + 原地写入 。dataset.py 中所有 tensor buffer 在 __init__ 中预分配,_convert_batch 直接写入,避免热路径上的内存分配。
无过度抽象。ModelInput 是 NamedTuple,FeatureSchema 是简单类,没有不必要的数据类嵌套。
工业级错误处理。OOB(越界)特征值的检测/记录/截断逻辑完善,带统计信息输出。
自包含可迁移。Checkpoint 自带 schema 和 config,不依赖外部文件。
双优化器 + 冷重启。不是创新概念,但在 baseline 中正确实现,这对复现结果至关重要。
九、可以进一步探索的方向
基于这个 baseline,有几个值得尝试的改进方向:
- 统一 Tokenization 优化:当前 NS tokens 和 Seq tokens 走不同的嵌入通路,能否进一步统一?
- LongerEncoder 的 top_k 选择策略:当前取"最近的 top_k",能否改为"最重要的 top_k"(可学习的 selection)?
- 时间分桶的端到端化:当前是 hard bucket,能否用可微分的 soft bucket?
- Scaling Law 实验:固定数据量,系统性地变化 d_model、num_blocks、num_heads,绘制 compute-optimal 曲线
- 推理优化:对 LongerEncoder 做 KV cache,减少 Cross-Attention 的重复计算
附录:关键超参数速查
| 参数 | 默认值 | 含义 |
|---|---|---|
d_model |
64 | Backbone 隐藏维度 |
emb_dim |
64 | per-feature embedding 维度 |
num_queries |
1 | 每个序列生成的 query token 数 |
num_hyformer_blocks |
2 | 可堆叠 block 数量 |
num_heads |
4 | 注意力头数 |
seq_encoder_type |
transformer | 序列编码器类型 |
hidden_mult |
4 | FFN 扩展倍数 |
dropout_rate |
0.01 | Dropout 比例 |
seq_top_k |
50 | LongerEncoder 压缩长度 |
batch_size |
256 | 批大小 |
lr |
1e-4 | Dense 参数学习率 |
sparse_lr |
0.05 | Embedding 学习率 |
num_workers |
16 | DataLoader 线程数 |
本文基于 KDD Cup 2026 Tencent UNI-REC Challenge 的公开 baseline 代码撰写。所有代码和赛题信息均来自比赛官方发布。