2025腾讯广告算法大赛的数据包下载下来之后,我做的第一件事不是打开baseline,而是把dataset.py翻了个底朝天。不是说这个脚本有多玄学,而是每届比赛里,决定大家排名层次的往往不是模型结构,而是数据从原始日志变成训练样本那一段路走得是否稳妥。这个文件看起来只负责“读数据”,可它管着所有特征的产生方式、训练/验证集的切分规则、以及模型能不能顺畅吃到数据。写不好,后面整个pipeline都会跟着遭殃。
这篇我把自己整理的一段典型dataset.py拆开讲清楚,会聊到数据域理解、脚本骨架设计、特征构造的因果边界、验证集切分、内存优化和几个真实踩坑case。不管你是第一次参加这类广告算法比赛,还是已经跑通了一版baseline想提升稳定性,这份分析应该都比直接抄一段代码更有参考价值。
1. 先用一小时想清楚数据域,再动笔写 dataset.py 的第一行代码
很多人拿到数据包就直接pd.read_csv("train.csv"),然后开始堆模型。这个做法在数据量小的时候没什么问题,但广告算法赛题的数据量级通常在几千万到几亿行,有些日志文件单表就有几十GB。如果一开始不把数据域想明白,后面每一轮迭代都会付出好几倍的返工成本。
1.1 赛题到底给了什么数据
以这类广告算法大赛的一贯设定来看,数据包里通常会有四类内容,字段名可能不一样,但结构大致是:
- 用户行为日志:曝光、点击、转化等行为记录,核心字段是
user_id、ad_id、creative_id、行为时间戳,以及当前广告曝光时的上下文信息。 - 用户画像表:性别、年龄、兴趣标签等,通常是稀疏的类别特征。
- 广告物料表:广告主、行业类目、创意素材尺寸、投放位置等。
- 转化日志:比如下载、注册、付费等深度转化目标,用于构造最终训练标签。
dataset.py最核心的职责,就是把这几张表正确串起来。注意我用的是“正确”而不是“快速”。先串对,再谈快。因为有大量错误是表之间join的时候产生的:用户历史行为和当前曝光混在一起了,广告属性拿错了版本,时间字段的时区没对齐,这些都会让特征隐含未来信息,导致线下指标虚高,线上直接崩。
1.2 读文件前先想清楚要哪些列
一个常见误区是拿到日志就把所有列都读进内存。实际赛题日志动辄上百列,但有价值的可能只有十几列。比如很多内部标识符、埋点上报的服务端字段、与业务无关的技术字段,对模型学习用户广告偏好没有直接帮助。
我在动手写dataset.py之前,会先把每个csv的header打出来,做一张字段清单表格,标出字段类型、占用空间、缺失率、有没有时间属性、是否适合做类别特征或者数值特征。这一步花不了一小时,但能省下后面好几天。
以广告日志为例,可能我会选择这样一列字段:
| 字段名 | 示例类型 | 用途 |
|---|---|---|
| user_id | int | 用户主键,用于分组和行为序列构建 |
| ad_id | int | 广告主键,类别特征 |
| creative_id | int | 创意主键,类别特征 |
| industry_id | int | 行业类目,交叉特征主体 |
| click_timestamp | int | 行为时间,所有滑窗特征的时间依据 |
| is_click | int8 | 曝光后是否点击,可做辅助标签 |
| label | int8 | 最终优化目标,训练标签 |
读文件时用usecols只加载这些列,IO压力会小很多。这个不是玄学,我第一次用完整日志直接load,32GB内存的机器直接OOM,问题不是代码写错,而是我把多个冷门字段都塞进了DataFrame,白白吃了十几个GB。
1.3 先制定schema,再写读入函数
数据表之间要做join,就必须有一套统一的字段类型定义。我在dataset.py里习惯放一个schema字典,把每个最终要用到的列名和dtype事先定好。比如:
COLUMN_DTYPES = { "user_id": "int32", "ad_id": "int32", "creative_id": "int32", "industry_id": "int16", "click_timestamp": "int32", "is_click": "int8", "label": "int8", } USEFUL_COLS = list(COLUMN_DTYPES.keys())之后所有加载函数都从这两个变量里拿列名和类型,保证train、valid、test走的路径完全一致。别小看这件事,比赛后期最烦的就是“线上推理时报特征缺失”“线下好好的提交后分数异常”,绝大多数都是因为某一步读文件时字段类型或者列名对不上,而一开始统一schema就能从源头上杜绝这类问题。
2. dataset.py 的骨架设计:不只是一把梭的读文件脚本
如果把dataset.py写成从上到下一长串pd.read_csv加merge,也能跑,但迭代几次之后就会想骂人。因为你需要反复调整特征、切分方式、负采样比例,如果所有逻辑都堆在同一个函数里,牵一发动全身,连自己改过什么都记不住。
2.1 一个可扩展的dataset.py长什么样
我习惯把dataset.py拆成几个职责单一的类和方法,骨架大概是这样的:
class AdLogDataset: def __init__(self, data_root, mode="train"): self.data_root = Path(data_root) self.mode = mode self.df = None self.user_history = None self.id_maps = {} def load_raw(self): raise NotImplementedError def clean(self): raise NotImplementedError def build_features(self): raise NotImplementedError def split(self): raise NotImplementedError def to_torch_dataset(self): raise NotImplementedError这样设计不是为了炫耀面向对象,而是为了每一层逻辑都可以单独调试。比如加载完原始数据后,可以先print(df.shape)确认行数;清洗完再检查缺失值;构造完特征再检查有没有未来信息泄漏。每一层都有明确的输入输出,出现问题时能快速定位到某一段逻辑。
2.2 加载原始数据与通用清洗函数
load_raw这一步的核心不是把csv读进来,而是把csv变成后续可以直接用的“干净表”。我在加载时会做几件固定的事:
- 把时间列统一转成
datetime64[s]或int时间戳,避免字符串比较的性能灾难。 - 对明显异常的数据做过滤,比如
click_timestamp <= 0、重复曝光但完全一致且无转化行为的记录。 - 把分类列的缺失值填成
-1或"unknown",数值列的缺失值初步填成中位数或0,具体策略在特征阶段可以再精细化。
这里要提醒一个细节:重复样本不一定要全部删除。广告日志里同一个用户短时间内反复看同一条广告,可能是自然行为,也可能是机器刷量。如果无脑去重,会损失用户互动强度的信息。我一般先按用户、广告、时间戳判断是否存在完全重复的曝光,再结合转化标签决定保留策略。比如完全重复但没有转化的可以保留一条,有转化的必须保留所有记录,否则标签会被稀释。
2.3 统一样本生成接口,后面三个月都会感谢自己
dataset.py最后一步通常是提供一个统一的入口,给模型训练脚本使用。比如:
def get_train_valid_dataset(cfg): train_ds = AdLogDataset(cfg.data_root, mode="train") train_ds.build() valid_ds = AdLogDataset(cfg.data_root, mode="valid") valid_ds.build() return train_ds.to_torch_dataset(), valid_ds.to_torch_dataset()这个接口的返回值不要直接用DataFrame,而是尽量转成PyTorch Dataset或者Tensor对象。这样模型代码就完全不关心数据是从csv来的、还是从parquet来的、还是从数据库来的。后面如果要换输入格式,只需要改dataset.py内部,不用动trainer。我在比赛后期经常半夜临时调特征重新训练,这种“小改动、不影响外部”的封装真的能救命。
3. 特征构造的正确姿势:时间窗口、行为序列与因果边界
特征决定上限,模型只是逼近这个上限。但比赛里很多特征构造错误并不是“算错了”,而是“用了不该用的未来信息”。广告场景的日志天然带时间顺序,如果dataset.py里没有把因果边界管好,再复杂的模型也会在测试集上失灵。
3.1 特征工程如果违反时间因果关系,模型再强也白搭
比如你有一个用户历史点击率特征,定义为“该用户在训练集所有曝光中的点击比例”。这个值在训练集上看起来很正常,但到了推理阶段,测试集样本的用户完整历史并不是当前时刻之前的,而是包含整个训练期之后的行为。如果你在全量训练数据上聚合统计后再构造特征,就已经把样本标签的信息泄露出去了。
正确的做法是:构造样本特征时,只允许使用当前样本时间戳之前的数据。具体到代码,就是不能在dataset.py里一口气对全表做groupby算均值,而要按时间切好窗口,每个样本都用窗口内的历史行为来计算。
3.2 统计滑窗:近7天曝光量、点击率这些值怎么算
在广告算法大赛里,最常用的特征就是时间滑窗统计。比如“用户过去7天曝光了多少条广告”、“过去3天点击了多少次”、“点击率是多少”。伪代码可以长这样:
def gen_sliding_window_features(df, ts_col="click_timestamp", window="7D"): df = df.sort_values(ts_col) user_weighted = df.groupby("user_id").rolling( window=window, on=ts_col ).agg( exposed_cnt=("ad_id", "count"), clicked_cnt=("is_click", "sum"), ) return user_weighted这份代码思路没问题,但实际运行时会有几个坑:一是rolling按用户的groupby在大数据上非常慢;二是窗口的起点和终点很容易差1秒,导致边界样本数据缺失。我的建议是提前把时间戳按小时对齐,或者直接用polars来实现这类需要按窗口滚动的计算,它的性能和内存控制都比pandas好很多。比如polars的rolling操作经过优化,处理上亿行的广告日志也扛得住。
如果坚持用pandas,不要每次都groupby+rolling,可以在准备阶段把用户的历史行为按天、按小时事先聚合成宽表,然后切分时通过merge_asof把截止到当前样本时刻的统计值带过来。merge_asof的好处是能精确匹配“样本时刻之前最近的一条统计记录”,不会把未来的值算进去。
3.3 行为序列与类别特征的编码策略
广告场景里,用户的兴趣会随时间漂移,所以很多队伍会用用户最近行为序列来提升模型记忆能力。这个序列需要离线构建,而不是在训练时临时逛遍全量日志。
我在dataset.py里会单独保存一份用户历史序列缓存,比如每个用户最近的50个creative_id列表、最近的30个industry_id列表,后面模型要用时直接从缓存里取。这些序列是定长的,不够的补0,超出长度的截断。
类别特征编码也需要统一规划。常见的factorize或LabelEncoder如果直接对全量数据做,训练和测试会共享一套编码,但新增类别会被映射成-1,引发问题。更稳妥的方式是手动维护全局类别映射表:
for col in CATEGORICAL_COLS: all_values = pd.concat([train_df[col], test_df[col]]).astype("category").cat.categories id_map = {v: i + 1 for i, v in enumerate(all_values)} # 0留给unknown train_df[col] = train_df[col].map(id_map).fillna(0).astype("int32") test_df[col] = test_df[col].map(id_map).fillna(0).astype("int32")这里有个经验:0永远留给unknown,不要从0开始给真实类别编号。否则模型embedding查表时会混淆“缺失”和“第一个类别”,影响泛化能力。
4. 训练集和验证集到底该怎么切:时间切分与样本重叠的博弈
很多队伍在dataset.py里用train_test_split(test_size=0.2, random_state=42)一把梭,结果线下分数很漂亮,提交后直接掉好几个点。这不是模型问题,是验证集构造方式不符合测试集场景。
4.1 为什么不能random split:排行榜靠后的常见原因
比赛测试集是未来一段时间的样本,意味着样本分布和特征分布都跟训练集有细微差异。如果用随机切分,训练集和验证集的时间分布完全重叠,等于拿“同分布数据”评估模型。可线上要做的是“时间外推”,这俩不是一回事。
随机切分最典型的症状是:验证集AUC很高,但整个排行榜中等偏下,或者本地跑了十折交叉验证依然稳如老狗,一提交就崩。遇到这种情况,先检查dataset.py里的切分逻辑,十有八九是切分方式不对。
4.2 按时间切分与按用户切分的取舍
广告算法大赛通常建议优先按时间切分。比如把数据按行为时间排序,前80%做训练,后20%做验证。这最接近线上“训练历史、预测未来”的场景。
但也有一种情况需要按用户切分:如果赛题关注冷启动效果,比如测试集里的用户几乎都没在训练集出现过,那就要用按用户划分的方式,让验证集里的用户完全不可见。这种方式更严格,但会造成训练数据减少,因为同一用户的样本不能跨集合。
在实际比赛中,两个方向并不冲突。我会先按时间切出一个大验证集,再在内部检查用户重叠率。如果时间切分后验证集和训练集的用户重叠率低于某个阈值,说明冷启动特征比较重要,可以再做一个按用户切分的对照组,用来评估模型的泛化边界。
4.3 切分代码里的隐藏陷阱
切分时最容易被忽略的是转化延迟。广告转化不是即时发生的,用户今天看到广告,可能三天后才下载App。如果验证集取的是最后一天数据,这些样本的转化标签还没有足够时间充分暴露,标签会系统性偏低,导致验证集上负样本比例虚高,模型性能被低估。
处理办法是留出“标签观察期”。具体来说,如果建模目标需要7天内的转化,那么验证集不能选最后7天,而应该选最后7天之前的一段完整窗口。举个例子:
max_ts = df["click_timestamp"].max() label_window_days = 7 val_end = max_ts - pd.Timedelta(days=label_window_days) val_start = val_end - pd.Timedelta(days=7) train_df = df[df["click_timestamp"] < val_start] val_df = df[(df["click_timestamp"] >= val_start) & (df["click_timestamp"] < val_end)]这一步做对了,线下评估才有意义。我在一次比赛里就是因为没留观察期,线下AUC比实际线上低了不少,白白浪费了好几天调参时间。
5. 内存优化三板斧:dtype、全局编码、惰性加载
广告日志数据量动不动就是几十GB,dataset.py如果内存管理做不好,其他所有优化都是空谈。我总结过自己的三板斧,基本够用。
5.1 第一板斧:把所有int64降到能用的最小精度
pandas默认读整数是int64,但大部分ID和计数根本用不到那么大的范围。比如用户ID最多几千万,int32足够;广告点击次数最多几百次,int8或int16足够。把int64降到int32,内存直接砍半;降到int8,又砍一半。
读文件时直接指定dtype是最简单的:
df = pd.read_csv( "ad_log.csv", usecols=USEFUL_COLS, dtype=COLUMN_DTYPES, )如果没有一开始指定,事后可以用pd.to_numeric(..., downcast="integer")批量转。转完再看df.info(memory_usage="deep"),内存数字会非常明显降下来。我在本地上试过,一个8GB的csv,光靠dtype优化就降到2.5GB。
5.2 第二板斧:全局类别映射,别让特征编码在验证集上崩掉
类别特征直接用astype("category")虽然能省内存,但有个隐患:如果训练集和验证集分别读入,各自生成的category码表可能不一致。比如训练集里creative_id=100被编码成1,验证集里同样一个ID可能被编码成2,模型向量完全错位。
解决办法是全局统一编码。先在所有数据上拿到类别全集,再分别映射到训练集和验证集。建议在dataset.py里把映射表保存成pickle或者json,方便推理阶段复用:
with open("id_map.pkl", "wb") as f: pickle.dump(id_maps, f)这样后期预测新数据,也走同一个编码器,线上线下完全一致。
5.3 第三板斧:从DataFrame到PyTorch Dataset的惰性加载
用DataFrame直接冒特征、喂模型,在几亿行样本上不现实。做好前面步骤后,数据可能也被压缩到几个GB,但训练时如果一次性把所有样本都转成Tensor加载进显存,照样会爆。
更可行的方案是让dataset.py输出一个自定义的torch.utils.data.Dataset,在__getitem__里按索引实时取特征。行为序列这种长度不固定的数据,可以预先存在numpy数组里,用padding和mask统一长度。
class AdDataset(torch.utils.data.Dataset): def __init__(self, dense_feats, sparse_feats, seq_feats, labels): self.dense_feats = dense_feats self.sparse_feats = sparse_feats self.seq_feats = seq_feats self.labels = labels def __len__(self): return len(self.labels) def __getitem__(self, idx): return ( torch.from_numpy(self.dense_feats[idx]), torch.from_numpy(self.sparse_feats[idx]), torch.from_numpy(self.seq_feats[idx]), torch.tensor(self.labels[idx], dtype=torch.float32), )这样训练时不会一次性把所有张量都搬进显存。再加上DataLoader的num_workers,数据加载和GPU计算能重叠起来。
6. 这段代码里我踩过最深的坑:分享五个真实case
最后聊几个我在实际写dataset.py时踩过的坑,每一个都让我返工过至少一天。列出来给你提个醒。
6.1 坑1:验证集特征归一化前用了全量统计量
我一开始写数值特征归一化,图省事,在整份数据上算mean和std,然后对训练集和验证集做同一个变换。线下看没毛病,但线上推理时测试集新样本的均值和标准差跟全量统计有偏差,模型输入分布变了,效果立刻波动。
修复方式很简单:归一化参数只允许从训练集计算,验证集和测试集直接用训练集保存下来的mean和std。这是所有数据处理环节里最基本的一条纪律。
6.2 坑2:category类型在train/val并集上错位
有一次我用pd.concat([train_df, valid_df])一起做特征编码,一切正常。后来数据量大了,为了省内存改成分别读入、分别处理,结果忘了统一映射,导致验证集和训练集同一ID的编码对不上。当时模型结构没怎么动,线下分数却掉了不少,排查了很久才反应过来是编码错位。
从那以后,我在dataset.py里强制要求所有类别特征的映射表只能生成一次。每次跑实验前都会检查一下train和valid的编码范围是否一致,比如打印train["industry_id"].max()和valid["industry_id"].max(),如果验证集出现训练集没有的新编码,先处理成0。
6.3 坑3:行为序列字典挤爆了内存
为了给模型喂用户历史行为序列,我一开始把序列装进DataFrame的object列,每个单元格是一个Python列表。内存直接爆掉,因为Python对象的开销远大于数值数组。
正确做法是单独用两个一维数组存“序列起始位置”和“序列内容”。比如所有用户的行为拼接成一个大数组seq_values,再维护每个用户在这个大数组中的起始下标和长度。模型取第i个用户的序列时,直接切片,速度和内存都友好很多。
6.4 坑4:多进程读取时每进程复制一份DataFrame
Linux下multiprocessing使用fork方式创建子进程,如果先加载了一个大DataFrame,再开多进程处理特征,子进程会通过写时复制机制共享这份数据。听起来没问题,但一旦某个进程中修改了这个DataFrame,就会触发整页复制,内存瞬间膨胀,甚至OOM。
我的经验是:不要在多个worker里各读各的原始大文件,也不要让每个worker都持有完整DataFrame。更稳的做法是先用单进程把数据压缩、切分好,保存为parquet或npy,后续多进程训练时只读需要的字段;如果非要并行处理,用process_map配合共享内存或者用polars的惰性计算,让底层自己优化。
6.5 坑5:时间边界差一天,线下线上差了5个点
还有一次,我自以为做了时间切分,但验证集直接取最后一天。线下验证集上模型表现平平,可线上反而好了不少,整个人很懵。后来复盘发现,就是转化延迟影响,最后一天的样本标签没有充分暴露,导致验证集不可信。
从那以后我养成了习惯:不只是切分时间,还要画一张验证集正样本率随日期变化的图。如果最后几天的正样本率突然下降,基本就是标签没充分暴露,需要把最后这段数据排除在验证集外。这个检查和看AUC一样重要。
写到这里,dataset.py里最核心的坑差不多都过了一遍。如果你也在打广告算法类似的比赛,我最大的建议是:把这个脚本当成一个可持续迭代的小型数据产品来维护,而不是临时拼凑的读文件工具。数据域想清楚、schema定清楚、时间边界管清楚、内存压到位,后面模型哪怕只是用最简单的deep FM,你的下限也会比很多人硬核堆模型的上限高。