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

04 — LightGCN 第一性原理

目标读者: 懂一点 ML、但不完全熟悉图推荐系统的同学。本文从"推荐是什么"出发, 推导到本项目的 LightGCN 实现, 再到它的盲区与为何需要 stacking。 权威数字来源: train_val_lgcn_ensemble.py 源码 + 代码复算, 与 fact_sheet §2.3 对齐。


1. 从推荐问题到二部图

1.1 推荐本质: 给"用户×物品"打分排序

推荐系统的核心问题可以写成一行:

给定用户 u 和物品 i, 预测他们之间发生交互(读 / 点击 / 购买)的概率。 \text{给定用户 } u \text{ 和物品 } i,\ \text{预测他们之间发生交互(读 / 点击 / 购买)的概率。}

我们已有的是一批"发生过"的交互(显式评分, 或隐式行为), 要对一大批没见过的 $(u,i)$ 对打分并排序, 把高分物品推给用户。本项目里:

通用推荐 本项目
user author(作者), 共 6,611 个 (id 0–6610)
item paper(论文), 共 79,937 个 (id 0–79936)
交互 author 读了 / 引用了 paper (作者→论文正边)
训练正边 bipartite_train_ann.txt, 682,421
待预测对 bipartite_test_ann.txt, 2,047,262 对 (≈2.05M)
评估指标 F1 (标准 $2PR/(P+R)$), 公开榜仅评 50% 测试集

1.2 推荐为何可看成"二部图链路预测"

把所有已知的 author→paper 交互画出来, 会得到一张二部图 (bipartite graph): 左边全是 author 节点, 右边全是 paper 节点, 边只跨左右连接, 内部不连。

"推荐一篇 paper 给某 author" = "在这张二部图上, 给一个还没边相连的 (author, paper) 对预测一条边"。这正是**链路预测 (link prediction)**。所以推荐 = 二部图链路预测, 这一句话把两类问题统一了。

本项目的图其实比纯二部图更丰富, 还有两类辅助边(构成异构图 heterogeneous graph):

边类型 文件 规模 语义
author → paper bipartite_train_ann.txt 682,421 作者读了论文
author ↔ author author_file_ann.txt 9,663 合著(无向)
paper → paper paper_file_ann.txt 327,113 引用(有向 former→latter)

二部图密度约 $682421/(6611\times79937)\approx 1.29\times10^{-3}$, 极稀疏; 且约 56% 的作者度=1(长尾)。稀疏 + 长尾, 是后续要用图传播和 stacking 的根本动因。


2. Embedding 是什么: 把节点变成向量

2.1 直觉

计算机不能直接"理解"一个 author id 或 paper id。Embedding 就是给每个节点学一个 $d$ 维向量, 让"语义/行为相近"的节点在向量空间里几何上靠近。学完之后, 推荐就退化成向量运算: 对候选 (author $a$, paper $p$), 用一个打分函数 $s(a,p)$ 估出"亲近度", 排序即推荐。

本项目最经典的打分函数是点积:

s(a,p)=ea, ep=k=1dea(k)ep(k) s(a,p) = \langle \mathbf{e}_a,\ \mathbf{e}_p\rangle = \sum_{k=1}^{d} e_a^{(k)}\, e_p^{(k)}

对应 code/train_val_lgcn_ensemble.py:102-104:

def decode(self, z_dict, edge_index):
    src, dst = edge_index
    return (z_dict["author"][src] * z_dict["paper"][dst]).sum(dim=-1)

2.2 两种 embedding 来源

本项目对两类节点用不同方式初始化 embedding:

  • author 节点: 没有任何输入特征, 直接用一个可训练的 nn.Embedding 学 6,611×$d$ 的查找表。即"作者身份"完全由其在图里的连接结构决定 (train_val_lgcn_ensemble.py:76, :89)。
  • paper 节点: 有现成的 512 维 USE 句向量 (feature.pkl, 形状 (79937, 512), float32)。再拼上 3 列 log 归一化度数特征 (ref 度 / 引用出度 / 引用入度, :199-202), 共 515 维, 经一个 nn.Linear(515, d) 线性投影成 $d$ 维 (:77, :90)。

