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)学的是:
即每个 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。它适合这个角色的三个原因:
- 能吃异质特征:点积分分、计数、Jaccard、log、rank01 混在一起,树模型对量纲和分布不敏感,不像 logistic regression 需要标准化。
- 能学硬切分与交互:树天然捕获"A-A-P 计数 > 1 且 paper 归一化度 < 0.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 |