03 · 数据流与产物文件(从原始 txt 到最终 submission)
本文档从第一性原理讲清:一个 author-paper 对,从原始文本文件出发,经过哪些确定性步骤,最终变成 submission CSV 里的一行 0/1。重点不在"算法是什么"(那是 04–09 的事),而在"数据怎么流动、每个中间产物是什么、文件名约定怎么读、哪些能复用、哪些换了 seed 就失效"。 所有数字与路径以
docs_first_principles/_fact_sheet.md为准(状态日期 2026-06-18)。代码路径相对仓库根,带行号。
目标读者:第一次打开本仓库、被 validation_runs/ 下一堆 .npy/.npz/.csv/.model 绕晕的人。读完本文你应当能回答:① val_* 和 test_* 文件差在哪?② dynamic_seed202 是什么、为什么换不得?③ feature_cache/ 的缓存怎么命中、怎么强制重算?④ 最终 submission 到底是怎么算出来的。
1. 起点:五类原始输入(全在 data_and_docs/)
整个 pipeline 的全部输入就是这五个文件,没有任何外部数据集或预训练模型(课程规则禁止,见 data_and_docs/project_description.md)。
| 文件 | 行数/规模 | 语义 | 谁读它 |
|---|---|---|---|
bipartite_train_ann.txt |
682,421 行(实测 wc -l) |
author→paper 已知正边(训练目标) | 几乎所有脚本,作 train_refs |
bipartite_test_ann.txt |
2,047,262 行(≈2.05M) | 待预测 author-paper 对(只给 pair,不给标签) | 所有 submission 生成器,作 test_pairs |
author_file_ann.txt |
9,663 行 | author↔author 无向合著边 | LightGCN、高阶传播 $G_k$、meta-path A-A-P |
paper_file_ann.txt |
327,113 行 | paper→paper 有向引用边(former→latter) | 高阶传播 $C^k$、LightGCN 引用通道 |
feature.pkl |
(79,937, 512) torch.float32,~156 MB | 每篇论文一句 512 维 Universal Sentence Encoder 嵌入 | content 画像(05 rich18、content mean-cos) |
第一性原理要点: 这五类输入刻画了三种不同语义的关系——"谁读了什么"(二部,稠密)、"谁和谁合作"(合著,极稀疏)、"谁引用了谁"(引用,有向)。后面所有"特征"都是对这三种关系做不同代数运算的产物。理解了输入,就理解了 pipeline 的天花板。
2. 切分:dynamic_seed202 到底是什么
2.1 为什么需要一个切分
测试集 bipartite_test_ann.txt 没有标签(只有 pair),所以无法在测试集上算 F1。要调方法、比较模型,必须自己造一个带标签的验证集。本项目用一个叫 make_notebook_style_split 的确定性函数造它(code/train_val_lgcn_ensemble.py:132-165):
def make_notebook_style_split(root, seed=202, train_frac=0.9):
# 1. 取全部 682,421 条训练正边
# 2. 用 pandas.sample(random_state=seed) 留出 train_frac=0.9 → 10% 作验证正例
# seed=202 时 → 验证正例 = round(682421 × 0.1) = 68,242 条
# 3. 用 np.random.default_rng(seed) 采样等量随机非边作验证负例 = 68,242 条
# 4. 拼成 1:1 的人工验证集 = 136,484 对
2.2 为什么叫 "dynamic_seed202"
- dynamic = 切分在运行时按函数重建,而不是预先生成落盘的静态文件(尽管验证 pair/label 会缓存,见下)。同一个 seed 跑出来的切分完全可复现。
- seed202 = 上面两个随机源(
pandas.sample的random_state=202、np.random.default_rng(202))都被钉死成 202。
2.3 为什么 1:1 正负
真实测试集里正例比例未知且肯定远低于 50%(二部密度才 1.29e-3)。但验证集人为做成 1:1 平衡,是为了让 F1 的优化目标不失衡、阈值好搜。代价是:验证集的概率分布和测试集系统性不同,这正是后来"测试决策改用 rank-cutoff 而非概率阈值"的根本原因(详见 08_calibration_and_rank_cutoff.md)。
2.4 切分产物(落盘缓存)
validation_runs/dynamic_seed202/ 下两个核心文件是切分的快照:
| 文件 | shape/dtype | 含义 |
|---|---|---|
val_pairs_seed202.npy |
(136484, 2) int64 | 每行 [author_id, paper_id] |
val_labels_seed202.npy |
(136484,) int8 | 1=正(留出边),0=负(随机非边) |
可复现性已验证:
val_labels_seed202.npy对high_order_graph_stack/rich_rw7_highorder_directed_oof.npy复算 best-F1 = 0.966874 / AUC 0.994918,与validation_summary.csv记录值逐位一致。这说明验证标签与所有同源 OOF 分数行序对齐、无泄漏,后面所有 PR/ROC/校准图都可信。
2.5 为什么 --split-seed 是"承重墙(load-bearing)"
切分种子 202 被烤进(baked into)了每一个分数/特征文件:val_*_s202.npy、dyn202_* 目录名、bigbatch_more 的 LightGCN 分数,全部是针对 seed=202 这 136,484 对算出来的。换 seed = 整条链作废,必须从 Stage-1 端到端重跑。 这也是为什么 README/CLAUDE.md 反复强调"默认 202"。
3. 分数文件:.npy 是什么、val_* 和 test_* 差在哪
3.1 .npy 分数文件的本质
每个 Stage-1 模型(LightGCN、BPR-MF、DeepWalk…)对一组 pair 输出一个标量分数(越大越像正边)。这组分数存成一个一维 .npy,第 i 个数 = 第 i 个 pair 的分数,顺序与 val_pairs_seed202.npy 完全对齐。
scores/val_vanilla_ensemble_mean.npy:LightGCN 集成对 136,484 验证对的分数(主分数)。val_mf_bpr_s202_d256.npy:BPR-MF 对验证对的分数。- 以此类推。
关键不变量: 凡是
val_*分数,长度必为 136,484,行序必与val_pairs_seed202.npy一致。长度对不上 = 切分或文件不匹配,不能直接用。
3.2 val_* vs test_*:同一模型的两套分数
每个模型其实算了两套 pair:
val_*:验证对(136,484 个,带标签)→ 用来训练 LightGBM stacker、算 OOF F1。test_*:测试对(2,047,262 个,无标签)→ stacker 训好后用它出 submission。
命名约定:val_* 与 test_* 的"主体名"一一对应。例如:
| val 分数 | test 对应 | 说明 |
|---|---|---|
scores/val_vanilla_ensemble_mean.npy |
post95_test_scores/test_vanilla_ensemble_mean.npy |
LightGCN 主分数 |
val_mf_bpr_s202_d256.npy |
test_mf_bpr_*.npy |
BPR-MF |
| content_mean / variant43 / topk3 … | 各自 test 镜像 | 同模型,换 pair 集合 |
为什么分两套? 因为 stacker 的训练必须用带标签的 val(否则没法学),而预测必须用 test(出提交)。Stage-1 模型在这两套 pair 上用的是同一套学好的参数,只是换了个 pair 表去算分。
3.3 一句话区分三组文件名前缀
| 前缀 | 含义 | 长度 |
|---|---|---|
val_… |
验证对上的分数/特征 | 136,484 |
test_… |
测试对上的分数/特征 | 2,047,262 |
*_oof.npy |
LightGBM 在验证对上的 out-of-fold 预测(见 §4) | 136,484 |
4. OOF(Out-of-Fold):为什么 stacker 的验证分"无泄漏"
4.1 问题
LightGBM stacker 要在 136,484 验证对上训练。但如果它在全部 136,484 对上训练、又在同样的 136,484 对上自评 F1,就是"拿考题当练习题"——F1 会虚高(过拟合)。
4.2 解法:5 折 StratifiedKFold OOF
stack_rank_calibration.py:163-184 的 fit_oof / fit_full_predict 做:
- 把 136,484 对按标签分层切成 5 份。
- 轮流用 4 份训练、预测剩下 1 份 → 每对都被一个没见过它的模型预测。
- 拼回一个长度 136,484 的
*_oof.npy。
这样 OOF 分数里每个 pair 的预测都来自"未见过该 pair 的模型",无泄漏。对 *_oof.npy 调 best_f1()(PR 曲线 argmax,train_val_lgcn_ensemble.py:340-346)得到的 F1 才是可信的验证 F1。
最终 OOF:
high_order_graph_stack/rich_rw7_highorder_directed_oof.npy,best-F1 = 0.966874(seed=202, 259 维)。
4.3 测试预测怎么来
OOF 只解决"验证评分无泄漏"。出 submission 时,stacker 在全量 136,484 对上重训一遍,再对 2,047,262 测试对预测,得 rich_rw7_highorder_directed_test_pred.npy。注意:测试预测没有 OOF 概念(测试无标签、也无 fold),就是一次全量模型的直接推断。
5. validation_runs/dynamic_seed202/ 目录里有什么
按 Stage 分子目录,每个 stage 把自己的分数/特征/模型写进去。典型结构:
validation_runs/dynamic_seed202/
├─ val_pairs_seed202.npy, val_labels_seed202.npy # 切分快照(§2)
├─ dyn202_l2d512_bpr_bigbatch_more/scores/ # LightGCN 主分数
│ └─ val_vanilla_ensemble_mean.npy
├─ post95_test_scores/ # val_* 的 test 镜像
├─ extra_score_sources/ # BPR / content_mean 分数
├─ content_rich/ # rich18 内容画像
├─ randomwalk_systematic/
│ ├─ models/ (7× Word2Vec .model)
│ └─ pair_features/ (7× .npz, 每块 11 维)
├─ high_order_graph_stack/
│ ├─ validation_summary.csv (4 行高阶消融)
│ ├─ rich_rw7_highorder_directed_oof.npy (val OOF)
│ ├─ rich_rw7_highorder_directed_test_pred.npy (test 预测)
│ └─ submissions/submission_rich_rw7_highorder_directed_r0.500000.csv
└─ error_group_calibration/error_analysis_buckets.csv (误差分桶,出图用)
注:
validation_runs/feature_cache/(见 §6)与cached_scores/(含test_known_mask.npy,见 §8)在dynamic_seed202/之外,是跨 stage 共享的缓存层。
6. feature_cache/:cache-or-compute 约定
6.1 为什么需要缓存
内容画像(从 feature.pkl 算 18 维 rich)、高阶传播(在 79937×79937 论文图上做稀疏幂乘)都是秒级到分钟级的计算。每次跑 stacker 都重算太慢。于是用 cache-or-compute:算过就存盘,下次直接读。
6.2 缓存文件名怎么生成(key 的身份)
key 由 pair 集合的"身份指纹"拼成(validation_runs/feature_cache/):
{tag}_{npairs}_{sum(author_ids)}_{sum(paper_ids)}[_k{topk}].npy
tag:特征族标签(如content_rich、high_order_dir)。npairs:pair 数(验证=136484,测试=2047262)。sum(author_ids)、sum(paper_ids):所有 pair 的作者 id 之和、论文 id 之和——这两个和唯一确定了这组 pair 集合(几乎不会碰撞)。k{topk}:若该特征依赖 top-k 参数则带上(如 top-k 内容相似度)。
命中逻辑: 若文件存在 → 直接 np.load 返回;否则计算 → 存盘 → 返回。
强制重算: 删除该文件即可。改了特征实现又想刷新缓存,删文件重跑。
一致性说明:高阶无向/有向特征的列名另存于
feature_cache/*_names.txt(24 + 45 维),供特征重要性/可解释性读取。
7. 全流程数据流文本图(一图看完)
原始输入 data_and_docs/ (§1)
├─ bipartite_train_ann.txt (682,421 正边)
├─ bipartite_test_ann.txt (2,047,262 待预测对)
├─ author_file_ann.txt (9,663 合著, 无向)
├─ paper_file_ann.txt (327,113 引用, 有向)
└─ feature.pkl (79,937×512 USE)
│
▼ make_notebook_style_split(seed=202, train_frac=0.9) (§2)
切分快照 validation_runs/dynamic_seed202/
├─ val_pairs_seed202.npy (136,484)
└─ val_labels_seed202.npy (1:1)
│
├──── Stage-1 多源分数/特征生产者 (各自独立, §3)
│ ├─ train_val_lgcn_ensemble.py → scores/val_vanilla_ensemble_mean.npy (主分数)
│ ├─ extra_score_sources_ablation.py → val_mf_bpr_s202_d256.npy, content_mean
│ ├─ content_rich_ablation.py → content_rich_*.npy (18维, 命中 feature_cache)
│ ├─ randomwalk_systematic_ablation.py → 7× .model + pair_features/*.npz (11维/块)
│ ├─ generate_randomwalk_ensemble_submission.py (aggregate) → RW 一致性 11维
│ ├─ generate_post95_submission.py → variant43, topk3 (val + test 镜像)
│ ├─ stack_rank_calibration.py → explicit18 + rank4
│ └─ high_order_graph_stack.py (build_high_order*) → undir24 + directed45
│ (H_k=R·C^k, G_k=S·R·C^k, 命中 feature_cache)
│
▼ 横向拼接 259 维 X (84 X_base + 18 rich + 77 RW + 11 agg + 24 undir + 45 dir)
Stage-2 stacker (high_order_graph_stack.py, fit_full_predict) (§4)
└─ LightGBM(num_leaves=15, reg_lambda=8, lr=0.022, n_est=1400) 5-fold OOF
├─ rich_rw7_highorder_directed_oof.npy (val OOF, F1=0.966874)
└─ rich_rw7_highorder_directed_test_pred.npy (test 预测分)
│
▼ 决策 (§8)
最终 submission
└─ submission_rich_rw7_highorder_directed_r0.500000.csv
= sort test by score → top 50% = 1
+ cached_scores/test_known_mask.npy 强制已知训练正边 = 1
→ 公开 LB F1 = 0.96626
8. 最终 submission 是怎么生成的(决策规则)
最终测试决策不是"概率 > 阈值",而是 rank-cutoff + 已知正边强制:
1. 取 rich_rw7_highorder_directed_test_pred.npy (2,047,262 个测试分数)
2. 按分数降序排序
3. 预测分数最高的 50% (ratio=0.500000) 标 1, 其余标 0
4. 加载 cached_scores/test_known_mask.npy (2,047,390 字节):
凡是出现在 bipartite_train_ann.txt 训练正边里的测试对 → 强制 = 1
5. 写 submission_rich_rw7_highorder_directed_r0.500000.csv
列: Index, Predicted (0/1)
8.1 为什么用 rank-cutoff 而不是概率阈值
验证最优概率阈值是 0.4617(validation_summary.csv)。但验证集是人为 1:1,把 0.4617 直接搬到测试集,正例率会漂移到约 0.5242(系统性高估 2.4 个百分点)。改用"固定取 top 50%"对分数的单调变换和先验漂移天然鲁棒。详见 08_calibration_and_rank_cutoff.md。
8.2 为什么要强制已知正边 = 1
test_known_mask.npy 标记的是既在训练正边、又在测试对里的那些对(占测试 25.6%,约 524,083 条)。这些对铁定是正,若模型因分数低判成 0 就是硬错误。强制置 1 是"白捡"的精确率,消除这类不可接受的失误。
提交格式裁决(见事实表台账 #1):列名为
Index,Predicted(dataset.md + CLAUDE.md + 实际提交一致);project_evaluation.md早期出现的 "Id and Probability" 为残留措辞,以Index,Predicted为准。
9. 复现路径:最短 vs 从零
9.1 最短路径(产物已缓存,推荐)
包内已含全部中间产物(feature_cache/、randomwalk_systematic/models/、LightGCN 分数、各阶段 OOF)。只重跑最终 stacker 即可从缓存再生 submission:
python code/high_order_graph_stack.py \
--package-root . --split-seed 202 --seed 202 --n-splits 5 --make-submission
- 不加
--make-submission= 仅验证(OOF + F1),不出 CSV。 - 加上 = 同时对测试对打分、出 submission。
9.2 从零端到端(产物缺失时,需 GPU)
按 CLAUDE.md 的 stage 顺序逐级重跑,LightGCN/BPR/随机游走训练需要 GPU(--device cuda:0),LightGBM 第二阶段 CPU 即可:
1. train_val_lgcn_ensemble.py → LightGCN 集成主分数 (val + test)
2. generate_post95_submission.py → 选 top-N 变体分数 + test 镜像
3. extra_score_sources_ablation.py → BPR-MF + content_mean_cos
4. randomwalk_systematic_ablation.py → 7 DeepWalk/Node2Vec (先 --mode small 再 --mode graph)
5. high_order_graph_stack.py → 最终 stacker + submission (§9.1 命令)
换 seed 必须从 step 1 重跑整条链——seed 烤进了每个分数文件(§2.5)。
9.3 快速验证(不重训,只核对已有 submission)
README.md → "Quick Verification":直接读 submission_rich_rw7_highorder_directed_r0.500000.csv,确认列名 Index,Predicted、正例比例 ≈ 0.5、行数 2,047,262 即可。
10. 常见文件阅读陷阱(第一性原理提醒)
| 陷阱 | 真相 |
|---|---|
看到 val_*.npy 就当"验证集预测" |
是 Stage-1 模型对验证 pair 的分数,不是 stacker 的 OOF;stacker OOF 才是 *_oof.npy |
以为 dynamic_seed202 是模型名 |
是切分目录;模型在它下面的子目录里 |
| 改了 seed 还想复用缓存 | 不行,seed 烤进文件,必须端到端重跑 |
| feature_cache 命中但想用新特征 | 删缓存文件强制重算(key 见 §6.2) |
test_* 比 val_* 长很多 |
正常:测试 2,047,262 vs 验证 136,484 |
读到绝对路径 /data/lzc/... |
遗留元数据(selected_variant_val_scores.txt 等),以 --package-root 相对路径为准(台账 #18) |
.npz(RW pair 特征)vs .npy(分数) |
RW 每块存多列 → .npz;单列分数 → .npy |
可迁移到论文中的写法
实验设置段(Setup,可直译进 TeX)。 我们采用基于种子的确定性切分进行离线验证。具体地,以
make_notebook_style_split函数在随机种子 202 下,将 682,421 条已知 author→paper 正边按 9:1 划分:90% 用于训练,留出的 10%(68,242 条)作为验证正例,并采样等量随机非边作为验证负例,构成规模为 136,484 的平衡验证集。该切分种子被固化于全部中间分数文件,保证不同模型在同一验证集上严格可比。Stage-2 的 LightGBM 元学习器采用 5 折分层 out-of-fold(OOF)训练以获得无泄漏的验证分数;最终 F1 由 OOF 预测在精确率-召回率曲线上取 argmax 得到。
决策规则段(Decision Rule)。 测试阶段我们不采用固定概率阈值。由于验证集为人工 1:1 平衡而测试集真实正先验未知,验证最优阈值(0.4617)直接迁移会导致正例率漂移。我们改用 rank-cutoff 决策:按预测分数对 2,047,262 个测试对排序,取分数最高的 50% 预测为正;此外,凡出现在训练正边中的测试对(占测试集约 25.6%)均被强制预测为正。该方法在公开榜单(评测 50% 测试集)取得 F1 = 0.96626。