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 的书中论断。讲解时请结合实际代码、输入输出与原书页码。

源码结构与阅读入口

  1. make_lists · 第 57 行返回 (X, rel),X:[n_list, LIST_LEN, FEAT_DIM],rel:[n_list, LIST_LEN]。
  2. dcg · 第 94 行
  3. ndcg_at_k · 第 99 行
  4. map_score · 第 106 行以 rel >= 3 为「相关」计算 Average Precision。
  5. evaluate · 第 121 行
  6. make_scorer · 第 138 行
  7. loss_pointwise_mse · 第 151 行
  8. loss_pointwise_bce · 第 155 行
  9. _pair_masks · 第 160 行返回 [B, L, L] 的 mask,(i, j) 为 True 表示 rel_i > rel_j。
  10. loss_ranknet · 第 165 行
  11. loss_bpr · 第 174 行BPR:每个 list 内随机采一对 (高档, 低档) 做对比。
  12. loss_listnet · 第 193 行ListNet Top-1 概率分布交叉熵。
  13. loss_lambdarank · 第 200 行LambdaRank:RankNet pairwise 损失按 |ΔNDCG| 加权。
  14. train_one · 第 237 行

完整源码 · 301 行

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 直接优化最终排序效果」。")

如何用于学习或编写新代码

  1. 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
  2. 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
  3. 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
  4. 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。