广告算法竞赛中的dataset.py设计与特征工程实战
我这两年打各种广告算法比赛有个特别深的体会很多队伍不是死在模型上而是死在数据上。腾讯广告算法大赛这类比赛数据量大、字段杂、线上线下分布差异明显谁先把 dataset.py 写明白谁就赢了一半。这次想专门聊聊我在 2025 赛季项目里这个最基础也最关键的脚本——dataset.py它到底承担了什么、怎么一步步把原始日志变成能直接喂给模型的张量以及我踩过的那些坑。这个脚本对第一次参赛的同学来说可能觉得就是“读文件、做特征、返回 batch”没什么技术含量。但实际上广告算法竞赛里的 dataset.py 承担了比想象中多得多的职责字段解析、缺失值兜底、特征编码、负采样、时间窗口切分、缓存加速甚至部分线上一致性校验都藏在里面。如果你的 dataset.py 写得糙后面模型再牛也会被数据对齐、特征穿越、内存溢出这些问题拖死。这篇文章适合所有正在准备广告算法比赛、或者想入门大规模 CTR/CVR 预估的同学我会把 dataset.py 里每个模块为什么存在、怎么实现、踩过什么坑都展开讲清楚。1. 腾讯广告算法大赛里的数据形态与 dataset.py 的设计目标1.1 数据从哪来长什么样腾讯广告算法大赛的数据集本质上是一段真实的广告请求与反馈日志。每条样本通常对应一次广告曝光或者一次点击后的转化行为核心字段可以分成三类用户侧信息、广告侧信息、上下文信息。用户侧常见的有年龄、性别、兴趣标签、历史行为序列等广告侧常见的有行业类目、广告素材 id、创意属性等上下文则包括请求时间、流量位置、设备类型、网络环境等。看到这个结构你应该就明白了这跟我们平时在教科书上玩的 sklearn 自带数据集完全不是一回事。原始数据里基本没有数值型特征全是 id 型特征和稀疏的类别特征。以某个历史赛季的样本为例一条曝光日志可能有几十个字段其中一部分是定长字段一部分是变长序列字段还有一部分是嵌套的键值对比如用户兴趣标签的置信度分布。dataset.py 的核心目标就是把这种乱糟糟的异构数据统一转换成模型训练时需要的数值特征或嵌入索引。另外样本量非常夸张。腾讯广告比赛的训练集经常是几千万到上亿级别的曝光日志一个 CSV 文件解压后可能就 20GB、30GB甚至更多。如果你沿用“pandas 全量读进来再处理”的习惯几乎一上来就是死局。所以 dataset.py 的设计目标第一优先级不是“方便”而是“能在有限内存里高效地把数据变成 batch”第二优先级才是特征处理灵活性。1.2 为什么需要单独一个 dataset.py而不是训练脚本里顺手写有的同学觉得特征处理写在训练循环里不也一样吗区别很大。比赛迭代速度决定胜负你需要频繁跑实验换一个损失函数、改一下负采样比例、加一个特征交叉都得重开一轮训练。如果数据预处理跟模型代码耦在一起每次做数据实验都可能把训练代码改崩排错成本非常高。单独一个 dataset.py本质上是在工程上做了一个分层数据层负责把“原始文件”变成“样本”模型层只负责吃样本、出梯度。这样你调试模型时不用关心数据细节调数据时也不用担心把模型结构改坏。到了比赛后期队伍多人并行时这个边界就更重要了。各人负责不同实验分支只要约定好 dataset.py 的输出接口不变谁改数据逻辑都不会影响别人的模型代码。很多队伍前期猛冲模型后期发现特征 bug 回滚时靠的就是 dataset.py 这块隔离层兜底。1.3 dataset.py 的典型模块划分我在 2025 赛季项目里把 dataset.py 拆成了几个职责清晰的模块配置解析读取特征列名、类型、编码映射文件路径、 batch size 等参数。原始数据读取器负责分块或按行流式读取大文件避免一次性载入内存。特征编码器维护类别特征到索引的映射处理低频过滤、缺省值、哈希碰撞等。负采样与样本生成器实现点击/转化样本的采样策略包括随机负采样、hard negative 构造。序列特征处理器把广告侧的用户历史行为序列做截断、padding、mask。缓存管理器把经过编码的中间结果缓存在磁盘或内存避免每次 epoch 都重复解析字符串。Dataset 包装类实现迭代协议输出模型需要的特征字典和标签。如果你是第一次搭这种系统不建议一上来追求大而全而是先有一个能跑通的最小实现再按需加模块。但无论怎么简化上面第三点和第六点特征编码器与缓存管理都建议一开始就考虑进去否则后面重构的代价会非常大。2. 核心细节解析dataset.py 里的特征处理与标签构造2.1 类别特征编码与频率过滤广告数据里特征值几乎没什么干净的数值比如“city_id200”、“creative_type21”。要喂给模型得先转成连续整数 id让模型去查 embedding 表。最简单的方式是把所有出现过的值按字典序或出现顺序编号。但在腾讯广告赛这种量级下直接枚举所有值会有几个问题低频长尾值非常多、 embedding 表过大、模型很难学到有意义的表征。所以一般会做频率截断统计每个特征值在全量训练集里的出现次数只保留出现次数超过阈值的值其余统一映射到UNK这类缺省 id。频率阈值怎么定我一般先做一次一次性的特征统计输出每个特征的基数唯一值数量和 Zipf 分布再决定阈值。像 city_id 这种高基特征阈值通常定在 5 到 20像 gender 这种低基特征不截断也无所谓。要记住截断只应基于训练集统计验证集和测试集的映射表必须由训练集统计导出否则会造成数据泄漏。我自己踩过这个坑后面专门有一个小节讲这个问题。2.2 Embedding 索引与多值特征处理广告场景里有一大类“多值特征”比如用户对某个广告素材感兴趣的历史类目列表[1001, 2302, 4023, 8842]。这类字段在原始表中通常存成逗号分隔字符串或者 JSON 数组。dataset.py 要负责解析成 int 列表再按设定长度截断或补齐。比如滑窗长度设为 50不足的补 0超过的取最近 50 个。补 0 不是随便补的0 对应 embedding 里的PADtoken要保证 embedding 矩阵第 0 行初始化为全零且训练中不更新否则 padding 会被模型当成有意义的信号。另外一个容易被忽略的点是多值特征内部的顺序。对于行为序列先后顺序往往代表时间顺序必须严格保留但对某些标签集合比如用户兴趣 tag 列表顺序本身无意义。在设计 dataset.py 时最好区分这两种类型不要统一走同一个截断函数。2.3 缺失值与默认值兜底广告数据里缺字段太正常了。用户没登录时没有 user_id冷启动广告没有历史点击统计网络类型解析失败等都会导致字段缺失。dataset.py 里每一种特征都要有明确的分桶策略。比如类别特征缺失填0或单独分一个缺省 bucket数值特征缺失填 -1序列特征缺失填空序列。不要天真地以为“填 0 就好”因为 0 在不同特征里含义可能不同。我们队伍的习惯是在 dataset.py 里为每个特征显式声明一个缺省值并且在缓存数据时保留一个字段级 mask。比如缺失的类别特征在输入模型前如果做 embedding 化mask 可以让注意力模块略过缺失位置。这个处理细节在精排模型里对 AUC 提升挺明显的因为天然缺失样本具备某种业务含义模型是可以学习到的。2.4 标签与样本权重设计腾讯广告赛的标签不是简单一个“是否点击”很多赛季还会涉及转化延迟、点击到转化的时长、是否有效观看等多目标设定。比如一个样本可能是点击后 1 秒转化的另一个是点击后 1 天转化如果不做时间窗口截断就会出现“ label1 但特征根本还没发生转化”的失真情况。dataset.py 里必须根据训练集时间戳和转化时间戳做标签判定。通常做法是定义归因窗口若点击发生在 T 时刻转化发生在 T 到 TW 之间则该样本视为正样本如果训练数据的时间范围跨越窗口边界需要把“未观测到转化”的尾部样本做特殊处理。一旦这里处理错了模型的预测值会被系统性拉偏。样本权重方面点击率预估任务里负样本往往占绝大多数我们一般不直接在 dataset.py 里做全局下采样而是保留全部负样本在 loss 里做 weight但如果是多目标联合建模会按目标分别设计采样策略。3. 实操过程从原始日志到可训练 Dataset 的实现要点3.1 代码骨架与数据流转下面是我在 2025 赛季实际使用过的 dataset.py 精简版主干去掉了队伍私有逻辑保留了能直接跑通的最小框架import os import json import numpy as np import torch from torch.utils.data import Dataset class TencentAdDataset(Dataset): def __init__(self, data_path, feature_cfg, id_maps, modetrain, max_seq_len50): super().__init__() self.data_path data_path self.feature_cfg feature_cfg self.id_maps id_maps self.mode mode self.max_seq_len max_seq_len # 记录样本起始偏移实现随机访问 self.sample_offsets [] self._build_index() self._load_cache_or_init() def _build_index(self): # 按换行符扫描文件记录每行 offset避免全量载入 self.offsets [0] with open(self.data_path, r, encodingutf-8) as f: while True: line f.readline() if not line: break self.offsets.append(f.tell()) # 去掉最后一个无效 offset self.offsets.pop() def _parse_line(self, line): # 解析一行 JSON/TSV返回原始字段 dict # 这里以 JSON 为例 return json.loads(line) def _encode_features(self, raw): # 特征编码类别特征查 id_maps多值特征截断/补全 features {} for feat_name, feat_type in self.feature_cfg.items(): if feat_type categorical: val str(raw.get(feat_name, )) features[feat_name] self.id_maps[feat_name].get(val, 0) elif feat_type sequence: seq raw.get(feat_name, ) or if isinstance(seq, str): seq seq.strip().split(,) ids [] for s in seq: ids.append(self.id_maps[feat_name].get(str(s), 0)) # 截断最近 max_seq_len不足左 padding if len(ids) self.max_seq_len: ids ids[-self.max_seq_len:] else: ids [0] * (self.max_seq_len - len(ids)) ids features[feat_name] np.asarray(ids, dtypenp.int64) elif feat_type numeric: try: features[feat_name] float(raw.get(feat_name, 0.0)) except (TypeError, ValueError): features[feat_name] 0.0 return features def __getitem__(self, idx): if self.mode train: # 随机读取一行通过 offset seek pos self.offsets[idx] with open(self.data_path, r, encodingutf-8) as f: f.seek(pos) line f.readline() raw self._parse_line(line) else: # validation/test 固定顺序 ... feat self._encode_features(raw) label float(raw.get(label, 0.0)) return feat, label def __len__(self): return len(self.offsets)这里的关键设计是用偏移量索引实现随机访问而不是把所有样本读进内存。这样内存占用几乎与文件大小无关代价是每个 epoch 都要随机磁盘 IO。比赛机器如果是机械硬盘速度会非常痛苦SSD 上还好。更理想的做法是第一次读完把所有特征编码结果缓存到内存 numpy 或 memmap后续 epoch 直接取缓存。3.2 特征统计与映射构建细节在构建 id_maps 之前我会单独跑一个离线统计脚本扫描全量训练数据输出每个特征的频次表。频率过滤之后再为每个特征分配从 1 开始的编号0 留给缺省或 padding。这里有一个细节训练集里哪个特征值的缺省值需要先定义比如 missing 字符串、空字符串、空列表统一替换为 0。这样即使线上出现训练集没见过的值也能映射到 0而不会索引越界。id_maps 的保存格式建议用 pickle 或 parquet不要用 json。因为有些特征基数几百万json 加载慢且占内存。我自己会把映射表存成feat_name -- {value_str: int}的字典再用torch.save保存加载速度快得多。训练脚本启动时先加载这批映射表再创建 Dataset。3.3 缓存机制与 memmap 加速如果你的 dataset.py 每次__getitem__都解析字符串那么训练瓶颈可能不在 GPU 上而在 CPU 数据加载上。我实际测量过在 32 核机器上用 PyTorch DataLoadernum_workers16如果每行是复杂 JSON解析速度大约只能到每秒 3 万到 5 万行。对于几千万样本光一个 epoch 的数据加载就可能拖到 20 分钟以上非常不划算。我的优化方案是“先缓存后训练”。第一次创建 Dataset 时会做一次完整解析把编码结果存成 numpy 的.npz或内存 memmap。后续再从磁盘加载。举个例子def _load_cache_or_init(self): cache_path self.data_path .cache.npz if os.path.exists(cache_path): data np.load(cache_path) self.cached_feats data[feats] self.cached_labels data[labels] else: # 遍历所有样本并编码保存到 cache feats_list, labels_list [], [] for line in open(...): ... self.cached_feats np.asarray(feats_list) np.savez_compressed(cache_path, featsself.cached_feats, labelslabels_list)缓存机制还能避免验证集、测试集重复解析。特征统计只做一次编码也就只需做一次缓存下来后续实验切换模型只用改模型部分数据加载秒级完成。3.4 DataLoader 的 batch 组装与 collate_fnPyTorch 的 DataLoader 在返回字典类型样本时需要自定义collate_fn把多个样本的特征堆叠成 batch。最常见的错误是在__getitem__里已经返回了不同长度的序列然后default_collate直接崩掉。所以我在 dataset.py 里实现序列特征时统一将长度 pad 到 max_seq_len再在 collate 时堆叠为[batch_size, max_seq_len]的张量。数值权重、样本 id 这类元信息也需要一起返回。通常我会让__getitem__返回(feat_dict, label, weight, sample_id)而collate_fn负责把 feat_dict 中每个 key 对应的张量整理成 batch。如果后面要上多任务或者打分可视化sample_id 很有用否则排查问题时根本不知道哪个样本预测错了。4. 常见问题与排查技巧实录4.1 训练集与验证集特征映射不一致这大概是 dataset.py 里最经典的 bug。特征映射表只应该基于训练集统计生成验证集在做频率过滤时不能再单独统计而应该直接查训练集的映射表。如果两边各自统计验证集里一个低频特征可能在训练集里被归为 UNK在验证集里却出现了独立编号导致 embedding 索引错位。虽然 PyTorch 不会报错但模型会学到很诡异的东西。我的排查方式是模型训练完成后单独验证几个“低频特征” embedding 是否与 UNK 一模一样如果差很大就说明映射不一致了。4.2 跨天样本导致的时间泄漏腾讯广告赛的时间跨度经常是几周甚至几个月。如果你把数据全部 shuffle 后直接切出验证集验证集里可能出现“未来”的数据而模型在训练阶段已经见过邻近时间的转化结果这会造成验证指标虚高。解决办法是在 dataset.py 里支持按时间戳做样本过滤训练集只取窗口内样本验证集严格晚于训练集时间。我当时为了快速验证犯过这个错误导致线下 AUC 比线上高了近两个点浪费了整整一周的调参时间。4.3 内存爆炸与 DataLoader 卡死这种问题出现时先打开top看内存占用再用小批量样本做单进程调试。很多时候是__getitem__中对字符串做了重复 split产生大量临时对象而 DataLoader 多进程复制数据时导致内存放大。如果你不想做完整缓存也可以考虑只在__getitem__里做轻量索引读取把复杂解析放到训练前一次性完成。4.4 特征缺失时 embedding 对不齐如果同一份缓存被多个人使用可能有人改了特征版本映射表却还是旧的。这会导致一部分样本特征全部映射成缺省 0看似能训练实际模型根本学不到信息。我的经验是在 dataset.py 里加一个version字段每次改动特征工程同步刷新版本号并在缓存文件头部记录版本号。加载缓存时校验版本不一致就重新生成避免这种“假训练”状态。4.5 类别特征低频截断阈值怎么调截断阈值太严大量特征变成 UNK模型区分度下降太松embedding 表膨胀、低频特征噪声大。我一般会先看每个特征的基数分布设定一个目标基数上限比如 embedding 总量不超过 8000 万。对于像 creative_id 这种高基特征如果跑出基数 5000 万就需要更激进的频率截断但截断后依然很大可能要考虑 hash embedding 或 frequency embedding 这些特殊技巧而不是硬生生保留原始 id。4.6 序列特征里的时间顺序陷阱行为序列字段的处理最容易出错。有的序列在原始数据里是“按时间正序存储”有的却是“按后台返回顺序”这个顺序如果不提前确认模型就会学到虚假的模式。我在 dataset.py 里写过一个诊断函数随机抽查 1000 条样本的序列相邻时间戳判断是否单调递增。这个诊断函数很便宜但能避免后面大量无效实验。5. 一些我认为值得单独说的工程心得最终能跑出好成绩的 dataset.py未必是代码最漂亮的但一定是最“可审计”的。我的桌子上永远会放一张特征说明表记录每个特征是什么含义、来源字段是哪一列、缺失值怎么处理、编码维度是多少。这个表跟代码放在同一个仓库里每次跑实验前先翻一遍表。不要嫌这个动作多余广告大赛数据复杂程度远超想象很多时候模型输给 baseline不是模型烂而是数据管线里某个字段被悄悄改变却没人知道。另外一个很实用的习惯是把所有随机操作都固定 seed并且把随机种子作为 dataset.py 构造参数传进来。负采样和 shuffle 都需要可复现性。比赛期间你可能同时跑很多实验如果两次相同配置跑出的指标对不上你很难判断是数据问题还是模型问题。从一开始就做好 seed 控制能省掉很多无效的争论。多次实际对比后我发现数据加载管线和模型结构对 AUC 的贡献根本不是一回事。模型结构好比是运动员的临场发挥而 dataset.py 是平时的饮食和训练计划。饮食不规律发挥再好也拿不了冠军饮食科学哪怕运动员天赋一般也能稳定进决赛。这个类比可能有点夸张但像腾讯广告算法大赛这种场景数据规模大到一定程度后数据管线的稳定性和一致性就是决定你能否高效迭代的唯一关键。