注意: 论文的 USE 嵌入只作为输入特征经线性投影喂进去, 被解码层直接当成最终表示。这是 LightGCN 纯协同过滤 (collaborative filtering, CF) 思路的一个体现——内容语义在它眼里只是"初值", 真正的表示靠图传播学出来。


3. 邻居传播为何有助于推荐

3.1 一个朴素道理

如果 author A 和 author B 都读了同一批论文, 那 A 和 B 的兴趣相近; 于是 B 读了而 A 没读的那篇论文, 大概率 A 也想读。这条朴素道理的数学化, 就是沿边把邻居的信息聚合到自己身上: 节点的新表示 = 它自身表示 + 邻居表示的某种平均。

这正是图卷积 (graph convolution) 的动机。在二部图上做一轮传播, 就让每个 author 沾上了"它读过论文的表示", 每篇 paper 沾上了"读过它的作者的表示"; 多轮传播, 信息就能跨多跳扩散 (author→paper→author→paper…)。

3.2 LightGCN 的层聚合公式

本项目实现的 LightGCNLayer纯加权和邻居聚合, 没有非线性, 没有可学的特征变换矩阵 $W$ (train_val_lgcn_ensemble.py:49-70):

e(l+1)=1degjN(i)ej(l) \mathbf{e}^{(l+1)} = \frac{1}{\deg}\sum_{j\in\mathcal{N}(i)} \mathbf{e}_j^{(l)}

具体实现 (:60-66): 用 index_add_ 把每条边的源节点嵌入累加到目标节点, 除以目标节点入度 (clamp(min=1) 防除零); 同一节点类型有多条入边类型时取平均 (:67-69)。

最终表示是所有层的 layer-0 加权和 (:87-100):

ei=1L+1l=0Lei(l) \mathbf{e}_i = \frac{1}{L+1}\sum_{l=0}^{L} \mathbf{e}_i^{(l)}

权重是均匀的 $\frac{1}{L+1}$ (vanilla variant)。另有 LearnableWeightLightGCN 用 softmax 学权重 (:112-125, variant=learnw)。

直观解释: 第 0 层是"自身输入", 第 1 层是"直接邻居", 第 2 层是"邻居的邻居"…… 把各层平均起来, 相当于让一个节点同时记着自己、直接邻居、二跳邻居的混合信息。score(a,p)=\langle \mathbf{e}_a,\mathbf{e}_p\rangle 因此同时编码了"作者-论文直接共现"和"多跳结构相似度"两种信号。


4. GCN vs LightGCN: 为何要"砍掉"

4.1 标准 GCN 在做什么

标准 GCN(用于节点分类等)的一层:

H(l+1)=σ ⁣(D~1/2A~D~1/2H(l)W(l)) \mathbf{H}^{(l+1)} = \sigma\!\left(\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}\,\mathbf{H}^{(l)}\mathbf{W}^{(l)}\right)

它有三件"重装备": (1) 线性变换矩阵 $\mathbf{W}^{(l)}$; (2) 非线性激活 $\sigma$ (ReLU 等); (3) 归一化邻接矩阵。

4.2 LightGCN 的发现与简化

LightGCN(He et al., 2020) 在纯 CF 推荐场景做了一组对照实验, 发现那两件"重装备"($\mathbf{W}$ 和非线性)不仅没帮助, 反而拖后腿。原因是推荐用的是隐式反馈、目标是"排序的相对正确性", 不是节点分类那种"语义可分性"; 多余的变换层引入了需要拟合的参数, 在稀疏图上更容易过拟合。

于是 LightGCN 把一层砍成只剩邻居加权和平均:

