"""
=============================================================================
第 11 讲 · 代码 4：选择架构 —— 系统怎样摆放选项，既影响人，也影响系统能学到什么
=============================================================================

对应讲义：lectures/lecture11_assistance_bandits.md 第 6 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
"选择架构"（Thaler & Sunstein）：选项怎样摆放（顺序、默认值、位置）会影响人的选择。
对一个边推荐边学习的系统来说，这有两层后果：它影响用户，也影响系统收集到的数据 —— 而系统又用这些数据决定下一次怎样摆放。

    演示 1：推荐列表的位置偏差（第 2 讲）。一个用老虎机算法排序的推荐系统，如果直接用"点击数 / 展示数"
            估计每个内容的吸引力，排在下面的内容永远被低估，排在上面的被高估 —— 反馈循环把错误锁定。
            按位置修正之后呢？
    演示 2：偏好标注里的顺序效应。如果总是把模型甲的回答放在左边，而标注员有一点"偏爱左边"，
            从数据里估计的"甲比乙强多少"就会偏；随机化左右顺序就没事了。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_choice_architecture.py        # 约 10 秒

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

import math

import numpy as np

from bandit_tools import pad, print_header

EXAMINE = np.array([1.0, 0.5, 0.25])      # 推荐列表 3 个位置被用户"看到"的概率（第 2 讲的检查假设）


def run_recommender(estimator, policy, rep, K=20, T=20000, c=0.5):
    """
    模拟一个推荐系统：每一轮从 K 个内容里挑 3 个排成列表，用户按位置偏差点击，系统更新估计。

    【参数】
        estimator："naive"   —— 吸引力 ≈ 点击数 / 展示次数（不管放在哪个位置）
                   "position"—— 吸引力 ≈ 点击数 / Σ(展示时那个位置被看到的概率)（按位置修正，第 2 讲的 IPS 思路）
        policy   ："greedy"（按估计排序）或 "ucb"（估计 + 探索奖励，代码 03）
    【用户模型】内容 k 放在第 j 个位置时被点击的概率 = 看到的概率 EXAMINE[j] × 吸引力 a_k（"检查假设"）。
    【返回】(累计遗憾, 最后 10% 的轮次里最好的内容排在第一位的比例, 估计和真实吸引力的秩相关)
        遗憾：每一轮的期望点击数和"最好的 3 个按吸引力排好"的期望点击数之差，加起来。
    """
    rng = np.random.default_rng(rep)
    a = rng.uniform(0.05, 0.30, K)                      # 真实吸引力（系统看不到）
    clicks, shown, examined = np.zeros(K), np.zeros(K), np.zeros(K)
    best_value = np.sort(a)[::-1][:3] @ EXAMINE
    best = int(np.argmax(a))
    regret, top_best = 0.0, 0
    for t in range(T):
        if t < K:                                       # 先让每个内容轮流排一次第一位
            order = np.array([t, (t + 1) % K, (t + 2) % K])
        else:
            if estimator == "naive":
                est, n = clicks / shown, shown
            else:
                est, n = clicks / examined, examined
            score = est + (np.sqrt(c * math.log(t + 1) / n) if policy == "ucb" else 0.0)
            order = np.argsort(-(score + 1e-9 * rng.random(K)))[:3]
        click = rng.random(3) < EXAMINE * a[order]
        clicks[order] += click
        shown[order] += 1
        examined[order] += EXAMINE
        regret += best_value - EXAMINE @ a[order]
        if t >= 0.9 * T:
            top_best += int(order[0] == best)
    est = clicks / (shown if estimator == "naive" else examined)
    rank_corr = np.corrcoef(np.argsort(np.argsort(est)), np.argsort(np.argsort(a)))[0, 1]
    return regret, top_best / (0.1 * T), rank_corr


def demo_position_bias(reps=20):
    print_header("演示 1：推荐列表的位置偏差与反馈循环")
    print(f"20 个内容，每轮推荐 3 个；3 个位置被看到的概率 {EXAMINE.tolist()}。每种做法跑 {reps} 次、每次 2 万轮：\n")
    print(f"  {pad('估计方法', 18)} {pad('排序策略', 10)} {pad('累计遗憾', 9, 'right')} {pad('最好的内容排第一', 17, 'right')} "
          f"{pad('估计与真实的秩相关', 19, 'right')}")
    for est, est_name in [("naive", "点击数/展示数"), ("position", "按位置修正")]:
        for pol, pol_name in [("greedy", "贪心"), ("ucb", "UCB")]:
            res = np.array([run_recommender(est, pol, rep) for rep in range(reps)])
            print(f"  {pad(est_name, 18)} {pad(pol_name, 10)} {res[:, 0].mean():>9.1f} {res[:, 1].mean():>17.3f} "
                  f"{res[:, 2].mean():>19.3f}")
    print("""
