Skip to content

Repository files navigation

TAAC 2026 总结

本仓库是在腾讯广告算法大赛 2026 baseline 上继续迭代的一版 PCVR 预测代码。相比 initial commit,当前代码主要围绕数据管道、负采样、用户 paired 特征融合、序列历史时间特征、动态序列行为门控、训练稳定性和推理兼容做了改造。

下面只总结当前根目录 .py 文件中的代码改动。

代码改动路径

步骤 改动 目的
Baseline 原始 PCVRHyFormer baseline 读取 Parquet,构建 NS tokens 和序列 tokens,训练二分类 PCVR 模型
1 数据管道优化与负样本下采样 降低读取开销,并通过 importance correction 支持负采样训练
2 用户 paired int+dense 特征融合 对齐 user_intuser_dense 中共享 fid 的特征,避免 paired dense 重复走普通 dense 路径
3 序列历史时间特征 给历史行为 token 注入 hour/weekday 周期信息
4 动态序列行为门控 对不同 sequence domain 的贡献做 sample-level 自适应加权
5 训练稳定性与性能参数 控制 AMP、TF32、梯度裁剪、日志同步、Sparse Adagrad rebuild 和 EarlyStopping 显存
6 自包含推理脚本 从 checkpoint sidecar 自动复原模型结构并生成 predictions.json

1. 数据管道与负采样

当前 dataset.py 对数据读取做了几类工程优化。

第一类是 Parquet 列投影。PCVRParquetDataset 会先用 _build_read_columns 构造训练或推理真正需要的列,再传给 pf.iter_batches(columns=self._read_columns),避免读取未进入模型的字段。

required_columns = self._build_read_columns()
self._read_columns = [name for name in schema_names if name in required_set]
...
for batch in pf.iter_batches(..., columns=self._read_columns):

第二类是 metadata 控制。训练和验证默认 include_metadata=False,不再把未使用的 timestampuser_id 搬到 batch 和 device 上;推理场景仍可保留 user_id 用于输出。

第三类是负样本下采样。PCVRParquetDataset 新增 neg_keep_prob,正样本永远保留,负样本按概率保留。get_pcvr_data 中训练集使用该概率,验证集固定 neg_keep_prob=1.0

if neg_keep_prob is None:
    neg_keep_prob = float(os.environ.get('NEG_KEEP_PROB', '0.5'))
...
train_dataset = PCVRParquetDataset(..., neg_keep_prob=neg_keep_prob)
valid_dataset = PCVRParquetDataset(..., neg_keep_prob=1.0)

训练侧在 trainer.py 中对 BCE loss 做 importance correction,负样本权重乘以 1 / neg_keep_prob,避免采样后改变原始正负比例语义。

weights = torch.where(
    label > 0.5, weights, weights / self.neg_keep_prob)
loss = (bce * weights).sum() / weights.sum().clamp_min(1.0)

2. 用户 paired int+dense 特征融合

当前代码显式把 user_int_feats_{62-66,89-91}user_dense_feats_{62-66,89-91} 当作成对特征处理。

数据侧在 dataset.py 中定义:

PAIRED_USER_FIDS = (62, 63, 64, 65, 66, 89, 90, 91)

build_paired_user_feature_specs 会检查这些 fid 是否同时存在于 user_int_schemauser_dense_schema,并要求两侧长度一致。build_user_dense_keep_indices 则返回普通 dense FFN 路径还需要保留的 dense 列位置,把 paired dense 列排除出去。

模型侧在 model.py 中新增 _PairedFeatureFusionMixin。对 paired fid,先查 id embedding,再把每个位置的 dense value 拼到 embedding 后面,经过 pair_mlps 做融合,最后按非零 id mask mean pooling。

dense_vals = dense_feats[:, dense_offset:dense_offset + dense_length]
dense_vals = torch.nan_to_num(dense_vals.float()).unsqueeze(-1)
pair_input = torch.cat([emb_all, dense_vals], dim=-1)
emb_all = self.pair_mlps[str(fid_idx)](pair_input)

这个融合发生在 fid embedding 层面,不是“每个 paired fid 单独生成一个最终 token”。后续仍然由 GroupNSTokenizerRankMixerNSTokenizer 按原有 tokenizer 规则生成 NS tokens。

普通 user dense token 也同步改造。PCVRHyFormer._project_user_dense_token 会先用 user_dense_keep_indices 过滤掉 paired dense,再进入 user_dense_proj

if self.user_dense_keep_indices is not None:
    user_dense_feats = user_dense_feats.index_select(
        dim=1,
        index=self.user_dense_keep_indices,
    )
