# 05 · 手工图特征与 LightGBM 二阶段融合 > 本文档对应 `code/stack_rank_calibration.py`(事实共享库,被 16 个下游脚本运行时加载)与 `code/high_order_graph_stack.py`(最终 259 维 LightGBM meta-learner)。目标读者:懂一点 ML、但不完全懂图推荐系统的人。本文从"为什么"出发,而非罗列结论。 --- ## 1. 一句话定位 CS3319 项目的最终模型不是"一个神经网络",而是**两阶段 stacking**(stacking ensemble): - **Stage-1**:多个**相互独立的弱模型**各自从图里提炼出 raw 分数或嵌入 —— LightGCN 集成、BPR-MF、DeepWalk/Node2Vec(7 块)、内容画像、显式图/meta-path、高阶引用传播等。 - **Stage-2**:一个 **259 维的 LightGBM 二级 meta-learner**,把上面所有产物的输出"重新读一遍",学一个**最优组合权重**,输出一个最终概率。 本文聚焦 Stage-2 中最关键、也是**单一最大增益来源**的一类 Stage-1 输入:**手工图/元路径特征(handcrafted graph features)**。它在同一 seed=202 的 1:1 OOF 上把 F1 从 LightGCN raw 的 0.938576 拉到 0.955992,**单组增量 +0.0174**(reports/exploration_summary.md L113),是整个 pipeline 里"突破 0.95"的那一脚。 --- ## 2. 第一性原理:为什么需要手工图特征? ### 2.1 LightGCN 在表达什么、又不表达什么 LightGCN(本仓库 Stage-1 主分数生产者,`code/train_val_lgcn_ensemble.py`)学的是: $$\text{score}(a, p) = \mathbf{e}_a^\top \mathbf{e}_p$$ 即每个 author 和 paper 各一个隐向量 $\mathbf{e}\in\mathbb{R}^{512}$,打分就是**两个向量的点积**。它通过多层邻居加权聚合(LightGCN.encode,`train_val_lgcn_ensemble.py:87-100`)把"二部图上的协同过滤信号"压进向量里。这是**隐式(latent)**的表示:它擅长"长得像的 author 会读长得像的 paper",但有几个天生短板: | LightGCN **不直接表达** 的信号 | 为什么 | |---|---| | 作者 $a$ 与候选论文 $p$ 的合著者是否也读过 $p$ | 这是**带类型的两跳邻居关系**(author→author→paper),LightGCN 只在二部图上传播,合著边要靠 `build_data` 拼成无向边间接进入传播,信号被稀释。 | | 作者历史论文集合与候选论文的**引用集是否重叠** | 引用方向(paper→paper)的显式交集,是离散集合运算,点积内积只能近似,无法精确表达"重叠数=3"。 | | 候选论文的**入度/出度、popularity** | LightGCN 隐向量里混进了度信号但不可分离;LightGBM 需要可解释、可加权的"度"列做归一化分母。 | | **跨多跳的带类型元路径证据**(A-P-A-P 等) | 4 跳、带中间节点类型约束的路径,LightGCN 的均匀权重层聚合会把噪声路径也算进来。 | 一句话:**LightGCN 是"模糊相似度",手工特征是"精确计数 + 显式关系"**。两者互补,后者能直接喂给树模型做硬切分。 ### 2.2 那么"手工图特征"和"meta-path"到底是什么 - **手工图特征(handcrafted graph features)**:不依赖任何可学习参数,直接用图的拓扑结构(节点集合、边集合)计算出的**确定性数值**。例如"作者 $a$ 有多少合著者"、"候选论文 $p$ 被引几次"。本仓库实现见 `ExplicitGraphFeatures.transform`(`code/stack_rank_calibration.py:108-145`),产出 **18 维**。 - **meta-path(元路径)**:异构图上一条**节点类型交替**的路径模板,描述一类语义关系。本项目三类边:author↔paper(阅读)、author↔author(合著)、paper→paper(引用),于是常见 meta-path 有: | meta-path | 起止节点 | 文字含义 | 代码列 | |---|---|---|---| | **A-A-P** | author→author→paper | 我的某个合著者读过这篇候选论文 | out[i,3]/[i,11]/[i,12] | | **A-P-P** | author→paper→paper | 我读过的历史论文,与候选论文有引用/被引关系 | out[i,7]/[i,8]/[i,13] | | **A-P-A-P** | author→paper→author→paper | 与我共享过历史论文的同行,也读了这篇候选论文 | out[i,14]/[i,15] | > meta-path 的本质是**"用一串类型约束,从图里精确抠出一种特定的社会/学术证据"**。例如 A-A-P 在问"同事推荐的论文你会不会也读",A-P-P 在问"我读的论文引用了/被候选引用,说明话题相关",A-P-A-P 在问"学术品味相似的人都在读这篇"。 ### 2.3 各 meta-path 直觉详解 - **A-A-P(合著者共读)**:`co_read_count = #{c ∈ coauthors(a) : p ∈ author_papers[c]}`(`stack_rank_calibration.py:120`)。捕捉**同行推荐**信号。归一化版 `out[i,4]=co_read_count/len(coauthors)` 给出"多大比例的合著者读过"。 - **A-P-P(历史-引用重叠)**:`hist_ref_overlap = |hist(a) ∩ refs(p)|`(`stack_rank_calibration.py:121`),即作者历史论文集与候选论文**引用集**的交集;`hist_cited_by_overlap`(`L122`)对被引集同理。这是**话题延续性**证据:我读的文献正好出现在候选的参考文献里,说明同属一个研究脉络。 - **A-P-A-P(品味群体共识)**:`shared_author_read_count = |shared_paper_authors(a) ∩ readers(p)|`(`L125`)。这里 `shared_paper_authors[a]` 是预计算的 A-P-A 邻域 —— 所有与 $a$ 至少共享一篇历史论文的作者(`L91-98`),归一化版 `out[i,15]` 给出"多大比例的品味同行读过候选"。这是最强的**协同过滤式协同信号**,因为它同时利用了"历史阅读相似"和"当前候选一致"两重约束。 > 设计 tip:每个 meta-path 都同时存了**原始计数**与**归一化比率**(Jaccard、按合著者数归一、log1p 压缩度)。原因是 LightGBM 对未归一化的绝对计数(受 popularity 污染)与归一化比率(纯关系强度)各有用处,两者都喂,让树自己选。 --- ## 3. 18 维手工特征的完整构成 `ExplicitGraphFeatures.transform`(`code/stack_rank_calibration.py:108-145`)逐 pair 产出 18 列。按下表分组: | 列号 | 表达式 | 来源 | 直观含义 | |---|---|---|---| | 0 | `author_degree[a]` | author 侧 | 作者历史论文数(popularity) | | 1 | `paper_degree[p]` | paper 侧 | 候选论文被读次数 | | 2 | `len(coauthors)` | author 侧 | 合著者邻居数 | | 3 | `co_read_count` | **A-A-P** | 合著者读过候选的计数 | | 4 | `co_read_count/len(coauthors)` | A-A-P 归一 | 合著者读过比例 | | 5 | `cite_in_degree[p]` | paper 侧 | 被引次数 | | 6 | `cite_out_degree[p]` | paper 侧 | 引用他文次数 | | 7 | `hist_ref_overlap` | **A-P-P** | 历史论文 ∩ 候选引用集 | | 8 | `hist_cited_by_overlap` | A-P-P | 历史论文 ∩ 候选被引集 | | 9 | Jaccard(hist, refs) | A-P-P 归一 | 引用方向重叠率 | | 10 | Jaccard(hist, cited_by) | A-P-P 归一 | 被引方向重叠率 | | 11 | `p in co_papers` | A-A-P 二值 | 是否任一合著者读过 | | 12 | `co_read_count` | A-A-P 计数 | **与列 3 恒等(冗余)** | | 13 | `hist_ref_overlap + hist_cited_by_overlap` | A-P-P 合并 | 引用+被引重叠总和 | | 14 | `shared_author_read_count` | **A-P-A-P** | 品味同行读过候选的计数 | | 15 | `shared / len(shared_paper_authors)` | A-P-A-P 归一 | 品味同行读过比例 | | 16 | `log1p(author_degree)` | author 侧 | 度对数压缩 | | 17 | `log1p(paper_degree)` | paper 侧 | 度对数压缩 | > 已知冗余:列 12 与列 3 表达式字面相同(`stack_rank_calibration.py:130` 与 `L139`),"18 维"实为 **17 维独立信息**。LightGBM 对完全共线列自动忽略,不影响最终结果(审计 inconsistency #13)。 ### 输出表:特征组 × 图结构 × 推荐信号 | 特征族 | 维度 | 直观含义 | 对应图结构 | 可能捕捉的推荐信号 | |---|---|---|---|---| | A-A-P | 4 | 合著者读过候选 | author→author→paper | 同行/导师推荐 | | A-P-P | 6 | 历史论文与候选引用重叠 | author→paper→paper | 话题延续、同一研究脉络 | | A-P-A-P | 2 | 品味群体读过候选 | author→paper→author→paper | 协同过滤式品味共识(最强协同信号) | | author-side 度 | 4 | 作者活跃度/规模 | author 节点 + 邻居 | popularity、是否高产作者 | | paper-side 度 | 4 | 候选论文流行度 | paper 节点 + 引用度 | popularity 归一分母、热门论文 | | `add_rank_features` | 4 | raw 分及全局/组内排序 | (作用于 score) | 把绝对分转成相对位次,纠正 LightGCN 概率漂移 | 最后一行 `add_rank_features`(`stack_rank_calibration.py:148-160`)输出 `[score, global_rank01, author_pct, author_rank]`。它把 LightGCN 的 raw 点积分两层:**全局 rank01** 与**同 author 组内的相对百分位/序号**。这一步很重要,因为不同作者的 LightGCN 打分量纲不同(高产作者分普遍高),组内排序做相对校准。 --- ## 4. 实现细节:为什么是 set 运算而非稀疏矩阵? 尽管 CLAUDE.md 在总体架构里提到"sparse-matrix shapes",但 `stack_rank_calibration.py` **本身零 scipy.sparse 调用**(审计 inconsistency #12)。meta-path 全部用 **Python `set` 的交/并集 + 预计算邻域**实现: ``` # 预计算(L91-106) shared_paper_authors[a] = ∪_{p∈author_papers[a]} paper_readers[p] \ {a} # A-P-A 邻域 coauthor_paper_union[a] = ∪_{c∈coauthors[a]} author_papers[c] # A-A-P 复用 # 逐 pair(L120-125) co_read_count = #{c∈coauthors[a] : p∈author_papers[c]} # A-A-P hist_ref_overlap = |author_papers[a] ∩ paper_refs[p]| # A-P-P fwd hist_cited_by_overlap = |author_papers[a] ∩ paper_cited_by[p]| # A-P-P bwd shared_author_read_count = |shared_paper_authors[a] ∩ paper_readers[p]| # A-P-A-P ``` 为什么用 set 而非稀疏矩阵乘法?因为本图的 **A-P-A-P 等 4 跳路径** 是**带中间类型约束**的,用稀疏矩阵连乘会得到所有 4 跳可达(含噪声路径),而 set 交集是**精确的"同时满足多类型约束"的计数**,语义干净。代价是逐 pair 的 Python for 循环(无向量化),但 136,484 验证对仍可在秒级跑完。 > 注意:`high_order_graph_stack.py` 里的 `build_high_order*`(下一篇文档详述)才是**真正的稀疏矩阵传播** $H_k=R\cdot C^k$,与本文件的 set 运算是两套不同机制,勿混。 --- ## 5. LightGBM 作为"二阶段融合器"而非主模型 ### 5.1 概念:为什么 LightGBM 适合做融合 LightGBM 是梯度提升树(GBDT)。在本项目里它**不是从原始图学嵌入**的主模型,而是吃"一堆已经算好的特征列"、输出一个组合概率的**meta-learner**。它适合这个角色的三个原因: 1. **能吃异质特征**:点积分分、计数、Jaccard、log、rank01 混在一起,树模型对量纲和分布不敏感,不像 logistic regression 需要标准化。 2. **能学硬切分与交互**:树天然捕获"A-A-P 计数 > 1 且 paper 归一化度 < 0.3"这类**条件组合**,这是手工特征最有用的用法 —— 单一特征弱,组合起来强。 3. **自带正则**:`num_leaves=15`、`reg_lambda=8.0`、`min_child_samples=100`(`high_order_graph_stack.py:184-190`)把模型容量压得很低,防止 259 维特征在小验证集上过拟合。 ### 5.2 输入输出 | 方向 | 数据 | 形状 | |---|---|---| | 输入 `X` | 拼接后的特征矩阵 | `(npairs, 259)`,npairs = 136484(验证)/ 2047262(测试) | | 输入 `y` | 验证标签(1:1 正负) | `(136484,)` 二值 | | 输出(验证) | OOF 概率 | `(136484,)` → `rich_rw7_highorder_directed_oof.npy` | | 输出(测试) | 测试概率 | `(2047262,)` → 供 rank-cutoff 决策 | 259 维的拼接顺序(`high_order_graph_stack.py:292`): ``` X_base(84) = add_rank(4) + Explicit(18) + neg_evidence(8) + topk_content(3) + variant(43) + content_mean(4) + mf_bpr(4) X_rich(18) = content_rich_features(feature.pkl) 7×RW(77) = 7 个随机游走块 × 11 RW_agg(11) = 一致性聚合 HO_undir(24) + HO_dir(45) = 高阶引用传播 ───────────────────────────────────────── 合计 259 维 ``` 其中本文聚焦的 **graph/meta-path 特征 = `add_rank(4) + Explicit(18) = 22 维`**,是整条链最底层的"地基"(`post95_ablation` 的 baseline_stacking 正是这 22 维,F1=0.9559917)。 --- ## 6. OOF stacking:如何避免验证泄漏? 这是本节最关键的工程问题。朴素想法:把 259 维特征算好,在 136,484 验证对上训一个 LightGBM,再用它打分验证对自身 —— **这会泄漏**(模型见过验证样本的特征与标签)。 本仓库用 **5-fold StratifiedKFold Out-Of-Fold**(`fit_lgb_oof`, `high_order_graph_stack.py:179-198`;`fit_oof`, `stack_rank_calibration.py:163-184`): ``` 把 136484 个验证对分成 5 折 for fold in 1..5: 训练集 = 另外 4 折 (X[tr], y[tr]) 验证集 = 当前折 (X[va]) clf.fit(X[tr], y[tr]) oof[va] = clf.predict_proba(X[va])[:, 1] # 模型从未见过 va 的标签 最终 oof 是"每个样本被一个没见过它的模型打出的分" F1 = best_f1(y, oof) # 在 PR 曲线上取最优阈值 ``` **为什么 OOF 是 leak-free 的**:每个验证样本的预测来自一个**训练时从未碰过它**的模型。F1 在 `oof` 上算等价于在一个独立测试集上算,因此 0.966874 这个数可以被信任。 **一个容易被忽视的细节**:graph/meta-path 特征本身是**用训练正边(`bipartite_train_ann.txt`)构造的**,而验证正例是**从训练正边里留出 10%**(`make_notebook_style_split`,`train_val_lgcn_ensemble.py:132-165`)。如果把留出的验证正边也算进 `train_refs` 去 `ExplicitGraphFeatures`,就是 label leakage。本仓库在 test 阶段用 `--test-feature-source full`(`stack_rank_calibration.py:213`)用全量训练集重建特征,这是 test 专用;验证阶段则只在不留出的 90% 上构造,保证干净。复算确认:`val_labels_seed202.npy` 对 `rich_rw7_highorder_directed_oof.npy` 的 best F1 = 0.966874 / AUC = 0.994918(事实表 §2.8,标签与 OOF 已对齐无泄漏)。 --- ## 7. 为什么 graph/meta-path stacking 是最大增益之一? 数据说话(同一 seed=202、1:1 OOF,来源 reports/exploration_summary.md L113): | 阶段 | 特征维 | 验证 F1 | 增量 | |---|---|---|---| | LightGCN raw | — | 0.938576 | — | | + 手工图特征 only(22 维) | 22 | 0.944992 | **+0.0064** | | + 手工图特征 stacking(LightGBM 融合 LightGCN 分数) | — | **0.955992** | **+0.0174** | **为什么 stacking(+0.0174)远大于 handcrafted-only(+0.0064)?** 因为单看 18 维手工特征,信息量有限(0.945);但当 LightGBM 把它们与 LightGCN 的隐式分数**放在一起学交互**时,模型学到的是"LightGCN 觉得一般、但合著者共识很强的对"该往上调、"LightGCN 觉得高、但 A-P-A-P 为零"该往下压。**手工特征的真正价值不在自身,而在于给 LightGCN 这个模糊系统提供"可被组合的精确证据"**。这正是 stacking 的精髓 —— 整体大于部分之和。 对比后续其他特征组的增量(单组,1:1 OOF):content mean-cos +0.00052、BPR-MF(到 84 维 X_base)+0.0017、rich content(18 维)+0.00062、7 RW 块集成约 +0.002、高阶有向传播 +0.0003(214→259 维)。**graph/meta-path 的 +0.0174 仍遥遥领先**,是 pipeline 里单一最大增益。 --- ## 8. feature-group contribution 怎么解释(论文图建议) 本机未装 lightgbm,无法直接出 gain importance 图(事实表 §7 图表规则)。替代方案是**消融贡献瀑布(ablation waterfall)**:固定其他组,逐组"拆掉/加上",看 OOF F1 的差值。 | 增量来源 | 维 | ΔF1(1:1 OOF) | 解读 | |---|---|---|---| | LightGCN → +graph/meta-path stacking | 22 | +0.0174 | 最大单一增益 | | + variant(Post95 变体特征) | 43→76 | +0.0011 | LightGCN 多变体聚合 | | + BPR-MF | +4 | +0.0017 | 互补 CF(content 0.95763 → BPR 0.95931)| | + rich content | +18 | +0.0006 | 深度内容画像 | | + 7 RW blocks | +77+11 | ≈+0.003 | 游走全局接近度 | | + 高阶引用传播(undir→dir) | +24,+45 | +0.0016,+0.0003 | 高阶扩散 | > 已知张力:post95 内部消融里 negative_evidence / topk 组在 baseline 上**微负/微正**(Δ≈-0.000235 / +0.00058,post95_ablation/ablation_table.csv)。这些组作为**交互项**在更大栈中才有用,单独贡献弱。论文叙事若把每组都列为"正向",应注明是"在最终栈中"而非"单独增量"(审计 inconsistency #16)。 --- ## 9. 可迁移到论文中的写法 以下表述可直接进 TeX,数字与事实表一致。 > **模型结构(中文 ACM 论文,可直译)。** 本文采用两阶段 stacking 架构。第一阶段由多个独立模型从异构学术网络中提取原始分数与嵌入,包括 LightGCN 集成、BPR 矩阵分解、DeepWalk/Node2Vec、内容画像以及显式图/元路径特征。第二阶段由一个 259 维的 LightGBM 二级元学习器融合所有一阶段输出,输出最终链接概率。在 seed=202 的 1:1 人工验证集上,我们用 5 折分层交叉验证(out-of-fold, OOF)得到无泄漏验证 F1 = 0.9669(AUC = 0.9949);测试决策采用 rank-cutoff(取分数最高的 50% 预测为正)并强制训练集已知正边预测为 1,公开榜单 F1 = 0.96626。 > **手工图/元路径特征(方法节)。** 为弥补 LightGCN 隐向量难以精确表达带类型多跳关系这一不足,我们构造了一组 18 维手工图特征,覆盖三类元路径:A-A-P(合著者共读)、A-P-P(历史论文与候选论文的引用/被引重叠)以及 A-P-A-P(共享历史论文的品味同行也读了候选)。每条元路径同时保留原始计数与归一化比率(Jaccard、按合著者数归一、$\log 1\!p$ 度压缩),使树模型可在绝对与相对量纲间自适应选择。此外对 LightGCN 原始分数额外计算 4 维排序特征(全局 rank01 与同作者组内百分位),以纠正不同作者打分量纲不一致。在 seed=202 验证集上,仅用手工图特征训练的 LightGBM 达 F1=0.9450,而将其与 LightGCN 分数一并交给 LightGBM 做 stacking 后,F1 提升至 0.9560,单组增量 +0.0174,为整个管线中最大的单一特征组贡献,验证了"手工精确证据与隐式相似度互补"的假设。 > **OOF 评估(实验节)。** 为避免验证泄漏,所有 F1 报告均基于 5 折分层 K 折 OOF:每个验证样本的预测来自一个训练时未见过该样本的模型,从而等价于独立测试集评估。手工特征仅用未留出的 90% 训练正边构造,验证正例被严格排除在特征构建之外。 > **决策规则(实验节)。** 由于 1:1 人工验证集与真实测试集正先验分布不同,验证最优概率阈值($\tau^*=0.4617$)无法直接迁移。我们改用 rank-cutoff 决策:将测试对按 LightGBM 分数排序,预测分数最高的 50% 为正,并强制所有训练集已知正边预测为 1。 --- ## 附:关键代码与文件速查 | 内容 | 位置 | |---|---| | 18 维手工特征定义 | `code/stack_rank_calibration.py:108-145` | | A-P-A / A-A-P 邻域预计算 | `code/stack_rank_calibration.py:91-106` | | 4 维排序特征 | `code/stack_rank_calibration.py:148-160` | | 5 折 OOF LightGBM(stack 库) | `code/stack_rank_calibration.py:163-184` | | 最终 259 维 OOF + 全量预测 | `code/high_order_graph_stack.py:179-216` | | 259 维拼接 | `code/high_order_graph_stack.py:292` | | rank-cutoff 决策 | `code/high_order_graph_stack.py:219-229` | | 验证 F1 / AUC / 阈值 | `validation_runs/dynamic_seed202/high_order_graph_stack/validation_summary.csv` |