# 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`): ```python 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` 做: 1. 把 136,484 对按标签分层切成 5 份。 2. 轮流用 4 份训练、预测剩下 1 份 → 每对都被一个**没见过它**的模型预测。 3. 拼回一个长度 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: ```bash 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。