部件 标准 GCN LightGCN
线性变换 $W$
非线性 $\sigma$
层间组合 通常取最后一层 所有层均匀加权求和
目标 节点表示判别 嵌入点积排序

对应代码里 LightGCNLayer 全程无 nn.Linear、无 F.relu(:49-70)。这种"更轻"的设计在纯 CF 推荐里训练更快、泛化更好。

4.3 本项目的图构造细节

build_data (:239-270) 把异构图搭成 4 种边类型的 PyG HeteroData:

author --ref-->   paper     (训练正边)
paper  --beref--> author    (ref 的反向, 让论文也能收到作者信息)
paper  --cite-->  paper     (引用边, 双向拼接, 当无向用)
author --coauthor--> author (合著边, 双向拼接)

引用 / 合著边都被双向拼接(:263-269), 即在异构图里当成对称边处理。这是一个建模选择: 把有向引用近似成无向, 让信息双向流动。


5. BPR loss: 为何比 BCE 更适合排序

5.1 排序任务要的是"相对顺序", 不是"绝对概率"

推荐最在乎的是: 对同一个 user, 真正会交互的 item 排名应当高于不会交互的 item。这是相对比较, 不是"预测出 0.9 还是 0.1"的绝对值。BPR (Bayesian Personalized Ranking) 正是为此设计的——它直接优化正样本分高于负样本分的间隔:

LBPR=1B(a,p,n)logσ ⁣(s(a,p)s(a,n)) \mathcal{L}_{\text{BPR}} = -\frac{1}{|B|}\sum_{(a,p,n)}\log\sigma\!\big(s(a,p)-s(a,n)\big)

其中 $\sigma$ 是 sigmoid。直觉: 当正样本分 $s(a,p)$ 远高于负样本分 $s(a,n)$ 时, $\sigma(\cdot)\to1$, $\log\to0$, 损失趋近 0(几乎不更新); 当正样本分不够高时, 损失大, 把它们拉开。对应 train_val_lgcn_ensemble.py:376-377:

if args.loss == "bpr":
    loss = -F.logsigmoid(pos_scores - neg_scores).mean()

5.2 与 BCE / hinge 对比

代码同时支持三种损失 (:376-385):

损失 公式 适合场景
BPR $-\log\sigma(s^+ - s^-)$ 隐式反馈排序(本项目默认, --loss bpr)
hinge $\max(0,,m - s^+ + s^-)$ 强间隔要求
BCE $\text{BCE}(\text{logits}, y)$ 显式二分类、概率校准

为何 BPR 比 BCE 更适合本项目? (1) 我们只有"读过 / 没读过"的隐式信号, 没有"打几分"的显式偏好; (2) 评估指标 F1 最终依赖排序质量(尤其 rank cutoff 决策), BPR 直接优化相对排序; (3) BCE 把"未观察到"当负标签, 在稀疏图里会引入大量假负 (作者其实会读但还没读), BPR 的成对比较对此更稳健。


6. Negative Sampling: 没有边就是负样本吗?

6.1 为何要负采样

BPR 需要正样本对负样本比较。正样本(已观察边)很清楚, 但负样本呢? 二部图里绝大多数 (author, paper) 对是"没边"的——但"没观察到"不等于"作者不喜欢"。这是隐式反馈的核心难点: 缺失值 ≠ 负值。

实践做法是 negative sampling: 从"训练集里没出现过的对"中采样一部分当负样本, 而不是全用。

6.2 本项目的混合硬负采样

sample_hard_negatives (:273-305) 不是纯随机采负, 而是按比例混合三类负样本:

来源 比例 直觉
随机 (author, paper) 50% 简单负样本, 提供基准
热门论文 (popular) 25% 让模型别"无脑推荐热门"; 流行但作者没读的, 是更难的负例
合著者读过、自己没读的 (coauthor_pool) 25% 最难、最 informative 的负例——同事读了, 你大概率也"应该"读过却没读

