尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

广告算法大赛dataset.py深度拆解:特征工程与验证集切分的实战指南

广告算法大赛dataset.py深度拆解:特征工程与验证集切分的实战指南 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_idint用户主键用于分组和行为序列构建ad_idint广告主键类别特征creative_idint创意主键类别特征industry_idint行业类目交叉特征主体click_timestampint行为时间所有滑窗特征的时间依据is_clickint8曝光后是否点击可做辅助标签labelint8最终优化目标训练标签读文件时用usecols只加载这些列IO压力会小很多。这个不是玄学我第一次用完整日志直接load32GB内存的机器直接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, modetrain): 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, modetrain) train_ds.build() valid_ds AdLogDataset(cfg.data_root, modevalid) 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_colclick_timestamp, window7D): df df.sort_values(ts_col) user_weighted df.groupby(user_id).rolling( windowwindow, onts_col ).agg( exposed_cnt(ad_id, count), clicked_cnt(is_click, sum), ) return user_weighted这份代码思路没问题但实际运行时会有几个坑一是rolling按用户的groupby在大数据上非常慢二是窗口的起点和终点很容易差1秒导致边界样本数据缺失。我的建议是提前把时间戳按小时对齐或者直接用polars来实现这类需要按窗口滚动的计算它的性能和内存控制都比pandas好很多。比如polars的rolling操作经过优化处理上亿行的广告日志也扛得住。如果坚持用pandas不要每次都groupbyrolling可以在准备阶段把用户的历史行为按天、按小时事先聚合成宽表然后切分时通过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_size0.2, random_state42)一把梭结果线下分数很漂亮提交后直接掉好几个点。这不是模型问题是验证集构造方式不符合测试集场景。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(dayslabel_window_days) val_start val_end - pd.Timedelta(days7) train_df df[df[click_timestamp] val_start] val_df df[(df[click_timestamp] val_start) (df[click_timestamp] val_end)]这一步做对了线下评估才有意义。我在一次比赛里就是因为没留观察期线下AUC比实际线上低了不少白白浪费了好几天调参时间。5. 内存优化三板斧dtype、全局编码、惰性加载广告日志数据量动不动就是几十GBdataset.py如果内存管理做不好其他所有优化都是空谈。我总结过自己的三板斧基本够用。5.1 第一板斧把所有int64降到能用的最小精度pandas默认读整数是int64但大部分ID和计数根本用不到那么大的范围。比如用户ID最多几千万int32足够广告点击次数最多几百次int8或int16足够。把int64降到int32内存直接砍半降到int8又砍一半。读文件时直接指定dtype是最简单的df pd.read_csv( ad_log.csv, usecolsUSEFUL_COLS, dtypeCOLUMN_DTYPES, )如果没有一开始指定事后可以用pd.to_numeric(..., downcastinteger)批量转。转完再看df.info(memory_usagedeep)内存数字会非常明显降下来。我在本地上试过一个8GB的csv光靠dtype优化就降到2.5GB。5.2 第二板斧全局类别映射别让特征编码在验证集上崩掉类别特征直接用astype(category)虽然能省内存但有个隐患如果训练集和验证集分别读入各自生成的category码表可能不一致。比如训练集里creative_id100被编码成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], dtypetorch.float32), )这样训练时不会一次性把所有张量都搬进显存。再加上DataLoader的num_workers数据加载和GPU计算能重叠起来。6. 这段代码里我踩过最深的坑分享五个真实case最后聊几个我在实际写dataset.py时踩过的坑每一个都让我返工过至少一天。列出来给你提个醒。6.1 坑1验证集特征归一化前用了全量统计量我一开始写数值特征归一化图省事在整份数据上算mean和std然后对训练集和验证集做同一个变换。线下看没毛病但线上推理时测试集新样本的均值和标准差跟全量统计有偏差模型输入分布变了效果立刻波动。修复方式很简单归一化参数只允许从训练集计算验证集和测试集直接用训练集保存下来的mean和std。这是所有数据处理环节里最基本的一条纪律。6.2 坑2category类型在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多进程读取时每进程复制一份DataFrameLinux下multiprocessing使用fork方式创建子进程如果先加载了一个大DataFrame再开多进程处理特征子进程会通过写时复制机制共享这份数据。听起来没问题但一旦某个进程中修改了这个DataFrame就会触发整页复制内存瞬间膨胀甚至OOM。我的经验是不要在多个worker里各读各的原始大文件也不要让每个worker都持有完整DataFrame。更稳的做法是先用单进程把数据压缩、切分好保存为parquet或npy后续多进程训练时只读需要的字段如果非要并行处理用process_map配合共享内存或者用polars的惰性计算让底层自己优化。6.5 坑5时间边界差一天线下线上差了5个点还有一次我自以为做了时间切分但验证集直接取最后一天。线下验证集上模型表现平平可线上反而好了不少整个人很懵。后来复盘发现就是转化延迟影响最后一天的样本标签没有充分暴露导致验证集不可信。从那以后我养成了习惯不只是切分时间还要画一张验证集正样本率随日期变化的图。如果最后几天的正样本率突然下降基本就是标签没充分暴露需要把最后这段数据排除在验证集外。这个检查和看AUC一样重要。写到这里dataset.py里最核心的坑差不多都过了一遍。如果你也在打广告算法类似的比赛我最大的建议是把这个脚本当成一个可持续迭代的小型数据产品来维护而不是临时拼凑的读文件工具。数据域想清楚、schema定清楚、时间边界管清楚、内存压到位后面模型哪怕只是用最简单的deep FM你的下限也会比很多人硬核堆模型的上限高。
返回列表