chaoheng
发布于 2026-09-13 / 29 阅读
1
0

从规则基线到时序图候选排序:一次竞赛项目的完整探索与复盘

巴巴博弈队 · 计图挑战赛赛道一项目复盘

A 榜:1.534806,第 5 名;B 榜:1.3829,第 8 名

写在前面

如果只看最终方案,这个项目似乎可以被概括成一句话:提取时序图特征,训练 LightGBM 和 Jittor 排序模型,再把两者融合。

但这句话恰好省略了最重要的部分。

我们真正经历的,是从“为两个数据集各找一个模型”,到“把问题理解为给定候选集合下的条件排序”;从相信更多数据、更多特征和更大模型,到承认错误分布上的海量样本不如少量同分布样本;从追逐一次离线涨分,到建立时间门控、产物校验和可复现发布链。项目里最有价值的东西,并不是某个孤立技巧,而是一次次失败迫使我们更新了对问题的认识。

这篇文章试图把这段过程完整记录下来:我们最初如何理解任务,哪些路线曾经有效,哪些尝试为什么失败,最终系统如何形成,以及这段经历给我们留下了什么。


一、我们面对的到底是什么问题

每条测试查询包含一个源节点 src、一个时间 time 和 100 个候选目标。模型需要为这 100 个位置输出一组概率,真实目标排得越靠前,MRR 越高。

更准确地说,我们要估计的是:

P(v\text{ 是下一目标}\mid u,t,\mathcal{H}_{<t},C_{u,t})

其中,u 是源节点,t 是查询时刻,\mathcal{H}_{<t} 是严格发生在查询时刻之前的历史,C_{u,t} 是该查询给出的候选集合。

这看似只是一个“100 选 1”的分类题,实际却同时包含四层难点:

  1. 图结构。 直接交互很重要,共同邻居、相似连接模式和多跳关系也可能提供证据。

  2. 时间。 同一条边在刚刚发生和很久以前发生,意义完全不同。

  3. 候选集合。 指标只关心同一行 100 个候选的相对顺序,候选组本身就是条件。

  4. 分布外推。 验证必须只使用过去预测未来,而且验证跨度要尽量接近正式测试。

比赛还包含两个行为差异明显的数据集。A 榜阶段我们把它们分别建模:一个更偏向重复关系和规则统计,另一个更像新关系发现和时序推荐。这个“不要强迫所有场景共用一套归纳偏置”的判断,成为整个项目最早站稳的原则。


二、第一阶段:先建立一个不会轻易输掉的基线

项目最初从很朴素的单样本 MLP 和静态图特征开始。很快我们发现,复杂模型并不会自动赢过规则:对于重复行为明显的数据集,历史次数、最近一次交互、互惠关系、目标热度等统计量本身就很强。

因此,第一版真正可靠的系统没有追求“统一而优雅”,而是拆成两条分支:

  • 对重复关系较强的数据集,保留稳健的统计与规则排序器;

  • 对新关系更多的数据集,使用 Jittor 学习源节点、目标节点、近期序列和时间间隔的表示,再与较弱的统计分数融合。

这一阶段最重要的产物不是某个网络,而是一套基本纪律:按时间切分训练与验证;分别观察 repeated、new pair、cold source、cold destination 等子群;任何模型升级都必须和稳定基线比较。

随后,项目从静态特征逐步走向时序图排序。我们加入候选级图统计、多视图关系表示、共同邻居与支持信号,并用 LambdaRank 直接学习候选组内的顺序。A 榜最终形成了 Dataset1 的 340 维树排序分支,以及 Dataset2 的多模型 Jittor 排序分支,取得 1.534806、A 榜第 5 名。

这次成功也埋下了一个危险错觉:既然 A 榜的先验有效,那么把同一套先验做得更大、更全,或许就能自然迁移到 B 榜。

事实证明,并不能。


三、B 榜的第一次教训:旧先验不是资产,也可能是负债

B 榜数据到来后,我们一度尝试同时重构两个数据集:更宽的特征、更大的模型、更强的掩码、更长的流水线。结果,一个 Temporal4 单种子版本线上只有 0.870607。

