本仓库是在腾讯广告算法大赛 2026 baseline 上继续迭代的一版 PCVR 预测代码。相比 initial commit,当前代码主要围绕数据管道、负采样、用户 paired 特征融合、序列历史时间特征、动态序列行为门控、训练稳定性和推理兼容做了改造。
下面只总结当前根目录 .py 文件中的代码改动。
| 步骤 | 改动 | 目的 |
|---|---|---|
| Baseline | 原始 PCVRHyFormer baseline | 读取 Parquet,构建 NS tokens 和序列 tokens,训练二分类 PCVR 模型 |
| 1 | 数据管道优化与负样本下采样 | 降低读取开销,并通过 importance correction 支持负采样训练 |
| 2 | 用户 paired int+dense 特征融合 | 对齐 user_int 和 user_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 |
当前 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,不再把未使用的 timestamp、user_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)当前代码显式把 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_schema 和 user_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”。后续仍然由 GroupNSTokenizer 或 RankMixerNSTokenizer 按原有 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。
原 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: dictPCVRHyFormer 中用 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。
当前模型新增 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 关闭。
当前 train.py 和 trainer.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。
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 目录。
当前新增 infer.py,用于比赛评估环境中加载 checkpoint 并输出 predictions.json。
推理入口依赖环境变量:
MODEL_OUTPUT_PATH:包含model.pt和 sidecar 的 checkpoint 目录。EVAL_DATA_PATH:评估数据目录。EVAL_RESULT_PATH:输出目录。
推理流程:
- 优先从 checkpoint 目录读取
schema.json和train_config.json。 - 根据
train_config.json还原模型结构参数。 - 先读取 state_dict,再判断 checkpoint 是否使用 paired user feature fusion。
- 根据 checkpoint key 和 shape 构建匹配的
PCVRHyFormer。 strict=True加载权重。- 逐 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 调参数,并没有一个系统性的规划。现在复盘下来应该这样做:
- 首先跑通 baseline,分析 baseline 大概有哪些可以调参的部分。
- 对数据做详细的EDA。现在开源的高分方案绝大多数提分点都是在数据侧进行 EDA 后做的:
- Focal Loss:PVCR 任务正负样本、难易样本分布不均,BCE 不是最优解;
(user_dense_feat, user_int_feat) - pair:源于官方的描述,同时分析数据可以考虑做log1p变换。
- 此次比赛的实验记录不充分;
- 消融实验不完全,想到一个新 idea 就直接做了。
- 训练时对模型做 EMA;
- 模型侧:对 NS Tokens 的数目、
d_model做 scale up; semi-local 因果掩码; - 数据侧:更细粒度的时间特征。
- 时区转换:把 Unix 时间转换到北京时间
- 特殊节日。
- 训练推理:EMA。
待补充。如有建议可以在 issue 里提出。