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):

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.samplerandom_state=202np.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.npyhigh_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.npydyn202_* 目录名、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-184fit_oof / fit_full_predict 做:

  1. 把 136,484 对按标签分层切成 5 份。
  2. 轮流用 4 份训练、预测剩下 1 份 → 每对都被一个没见过它的模型预测。
  3. 拼回一个长度 136,484 的 *_oof.npy

这样 OOF 分数里每个 pair 的预测都来自"未见过该 pair 的模型",无泄漏。对 *_oof.npybest_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_richhigh_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。