return F.silu(self.user_dense_proj(user_dense_feats)).unsqueeze(1)

这样 paired dense 不会既参与 paired fusion,又重复进入普通 dense token。

3. 序列历史时间特征

原 baseline 已有 timestamp - seq_ts 的 time bucket。当前代码额外加入历史行为发生时刻自身的周期特征,分别是 hour-of-day 和 weekday。

数据侧在 dataset.py 中为每个 sequence domain 生成:

  • {domain}_hist_hour:形状 [B, L, 2],hour 的 cos/sin。
  • {domain}_hist_weekday:形状 [B, L, 2],weekday 的 cos/sin。

这些特征只由历史行为 token 的 timestamp 计算,不使用当前样本 timestamp 的 hour/weekday。

np.cos(hour_phase, out=hist_hour[:, :, 0])
np.sin(hour_phase, out=hist_hour[:, :, 1])
...
np.cos(weekday_phase, out=hist_weekday[:, :, 0])
np.sin(weekday_phase, out=hist_weekday[:, :, 1])

模型侧扩展 ModelInput

seq_hist_hours: dict
seq_hist_weekdays: dict

PCVRHyFormer 中用 hist_time_mlp 将 4 维周期特征投影到 d_model,再加到 sequence token embedding 上。

hist_time_feats = self._make_hist_time_features(
    hist_hour_feats, hist_weekday_feats, token_emb)
token_emb = token_emb + self.hist_time_mlp(hist_time_feats)

这条路径是 token 级历史时间特征,不是在 attention score 中直接加时间 bias。

4. 动态序列行为门控

当前模型新增 DynamicSeqBehaviorGate,用于给不同 sequence domain 学习 sample-level 权重。

它先对当前 NS tokens 做平均得到 ns_ctx,再对每个 domain 的 sequence tokens 做 masked mean 得到 seq_ctx,拼接后通过 MLP 得到该 domain 的 score。

ns_ctx = curr_ns.mean(dim=1)
seq_ctx = self._masked_mean(seq_tokens, padding_mask)
gate_input = torch.cat([ns_ctx, seq_ctx], dim=-1)
score = self.score_mlp(gate_input).squeeze(-1) + self.domain_bias[i]

最后对所有 domain 做 softmax,并乘以 sequence 数量,让平均权重保持在 1 附近。

return F.softmax(score_tensor, dim=1) * self.num_sequences

在 HyFormer blocks 跑完后,模型用这个 gate 缩放各 domain 的 Q tokens:

curr_qs = [
    q * gate_weights[:, i].view(-1, 1, 1)
    for i, q in enumerate(curr_qs)
]

训练入口通过 --seq_gate_type 控制,当前默认是 dynamic;也可以设为 none 关闭。

5. 训练稳定性与性能改造

当前 train.pytrainer.py 增加了一批训练控制参数。

数据加载相关:

  • --valid_num_workers
  • --prefetch_factor
  • --neg_keep_prob

训练循环相关:

  • --log_every_n_steps
  • --grad_clip_norm
  • --grad_clip_every_n_steps
  • --empty_cache_after_eval

数值与性能相关:

  • --amp
  • --amp_dtype
  • --deterministic
  • --matmul_precision

训练时默认不会自动开启 AMP,只有显式传入 --amp 才启用。CUDA 下会按 matmul_precision 设置 TF32 相关开关。

if hasattr(torch, 'set_float32_matmul_precision'):
    torch.set_float32_matmul_precision(args.matmul_precision)
torch.backends.cuda.matmul.allow_tf32 = args.matmul_precision != 'highest'
torch.backends.cudnn.allow_tf32 = args.matmul_precision != 'highest'

训练循环中,loss 先以 tensor 形式累加,只在 log_every_n_steps 到达时 .item(),减少 GPU 同步。

if total_step % self.log_every_n_steps == 0:
    loss_value = (log_loss_sum / log_loss_count).item()

梯度清零改成:

self.dense_optimizer.zero_grad(set_to_none=True)
self.sparse_optimizer.zero_grad(set_to_none=True)

验证阶段改成 torch.inference_mode()。验证后是否 torch.cuda.empty_cache()empty_cache_after_eval 控制,默认不清。

Sparse Adagrad rebuild 也做了保护:当 reinit_cardinality_threshold <= 0 或实际没有 embedding 被 reinit 时,不重建 optimizer,避免无效扰动 optimizer state。

6. EarlyStopping 与 checkpoint sidecar