这次回退不是简单的“模型没训练好”,而是多个问题叠加:

  • Dataset3 被大幅替换,丢掉了 A 榜已经验证过的支持信号;

  • Dataset4 的新模型与旧冠军 Top-1 一致率很低,外推却不充分;

  • 验证时如果正例被掩码,代码会撤销整行掩码;正式测试没有标签,不会撤销,导致验证与推理口径不一致;

  • A 榜中“历史 pair 不应再次出现”的先验被直接迁移,B 榜里本来有价值的重复交互反而被压到了行尾;

  • 某些滚动特征存在未来事件进入验证状态的风险;

  • 本地折最远只模拟约 5.42 小时,正式测试外推跨度却约为 11.41 小时。

真正扭转局面的,不是重新训练,而是修正部署掩码:不再把历史出现过的 (src,dst) 一律屏蔽,只过滤训练中从未出现的目标。严格离线两折分别提升约 +0.05554 和 +0.08994,线上从约 0.9592 上升到 1.05907。

这次修复让我们第一次非常具体地体会到:

一个结构性错误的价值,可能远大于再加一百维特征;训练、验证和推理的一致性,可能比模型容量更重要。

从此以后,我们给每个新方案多加了三个问题:对照是不是当前线上最好版本?验证是否完全无标签?验证跨度是否覆盖正式外推?


四、第二次教训:离线分数很高,也可能只是在复现造题过程

为了扩大监督规模,我们曾从历史交互构造约 32 万道训练题:借用官方候选模板,如果真实目标不在 100 个候选里,就替换一个候选把正例插进去。随后使用 421 维 RankAware 集合网络进行训练。

这条路线的离线结果很好看,Fold A/B 总 MRR 约为 0.656/0.651。但上线后,总分只有 1.0106。

审计训练数据后,问题变得非常清楚:

| 切片 | 总查询数 | 自然正例 | 人工插入正例 | 插入比例 |

|---|---:|---:|---:|---:|

| train | 320,000 | 2,536 | 317,464 | 99.21% |

| Fold A | 40,000 | 296 | 39,704 | 99.26% |

| Fold B | 40,000 | 420 | 39,580 | 98.95% |

模型很容易学会识别“哪个候选像后来塞进来的外来项”,而不是学习真实的下一目标。验证集采用了同样的造题机制,于是离线分数越高,可能只是说明模型越擅长复现我们的数据构造过程。

更关键的是,人工插入样本上的高分掩盖了自然样本上的薄弱表现:原模型在自然子集上的 MRR 只有约 0.349/0.382。

我们也尝试过只把神经网络换成 residual LambdaRank,但仍让 32 万合成查询主导训练,结果自然 MRR 只有 0.2811/0.2940。这说明根因不在模型家族,而在训练分布。

这段经历最终浓缩成一句话:

少而同分布的数据,往往胜过多而错分布的数据。


五、转折点:把任务重新定义为“条件候选排序”

我们开始只保留真实目标原本就在候选模板中的训练题。第一轮只有 2,536 条自然查询,每条完整保留 100 个候选,总计 253,600 个候选训练位置。

样本量一下缩小了两个数量级,但训练与测试终于满足了同一个关键条件:正例和其他候选来自同一套候选机制。

与此同时,我们重新审视指标。MRR 不关心一个候选在全局有多高的分,只关心它在当前 100 个候选里排第几。因此每个候选通道不再只提供原始值,还扩展出:

  • 查询内 z-score;

  • 查询内 tie-aware 百分位排名。

这让模型同时看到绝对规模、相对异常程度和组内名次。

第一轮 natural-full 使用 31 个时序残差通道,覆盖历史 pair、重复间隔、近期访问顺序、session 状态、多时间窗目标热度、候选频率与密度等信息,再把原始值、z-score 和百分位排名一起交给 LambdaRank。树模型与旧神经集合模型按 75:25 融合后,线上总分从 1.0106 提升到 1.2313。

这 +0.2207 是整个项目最关键的一次跃迁。它证明我们改变的不是一个参数,而是学习问题本身。


六、从 natural-full 到 natural-rich:把图结构重新加回来

自然候选路线站稳后,我们没有立刻继续堆网络,而是问:在训练分布正确的前提下,图结构还能提供多少增量?

第二轮 natural-rich 加入三类信息:

  1. 基础图统计,例如节点度、pair 关系和局部结构;

  2. 源节点的序列上下文;

  3. 全历史、较近历史和近期历史三个视图的图嵌入与协同摘要。

早期 rich 版本形成 232 个候选级输入通道。双时间折中,自然 MRR 从 0.588479/0.579899 提升到 0.646822/0.627741;线上总分进一步达到 1.2923。

这一阶段还有两个对最终方案影响很大的消融结果。

