"""
=============================================================================
第 13 讲 · 代码 3：信息检索里的对决 —— 交错评估（interleaving）与 DBGD
=============================================================================

对应讲义：lectures/lecture13_dueling_pbo.md 第 4 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
搜索引擎有两个排序算法 A 和 B，哪个更好？用户不会告诉你"这个排序值几分"，但会点击。

    · A/B 测试：一半用户看 A 的结果，一半看 B 的结果，比较两组的平均点击数。
    · 交错评估（team-draft interleaving，Radlinski 等 2008）：给**同一个用户**看一个"混合"的结果列表 ——
      A 和 B 轮流（谁先挑由抛硬币决定）把自己排得最高、还没放进列表的文档放进来。用户点了哪个文档，
      就给贡献它的那一方记一分；这一次谁的分多，谁赢。这就是一次"对决"：每个用户都在比较 A 和 B。

    演示 1：两个质量很接近的排序算法：要多少次搜索，才能以 95% 的把握选对？A/B 测试 vs 交错评估。
    演示 2：对决老虎机梯度下降（DBGD，Yue & Joachims 2009）：只用交错评估的胜负，在线地学习一个线性排序函数。

用户模型（"位置偏差"，第 2、11 讲）：第 k 个位置被看到的概率随 k 下降；看到了，按文档的真实相关度决定点不点。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_interleaving.py        # 约 20 秒

只需要 numpy。依赖本目录的 duel_tools.py（只用打印工具）。
"""

import numpy as np

from duel_tools import pad, print_header

N_QUERIES, N_DOCS, TOP, D = 50, 30, 10, 5
EXAMINE = 1.0 / np.arange(1, TOP + 1) ** 0.8          # 第 k 个位置被看到的概率
CLICK = np.array([0.05, 0.3, 0.6, 0.9])               # 相关度 0/1/2/3 的文档被看到后被点击的概率


def make_collection(rng):
    """
    50 个查询，每个查询 30 个候选文档。每个文档有真实相关度（0~3）和 5 个特征（相关度的有噪声的"信号"）。

    【返回】(rel, feats)：rel (50, 30)；feats (50, 30, 5)
    【思路】特征 k = 相关度 × 信号强度_k + 噪声：有的特征很有用（信号强），有的几乎没用。
        好的排序函数要学会给有用的特征大权重。
    """
    rel = rng.choice(4, size=(N_QUERIES, N_DOCS), p=[0.5, 0.25, 0.15, 0.1])
    strength = np.array([1.0, 0.7, 0.4, 0.2, 0.0])
    feats = rel[:, :, None] * strength[None, None, :] + rng.normal(size=(N_QUERIES, N_DOCS, D))
    return rel, feats


def rank(w, feats_q):
    """用线性排序函数 score = feats · w 给一个查询的文档排序，返回前 TOP 个文档的下标。"""
    return np.argsort(-(feats_q @ w), kind="stable")[:TOP]


def ndcg(w, rel, feats):
    """
    离线指标 NDCG@10 在所有查询上的平均（只用于评估，"上帝视角"）。

    【思路】DCG = Σ_k (2^相关度 − 1) / log2(k + 1)，再除以"理想排序"的 DCG。
    """
    disc = 1.0 / np.log2(np.arange(2, TOP + 2))
    total = 0.0
    for q in range(N_QUERIES):
        gains = 2.0 ** rel[q, rank(w, feats[q])] - 1
        ideal = 2.0 ** np.sort(rel[q])[::-1][:TOP] - 1
        total += (gains @ disc) / (ideal @ disc)
    return total / N_QUERIES


def clicks_on(docs, rel_q, rng):
    """用户看一个结果列表，返回每个位置是否被点击（布尔数组）。"""
    return rng.random(len(docs)) < EXAMINE[:len(docs)] * CLICK[rel_q[docs]]


def team_draft(ra, rb, rng):
    """
    团队选秀式交错（team-draft interleaving）。

    【思路】像两个队长轮流挑人：每一轮先抛硬币决定谁先挑，各自挑"自己排名最高、还没被挑走"的文档放进列表，
        记下每个位置是谁挑的。重复到列表满 TOP 个。
    【返回】(列表, 每个位置属于哪一方：0 = A，1 = B)
    """
    out, team = [], []
    ia = ib = 0
    while len(out) < TOP:
        order = (0, 1) if rng.random() < 0.5 else (1, 0)
        for side in order:
            if len(out) >= TOP:
                break
            r = ra if side == 0 else rb
            i = ia if side == 0 else ib
            while r[i] in out:
                i += 1
            out.append(int(r[i]))
            team.append(side)
            if side == 0:
                ia = i + 1
            else:
                ib = i + 1
    return np.array(out), np.array(team)


def interleave_duel(wa, wb, q, rel, feats, rng):
    """一次交错评估：返回 +1（A 赢）、−1（B 赢）或 0（平局）。"""
    lst, team = team_draft(rank(wa, feats[q]), rank(wb, feats[q]), rng)
    c = clicks_on(lst, rel[q], rng)
    a, b = int(np.sum(c & (team == 0))), int(np.sum(c & (team == 1)))
    return (a > b) - (a < b)


