cs3319-project2 / docs_first_principles /05_feature_stacking_and_lightgbm.md
NLP-beginner's picture
CS3319 Project 2 final deliverable (public F1 = 0.96626)
f28d994
|
Raw
History Blame Contribute Delete
19.3 kB

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)学的是:

score(a,p)=eaep\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:130L139),"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=15reg_lambda=8.0min_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_refsExplicitGraphFeatures,就是 label leakage。本仓库在 test 阶段用 --test-feature-source full(stack_rank_calibration.py:213)用全量训练集重建特征,这是 test 专用;验证阶段则只在不留出的 90% 上构造,保证干净。复算确认:val_labels_seed202.npyrich_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