popular 由论文 ref 度的 70 百分位定义 (:223); coauthor_pool 是每位作者合著者读过、自己没读的论文集 (:215-221)。负采样比例对应 --neg-per-pos=2(每个正例采 2 个负例, :433)。

这种"硬负"策略让模型聚焦于"易混"的对, 比纯随机负采样收敛后的排序质量更高。


7. 多 seed / 多 checkpoint ensemble 的价值

7.1 单模型为何不稳

GNN 训练有两个随机性来源: (1) 权重初始化 (set_seed, :28-33); (2) 每个 epoch 的 mini-batch 顺序和负采样 (torch.randperm, :365)。不同随机种子训出的模型, 在验证集上的 F1 会在小范围内波动(本项目跨 seed 201–204 的验证 F1 约 [0.9365, 0.9386], 方差 < 0.001)。

7.2 Ensemble 降低方差、提升主分数

main (:417-502) 的训练是双层循环 for dim × for seed, 每个 (seed, dim) 单独训一个 checkpoint 并保存其最佳 epoch 的验证分数 (:478-481)。最后对所有单模型分数取均值得到 ensemble 分数 (:496):

ensemble = np.mean(val_scores, axis=0)

为何取均值有用? 不同 seed 的模型犯的错不完全重叠(相关性 < 1), 平均后随机噪声相互抵消, 系统信号保留。本项目最佳 run dyn202_l2d512_bpr_bigbatch_more 就是 2 个 seed (41, 141) 的 512 维 / 2 层 / dot 模型的均值, 验证 F1 = 0.93857648, 阈值 3.5021, AUC 0.9837 (与 dynamic_summary.csv 及代码复算完全一致, 逐元素 max abs diff = 0.0)。