def demo_sensitivity(rng, reps=200):
    print_header("演示 1：A/B 测试 vs 交错评估 —— 要多少次搜索才能选对？")
    rel, feats = make_collection(rng)
    w_good = np.array([1.0, 0.7, 0.4, 0.2, 0.0])         # 按特征的真实有用程度加权
    w_bad = np.array([1.0, 0.7, 0.4, 0.2, 0.6])           # 给没用的第 5 个特征也加了权重
    na, nb = ndcg(w_good, rel, feats), ndcg(w_bad, rel, feats)
    print(f"排序算法 A：NDCG@10 = {na:.3f}；排序算法 B：NDCG@10 = {nb:.3f}（A 稍好）。")
    print(f"对每种预算重复 {reps} 次实验，统计“选对了 A”的比例：\n")
    budgets = [100, 300, 1000, 3000]
    print(f"  {pad('搜索次数', 9)} {pad('A/B 测试（比平均点击）', 24, 'right')} {pad('交错评估（比胜负）', 20, 'right')}")
    for n in budgets:
        ab_right = il_right = 0
        for _ in range(reps):
            qs = rng.integers(0, N_QUERIES, n)
            # A/B 测试：一半搜索用 A，一半用 B
            ca = sum(clicks_on(rank(w_good, feats[q]), rel[q], rng).sum() for q in qs[: n // 2])
            cb = sum(clicks_on(rank(w_bad, feats[q]), rel[q], rng).sum() for q in qs[n // 2:])
            ab_right += (ca > cb) + 0.5 * (ca == cb)
            score = sum(interleave_duel(w_good, w_bad, q, rel, feats, rng) for q in qs)
            il_right += (score > 0) + 0.5 * (score == 0)
        print(f"  {n:>9d} {ab_right / reps:>24.3f} {il_right / reps:>20.3f}")
    print("""
观察：
  · 两个算法的差别很小，A/B 测试要很多次搜索才能可靠地分出高下：两组用户看到的是不同的查询、
    不同的用户（点击率本身的波动很大），差别被噪声淹没了。
  · 交错评估让**同一个用户、同一个查询**同时比较两个算法 —— 每一次搜索都是一次"对决"，
    查询难易、用户习惯这些噪声在两边抵消了。同样的预算，选对的概率高得多。
    这里 300 次交错评估（0.90）就和 3000 次 A/B 测试（0.93）差不多 —— 省了约 10 倍的流量。Chapelle 等（2012）在真实的搜索引擎上也发现，
    交错评估需要的流量比 A/B 测试少得多（大约一到两个数量级）。
  · 代价：交错评估只告诉你“谁更好”，不告诉你“好多少”（例如对营收的影响）；它是一个**偏好**信号。""")
    return rel, feats


def demo_dbgd(rng, rel, feats, iters=6000, delta=1.0, gamma=0.1):
    print_header("演示 2：对决老虎机梯度下降（DBGD）—— 只用胜负在线学习排序函数")
    w = rng.normal(size=D)
    w /= np.linalg.norm(w)
    w_best = np.array([1.0, 0.7, 0.4, 0.2, 0.0])
    print(f"从一个随机的权重出发。每次搜索：在当前权重 w 附近随机找一个方向 u，候选 w' = w + {delta}·u；")
    print(f"让 w 和 w' 做一次交错评估；如果 w' 赢了，w ← w + {gamma}·u。不需要任何梯度或相关度标注。\n")
    print(f"  {pad('搜索次数', 9)} {pad('NDCG@10', 9, 'right')} {pad('和最好方向的余弦', 17, 'right')}")
    checkpoints = {0, 100, 500, 1000, 2000, 4000, iters}
    for t in range(iters + 1):
        if t in checkpoints:
            cos = w @ w_best / (np.linalg.norm(w) * np.linalg.norm(w_best))
            print(f"  {t:>9d} {ndcg(w, rel, feats):>9.3f} {cos:>17.3f}")
        if t == iters:
            break
        u = rng.normal(size=D)
        u /= np.linalg.norm(u)
        q = int(rng.integers(N_QUERIES))
        if interleave_duel(w + delta * u, w, q, rel, feats, rng) > 0:
            w = w + gamma * u
    print(f"  参照：用真实有用程度加权的排序函数 NDCG@10 = {ndcg(w_best, rel, feats):.3f}")
    print("""
观察：
  · DBGD 每一步只问“稍微改一下会不会更好”，用一次交错评估回答，赢了就往那个方向挪一点 ——
    这是“只能比较、不能求导”的随机梯度上升（Yue & Joachims 2009）。
  · 几千次搜索后，它学到的排序函数接近用真实相关度设计的那个：方向和最好的方向越来越一致。
    步长固定，所以它在最优附近徘徊（4000 次时 NDCG 甚至略高于参照，6000 次时又回落一些）——
    和第 5 讲的 Elo 一样，要逐渐减小步长才能稳定下来。
  · 每一次搜索都是一次对决：把用户的点击当作偏好，这正是第 2 讲“点击是选择”的在线版本，
    也让搜索引擎能持续地在真实用户身上改进。
  · 【边界】每一步只比较两个很接近的排序函数，胜负的信号很弱，需要很多次搜索；
    后来的“多方交错”（同时比较多个候选方向，Schuth 等 2014）更高效。""")


def main():
    rng = np.random.default_rng(31)
    rel, feats = demo_sensitivity(rng)
    demo_dbgd(rng, rel, feats)


if __name__ == "__main__":
    main()