utils.py 中的 EarlyStopping.best_model 不再深拷贝 CUDA tensor,而是复制到 CPU。

return {
    k: v.detach().cpu().clone()
    for k, v in model.state_dict().items()
}

这样可以避免 best model 快照额外常驻 GPU 显存。

trainer.py 中 checkpoint 目录会写入 sidecar 文件:

  • schema.json
  • 可选 ns_groups.json
  • train_config.json

_handle_validation_result 只在确认新 best 时写 sidecar,避免产生只有配置文件但没有 model.pt 的无效 checkpoint 目录。

7. 推理脚本

当前新增 infer.py,用于比赛评估环境中加载 checkpoint 并输出 predictions.json

推理入口依赖环境变量:

  • MODEL_OUTPUT_PATH:包含 model.pt 和 sidecar 的 checkpoint 目录。
  • EVAL_DATA_PATH:评估数据目录。
  • EVAL_RESULT_PATH:输出目录。

推理流程:

  1. 优先从 checkpoint 目录读取 schema.jsontrain_config.json
  2. 根据 train_config.json 还原模型结构参数。
  3. 先读取 state_dict,再判断 checkpoint 是否使用 paired user feature fusion。
  4. 根据 checkpoint key 和 shape 构建匹配的 PCVRHyFormer
  5. strict=True 加载权重。
  6. 逐 batch 推理并写出 predictions.json

paired / legacy checkpoint 兼容的关键点是 state_dict-first:

state_dict = torch.load(ckpt_path, map_location='cpu')
enable_paired_user_features = _checkpoint_uses_paired_user_features(
    state_dict,
    full_user_dense_dim=test_dataset.user_dense_schema.total_dim,
    paired_user_dense_dim=paired_dense_dim,
)

历史时间特征也会根据 checkpoint 参数自动对齐。如果 checkpoint 没有 hist_time_mlp,推理侧会关闭历史时间特征,避免 strict load shape mismatch。

代码文件对应关系

dataset.py   # Parquet 流式读取、列投影、负样本下采样、paired feature spec、历史时间特征
model.py     # PCVRHyFormer、paired int+dense fusion、hist_time_mlp、DynamicSeqBehaviorGate
train.py     # CLI 参数入口、paired/user dense keep 构建、训练配置持久化
trainer.py   # BCE/Focal 训练、负采样 correction、AMP、梯度裁剪、checkpoint sidecar
utils.py     # EarlyStopping、CPU best_model 快照、set_seed deterministic 开关、Focal Loss
infer.py     # checkpoint-first 模型复原、strict load、预测输出 predictions.json

当前代码重点

当前版本的核心变化不是单一模块替换,而是把数据侧、模型侧、训练侧和推理侧一起打通:

  • 数据侧知道哪些 user dense 是 paired dense。
  • 模型侧把 paired dense 和对应 sparse id 在 fid 层面融合。
  • 普通 dense token 不再重复消费 paired dense。
  • sequence token 同时拥有 time bucket 和历史 hour/weekday 周期特征。
  • 不同 sequence domain 通过动态 gate 做自适应加权。
  • checkpoint 保存足够的 sidecar,让 infer.py 能按训练结构复原模型。

我的感想

这是我第一次参加搜广推比赛,也是第一次参加 AI 方面的竞赛,没有相关的比赛经验。最开始做的时候先跑了一遍 baseline,然后直接开始闷头对 focal loss 调参数,并没有一个系统性的规划。现在复盘下来应该这样做:

  1. 首先跑通 baseline,分析 baseline 大概有哪些可以调参的部分。
  2. 对数据做详细的EDA。现在开源的高分方案绝大多数提分点都是在数据侧进行 EDA 后做的:
    • Focal Loss:PVCR 任务正负样本、难易样本分布不均,BCE 不是最优解;
    • (user_dense_feat, user_int_feat) - pair:源于官方的描述,同时分析数据可以考虑做 log1p 变换。

待完善的地方

  1. 此次比赛的实验记录不充分;
  2. 消融实验不完全,想到一个新 idea 就直接做了。

可以提分但我没想到的点

  1. 训练时对模型做 EMA;
  2. 模型侧:对 NS Tokens 的数目、d_model 做 scale up; semi-local 因果掩码;
  3. 数据侧:更细粒度的时间特征。
    • 时区转换:把 Unix 时间转换到北京时间
    • 特殊节日。
  4. 训练推理:EMA。

待补充。如有建议可以在 issue 里提出。

About

腾讯广告算法大赛 x KDD cup 2026 学术赛道。最终排名 607,线上 AUC 为 0.823788.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages