KDD Cup 2026 腾讯算法广告大赛:UNI-REC-PCVRHyFormer 源码深度解读

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


KDD Cup 2026 的推荐赛道瞄准了一个长期被忽视的结构性问题:特征交叉模型和序列模型各走各的,谁来把它们统一?

腾讯给出的 baseline------PCVRHyFormer,用约 1800 行高质量 Python 代码给出了一个答案。本文逐文件拆解这个 baseline,从数据管道到模型架构到训练策略,力求说清每一处设计意图。


一、赛题背景:两条平行线的交汇

推荐系统在过去二十年沿着两条几乎平行的路线演进:

路线 代表工作 核心能力
特征交叉 FM → DeepFM → DCN → xDeepFM 高维多域类别特征的显式/隐式交叉
序列建模 DIN → DIEN → SIM → HSTU 用户行为序列的时序动态捕捉

两条线各有所长,但在工业系统中一直分开部署、独立优化,带来四个结构性问题:

  1. 跨范式交互浅:特征交叉和序列建模之间只有简单的 embedding 拼接
  2. 优化目标不一致:两边各自优化子任务,缺乏端到端的统一 loss
  3. 扩展性差:序列长度和模型规模增长时,碎片化架构越来越低效
  4. 工程复杂度高:两套模型意味着两套特征工程、两套在线推理链路

这道赛题的核心命题是:能不能设计一个统一的 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 mixing
  • none:恒等映射

这个设计的工程价值:在延迟硬约束下提供了一种极轻量的跨 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 在架构层面做了多项针对性的取舍:

  1. LongerEncoder 的 top_k 压缩:序列无论多长,输出固定维度
  2. RankMixer 零参数 token mixing:相比标准 attention 的 O(T²d),mixing 是 O(Td)
  3. SwiGLUEncoder 选项:完全放弃 attention,只在 FFN 层面交互
  4. 可控的 sequence encoder 类型:不同序列域可以选不同 encoder,精度和延迟 trade-off 更细粒度
  5. num_hyformer_blocks 参数:block 数量可自由调节,直接控制模型深度和推理延迟

所有这些选择都通过单一 CLI 参数暴露,方便做 ablation study。


八、代码质量印象

几个值得学习的工程实践:

预分配 + 原地写入dataset.py 中所有 tensor buffer 在 __init__ 中预分配,_convert_batch 直接写入,避免热路径上的内存分配。

无过度抽象。ModelInput 是 NamedTuple,FeatureSchema 是简单类,没有不必要的数据类嵌套。

工业级错误处理。OOB(越界)特征值的检测/记录/截断逻辑完善,带统计信息输出。

自包含可迁移。Checkpoint 自带 schema 和 config,不依赖外部文件。

双优化器 + 冷重启。不是创新概念,但在 baseline 中正确实现,这对复现结果至关重要。


九、可以进一步探索的方向

基于这个 baseline,有几个值得尝试的改进方向:

  1. 统一 Tokenization 优化:当前 NS tokens 和 Seq tokens 走不同的嵌入通路,能否进一步统一?
  2. LongerEncoder 的 top_k 选择策略:当前取"最近的 top_k",能否改为"最重要的 top_k"(可学习的 selection)?
  3. 时间分桶的端到端化:当前是 hard bucket,能否用可微分的 soft bucket?
  4. Scaling Law 实验:固定数据量,系统性地变化 d_model、num_blocks、num_heads,绘制 compute-optimal 曲线
  5. 推理优化:对 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 代码撰写。所有代码和赛题信息均来自比赛官方发布。

相关推荐
数智化管理手记1 天前
手工统计指标误差大、效率低?指标管理系统如何告别人工算数痛点?
大数据·运维·数据库·人工智能·云计算
fthux1 天前
RenoPit 能为普通业主做什么?看懂图纸、审查合同,提前发现装修坑
javascript·人工智能·ai·开源·github·chrome扩展·open source·edge扩展·firefox扩展
火山引擎开发者社区1 天前
火山引擎混合云 veStack 智算平台 Day0 适配 Kimi K3
人工智能
RSABLOCKCHAIN1 天前
AI Agents in LangGraph-2
人工智能·python
cd_949217211 天前
哪些AI工具适合生成游戏开发用的3D道具模型?从道具草稿到引擎测试的选择方法
人工智能·3d
FII工业富联科技服务1 天前
工业设备运维如何Agentic化?拆解Factory Brain的设备运维架构
大数据·运维·人工智能·架构·制造
糖果店的幽灵1 天前
人已经用 WorkBuddy 找工作拿了面试,你还在一份份手工改简历
人工智能·面试·职场和发展·langgraph
qq29531 天前
2026 财搭子决策模拟器:多智能体投研架构能力拆解
大数据·人工智能·架构
满怀冰雪1 天前
12-PaddlePaddle, 飞桨, 分类模型, 训练循环, 损失函数, 优化器, cross_entropy, Adam
人工智能·深度学习·分类·paddlepaddle
冬奇Lab1 天前
AI 评测系列(08):评测 CI/CD——持续质量门控
人工智能