File size: 18,088 Bytes
f28d994 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | # 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。
|