观察：
  · 直接用“点击数 / 展示数”：一直排在第一位的内容，估计的是“吸引力 × 1”；常排在第三位的，估计的是
    “吸引力 × 0.25”。排在上面的被高估、排在下面的被低估，于是它们继续排在上面和下面 —— 自我强化的反馈循环。
    即使加了 UCB 的探索，最好的内容也只有约 10% 的时间排在第一位。有意思的是，这时整体排序的秩相关并不低
    （0.9 以上）—— 整体的大致顺序学对了，错误恰好出现在最关键的顶部：差距很小的几个好内容之间，
    谁占着第一位，谁的估计就被抬高，于是继续占着。
  · 按位置修正（除以“被看到的次数”而不是“被展示的次数”）：估计不再受位置影响，UCB 能把最好的内容
    稳定地排到第一位，遗憾也更小。
  · 选择架构影响的不只是用户：它决定了系统收集到什么数据。忽略它，系统会把“我把它放在哪里”误当成
    “用户有多喜欢它”。这正是第 2 讲点击数据里的位置偏差，在一个会“行动”的系统里被反馈循环放大了。
  · 【边界】这里假设系统知道每个位置被看到的概率；现实中它要靠随机化实验（例如偶尔交换位置）来估计。""")


def demo_order_effect(rng):
    print_header("演示 2：偏好标注里的顺序效应")
    true_delta, left_bias, n = 0.3, 0.4, 5000
    print(f"模型甲的回答比乙好：真实的 BT 分数差 Δ = {true_delta}。标注员有一点偏爱左边的回答：")
    print(f"P(选左边) = σ(左 − 右 + {left_bias})。各收集 {n} 次比较，估计 Δ（两个选项时 Δ̂ = log(甲胜 / 乙胜)，第 5 讲）：\n")
    print(f"  {pad('摆放方式', 28)} {pad('甲的胜率', 9, 'right')} {pad('估计的 Δ', 9, 'right')} {pad('偏差', 8, 'right')}")
    sig = lambda x: 1 / (1 + math.exp(-x))               # noqa: E731
    for name, p_left_a in [("甲总是在左边", 1.0), ("甲总是在右边", 0.0), ("随机左右", 0.5)]:
        a_left = rng.random(n) < p_left_a
        p_a = np.where(a_left, sig(true_delta + left_bias), sig(true_delta - left_bias))
        wins = int(np.sum(rng.random(n) < p_a))
        est = math.log(wins / (n - wins))
        print(f"  {pad(name, 28)} {wins / n:>9.3f} {est:>9.3f} {est - true_delta:>+8.3f}")
    print("""
观察：
  · 甲总是在左边：标注员的“偏爱左边”被算到了甲头上，Δ 被高估一倍多；甲总是在右边时反过来，
    估出的 Δ 甚至是负的 —— 结论完全反了。
  · 随机化左右顺序：偏差在两种摆放之间抵消，估计基本无偏（还略有一点向 0 的偏差，因为 σ 是非线性的）。
  · 真实的例子：人和“LLM 评委”都有位置偏差（常常偏爱第一个回答）；Chatbot Arena、MT-Bench 都随机化或交换顺序
    再评一次。不随机化，奖励模型会把“摆放方式”学成“质量”。这也是第 14 讲“风格控制”的前奏：
    任何和质量无关、却影响选择的因素，都要么随机化掉，要么显式地建模。""")


def main():
    demo_position_bias()
    demo_order_effect(np.random.default_rng(4))


if __name__ == "__main__":
    main()
