"""
=============================================================================
第 2 讲 · 代码 3：点击也是选择数据 —— 但选择集是"看到的"，不是"展示的"
=============================================================================

对应讲义：lectures/lecture02_choice_data_bt.md 第 3.5 节（隐式反馈）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
前两个文件里的数据都是"请人专门来比较"得到的（显式反馈）。现实中更多的偏好数据是**隐式**的：
用户在搜索结果、推荐列表里点了什么。点击量巨大、免费，但它有一个陷阱：

    用户只会点他**看到的**东西，而排在越后面的结果，越少有人看到。

所以"点击次数多"可能只是因为"排得靠前"。这叫**位置偏差**（position bias）。如果直接拿点击率
排序，排在前面的结果会一直排在前面（富者愈富），真正好的结果如果一开始排在后面，永远翻不了身。

这个文件用一个"检查假设"（examination hypothesis）的点击模型做实验：

        P(点击位置 k 上的结果 d) = P(看到位置 k) × P(看到以后觉得有吸引力 | d)
                                 =      e_k      ×        σ(u_d − 1)

    演示 1：按点击率排序 —— 被位置偏差带偏。
    演示 2：Joachims（2002）的"跳过上方"规则（点了第 3 条 ⇒ 第 3 条 ≻ 第 1、2 条）——
            在固定的排序下，所有比较都朝同一个方向，排序被整个翻过来。
    演示 3：两种修正：① 随机打乱一小部分流量的排序；② 按"被看到的概率"给点击加权（逆倾向加权，IPS）。

【重要的简化】真实的点击模型更复杂（级联模型：从上往下看，点了一个就走；还有"信任偏差"：用户相信
排在前面的就是好的）。这里只用最简单的"位置决定被看到的概率"模型说明问题。第 15 讲会更系统地讨论
"行为 ≠ 偏好"。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_clicks_as_choices.py        # 约 1 秒

只用到 Python 标准库，以及第 1 讲的 toy_world.py、本目录的 choice_data.py。
"""

import random

from choice_data import fit_choice_model, pair_to_event
from toy_world import kendall_tau, pad, print_header, sigmoid

# 6 个搜索结果（"文档"）和它们的真实吸引力（效用）。上帝视角，只用于评估。
TRUE_UTILITY = {"甲": 1.5, "乙": 0.5, "丙": 1.0, "丁": -0.5, "戊": 2.0, "己": 0.0}
DOCS = sorted(TRUE_UTILITY)
# 线上正在使用的排序（不完美：最好的"戊"被排在了第 5 位）
PRODUCTION = ["乙", "丙", "甲", "己", "戊", "丁"]
# 每个位置被看到的概率（越往下越少有人看）。真实系统要靠实验估计，这里假设已知。
EXAMINE = [1.0, 0.75, 0.55, 0.40, 0.30, 0.22]


def attract_prob(doc):
    """看到文档以后点击它的概率：σ(u − 1)。"""
    return sigmoid(TRUE_UTILITY[doc] - 1.0)


def simulate_session(ranking, rng):
    """
    模拟一次搜索：对每个位置，先决定用户有没有看到，看到了再决定点不点。

    【参数】ranking：文档列表（第 1 位在最前）；rng：random.Random
    【返回】list[bool]：每个位置有没有被点击
    【边界】这是"位置模型"（PBM）：各位置独立，一次搜索可能点多个，也可能一个都不点。
    """
    clicks = []
    for k, doc in enumerate(ranking):
        seen = rng.random() < EXAMINE[k]
        clicks.append(seen and rng.random() < attract_prob(doc))
    return clicks


def rank_text(scores):
    """把分数排成 "戊 > 甲 > ..." 的文字。"""
    return " > ".join(sorted(scores, key=lambda d: -scores[d]))


def demo_naive_ctr(rng, sessions):
    """演示 1：直接按点击率排序。返回日志，供后面的演示复用。"""
    print_header("演示 1：直接按点击率排序 —— 被位置偏差带偏")
    log = [(list(PRODUCTION), simulate_session(PRODUCTION, rng)) for _ in range(sessions)]
    shows = {d: 0 for d in DOCS}
    clicks = {d: 0 for d in DOCS}
    for ranking, cl in log:
        for doc, c in zip(ranking, cl):
            shows[doc] += 1
            clicks[doc] += c
    ctr = {d: clicks[d] / shows[d] for d in DOCS}
    print(f"线上排序：{' > '.join(PRODUCTION)}（最好的“戊”排在第 5 位）；记录 {sessions} 次搜索。\n")
    print(f"  {pad('位置', 4)} {pad('文档', 4)} {pad('被看到的概率', 12, 'right')} {pad('真实吸引力 σ(u−1)', 18, 'right')} {pad('点击率', 8, 'right')}")
    for k, d in enumerate(PRODUCTION):
        print(f"  {k + 1:>4d} {pad(d, 4)} {EXAMINE[k]:>12.2f} {attract_prob(d):>18.2f} {ctr[d]:>8.3f}")
    print(f"\n  按点击率排序：{rank_text(ctr)}")
    print(f"  真实的顺序：  {rank_text(TRUE_UTILITY)}")
    print(f"  肯德尔 τ = {kendall_tau(ctr, TRUE_UTILITY):.2f}")
    print("""
观察：
  · 点击率 = 被看到的概率 × 吸引力。排在第 1 位的“乙”吸引力一般，但人人都看得到，点击率反而比“戊”高；
    “戊”最有吸引力，可是排在第 5 位，只有 30% 的人看得到它。
  · 如果拿这个点击率去更新排序，“乙”会继续排在前面，“戊”继续没人看到 —— 偏差会自我强化（第 17 讲：反馈循环）。""")
    return log


