CH 06 / SOURCE EXAMPLE
ltr_pointwise_pairwise_listwise.py
ch06 精排模块/code/ch02_ranking_and_ltr_models/ltr_pointwise_pairwise_listwise.py
这个示例讲什么
│ └── ltr_pointwise_pairwise_listwise.py Pointwise / Pairwise / Listwise 三大 LTR 范式同网络对比
LTR 排序学习:Pointwise / Pairwise / Listwise 最小对比 (第 6 章 §6.2 精排模型与 LTR 模型) 对应正文概念: 正文指出 LTR 模型「不直接预测用户行为标签,而是基于业务经验构造排序 Reward 或排序监督信号(用户综合满意度、长周期价值、高价值行为组合), 通过排序学习的方式直接优化最终排序效果」。本脚本就围绕这个「排序 Reward」标签,把 LTR 的三大范式放在同一份数据、同一个打分网络上对比。 注意:正文 §6.2 只提出了「LTR 直接优化排序效果」这一思想,没有点名具体 算法。本脚本选用学界最经典的三条基线(RankNet / BPR / ListNet / LambdaRank),论文出处见 README。 本实现做了什么: 用同一个 3 层 MLP 打分器 f(x),分别用 5 种损失训练,在同一测试集上比较 ND
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
make_lists· 第 57 行返回 (X, rel),X:[n_list, LIST_LEN, FEAT_DIM],rel:[n_list, LIST_LEN]。dcg· 第 94 行ndcg_at_k· 第 99 行map_score· 第 106 行以 rel >= 3 为「相关」计算 Average Precision。evaluate· 第 121 行make_scorer· 第 138 行loss_pointwise_mse· 第 151 行loss_pointwise_bce· 第 155 行_pair_masks· 第 160 行返回 [B, L, L] 的 mask,(i, j) 为 True 表示 rel_i > rel_j。loss_ranknet· 第 165 行loss_bpr· 第 174 行BPR:每个 list 内随机采一对 (高档, 低档) 做对比。loss_listnet· 第 193 行ListNet Top-1 概率分布交叉熵。loss_lambdarank· 第 200 行LambdaRank:RankNet pairwise 损失按 |ΔNDCG| 加权。train_one· 第 237 行
1"""2LTR 排序学习:Pointwise / Pairwise / Listwise 最小对比3(第 6 章 §6.2 精排模型与 LTR 模型)45对应正文概念:6正文指出 LTR 模型「不直接预测用户行为标签,而是基于业务经验构造排序7Reward 或排序监督信号(用户综合满意度、长周期价值、高价值行为组合),8通过排序学习的方式直接优化最终排序效果」。本脚本就围绕这个「排序9Reward」标签,把 LTR 的三大范式放在同一份数据、同一个打分网络上对比。1011注意:正文 §6.2 只提出了「LTR 直接优化排序效果」这一思想,没有点名具体12算法。本脚本选用学界最经典的三条基线(RankNet / BPR / ListNet /13LambdaRank),论文出处见 README。1415本实现做了什么:16用同一个 3 层 MLP 打分器 f(x),分别用 5 种损失训练,在同一测试集上比较17NDCG@10 / NDCG@5 / MAP:18 1. Pointwise MSE :把 Reward 当回归值拟合19 2. Pointwise BCE :把「高 Reward」当二分类20 3. Pairwise RankNet :文档对的概率排序损失21 4. Pairwise BPR :正负样本对的 BPR 损失22 5. Listwise ListNet :Top-1 概率分布的交叉熵23 6. Pairwise LambdaRank:RankNet 梯度 × |ΔNDCG|,直接对齐排序指标2425关键公式:26 RankNet: P_ij = sigmoid(s_i - s_j), L = -log P_ij (当 y_i > y_j)27 BPR: L = -log sigmoid(s_pos - s_neg)28 ListNet: P(i) = exp(s_i)/sum_j exp(s_j), L = -sum_i P_y(i) log P_s(i)29 LambdaRank: lambda_ij = |ΔNDCG_ij| * (-sigmoid(-(s_i - s_j)))30 NDCG@k: DCG@k = sum_{i<=k} (2^{rel_i} - 1)/log2(i+1), NDCG = DCG/IDCG31"""3233import math34import random3536import torch37import torch.nn as nn3839FEAT_DIM = 1240LIST_LEN = 20 # 每个请求(query)下的候选物品数41N_TRAIN_LIST = 60042N_TEST_LIST = 20043MAX_REL = 4 # 相关度等级 0..4,由业务 Reward 分档而来444546# --------------------------------------------------------------------------47# 合成数据:每个 query 一组候选,标签是「业务排序 Reward」在 query 内分档48#49# 关键设定(真实 LTR 场景):不同请求的候选池质量差异很大(m_q 代表这一批50# 候选的整体水位),而排序标签是「query 内相对好坏」的档位。也就是说51# 「绝对特征值 -> 档位」的映射依赖同 query 其它候选,模型单看一条样本是看52# 不出来的。Pairwise/Listwise 只需学到 query 内的相对顺序,对 m_q 这种53# query 级平移天然免疫;Pointwise 要去拟合绝对档位值,就会被 m_q 混淆。54# --------------------------------------------------------------------------555657def make_lists(n_list: int, gen: torch.Generator):58 """返回 (X, rel),X:[n_list, LIST_LEN, FEAT_DIM],rel:[n_list, LIST_LEN]。"""59 # m_q:query 级候选池水位(有的请求候选整体优质,有的整体平庸)60 m_q = torch.randn(n_list, 1, 1, generator=gen) * 1.561 X = torch.randn(n_list, LIST_LEN, FEAT_DIM, generator=gen) + m_q62 g2 = torch.Generator().manual_seed(11)63 w_lin = torch.randn(FEAT_DIM, generator=g2)64 w_sq = torch.randn(FEAT_DIM, generator=g2) * 0.565 # 业务 Reward:时长项 + 互动项 + 非线性交叉项(模拟综合满意度)66 reward = (X @ w_lin67 + (X ** 2) @ w_sq68 + 1.2 * X[..., 0] * X[..., 1]69 - 0.8 * X[..., 2].abs())70 reward = reward + 0.6 * torch.randn_like(reward) # 行为噪声71 # 按 query 内分位数分档到 0..MAX_REL(工业上常把 Reward 离散成档位做 LTR)72 rel = torch.zeros_like(reward)73 order = reward.argsort(dim=1, descending=True)74 # 档位配比 5% / 10% / 15% / 25% / 45%,头部稀缺75 cuts = [int(LIST_LEN * c) for c in (0.05, 0.15, 0.30, 0.55)]76 for li in range(n_list):77 for pos, idx in enumerate(order[li]):78 if pos < cuts[0]:79 rel[li, idx] = 480 elif pos < cuts[1]:81 rel[li, idx] = 382 elif pos < cuts[2]:83 rel[li, idx] = 284 elif pos < cuts[3]:85 rel[li, idx] = 186 return X, rel878889# --------------------------------------------------------------------------90# 评估指标91# --------------------------------------------------------------------------929394def dcg(rels: torch.Tensor, k: int) -> float:95 return sum((2.0 ** r.item() - 1) / math.log2(i + 2)96 for i, r in enumerate(rels[:k]))979899def ndcg_at_k(scores: torch.Tensor, rel: torch.Tensor, k: int) -> float:100 ideal = dcg(rel.sort(descending=True).values, k)101 if ideal == 0:102 return 0.0103 return dcg(rel[scores.argsort(descending=True)], k) / ideal104105106def map_score(scores: torch.Tensor, rel: torch.Tensor) -> float:107 """以 rel >= 3 为「相关」计算 Average Precision。"""108 order = scores.argsort(descending=True)109 hits, ap = 0, 0.0110 binrel = (rel >= 3).float()111 total = binrel.sum().item()112 if total == 0:113 return 0.0114 for i, idx in enumerate(order):115 if binrel[idx] > 0:116 hits += 1117 ap += hits / (i + 1)118 return ap / total119120121def evaluate(model, X, rel):122 model.eval()123 with torch.no_grad():124 s = model(X).squeeze(-1) # [n_list, LIST_LEN]125 n = X.shape[0]126 n10 = sum(ndcg_at_k(s[i], rel[i], 10) for i in range(n)) / n127 n5 = sum(ndcg_at_k(s[i], rel[i], 5) for i in range(n)) / n128 mp = sum(map_score(s[i], rel[i]) for i in range(n)) / n129 model.train()130 return n10, n5, mp131132133# --------------------------------------------------------------------------134# 打分器(五种损失共用同一结构,保证对比公平)135# --------------------------------------------------------------------------136137138def make_scorer() -> nn.Module:139 torch.manual_seed(42)140 return nn.Sequential(141 nn.Linear(FEAT_DIM, 64), nn.ReLU(),142 nn.Linear(64, 32), nn.ReLU(),143 nn.Linear(32, 1))144145146# --------------------------------------------------------------------------147# 五种 LTR 损失148# --------------------------------------------------------------------------149150151def loss_pointwise_mse(s, rel):152 return ((s - rel) ** 2).mean()153154155def loss_pointwise_bce(s, rel):156 y = (rel >= 3).float() # 「高 Reward」作为正样本157 return nn.functional.binary_cross_entropy_with_logits(s, y)158159160def _pair_masks(rel):161 """返回 [B, L, L] 的 mask,(i, j) 为 True 表示 rel_i > rel_j。"""162 return (rel.unsqueeze(2) > rel.unsqueeze(1))163164165def loss_ranknet(s, rel):166 diff = s.unsqueeze(2) - s.unsqueeze(1) # s_i - s_j167 mask = _pair_masks(rel)168 if mask.sum() == 0:169 return s.sum() * 0.0170 # L = -log sigmoid(s_i - s_j) = softplus(-(s_i - s_j))171 return nn.functional.softplus(-diff)[mask].mean()172173174def loss_bpr(s, rel):175 """BPR:每个 list 内随机采一对 (高档, 低档) 做对比。"""176 B, L = s.shape177 pos_mask = rel >= 3178 neg_mask = rel == 0179 losses = []180 for b in range(B):181 pi = pos_mask[b].nonzero().flatten()182 ni = neg_mask[b].nonzero().flatten()183 if len(pi) == 0 or len(ni) == 0:184 continue185 p = pi[torch.randint(len(pi), (len(pi),))]186 n = ni[torch.randint(len(ni), (len(pi),))]187 losses.append(nn.functional.softplus(-(s[b, p] - s[b, n])))188 if not losses:189 return s.sum() * 0.0190 return torch.cat(losses).mean()191192193def loss_listnet(s, rel):194 """ListNet Top-1 概率分布交叉熵。"""195 p_true = torch.softmax(rel, dim=1)196 log_p_pred = torch.log_softmax(s, dim=1)197 return -(p_true * log_p_pred).sum(1).mean()198199200def loss_lambdarank(s, rel):201 """LambdaRank:RankNet pairwise 损失按 |ΔNDCG| 加权。"""202 B, L = s.shape203 gains = 2.0 ** rel - 1.0204 # 当前排名(按预估分)205 order = s.argsort(dim=1, descending=True)206 ranks = torch.empty_like(order)207 ar = torch.arange(L, device=s.device).expand(B, L)208 ranks.scatter_(1, order, ar)209 disc = 1.0 / torch.log2(ranks.float() + 2.0) # [B, L]210 idcg = torch.stack([211 torch.tensor(dcg(rel[b].sort(descending=True).values, L))212 for b in range(B)]).clamp(min=1e-6).unsqueeze(1).unsqueeze(2)213214 # 交换 i, j 两个位置带来的 NDCG 变化量215 dg = gains.unsqueeze(2) - gains.unsqueeze(1) # g_i - g_j216 dd = disc.unsqueeze(2) - disc.unsqueeze(1) # d_i - d_j217 delta_ndcg = (dg * dd).abs() / idcg.squeeze(2).unsqueeze(2)218219 diff = s.unsqueeze(2) - s.unsqueeze(1)220 mask = _pair_masks(rel)221 if mask.sum() == 0:222 return s.sum() * 0.0223 weighted = nn.functional.softplus(-diff) * delta_ndcg.detach()224 return weighted[mask].mean() * 10.0 # 放大量级便于与其它损失同 lr 训练225226227METHODS = [228 ("Pointwise-MSE", loss_pointwise_mse, "回归拟合 Reward 数值"),229 ("Pointwise-BCE", loss_pointwise_bce, "二分类拟合「高 Reward」"),230 ("Pairwise-RankNet", loss_ranknet, "全量文档对的概率排序损失"),231 ("Pairwise-BPR", loss_bpr, "正负样本对的 BPR 损失"),232 ("Listwise-ListNet", loss_listnet, "Top-1 概率分布交叉熵"),233 ("Pairwise-LambdaRank", loss_lambdarank, "RankNet 梯度 × |ΔNDCG|"),234]235236237def train_one(loss_fn, Xtr, reltr, epochs=40, bs=64, lr=2e-3):238 model = make_scorer()239 opt = torch.optim.Adam(model.parameters(), lr=lr)240 n = Xtr.shape[0]241 for _ in range(epochs):242 perm = torch.randperm(n)243 for st in range(0, n, bs):244 idx = perm[st:st + bs]245 s = model(Xtr[idx]).squeeze(-1)246 loss = loss_fn(s, reltr[idx])247 opt.zero_grad()248 loss.backward()249 opt.step()250 return model251252253if __name__ == "__main__":254 random.seed(42)255 torch.manual_seed(42)256 gen = torch.Generator().manual_seed(42)257258 Xtr, reltr = make_lists(N_TRAIN_LIST, gen)259 Xte, relte = make_lists(N_TEST_LIST, gen)260261 print("=" * 84)262 print("LTR 三大范式对比(同一打分网络,仅损失函数不同)")263 print("=" * 84)264 print(f"训练 {N_TRAIN_LIST} 个 query × {LIST_LEN} 候选,"265 f"测试 {N_TEST_LIST} 个 query × {LIST_LEN} 候选")266 dist = [int((reltr == r).sum().item()) for r in range(MAX_REL + 1)]267 print("训练集相关度档位分布 rel=0..4:" + ", ".join(268 f"{r}:{c}({c / reltr.numel():.0%})" for r, c in enumerate(dist)))269270 # 随机打分基线271 torch.manual_seed(0)272 rnd = torch.randn(N_TEST_LIST, LIST_LEN)273 base10 = sum(ndcg_at_k(rnd[i], relte[i], 10)274 for i in range(N_TEST_LIST)) / N_TEST_LIST275 base5 = sum(ndcg_at_k(rnd[i], relte[i], 5)276 for i in range(N_TEST_LIST)) / N_TEST_LIST277 basemap = sum(map_score(rnd[i], relte[i])278 for i in range(N_TEST_LIST)) / N_TEST_LIST279280 print("\n" + "=" * 84)281 print(f"{'方法':<22}{'NDCG@10':>10}{'NDCG@5':>10}{'MAP':>10} 说明")282 print("=" * 84)283 print(f"{'Random(基线)':<22}{base10:>10.4f}{base5:>10.4f}{basemap:>10.4f}"284 f" 随机打分")285286 rows = []287 for name, fn, desc in METHODS:288 torch.manual_seed(42)289 model = train_one(fn, Xtr, reltr)290 n10, n5, mp = evaluate(model, Xte, relte)291 rows.append((name, n10, n5, mp))292 print(f"{name:<22}{n10:>10.4f}{n5:>10.4f}{mp:>10.4f} {desc}")293294 best = max(rows, key=lambda r: r[1])295 print("=" * 84)296 print(f"最佳:{best[0]},NDCG@10 = {best[1]:.4f}"297 f"(较随机基线 +{best[1] - base10:.4f})")298 print("\n观察:Pointwise 只拟合绝对值、不关心 query 内相对顺序;Pairwise 显式"299 "优化两两序关系;\n Listwise/LambdaRank 把整个列表(乃至 NDCG 指标"300 "本身)纳入损失,因此在排序指标上\n 通常更优——这正是正文所说的"301 "「LTR 直接优化最终排序效果」。")如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。