广告算法竞赛中的dataset.py设计:数据管道与特征防泄漏实践 📅 发布时间:2026/9/7 17:04:24 👁 浏览次数: 今年腾讯广告算法大赛的消息放出来之后很多选手第一件事不是打开模型代码而是先把历年的开源方案翻出来看了一圈。结果看到最后大部分人都会回到同一个起点手里攥着一份几GB甚至几十GB的广告数据却不知道第一行代码该写在哪儿。我的习惯是先从dataset.py开始写。这个文件看似只是负责把数据读进来、切batch、喂给模型实际上它是整个项目里牵一发动全身的地方。特征对不对、标签有没有穿越、内存撑不撑得住、训练快不快源头都在这一个文件里。这篇文章就把我在2025年腾讯广告算法大赛项目里的第一个模块完整拆开讲一遍。有一点提前说明赛题每年的任务表述和数据格式都会变化但广告算法赛题的核心框架往往是稳定的。这篇文章里的方案基于通用广告点击率预估的竞赛场景展开包含了我在实际项目中对数据集加载、样本构造、特征编码和防泄漏的处理思路。你拿到新赛题之后可以直接拿来当模板用再根据赛题细节做调整。1. 整体设计思路为什么一上来就要死磕数据管道1.1 dataset.py在参赛工程里的真实定位很多人第一次打广告算法竞赛的时候容易把dataset.py理解成一个纯工具文件读取pandas表格转成Tensor然后交给模型训练。真正上手后才会发现这个文件承担的东西远比想象中多。首先广告算法数据集的典型特征是特征维度高、ID类特征极多、用户行为和时间关系复杂。原始文件的schema动不动就几十列其中大部分列不是纯数值而是字符串形式的ID比如用户ID、广告ID、素材ID、媒体ID。这些ID直接塞进模型是不行的必须做编码映射。编码映射放哪里最常见的就是dataset里。其次赛题数据往往不是一份干净的表格。曝光日志、点击日志、转化日志经常需要joinjoin完还存在大量缺失和冲突。数据清洗不能放在训练主循环里每次跑也不适合在模型文件里逐步写出逻辑最好的位置就是独立的数据模块。dataset.py作为数据以及模型之间的唯一接口承担了清洗、转换、编码的所有操作。第三从工程可维护性角度看数据模块应当是“可替换”的。你可以今天用pandas读csv明天把数据转成parquet后天再换到tfrecord或者h5只要dataset的对外接口不变其他代码就不受牵连。对竞赛项目这种快速迭代的场景来说这种隔离设计能省下大量时间。1.2 竞赛项目里数据管道的三条关键设计原则我在看过很多开源baseline之后把数据管道的设计原则总结成三条先看全貌再动手、所有处理必须可复现、train和test的处理路径必须完全一致。第一先看全貌再动手。写dataset.py之前我会先花一晚上做探索性数据分析把数据规模、每列的缺失率、类型分布、时间范围、标签分布都跑一遍。这一步的目的是搞清楚数据是“一眼能塞进内存”还是“必须分块处理”不要一上来就封装大模块。第二可复现。很多竞赛项目跑着跑着结果对不上了最大的嫌疑是shuffle种子没固定或者数据在处理过程中因为并发造成顺序不一致。dataset.py里所有随机操作必须支持传入固定种子数据和特征版本要有编号最好生成缓存文件这样能保证A/B测试时的公平性。第三train和test的处理路径必须完全一致。我见过太多踩坑案例训练时对特征A做了某种转换但预测时忘了做或者训练时删掉了某个样本离线指标很高一提交线上就崩。本质原因是数据管线分裂了。避免方法就是让同一个Dataset类同时服务train和test用参数区分模式而不是复制两份逻辑。1.3 内存、速度和精度的取舍广告赛题的数据量通常让人很难受。单个全量csv可能超过内存但压缩成parquet或者h5之后又能装下。于是会在“用什么方式读数据”上花很多心思。我推荐的做法是给数据处理流程画出三个清晰的阶段原始文件区、预处理缓存区、训练读取区。原始文件区就是你下载下来的数据千万不要去改动。预处理缓存区是第一次清洗完的特征列和编码映射表保存成parquet或bin文件。训练读取区则是Dataset类每次迭代时读取的区域。这个分层最大的好处是大部分耗时操作只执行一次而不是每次训练启动都去做。比如训练时你不可能每次都重新计算标准化参数的均值和方差这些应当提前算好并以常量形式缓存在预处理阶段。dataset.py的核心逻辑就是如何优雅地把缓存读进训练循环里。2. dataset.py的模块拆分与核心细节解析2.1 特征列的类型判断与内存压缩正式开始写dataset.py之前我会先设计一个schema对象把我的列类型和编码方式都集中放在一个字典里。这个步骤看起来不起眼但它决定了后续所有代码的扩展性。广告数据里的列大致分成三类第一类是数值特征比如曝光时间戳、点击序列长度、广告出价训练时要别标准化第二类是ID类特征比如用户ID、广告ID、素材ID训练时要映射到Embedding第三类是组合特征比如用户和广告的交叉行为这类特征由前两类拼出来的不需要直接对应原始列。在Python里最容易被忽略的就是内存类型。pandas默认情况下会把一个整数列读成int64字符串读成object。几百个特征列乘几千万行之后内存瞬间就爆掉了。我的做法是先读一小块数据然后用pandas的infer_objects配合手动指定能压到uint8、uint16、int32就绝不用int64。对于ID类列如果取值范围比较大就先编码成int32数组再转成Tensor如果取值范围确定较短就直接用uint16甚至uint8。这里强调一个容易被忽略的点类型压缩最好在原始数据读取阶段就做而不是等Dataset返回时再转。因为Dataset返回的时候pandas已经把这列存成Python对象了再做转换反而要消耗额外的内存拷贝容易OOM。2.2 Label的构造与多任务标签注意事项腾讯广告算法大赛近几年逐渐从纯二分类向多任务结构靠拢一个样本可能同时有曝光、点击、转化等多个标签。dataset.py里的标签构造直接影响模型结构。以常见的广告多任务为例数据里通常会给每一条曝光记录附带了点击标签和转化标签。如果只有点击标签但没有转化标签就要结合业务逻辑去推断。比如广告行业里转化通常发生在点击之后如果你的样本里既有曝光记录也有点击记录就可以从曝光表中关联出转化情况如果转化事件在观测窗口内没有出现那这条样本在转化任务上就应该判为负样本。这里面最大的坑是标签穿越。什么叫标签穿越就是在构造训练样本时无意中把未来时刻才能知道的事件当成了当前时刻的标签。比如你用某用户今天是否点击来做标签但构造特征时却加入了这个用户后半夜的行为序列这就是典型的穿越。dataset.py里处理时间相关任务时每一行样本对应的特征必须严格限制在该样本的曝光时刻之前标签必须来自曝光时刻之后的事件。2.3 特征编码顺序和使用hash的时机广告数据集里的ID类特征数量通常非常大几百万甚至几千万个取值都有。这种情况下不能像做图像分类那样做一个全量字典编码否则光存字典就占了几个G。常见的做法是截断编码加hash。截断编码就是统计每个ID出现的频次只保留高频的那部分ID并编号剩余低频的全部归到某个未知桶。这种做法的思路是出现频率太低的ID在Embedding训练中基本学不到有效表达保留它们只会增加参数量和过拟合风险。hash的做法则是利用哈希函数把任意长度的字符串映射到固定范围内的数字训练和预测都在同一个固定范围里做这样不需要维护字典。广告算法场景里大规模ID类特征常用hash bucket因为不用存储字典重启训练也不会不一致。它的缺点是不同的ID可能被哈希到同一个桶里造成冲突但实践中当桶容量足够大时冲突带来的影响非常小。我自己做赛题时会先用热度截断编码作为默认方案只有在碰到极大规模特征时才切到hash模式。原因是截断编码保留了原始ID的高频语义给模型收敛带来了更好的起点。但是如果数据集的用户量超过几千万截断编码需要预先全量扫描一遍数据统计频次这个过程比较耗时hash方案可以省掉这一步。2.4 负采样在dataset.py里的落法广告算法比赛里的数据类别比例极不均衡。曝光里真正被点击的比例通常只有百分之几甚至更低也就是说负样本能占到95%以上。如果不做负采样直接训练模型会严重偏向把大部分样本预测成负样本只能得到一个整体准确率高但AUC很差的模型。负采样操作可以放在dataset.py里也可以放在预处理阶段。放在预处理阶段的好处是采样逻辑只执行一次训练流程更稳定坏处是如果后续想调整采样率需要重新整套跑一遍数据。放在Dataset类里的好处是采样率可以作为参数灵活调试每次迭代时动态决定保留哪些负样本坏处是shuffle和采样之间可能会引入随机不一致导致训练和验证时样本分布不一样。我的推荐方案把负采样相关的字段提前在预处理阶段拼进样本但真正的采样判断放到Dataset内部。Dataset返回样本前根据sample_rate和随机种子判断当前负样本是否要丢弃。这样可以在不改动原始缓存的情况下快速实验不同采样率。为了避免每次跑同一个实验得到不同的数据集影响对比采样种子也要固定使用固定种子即可。3. 实操过程dataset.py的关键实现和完整流程3.1 工程目录结构先展示一个我常用的广告多任务赛题工程结构。dataset.py不是孤立文件它依赖目录下的配置和特征定义。project_root/ |-- configs/ | -- data_config.py |-- data/ | -- dataset.py |-- preprocessing/ | -- build_features.py |-- models/ | -- mmoe_model.py |-- utils/ | -- hash_utils.py |-- train.py -- predict.pyconfigs/data_config.py里记录所有数据路径、特征列名、编码方式和超参数data/dataset.py负责把原始列转成训练Tensorpreprocessing/build_features.py负责全量脏活比如把原始日志join成宽表并缓存。把特征构建和dataset分离的原因是build_features只需要在数据更新后跑一次而dataset每次训练都会被实例化所以后者必须轻量。3.2 定义数据Schema和缓存读取假设赛题提供了一张宽表每行是一个曝光样本包含以下列user_id用户IDad_id广告IDcreative_id素材IDproduct_id商品IDmedia_id媒体IDuser_feature用户侧的数值特征ad_feature广告侧的数值特征click点击标签0或1conversion转化标签0或1第一步是把列按类型归类。config里写清楚哪些列放进ID特征哪些放进数值特征哪些作为标签。ID_FEATURES [user_id, ad_id, creative_id, product_id, media_id] NUM_FEATURES [user_feature, ad_feature, user_active_days, ad_price] LABEL_COLS [click, conversion]在build_features阶段我会预先扫描ID列给每个ID列生成一个编码映射。扫描时要统计每个ID的出现频次保留频次大于设定阈值min_count的ID其他ID归入unknownunknown统一编码为10保留给padding。编码表会保存成json或pickle。dataset.py初始化时只负责把编码表加载进内存不要在训练过程中重新统计。3.3 Dataset类的初版代码实现下面这个类是我比赛里最常用的一个骨架环境是PyTorch。import os import numpy as np import pandas as pd import torch from torch.utils.data import Dataset class TencentAdDataset(Dataset): def __init__(self, data_path, id_maps, config, modetrain): super().__init__() self.data_path data_path self.id_maps id_maps self.mode mode self.config config self.df pd.read_parquet(data_path) if mode train: self.df self.df[self.df[tag] train].reset_index(dropTrue) elif mode valid: self.df self.df[self.df[tag] valid].reset_index(dropTrue) elif mode test: self.df self.df[self.df[tag] test].reset_index(dropTrue) # 如果启用了负采样只在训练阶段执行 if mode train and config.get(neg_sample_rate, 1.0) 1.0: self.df self._neg_sample(self.df, config[neg_sample_rate], config[seed]) self.num_features config[num_features] self.id_features config[id_features] self.label_cols config[label_cols] self.numeric_stats config[numeric_stats] def _neg_sample(self, df, rate, seed): rng np.random.default_rng(seed) positive_idx df[click] 1 negative_idx df[click] 0 negative_df df[negative_idx] positive_df df[positive_idx] keep_num int(len(negative_df) * rate) sample_idx rng.choice(len(negative_df), keep_num, replaceFalse) negative_df negative_df.iloc[sample_idx] return pd.concat([positive_df, negative_df], axis0).reset_index(dropTrue) def __len__(self): return len(self.df) def _encode_id(self, series, id_map): return series.map(id_map).fillna(1).astype(np.int64) def _normalize(self, x): mean self.numeric_stats[mean] std self.numeric_stats[std] return (x - mean) / (std 1e-6)这里有一个容易被忽略的地方负采样必须放在验证集切分之后。如果在切分验证集之前做了采样验证集的负样本也会被误删会导致离线指标失真。这是我连续踩了两次坑之后得出的教训。3.4 返回样本和Collate函数设计Dataset的__getitem__返回一条样本但训练时为了提高效率通常会一次性取一个batch再拼Tensor。PyTorch提供了collate_fn机制把列表形式的样本拼成Batch。我在数据量大的比赛里常用的一次取一条的做法反而慢建议直接用完整向量化方式。代码里可以在__init__阶段把所有样本转换成numpy数组然后__getitem__只做索引取值。这种方式内存占用略高但训练速度最快。但如果你要接的是时序类模型比如用户行为序列每条样本长度不一样那__getitem__里就需要动态切片而不能把所有序列都预加载成等长矩阵。这是两种代码风格要看任务里是否存在序列特征。推荐一个简洁的collate函数def collate_fn(batch): id_feats {} for key in batch[0][id_feats]: id_feats[key] torch.stack([torch.from_numpy(b[id_feats][key]) for b in batch]) num_feats torch.stack([torch.from_numpy(b[num_feats]) for b in batch]) labels torch.stack([torch.tensor(b[labels], dtypetorch.float) for b in batch]) return {id_feats: id_feats, num_feats: num_feats}, labels把一个batch拼成Tensor之后ID特征会变成二维矩阵一维是batch大小一维是该ID在映射表里的索引。模型拿到索引后经过Embedding层就能转成稠密向量。3.5 数值特征标准化在dataset里的执行顺序数值特征的标准化参数应该从哪里来如果从Dataset里临时计算每次epoch都会不一样导致训练不稳定验证阶段还要重新算。正确做法是在build_features阶段算好均值和方差放进configDataset只用读取并执行减法除法。对于广告数据里的数值特征不建议盲目使用min-max归一化很容易受离群点影响。曝光次数字段可能长期是个位数某天一个爆款广告突然产生了几十万次曝光如果按min-max处理绝大多数数据都会被压缩到贴近0的范围模型非常难学。标准化处理对这类长尾分布更友好它能保留相对大小关系。3.6 和训练主循环的联调Dataset写好之后最重要的验证不是单独跑一遍dataset代码而是跑一个极小规模的训练实验。用四五个batch看loss能不能正常下降。如果loss异常优先排查标签是否被错位特征是否全空以及padding和mask是否设置正确。训练主循环里常见的初始化方式train_dataset TencentAdDataset( data_pathdata/train_all.parquet, id_mapsid_maps, configdata_config, modetrain, ) valid_dataset TencentAdDataset( data_pathdata/train_all.parquet, id_mapsid_maps, configdata_config, modevalid, ) train_loader DataLoader( train_dataset, batch_sizedata_config[batch_size], shuffleTrue, num_workers4, pin_memoryTrue, collate_fncollate_fn, )完成这步之后训练流程在数据层面就可以跑通了。这里给一个具体的心得第一次跑全流程时把num_workers先设成0确认没有bug之后再往上加能省很多排查时间。4. dataset.py里的常见问题和排查实录4.1 明明训练正常验证偏差却很大的元凶一个典型的场景是训练集loss稳定下降验证集AUC看起来也不错但一提交线上就崩。这个问题不一定是模型的问题而很可能是train和test的样本分布不一致导致的。当你使用负采样或样本加权时训练集的click比例被调整到了某个预设值但验证集和测试集仍然是原始分布模型输出的预测概率就不能直接当作真实的点击概率来读取。如果赛题用AUC、GAUC等只看排序指标的评估方式影响会小一些如果赛题用了logloss等校准敏感指标就必须对训练时的负采样比例做概率校准。我写dataset时会在返回label的同时额外返回一个“样本权重”让模型根据实际曝光概率调整损失权重而不是简单丢弃负样本。4.2 多进程DataLoader卡死的三类原因PyTorch的DataLoader多进程模式在广告赛题工程里也很容易出问题。我整理过最常见的三类原因。第一类Windows或某些Linux环境下Dataset里用了lambda函数或者自定义对象无法pickle序列化导致worker进程创建失败。解决方法是把dataset代码里所有对象属性和lambda函数重构成普通函数或者给自定义类实现__getstate__方法避免在worker初始化时因pickle失败卡死。第二类在Dataset里初始化了CUDA资源。GPU资源在子进程里复制并初始化会非常慢甚至直接报错。数据加载阶段应该只做CPU上的数据读取不应有任何tensor.cuda操作。第三类主进程和子进程同时读同一个文件句柄导致文件偏移量冲突。如果每个worker都独立打开一次文件不做全局共享就不会遇到这个问题。所以Dataset里不要缓存文件句柄尽量用完整的文件路径在每次读取时重新打开。4.3 特征穿越排查方法特征穿越是最难发现的数据bug。它不会让程序报错但会让离线指标虚高实际线上效果很差。我排查穿越时主要看两个点。第一检查特征构建时是否只用到了当前时间点之前的信息。例如在构建“用户近7天点击次数”时如果直接用整份数据按用户分组后做统计就会把未来7天的点击也算进去。正确做法是按时间排序后只对当前行之前的行为做累计统计。这在代码里看起来只是多加了一个condition但漏掉它的后果非常严重。第二检查特征统计口径是否在训练和预测时一致。如果离线用了全局统计的广告平均点击率作为特征线上预测时不可能拿到未来日志那么这一维特征在线上就完全失效了。为了避免这种问题我倾向于所有统计型特征都做严格的时间截止约束。要计算某个用户的历史点击率就必须界定一个统计窗口并且确保每条样本使用的都是该样本曝光时刻之前、统计窗口之内的数据。这里有一个体验非常明显的排查技巧单独把关掉时序约束和有时序约束的两个模型跑一轮验证观察AUC差异。如果差异大得离谱先不要得意很可能是穿越了如果差异很小反而说明特征是安全的。4.4 部分ID列出现全0或全1的问题ID类特征经过编码后如果代码里用map方式做映射遇到不在映射表里的新ID会得到NaN然后经过fillna变成1。如果这个某列大部分取值都变成了1说明这一列的取值分布非常广泛并且很稀疏或者训练和预测数据的时间跨度差异太大。这种问题里真正的麻烦是1被同时用作padding、unknown和新ID的编码。模型无法区分真实的新用户和一个缺失值对训练会引入噪声。解决办法是保留一个unknown编码同时给每个样本生成一个mask在Embedding层之后做mask避免把unknown嵌入向量参与模型计算。如果觉得实现复杂度太高最直接的办法是把全部数据放在一起做频次统计让新ID的比例尽量小。4.5 训练速度慢卡在了数据读取环节广告数据量太大有时候数据处理环节会比模型前向反向更耗时。这种时候我第一步会用性能分析工具观察时间消耗在哪个函数。一般来说瓶颈通常不是数据读取本身而是数据转换过程中做了过多不必要的复制操作。举个例子某些选手会把ID列在Dataset里先转成Python字符串再调用字典查映射最后再int转Tensor。每次转类型都要创建新的Python对象这种开销在几百万次调用后非常明显。正确的做法是在预处理阶段直接把整列转成numpy的int64数组保存Dataset里直接按索引查numpy数组然后一次性生成Tensor。减少中间类型转换训练速度能提升好几倍。另一个被人忽视的细节是pin_memory。在GPU训练时把DataLoader的pin_memory参数设为True能大幅减少CPU到GPU的拷贝时间。如果你同时设置了num_workers0pin_memory的效果就不明显了只有配合多进程数据加载才能体现它的作用。4.6 标签极度稀疏时的稳妥状态广告数据里的转化率经常低到千分之一以下如果直接在原始数据上用二分类负采样即使有几个月的数据正样本数也少得可怜。遇到这种情况我建议不要在dataset.py层面强行暴力采样而是考虑样本加权。欠采样到1:20甚至1:10虽然也有效但数据利用率太低容易让模型对剩下那部分样本过拟合。把负样本权重降低、保留更多负样本是保险的做法。在损失函数里给不同样本分配不同权重控制困难样本和易分样本对梯度更新的贡献。权重方案由dataset.py负责产出每个样本返回一个weight字段。这个weight可以由数据本身的置信度决定也可以由业务逻辑指定。5. 从dataset.py引出的两个高阶思考5.1 为什么数据模块能决定你能走多远竞赛圈里长期流传一句话特征决定了模型的上限模型只是逼近这个上限。这句话放到广告赛题里更加适用。广告数据噪声高、分布变化快模型架构再先进如果没有一套干净、稳定、可复现的数据处理流程你做的所有尝试都建立在沙地上。dataset.py是这个流程的入口。它不只是提供一个可以迭代的数据批次更是在帮助你对齐训练和预测的数据语义。我见过太多选手在这个文件里堆了无数“临时逻辑”最后找不出问题在哪。好的做法是把数据模块当作正式软件来写变量名清楚、函数职责单一、逻辑抽象合理。5.2 把数据处理的时间轴前置到写模型之前有些选手拿到赛题后第一件事是搭模型结构DeepFM还是MMoE注意力机制用哪种恨不得第一天就把模型跑起来。我的建议刚好相反前两到三天只专心看数据、写数据清洗和特征构建。原因很简单模型代码写在数据模块之后少改一次数据接口就少一次bug。我在今年的项目上有一个很深的体会baseline模型跑出来的结果并不漂亮但因为我提前在dataset.py里把数据流程打磨稳定了后续调整特征的反馈速度非常快。后期上线之前A/B测试几乎一次通过没有出现离线在线不一致的问题。这些都要归功于最初几天耐着性子对数据模块做的设计。最后再分享一个实际经验给所有准备参赛的选手每次对dataset.py动刀之前先给当前的数据文件、特征文件、模型配置打一个tag保存成copy再动刀。别嫌麻烦这个习惯至少能让你在调参失控时多一条回退的路。广告算法大赛的项目周期不长少踩一个低级错误就多一分拿好成绩的机会。