def demo_skip_above(log):
    """演示 2：Joachims 的"跳过上方"规则。"""
    print_header("演示 2：“点了第 k 条 ⇒ 它比上面没点的更好” —— 在固定排序下会翻车")
    events = []
    for ranking, cl in log:
        for k, clicked in enumerate(cl):
            if clicked:
                for j in range(k):
                    if not cl[j]:
                        events.append(pair_to_event(ranking[k], ranking[j]))
    scores = fit_choice_model(events, DOCS, l2=0.01)
    print(f"从日志里提取了 {len(events)} 对“下面被点的 ≻ 上面被跳过的”。用 BT 模型拟合：")
    print(f"  拟合出的顺序：{rank_text(scores)}")
    print(f"  线上排序：    {' > '.join(PRODUCTION)}")
    print(f"  真实的顺序：  {rank_text(TRUE_UTILITY)}")
    print(f"  肯德尔 τ（和真实顺序）= {kendall_tau(scores, TRUE_UTILITY):.2f}")
    print("""
观察：
  · Joachims（2002）的想法很聪明：用户从上往下看，跳过了第 1、2 条而点了第 3 条，说明他看过前两条但更喜欢第 3 条。
    这把点击变成了“从看到的选项里选一个”—— 选择集是**看到的**结果，而不是全部展示的结果。
  · 但在一个**固定**的排序下，这条规则只能产生“下面的 ≻ 上面的”，永远不会产生“上面的 ≻ 下面的”：
    比较图里的箭头全部朝一个方向。BT 模型只好得出“越往下越好”—— 拟合出的顺序几乎就是线上排序倒过来
    （和第 4 节的“比较图不连通 / 某个选项从来没输过”是同一类问题）。
  · Joachims 和合作者后来的做法是**随机交换**相邻的结果，让两个方向的比较都能出现（下一个演示）。""")


def demo_corrections(log, rng, randomized_sessions):
    """演示 3：两种修正办法。"""
    print_header("演示 3：两种修正 —— 随机化，以及按“被看到的概率”加权")
    # ① 随机打乱：另取一小部分流量，每次搜索都把结果随机打乱后再展示
    shows = {d: 0 for d in DOCS}
    clicks = {d: 0 for d in DOCS}
    for _ in range(randomized_sessions):
        ranking = list(DOCS)
        rng.shuffle(ranking)
        for doc, c in zip(ranking, simulate_session(ranking, rng)):
            shows[doc] += 1
            clicks[doc] += c
    rand_ctr = {d: clicks[d] / shows[d] for d in DOCS}

    # ② 逆倾向加权（IPS）：在原来的日志上，每次点击按 1/P(被看到) 加权
    ips_sum = {d: 0.0 for d in DOCS}
    ips_n = {d: 0 for d in DOCS}
    for ranking, cl in log:
        for k, (doc, c) in enumerate(zip(ranking, cl)):
            ips_n[doc] += 1
            if c:
                ips_sum[doc] += 1.0 / EXAMINE[k]
    ips = {d: ips_sum[d] / ips_n[d] for d in DOCS}

    print(f"  {pad('文档', 4)} {pad('真实吸引力', 10, 'right')} {pad(f'随机化流量({randomized_sessions}次)的点击率', 30, 'right')} "
          f"{pad('IPS 加权的点击率', 16, 'right')}")
    for d in sorted(DOCS, key=lambda d: -TRUE_UTILITY[d]):
        print(f"  {pad(d, 4)} {attract_prob(d):>10.3f} {rand_ctr[d]:>30.3f} {ips[d]:>16.3f}")
    print(f"\n  随机化流量：{rank_text(rand_ctr)}   τ = {kendall_tau(rand_ctr, TRUE_UTILITY):.2f}")
    print(f"  IPS 加权：  {rank_text(ips)}   τ = {kendall_tau(ips, TRUE_UTILITY):.2f}")
    print("""
观察：
  · 随机化：每个文档在每个位置出现的机会相同，位置的影响被平均掉了，点击率的**顺序**就对了
    （数值是“平均被看到的概率 × 吸引力”，整体偏小，但不影响排序）。代价是这部分流量的用户体验变差，
    所以通常只随机化一小部分流量，或者只交换相邻的两条。
  · IPS（逆倾向加权）：被看到的概率只有 0.3 的位置上的一次点击，算作 1/0.3 ≈ 3.3 次。加权后的点击率是
    吸引力的**无偏估计**，直接在原来的日志上就能用。前提是要知道每个位置被看到的概率 —— 这通常又要靠
    一个随机化实验来估计。第 15 讲会把这个思路推广：从“行为”推“偏好”，必须对“行为是怎样产生的”建模。
  · 这个例子的教训适用于所有偏好数据：**弄清楚每个选择是从哪个选择集里做出的**。在 RLHF 里，
    “标注员看到了哪些回答、按什么顺序看到的”同样会影响他的选择（第 3 讲的情境效应、第 14 讲的位置偏差）。""")


def main():
    rng = random.Random(2002)        # 2002：Joachims 发表点击数据论文的年份
    log = demo_naive_ctr(rng, sessions=20000)
    demo_skip_above(log)
    demo_corrections(log, rng, randomized_sessions=4000)


if __name__ == "__main__":
    main()
