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 小时前
免费!开源!AI 全自动短视频工具,Comfyui本地接入0帧起手!
人工智能
qcx231 小时前
拆解 Warp AI Agent(五):跨生态联邦——10 种 Skill + MCP + 多 Harness 互操作设计
人工智能·rust·ai agent·skill·warp·mcp·harness
生成论实验室1 小时前
《事件关系阴阳博弈动力学:识势应势之道》第五篇:安全关键关系——故障、障碍与冲突
运维·服务器·人工智能·安全·架构
weixin_446260851 小时前
应用实战篇:利用 DeepSeek V4 构建生产级 AI 应用的全流程与最佳实践
大数据·linux·人工智能
AI科技星1 小时前
全域数学视角下N维广义数系的推广与本源恒等式构建【乖乖数学】
人工智能·机器学习·数学建模·数据挖掘
qcx231 小时前
拆解 Warp AI Agent(二):风险分级执行——Agent 如何做到安全并行、危险排队
人工智能·安全·ai·agent·源码解析·warp
小白蒋博客1 小时前
【ai开发段永平投资理财的知识图谱网站】第一天:搭 Vite + Vue 项目,跑通 Hello World
vue.js·人工智能·trae
MediaTea1 小时前
人工智能通识课:Scikit-learn 机器学习工具库
人工智能·python·机器学习·scikit-learn
AI木马人1 小时前
13.人工智能实战:RAG 多轮对话越问越偏?Query Rewrite、历史压缩与会话记忆的工程化方案
人工智能·搜索引擎