news 2026/9/7 18:19:02

广告算法竞赛中的dataset.py设计与特征工程实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
广告算法竞赛中的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_id=200”、“creative_type=21”。要喂给模型,得先转成连续整数 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 里的<PAD>token,要保证 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 天转化,如果不做时间窗口截断,就会出现“ label=1 但特征根本还没发生转化”的失真情况。dataset.py 里必须根据训练集时间戳和转化时间戳做标签判定。

通常做法是定义归因窗口:若点击发生在 T 时刻,转化发生在 T 到 T+W 之间,则该样本视为正样本;如果训练数据的时间范围跨越窗口边界,需要把“未观测到转化”的尾部样本做特殊处理。一旦这里处理错了,模型的预测值会被系统性拉偏。样本权重方面,点击率预估任务里负样本往往占绝大多数,我们一般不直接在 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, mode="train", max_seq_len=50): 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", encoding="utf-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, dtype=np.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", encoding="utf-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 DataLoader,num_workers=16,如果每行是复杂 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, feats=self.cached_feats, labels=labels_list)

缓存机制还能避免验证集、测试集重复解析。特征统计只做一次,编码也就只需做一次,缓存下来,后续实验切换模型只用改模型部分,数据加载秒级完成。

3.4 DataLoader 的 batch 组装与 collate_fn

PyTorch 的 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 是平时的饮食和训练计划。饮食不规律,发挥再好也拿不了冠军;饮食科学,哪怕运动员天赋一般,也能稳定进决赛。这个类比可能有点夸张,但像腾讯广告算法大赛这种场景,数据规模大到一定程度后,数据管线的稳定性和一致性就是决定你能否高效迭代的唯一关键。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 18:16:30

NSDBO算法在微电网多目标优化调度中的应用

1. 项目概述&#xff1a;微电网优化调度与NSDBO算法微电网作为分布式能源系统的核心单元&#xff0c;其优化调度直接关系到供电可靠性和经济性。传统调度方法在处理风光出力不确定性、负荷波动性等多目标优化问题时往往捉襟见肘。我们团队提出的NSDBO&#xff08;Non-dominated…

作者头像 李华
网站建设 2026/9/7 18:12:33

Qwen3-Coder自主编程实战:从工具调用到执行反馈闭环

1. 从“补全代码”到“在世界中自主编程” 第一次看到“Qwen3-Coder: 在世界中自主编程”这个标题时&#xff0c;我的第一反应是&#xff1a;这不只是一个模型发布预告&#xff0c;而是一整条产品思路的宣言。过去两三年&#xff0c;我们见证了编程助手从“自动补全”进化到“对…

作者头像 李华
网站建设 2026/9/7 18:11:00

城市运行管理试点政策脉络梳理

导语&#xff1a;随着我国常住人口城镇化率达到67%&#xff0c;城市治理正从粗放式管理迈向精细化、智能化转型。各地以城市运行管理服务平台为载体&#xff0c;探索“一委一办一平台”工作体系&#xff0c;推动城市管理体制机制改革。从重庆出台三级治理中心管理办法&#xff…

作者头像 李华