cs3319-project2 / docs_first_principles /03_data_flow_and_artifacts.md
NLP-beginner's picture
CS3319 Project 2 final deliverable (public F1 = 0.96626)
f28d994
|
Raw
History Blame Contribute Delete
18.1 kB
# 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。