第一,去掉候选频率、候选密度及其交互后,两折自然 MRR 降到 0.412230/0.413257。这说明候选集合并不是装答案的中性容器,它描述了一个条件采样机制。我们可以使用无标签候选统计,但必须清楚地把它称为候选条件,而不能把“出现次数”误解成“真实交互次数”。

第二,更多特征不一定更好。后期每折 20,000 条样本的时间审计中,292 维版本相对 274 维在 W2 仅提升 +0.000347,在更靠后的 W3 反而下降 −0.001605。因此最终系统保留 274 维,没有为了讲故事而选择更复杂的版本。


七、最终系统:树负责结构化判断,神经网络负责集合表示

最终版本不是单模型的胜利,而是两种互补评分函数的组合。

Dataset3:保护已经验证的资产

Dataset3 使用恢复版 340 维结构排序模型,结合时序互惠、source-support 和 friend-support 等已验证信号。我们不再为了“整体升级”同时替换稳定分支,而是把它作为受保护的发布资产,并用 CRC 锁定输出。

Dataset4 树分支:274 维自然候选排序

最终树输入的组成是:

274=(46+31)\times3+1+14\times3

其中包含 46 个图与上下文通道、31 个行为残差通道;二者都扩展为原始值、组内 z-score 和组内百分位排名;再加入已知候选标记,以及 14 个候选轨迹通道及其相对扩展。

树模型采用 LightGBM LambdaRank,在完整 W3 时间窗口训练,固定 seed 3026,共 337 轮。它擅长从计数、时间间隔、图相似度、候选密度等结构化变量中学习非线性条件组合。

Dataset4 神经分支:421 维候选集合网络

Jittor 分支为每个候选读取 421 维输入,经两层共享编码得到 256 维表示,再汇总整组 100 个候选的均值和方差,显式建模“候选自身”和“相对本组”的差异。

损失由组内交叉熵和 Top-16 难负例 hinge 组成。最终使用 seeds 3026、4035、5044 三个模型,每个模型重新初始化后在 982,860 道历史题上训练 4 轮。三个模型的行内标准化 logits 再做平均。

融合与发布

树和神经分数分别在每行 100 个候选内标准化:

s=0.75z(s_{tree})+0.25z(s_{jittor})

随后使用稳定 softmax 转成概率,写出八位小数,并修正最大概率位置以保证每行和为 1。最终 ZIP 固定包含 dataset3.csv 和 dataset4.csv,并执行行数、列数、有限值、概率和、CRC 与 SHA-256 校验。

最终提交取得 1.3829,B 榜第 8 名。

完整阶段结果如下:

阶段关键变化B 榜总分
Temporal4 大替换验证/推理错配,旧先验误迁移0.870607
known-only 修复删除历史 pair 硬屏蔽1.05907
全量神经路线421 维,合成候选主导1.0106
natural-full自然候选 + 31 维残差树1.2313
natural-rich增加图结构与多视图上下文1.2923
W3-full 最终版274 维树 75% + Jittor 25%1.3829

这张表只能用于回顾完整版本的演进,不能把相邻版本的全部差值归功于某一个模块;训练样本、特征、模型和发布配方往往同时发生过变化。


八、比模型更难的,是把两百多万道题稳定地跑完

Dataset4 正式测试包含 2,322,538 道查询,也就是 232,253,800 个候选评分位置。到了这个规模,工程系统本身就是算法能否落地的一部分。

项目后期形成了一条分阶段、可恢复的生产流水线:

四份原始 CSV
  → 严格按时间构建图快照与查询仓库
  → Dataset3 结构排序与支持信号
  → Dataset4 三种子 Jittor 训练与推理
  → Dataset4 274 维自然候选特征
  → W3-full LambdaRank 训练与推理
  → 行内标准化与 75/25 融合
  → softmax、CSV、ZIP
  → 行列数、概率、CRC、SHA-256 验收

特征必须分片和懒加载,大数组用 memmap 落盘,每个阶段写 marker,任务中断后从最近完成点恢复。干净运行一遍需要约 850GB 可用空间;在三张 RTX 4090 上仍需约 30—45 小时,而特征构建、磁盘吞吐和压缩阶段常常比 GPU 数量更决定总耗时。

我们也踩过大量“不属于模型”的坑:watcher 进程还活着但训练已经退出;NPU 利用率为零却仍在 CPU 构建特征;缓存维度和 checkpoint 不匹配;发布脚本指向旧冠军;磁盘空间不足;日志口径、配置注释和真实产物发生漂移。