重要澄清(见 fact_sheet 不一致台账 #8, #10, #11): argparse 默认 --layers=4--eval-mode=cos (:426, :441), 但最佳 run 显式用 --layers 2 --eval-mode dot, run 名 l2d512 即编码 L=2/d=512。早期另有一个 6-model 集成(public 0.93044), 与本 stacking 主分数(验证 0.9386, 未单独提交)是两个不同阶段的产物, 勿混用。


8. 验证切分: 人工 1:1 与最优阈值

8.1 make_notebook_style_split

make_notebook_style_split (:132-165) 是本仓库事实共享库的关键函数, 它这样造验证集:

  1. bipartite_train_ann.txt 全部 682,421 条正边;
  2. pandas.sample(frac=0.9, random_state=seed) 抽 90% 作训练边, 剩 10% (≈68,242) 作验证正例, label=1 (:148-150);
  3. np.random.default_rng(seed)等量随机非边作验证负例, label=0 (:152-159);
  4. concat 后 sample(frac=1) 打乱, 形成人工 1:1 验证集 (:163-164)。

实测 seed=202 复现: 正例 68,242 + 负例 68,242 = 136,484 对, 与 bigbatch_more/scores/val_vanilla_ensemble_mean.npy 长度 136,484 完全一致。

为何 1:1? 真实测试集正负极不均衡(正例是稀疏的), 但 1:1 让 F1 / PR 曲线在验证集上数值稳定、可解释, 便于比较不同特征/模型。代价是: 1:1 是"人工"的, 验证最优阈值等于测试最优阈值——这正是最终测试决策改用 rank cutoff 的根本原因(见 §10)。

8.2 best_f1: 验证最优排序阈值

best_f1 (:340-346) 用 precision_recall_curve 算 PR 曲线, 逐点算 $\text{F1}=2PR/(P+R)$, argmax 取最优阈值, 并用 roc_auc_score 算 AUC。这是验证集上leak-free 的排序型最优阈值, 只用于在训练时挑 best checkpoint (:402-406) 和报告验证 F1, 不是测试集的最终决策。


9. LightGCN 在本项目里的作用, 以及它的盲区

9.1 作用: Stage-1 主分数生产者

在整个两阶段 stacking 里, LightGCN 集成是最重要的 raw 分数来源high_order_graph_stack.py 的 259 维特征里, 排序特征 (add_rank_features) 和大量变体特征都直接建立在 LightGCN 分数之上。它的验证 F1 0.9386 已经把任务从 baseline 0.8850 拉到接近 0.94, 是后面所有 stacker 的地基。

9.2 LightGCN 的盲区(为何还需要 stacker)

LightGCN 只学到了协同过滤意义上的"作者-论文共现"嵌入 + 异构图邻居传播的结构相似度。它有几个结构性盲区, 而下游 stacking 正是逐个补上它们:

盲区 补它的 Stage-1 来源
不含内容语义(USE 嵌入仅作线性投影输入, 解码不直接用) content mean-cos、rich content(18 维, content_rich_ablation.py)
不含高阶 typed meta-path(A-A-P、A-P-P^k 等) 显式图 / meta-path 特征 18 维(stack_rank_calibration.py)、高阶引用传播 H_k=R·C^kG_k=S·R·C^k(high_order_graph_stack.py)
不含随机游走全局接近度 DeepWalk / Node2Vec 7 块 + RW 一致性聚合(randomwalk_systematic_ablation.py)
单一 CF 视角 BPR-MF(矩阵分解, 另一种 CF 互补视角, extra_score_sources_ablation.py)

正是把这些"互补信号"拼成 259 维喂给 LightGBM, 才把验证 F1 从 0.9386 推到 0.966874、公开 F1 到 0.96626

9.3 LightGCN 与显式特征 / stacker 的分工

  • LightGCN: 学隐式的、低维的、全局可微的结构表示——但它"黑盒", 单一视角, 难注入人类先验。
  • 显式特征: 抓人类可解释的强信号(meta-path 计数、度数、内容相似度)——但它"分段", 不能端到端学交互。
  • stacker (LightGBM): 把两类信号非线性地融合, 在 1:1 OOF 上 leak-free 地学"什么信号在什么条件下可信"。

三者互补, 缺一不可。这就是为什么本项目不是"LightGCN 一把梭", 而是"LightGCN 当地基 + 多源补盲 + LightGBM 融合"。


10. 测试决策: 为什么不用最优概率阈值

最终测试决策不是验证最优概率阈值 0.4617, 而是 rank cutoff top 50% + 强制已知训练正边为 1:

sort test pairs by LightGBM score → predict top 50% as 1
+ force any pair in train edges to 1 (test_known_mask.npy)

原因(见 fact_sheet §2.6): 1:1 验证集是人工的, 与真实测试集的类别先验不同; 迁移到 test 后正例率漂移到约 0.5242, 导致概率阈值校准失效。改用 rank cutoff 不依赖绝对概率, 只依赖相对排序, 对分布漂移更鲁棒。test_known_mask.npy(cached_scores/, 实测存在)则把"训练里就有的正边"强制置 1, 保证已知信息不丢。


11. 关键代码位置速查

关注点 位置
LightGCN 纯加权和邻居聚合层 code/train_val_lgcn_ensemble.py:49-70
所有层均匀加权求和 encode code/train_val_lgcn_ensemble.py:87-100
学习层权重 (learnw) code/train_val_lgcn_ensemble.py:112-125
点积解码 s(a,p)=<e_a,e_p> code/train_val_lgcn_ensemble.py:102-104
异构图 4 边类型构造 code/train_val_lgcn_ensemble.py:239-270
混合硬负采样 50/25/25 code/train_val_lgcn_ensemble.py:273-305
BPR / hinge / BCE loss code/train_val_lgcn_ensemble.py:376-385
BPR loss 主体 code/train_val_lgcn_ensemble.py:376-377
notebook 切分(1:1 验证) code/train_val_lgcn_ensemble.py:132-165
best_f1 PR argmax code/train_val_lgcn_ensemble.py:340-346
train_one 训练循环 code/train_val_lgcn_ensemble.py:349-414
main: dim×seed 双循环 + 均值 ensemble code/train_val_lgcn_ensemble.py:417-502
硬编码 6611 / 79937 :216 (range(6611)), :437-438 (argparse 默认)

可迁移到论文中的写法

以下为可直接进 TeX 的正式表述。

LightGCN 层聚合(模型定义): ei(l+1)=jN(i)1N(i)ej(l),ei=1L+1l=0Lei(l),s(a,p)=ea,ep \mathbf{e}_i^{(l+1)} = \sum_{j\in\mathcal{N}(i)} \frac{1}{\sqrt{|\mathcal{N}(i)|}}\,\mathbf{e}_j^{(l)},\qquad \mathbf{e}_i = \frac{1}{L+1}\sum_{l=0}^{L}\mathbf{e}_i^{(l)},\qquad s(a,p)=\langle \mathbf{e}_a, \mathbf{e}_p\rangle

BPR 目标(训练): LBPR=1B(a,p,n)Blogσ ⁣(s(a,p)s(a,n)) \mathcal{L}_{\mathrm{BPR}}=-\frac{1}{|B|}\sum_{(a,p,n)\in B}\log\sigma\!\left(s(a,p)-s(a,n)\right)

论文段落(可直接改写为英文/中文正文):

我们将 author–paper 推荐建模为异构二部图上的链路预测问题。图的左侧为 6,611 名作者, 右侧为 79,937 篇论文, 边包括作者–论文阅读边 (682,421 条)、作者–作者合著边 (9,663 条) 与论文–论文引用边 (327,113 条), 二部图密度仅约 $1.29\times10^{-3}$, 呈高度稀疏与长尾特性。

作为第一阶段主分数生产者, 我们训练一个 LightGCN 嵌入模型。相较标准 GCN, LightGCN 移除了逐层特征变换矩阵与非线性激活, 仅保留入度归一的邻居加权和; 这一"轻量化"在仅有隐式反馈的协同过滤场景下能减少过拟合、加速收敛。作者的表示由可训练 embedding 初始化, 论文的表示由 512 维 USE 句向量拼接 3 维对数度数特征经线性投影得到。模型以 $L$ 层传播后, 对所有层(含第 0 层)取均匀加权求和, 并以点积 $s(a,p)=\langle\mathbf{e}_a,\mathbf{e}_p\rangle$ 作为打分函数。

训练采用 Bayesian Personalized Ranking (BPR) 损失直接优化正样本相对于负样本的排序间隔, 这比逐样本的 BCE 更契合以排序质量(F1 / rank cutoff)为目标的隐式反馈任务。为缓解"未观察即负"的假负问题并提升负样本质量, 我们采用混合硬负采样: 50% 随机负例、25% 热门论文负例、25% 合著者读过而本人未读的负例。我们对多个随机种子与维度的 checkpoint 取分数均值形成集成, 在动态 1:1 验证集上获得 F1 = 0.9386, AUC = 0.9837。

LightGCN 仅捕获协同过滤意义上的共现与结构相似度, 缺乏内容语义、高阶 typed meta-path 与全局随机游走接近度。为此, 我们将其分数作为地基, 叠加内容画像、BPR 矩阵分解、显式 meta-path、DeepWalk/Node2Vec 游走特征以及有向高阶引用传播 $H_k=R\cdot C^k$、$G_k=S\cdot R\cdot C^k$ 共 259 维, 由一个 LightGBM 二级 meta-learner 在 5 折 stratified OOF 下融合, 将验证 F1 提升至 0.9669, 公开榜 F1 达 0.96626。

关于阈值与决策(论文方法节注脚):

由于动态验证集采用人工 1:1 正负比, 与测试集真实类别先验不同(迁移后正例率漂移至约 0.524), 概率阈值校准失效。最终测试决策因此改用 rank cutoff: 按 LightGBM 分数排序取 top 50% 为正, 并将训练集中已存在的正边由 test_known_mask 强制置 1, 以对分布漂移保持鲁棒。