小白一句话:数据泄露,就是训练时不小心用了"当时还看不到"的信息。后果很隐蔽——离线分数漂亮得离谱,一上线全露馅。这是建模里最坑、也最该刻进肌肉记忆的一条。
前面第6、7章反复念叨"特征只用评分日左边、标签只用右边",这一章把为什么讲透,并用示例数据演示泄露长什么样。
数据泄露是什么
一句话:模型在训练时,拿到了预测时刻根本不可能知道的信息。拿到这种信息,模型不是在"预测未来",而是在"复述已知"。
它通常有两种长相:
- 特征泄露:某个特征本身,就包含了标签的答案(或答案的近亲)。
- 切分泄露:训练集和测试集在时间上串了,导致"未来"混进了"过去"的训练数据里。
两种都会让离线评估虚高,但上线后用的是真实场景(特征全是历史、标签还没发生),模型顿时不会了。
最直观的泄露:把答案塞进特征
我们用示例数据造一个"看起来很合理"的特征,看看会出什么事。
活跃度模型的标签是active_7d:这个用户未来 7 天还会不会来领。现在假设我们手滑,多加了一列特征叫"未来 7 天活跃天数"——也就是评分日之后 7 天里他实际领了几天。在as_of = 2026-07-23下算几个用户:
| uid | 未来 7 天活跃天数(这个"特征") | 标签 active_7d | 一致吗 |
|---|---|---|---|
| U0036 | 7 | 1 | 一致 |
| U0009 | 0 | 0 | 一致 |
| U0019 | 0 | 0 | 一致 |
| U0005 | 1 | 1 | 一致 |
| U0030 | 1 | 1 | 一致 |
| U0040 | 1 | 1 | 一致 |
| U0020 | 0 | 0 | 一致 |
把示例里 8 个用户全算一遍,结果8/8 完全一致:“未来 7 天活跃天数 > 0” 这句话,和"标签 = 1"是同一个意思。用这个"特征"去训模型,必得 AUC = 1.0——完美分类。
但这分数是假的。上线后你要的是"在 07-23 这一刻,猜他未来 7 天来不来",而那时"未来 7 天活跃天数"根本还没发生,你算不出来。模型练的是"看着答案填答题卡",考试时不给答案,它就傻了。
这个例子夸张,但真实项目里泄露往往很隐蔽:比如特征里混进了"本周是否已结算奖励"(结算在领之后,含未来信息)、或者统计量用了全量数据拟合(把测试期的分布也吃进去了)。所以铁律只有一句——特征里出现的每一个数,必须是在评分日及之前就能算出来的。
评分日这根轴,是铁律的具象
把第6章那根轴画明白,铁律就不用背了:
时间 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→ ← 特征期(只看这里) →│← 标签期(只看这里) → 评分日 as_of 近7天/14天/全期汇总 未来7天(active_7d)- 评分日左边:你能看到的全部历史,用来算特征。
- 评分日右边:还没发生的事,用来算标签。
- 两侧永不越界。任何"偷偷跨过评分日"的数,都是泄露。
第7章那张特征表里的每一列(新近度、近 7 天、趋势、偏好熵……),当时算的时候都只用了as_of及之前的记录,就是守这条线。
切分不能随机,要按时间外推
特征干净了还不够,切分训练集 / 测试集的方式也会泄露。
新手容易顺手train_test_split随机打乱。这在很多表格数据里没问题,但在这个项目里会出事:我们的样本是"不同评分日"的快照,随机切分等于把"未来评分日的样本"放进训练、"过去评分日的样本"放进测试——等于用未来训、考过去,分数又是虚的。
正确做法是时间外推切分:按评分日排序,训练用较早的评分日,测试用较晚的评分日。这样训练集里所有信息,在测试集的评分日那一刻都已经发生了,和上线场景一致(上线时你也是用"到现在为止"的数据,去猜"之后")。
用示例数据演示:取评分日07-08、07-15当训练,07-22当测试。
- 训练样本:105 行(标签为 1 的有 92 行)
- 测试样本:57 行(标签为 1 的有 42 行)
训练评分日(最早 07-08)全在测试评分日(07-22)之前——用过去训、考未来,站得住。
净空期:训练集和测试集之间要留白
光按时间切还不够,边界上还会串。看这组切分的边界:
- 训练集最后一个评分日是07-15,它的标签窗口是未来 7 天:07-16 ~ 07-22。
- 测试集第一个评分日是07-22,它的 14 天特征窗口往回看:[07-09 ~ 07-22]。
两个窗口在07-16 ~ 07-22 这 7 天重叠了。意思是:训练集里某些样本的"标签来源期",正好被测试集样本当成了"特征来源期"。训练时模型从标签里学到的东西,测试时又从特征里看见了——信息串了,测试分数还是偏高。
解决办法是净空期(embargo):在训练集最后一个评分日和测试集第一个评分日之间,留一段"谁都不用"的空白日期,让两边的窗口彻底错开。所需空白长度大致等于"特征最长回看天数 + 标签前瞻天数"。本项目特征最多往回看 14 天、标签看未来 7 天,所以一段干净的净空大约要 21 天。
坦白说,这份 30 天的示例数据太短,演示不出完全干净的净空(要 21 天空窗,样本不够长)。但真实项目里数据以月、年计,留出 7~21 天净空很轻松。这里的关键是理解"为什么需要这段空白",而不是记住某个具体天数。
把评分日撑密:生成可行,切分是雷
第6章说过,评分日不是固定的,今天量一下、过几天再量一下,同一个人就多出一行样本。那如果干脆把每一天都当一个评分日,样本不就更多了?答案是可行,而且这正是真实项目里把有限数据"撑大"成训练集的常用办法——叫"滚动 / 密集评分日"。
用示例数据算一下规模:
- 把 07-15 ~ 07-23 里每一天都当评分日,共 9 天 × 58 个用户 =522 条样本;
- 如果从 07-01 起算,共 23 个合法评分日 × 58 =1334 条样本。
这些样本全合法:最晚评分日是 07-23,未来 7 天窗口到 07-30 刚好没掉出数据范围(第6章亲手数过)。生成阶段不算泄露——只要每条样本都守第6章那条线:特征只用自己评分日及之前、标签只用之后。
但造完样本之后,切分方式决定会不会翻车。密集评分日造出的样本有个特点:相邻两天的样本高度同源。比如同一用户as_of=07-15和as_of=07-16两条,历史几乎完全重叠(只差 07-16 这一天从"未来"挪到"过去"),特征长得几乎一样。这带来两条必须守的纪律:
第一,决不能随机按样本打乱切分。新手顺手train_test_split会把"近重复的双胞胎样本"一个扔训练、一个扔测试。模型在训练里记下了这个人的模样,测试时又见到几乎相同的它,分数虚高——这本质是"近重复样本造成的切分泄露",和本章开头讲的切分泄露是同一种坑。正确做法始终是按时间块切分:训练用早的一批评分日、测试用晚的一批,和上面演示的train{07-08,07-15} / test{07-22}一个道理,只是评分日排得更密。
第二,净空期照样不能省,反而更要警觉。即便用了时间块切分,训练集最后一个评分日和测试集第一个评分日之间仍可能窗口重叠。上一节算过train 07-15 / test 07-22时,两窗在 07-16~07-22 重叠 7 天。评分日越密,你越容易顺手把训练集推到离测试集很近的位置,重叠就悄悄露出来。所以所需净空(≈ 特征回看 14 天 + 标签前瞻 7 天 ≈ 21 天)一克都不能少。
还有两个边界要记:
- 评分日不能越过 07-23。再往后(07-24+),未来 7 天窗口会掉出 07-30 的数据范围,标签不全,样本作废。
- 冷启动用户早期样本近乎全空。一个 07-10 才首次出现的人,在
as_of=07-15时特征几乎全是 0。这种样本不能硬塞进正式训练,要走第10章讲的三级路由。
一句话收住:把评分日撑密来造样本,可行且常用;真正的雷在切分——必须按时间块切 + 留净空期,否则密集造出的近重复样本会把测试分数顶虚。这还是泄露,只是更隐蔽。
三切分契约:训练、验证、测试各司其职
前面把"训练 / 测试"讲成两段,真实项目里其实是三段,而且切分要当"契约"来定:
- 训练:最早的窗口,模型在这里学。
- 验证:中间的窗口,用来调超参数、选模型、拟合校准器(第16章那个 sigmoid 校准,严格说应该在独立验证集上拟合;示例数据太短,当时用训练集内部交叉验证代替)。
- 测试:最晚的窗口,只做一次最终评估(第19章那些指标只在这上面算),别来回看。
三个窗口之间同样要留净空期(和前面算的一样,≈ 特征回看 14 天 + 标签前瞻 7 天)。核心规则是:验证和测试里看到的一切,都不能反过来影响训练时的任何决定——包括超参数、特征选择、校准器。谁要是看到测试分数不好就回头调一下再测,测试就废了,等价于变相泄露。
两条配套纪律:
- 按 split 物理分区落地,禁止随机重切。数据生成时就按训练 / 验证 / 测试各写各的分区,之后任何人都不许再用
train_test_split重新切——“反复重切"等于"反复偷看测试集”,是慢性的、最不容易被抓到的泄露。示例训练表附件/05_活跃度预测篇/activity_train_table.csv就是按评分日写死的(07-08、07-15、07-22 三个as_of),后面所有章节都用同一份、没人重切,数字才对得上。 - 训练期快照要设 UID 上限。密集评分日造出来的样本,同一个用户一周可能有好几条,特征高度重复——全收进训练的话,高频用户会主导训练(第14章讲同用户多样本加权时,会从"权重"角度再碰一次这件事)。契约的做法二选一:训练期同一用户每周至多保留一个快照(抽样),或者训练时给每个快照按用户出现频次降权。示例数据 3 个评分日正好隔一周、天然满足"每周一个",所以训练表没这个问题;评分日一旦撑密,这条就得上。
一个常见误解:同一用户跨训练/测试,不算泄露
很多人第一次听到"按时间切",会紧张:“那同一个用户 U0036 不就同时出现在训练和测试里了?这不算作弊吗?”
不算。回到第6章:U0036 在 07-15 和 07-23 是两个不同的样本,特征、标签都可能不同,代表的是他两个不同状态下的快照。训练集用他在 07-15 的状态、测试集用他在 07-22 的状态,模型学到的是"某种状态下的人接下来会怎样",这正是我们要的泛化能力。
真正该防的,不是"同一用户跨集",而是"未来的信息混进过去"。只要切分守住了时间方向(训练评分日全早于测试评分日)、窗口不重叠,同一用户跨集完全 OK。
动手:给你的特征表做泄露体检
三件事,不用跑模型也能做:
- 扫一遍特征列。把你第7章算出的特征表每一列过一遍,问自己:"这一列在评分日那一刻,真的算得出来吗?"只要有一列用到了评分日之后的记录,就是泄露,删掉。
- 检查切分方向。假设你的样本覆盖多个评分日,用下面这段 pandas 按时间切分,确认训练集里最晚的评分日,严格早于测试集里最早的评分日:
importpandasaspd samples=pd.DataFrame({# 每行一个 (uid, as_of) 样本, 含特征与标签"uid":["U0036"]*3+["U0009"]*3,"as_of":pd.to_datetime(["2026-07-08","2026-07-15","2026-07-22"]*2),"label":[1,1,1,1,0,0],})samples=samples.sort_values("as_of")cut=int(len(samples)*2/3)train,test=samples.iloc[:cut],samples.iloc[cut:]print("训练最晚评分日:",train.as_of.max().date(),"| 测试最早评分日:",test.as_of.min().date())# 应看到 训练最晚 < 测试最早- 想一个边界题:如果特征里加了"本周是否已领取",算泄露吗?——评分日当天的领取记录是已经发生的,算进去没问题;但如果"本周"包含了评分日之后的几天,就泄露了。区别就在"评分日这根轴"画在哪。
把这三步养成习惯,后面跑任何模型,第一条就先查泄露。下一章开始进行为聚类(无监督),那时你会发现:无监督模型虽然没有标签、不存在"标签泄露",但"特征用了未来信息"这种泄露照样存在,铁律对两类模型都管用。