最终我们学会把每个实验当成一份有身份的资产:代码版本、配置、输入 manifest、特征维度、checkpoint、验证报告、提交 ZIP 和哈希必须能够互相对应。没有报告和产物链支撑的“我记得这个版本更好”,不再被当作有效结论。


九、那些没有进入最终答案的路

一个项目的真实边界,往往由被否决的方案共同画出。

  • 旧 repeat/recency 软重排: 在更靠后的时间折没有稳定收益,淘汰。

  • 全局 LogQ 线性校正: 一个时间折上涨,另一个回退,淘汰。

  • CRAFT / 大邻居扩展: 计算昂贵,历史线上结果显著回退,停止投入。

  • 585 维长外推模型: 离线有价值,但与正式缓存和归一化口径不兼容,不能直接部署。

  • known-only 硬屏蔽未知候选: 真实测试存在冷启动目标,最优 unknown penalty 最终为 0;“未知”应是特征,不应是统一死刑。

  • 继续增加 seed: 多个同结构模型高度相关,只降低方差,不创造新的信息源。

  • 292 维特征扩展: 更晚的验证窗口退化,退回 274 维。

  • 为了整齐同时替换两个数据集: 一旦线上回退,很难判断究竟是哪条分支造成问题。后期改为一次只提交一个可归因变化。

这些失败并不只是“没涨分的实验”。它们让最终方案的每一个保留项都有来由,也让我们对什么不能声称更加谨慎。


十、这段探索真正留下的七条经验

1. 先验证训练分布,再讨论模型容量

如果训练题和测试题的生成机制不同,更强的模型只会更精确地拟合错误目标。检查样本 provenance,应当先于调参。

2. 时间任务最怕“看似合理”的未来信息

图快照、缓存索引、滚动状态、候选统计和验证标签都有可能悄悄越过时间边界。因果性不能靠口头保证,必须落实到每个特征的可见历史范围。

3. 验证协议本身就是模型的一部分

随机切分无法回答长外推问题。训练/验证跨度、正式测试跨度、候选构造和推理掩码必须尽可能一致,并且要查看不同 cohort,而不是只盯一个总 MRR。

4. 候选集合不是背景,而是条件

一个 80 分的候选是否突出,取决于另外 99 个候选。原始值、组内 z-score、组内排名和集合统计解决的是同一个核心问题:把绝对证据转化为相对竞争力。

5. 不要把规则、树模型和神经网络放在一条“先进程度”轴上

规则适合表达确定先验,树模型擅长结构化条件组合,集合网络擅长共享表示和候选间上下文。真正重要的是它们是否提供互补信息,而不是谁听起来更复杂。

6. 保护已经被线上验证的资产

重构不应自动意味着全部替换。稳定分支、历史 logits、缓存和冠军提交都是实验对照的一部分。没有足够证据时,最好的升级方式通常是小权重融合和单变量提交。

7. 可复现发布不是收尾工作,而是研究方法

只有当一个结果能从原始数据、固定配置和模型检查点重建,并通过独立校验时,我们才真正知道“取得成绩的是什么”。流水线、哈希、CRC、manifest 和实验账本,最终帮助我们区分了事实、推测与记忆。


十一、回头看:我们最终学会的是如何更诚实地做实验

项目早期,我们倾向于用更大的词描述进展:更完整的特征、更强的模型、更充分的训练。后来,真正有效的问题变得越来越朴素:

  • 这道训练题和测试题真的是同一种题吗?

  • 这个特征在查询时刻真的可见吗?

  • 这次提升能在更靠后的时间窗口重现吗?

  • 线上变化究竟来自哪一个可归因改动?

  • 当前提交包能否被准确恢复?

从 A 榜第五到 B 榜第八,名次不是一路上升,分数也不是单调改善。我们经历过 0.870607 的重构失败,也经历过 32 万训练题输给 2,536 条自然样本的反常结果;最终又从自然样本的小规模方向验证,走回更完整的 W3 训练池和两类模型融合。

这条路线看上去绕远了,但如果没有那些回退,我们大概不会真正理解候选生成、时间外推和验证错配的影响。

最终方案的核心可以概括为一句话:

从历史关系中提取证据,用时间刻画证据的新旧,在给定候选集合内部学习相对顺序,并用可复现的工程链把判断稳定地交付出去。

而这段项目经历更值得保留的,也许是另一句话:

比找到一个高分模型更重要的,是知道它为什么有效、在什么边界内有效,以及下一次它为什么可能失效。


评论