"""
=============================================================================
第 3 讲 · 代码 3：红色公交车 / 蓝色公交车 —— 相似的选项会"互相抢份额"
=============================================================================

对应讲义：lectures/lecture03_random_utility.md 第 5.1、5.2、5.5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
IIA 最著名的反例（Debreu 1960 用的是两张同一首交响曲的唱片；交通经济学里通常讲成公交车）：
一个人上班时开车和坐蓝色公交车各占一半。现在加开一条**红色**公交车，除了颜色和蓝色的一模一样。
直觉上：开车的人还是一半，坐公交的那一半被红、蓝两种车平分，各 1/4。
但 logit 模型（满足 IIA）说：三个选项效用相同，各 1/3 —— 开车的人从 1/2 掉到了 1/3。
问题出在 logit 假设每个选项的噪声**独立**：它把红色公交车当成一个和其他选项"一样不同"的新选项。

    演示 1：logit vs 嵌套 logit vs 相关 probit 对红色公交车的预测。
    演示 2：替代模式 —— 一个选项被拿掉以后，它的份额流向哪里？logit 说"按比例分给所有人"，
            嵌套 logit 说"主要流向和它相似的选项"。
    演示 3：语言模型里的红色公交车：候选回答里有两个几乎一样的改写（B 和 B'）。
            两两比较的数据完全服从 BT，可是代进 softmax 预测"三选一"会出错；用 Plackett–Luce 拟合
            排序数据，另一个回答 A 的分数会被系统性地低估。

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

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

import math
import random

from rum import (correlated_normal_sampler, fmt_probs, logit_probs, mc_choice_probs, nested_logit_probs)
from toy_world import fit_bradley_terry, mean, pad, print_header, sample_gumbel, sigmoid

GUMBEL_VAR = math.pi ** 2 / 6      # 标准 Gumbel 的方差，相关 probit 用同样的方差


# =============================================================================
# 演示 1：红色公交车
# =============================================================================
def demo_red_bus(rng, draws=40000):
    print_header("演示 1：加开一条红色公交车，开车的人会变少吗？")
    utils = {"汽车": 1.0, "蓝巴士": 1.0, "红巴士": 1.0}
    two = ["汽车", "蓝巴士"]
    three = ["汽车", "蓝巴士", "红巴士"]
    print("开车和坐蓝色公交车的平均效用相同（各占一半）。加开一条除了颜色以外完全一样的红色公交车。")
    print("直觉的答案：汽车 0.50，蓝巴士 0.25，红巴士 0.25。\n")
    print(f"  {pad('模型', 34)} {pad('只有汽车和蓝巴士', 22, 'right')}   加上红巴士以后")
    p2 = logit_probs(utils, two)
    p3 = logit_probs(utils, three)
    print(f"  {pad('logit（独立 Gumbel，满足 IIA）', 34)} {fmt_probs(p2):>22s}   {fmt_probs(p3)}")
    nests = [["汽车"], ["蓝巴士", "红巴士"]]
    for lam in [1.0, 0.5, 0.2, 0.05]:
        p3 = nested_logit_probs(utils, nests, lam, three)
        p2 = nested_logit_probs(utils, nests, lam, two)
        print(f"  {pad(f'嵌套 logit（λ = {lam:g}）', 34)} {fmt_probs(p2):>22s}   {fmt_probs(p3, three)}")
    for rho in [0.0, 0.5, 0.9, 0.99]:
        cov = [[GUMBEL_VAR, 0.0, 0.0],
               [0.0, GUMBEL_VAR, rho * GUMBEL_VAR],
               [0.0, rho * GUMBEL_VAR, GUMBEL_VAR]]
        sampler = correlated_normal_sampler(utils, three, cov)
        p3 = mc_choice_probs(sampler, three, draws, rng)
        print(f"  {pad(f'相关 probit（两种巴士相关系数 {rho:g}）', 34)} {'（略）':>22s}   {fmt_probs(p3, three)}")
    print("""
观察：
  · logit 预测开车的人从 0.50 掉到 0.33：它认为红色公交车从汽车和蓝色公交车那里**按比例**各抢了一份。
    这是 IIA 的直接后果：汽车 : 蓝巴士 的比值必须保持 1 : 1，三者效用又相同，只能各 1/3。
  · 嵌套 logit 把两种公交车放进同一个“巢”。λ 越小，巢里的选项越相似（噪声越相关），预测越接近直觉的 0.5/0.25/0.25。
    λ = 1 时退化成普通的 logit。注意只有汽车和蓝巴士时，所有模型都给出 0.5/0.5 —— 二选一的数据看不出区别。
  · 相关 probit 用另一种方式达到同样的效果：两种公交车的噪声相关系数越接近 1，红色公交车越像蓝色公交车的“影子”。
    代价是没有封闭公式，只能模拟或数值积分（选项多了很慢）。""")


# =============================================================================
# 演示 2：替代模式
# =============================================================================
def demo_substitution():
    print_header("演示 2：拿掉一个选项，它的份额流向哪里？")
    utils = {"汽车": 1.0, "蓝巴士": 0.6, "红巴士": 0.6, "火车": 0.3}
    full = ["汽车", "蓝巴士", "红巴士", "火车"]
    without = ["汽车", "蓝巴士", "火车"]
    nests = [["汽车"], ["蓝巴士", "红巴士"], ["火车"]]
    print("4 种交通方式（两种巴士在同一个巢里）。停开红色公交车以后，原来坐红巴士的人去了哪里？\n")
    print(f"  {pad('模型', 22)} {pad('去汽车', 8, 'right')} {pad('去蓝巴士', 9, 'right')} {pad('去火车', 8, 'right')}")
    for name, fn in [("logit", lambda o: logit_probs(utils, o)),
                     ("嵌套 logit（λ=0.3）", lambda o: nested_logit_probs(utils, nests, 0.3, o))]:
        before = fn(full)
        after = fn(without)
        moved = before["红巴士"]
        shares = {k: (after[k] - before[k]) / moved for k in without}
        print(f"  {pad(name, 22)} {shares['汽车']:>8.1%} {shares['蓝巴士']:>9.1%} {shares['火车']:>8.1%}")
    print("""
观察：
  · logit 的替代是“成比例”的：红巴士的乘客按各选项原来的份额分流，汽车份额最大，所以分到的最多。
    这和直觉相反 —— 坐红巴士的人最可能改坐蓝巴士。
  · 嵌套 logit 让大部分人流向同一个巢里的蓝巴士。
  · 这种“替代模式”在经济学里非常重要（新产品上市会抢走谁的市场？），在偏好学习里也一样：
    往候选集合里加一个新回答，它会抢走哪些回答的“选票”？（演示 3）""")


# =============================================================================
# 演示 3：语言模型里的红色公交车
# =============================================================================
def feel_with_clone(utils, keys, rng, clone_of=("B'", "B"), jitter=0.1):
    """
    一次感受：每个回答 = 效用 + Gumbel 噪声；但改写版 B' 的感受 = B 的感受 + 一点点独立的小噪声。

    【思路】B' 是 B 的改写（意思一样、措辞略有不同），读者对两者的感受几乎完全一样（强相关）。
        这样构造的好处：任意两两比较的数据都**严格服从 BT**（A 和 B'的噪声之差仍近似是逻辑斯蒂分布），
        但三选一不服从 IIA —— 两两数据完全看不出问题。
    【参数】utils；keys：要读的回答；rng；clone_of：(克隆, 原版)；jitter：克隆相对原版的小噪声
    【返回】dict {回答: 感受}
    """
    feel = {k: utils[k] + sample_gumbel(rng) for k in keys if k != clone_of[0]}
    if clone_of[0] in keys:
        base = feel[clone_of[1]] if clone_of[1] in feel else utils[clone_of[1]] + sample_gumbel(rng)
        feel[clone_of[0]] = base + rng.gauss(0.0, jitter)
    return feel


def demo_llm_clones(rng, n=20000):
    print_header("演示 3：候选回答里有两个几乎一样的改写 —— 排序数据会被扭曲")
    utils = {"A": 0.5, "B": 0.0, "B'": 0.0}
    keys = ["A", "B", "B'"]
    print(f"三个回答：A（效用 {utils['A']}）、B（效用 0）、B'（B 的改写，读者感受几乎一样）。")

    # 两两比较的数据
    pairs = []
    for _ in range(n):
        x, y = rng.sample(keys, 2)
        f = feel_with_clone(utils, [x, y], rng)
        pairs.append((x, y) if f[x] > f[y] else (y, x))
    s_pair = fit_bradley_terry(pairs, keys)
    # 三个回答一起排序（Plackett–Luce 拆成选择事件后拟合）
    from_rankings = []
    top_counts = {k: 0 for k in keys}
    for _ in range(n):
        f = feel_with_clone(utils, keys, rng)
        order = sorted(keys, key=lambda k: -f[k])
        top_counts[order[0]] += 1
        from_rankings.append(order)
    s_pl = fit_plackett_luce(from_rankings, keys)

    print(f"\n  {pad('', 28)} {pad('s_A − s_B', 10, 'right')} {pad('预测三选一中 A 的份额', 22, 'right')}")
    actual_text = f"{top_counts['A'] / n:.3f}（实际比例）"      # 先单独算好：Python 3.11 及以前，f-字符串里不能再嵌套同种引号
    print(f"  {pad('真实', 28)} {utils['A'] - utils['B']:>10.2f} {pad(actual_text, 22, 'right')}")
    for name, s in [("用两两比较拟合 BT", s_pair), ("用三个回答的排序拟合 PL", s_pl)]:
        pred = logit_probs(s, keys)["A"]
        print(f"  {pad(name, 28)} {s['A'] - s['B']:>10.2f} {pred:>22.3f}")
    print(f"\n  参考：如果 B' 不存在，A 胜 B 的概率 σ(0.5) = {sigmoid(0.5):.3f}；三选一时 A 的实际份额几乎就是它。")
    print("""
观察：
  · 两两比较的数据完全正常：BT 拟合出的 s_A − s_B 接近真实的 0.5。但是代进 softmax 预测三选一，
    A 的份额被严重低估 —— 模型以为 A 要同时打败两个“独立的”对手，其实 B 和 B' 基本上是同一个对手。
  · 用排序数据拟合 Plackett–Luce，s_A − s_B 被**低估**了。看看排序是怎样拆成事件的：当 B 或 B' 排第一时，
    它的“双胞胎”几乎必然紧跟在第二位，于是第二步的事件“从 {A, 双胞胎} 里选了双胞胎”几乎总是发生。
    PL 把它当成一次独立的“双胞胎胜过 A”的证据，而它其实只是“两个回答几乎一样”的副产品。
  · 两种用法错在不同的地方：BT 的分数是对的、拿它去预测三选一错了；PL 连分数本身都估错了。
    这正是“模型假设错了”的典型症状：错在哪里、错多少，取决于你用什么数据、拿模型做什么，很难事先猜到。
  · 语言模型的场景里，这很常见：同一个模型采样出的几个回答常常大同小异；Best-of-n、排序标注、
    “从 K 个回答里挑最好的”都会遇到“近乎重复的候选”。用满足 IIA 的模型（BT / PL / DPO）去学，
    就会出现这种偏差。对策：标注前去重、把相似的回答合并成一个“巢”，或用不满足 IIA 的模型。""")


def fit_plackett_luce(rankings, keys):
    """
    用 Plackett–Luce 拟合排序：把每个排序拆成"选择事件"，再用 softmax 最大似然。

    【思路】和第 2 讲的 choice_data.fit_choice_model 一样（这里为了让本讲不依赖第 2 讲，单独写一个简单版）：
        梯度 = 被选中的次数 − 模型预期的被选中次数。
    【参数】rankings：排序的列表；keys：选项
    【返回】dict {选项: 分数}，平均值为 0
    """
    events = {}
    for order in rankings:
        for i in range(len(order) - 1):
            key = (order[i], tuple(sorted(order[i:])))
            events[key] = events.get(key, 0) + 1
    n = sum(events.values())
    s = {k: 0.0 for k in keys}
    for _ in range(5000):
        grad = {k: 0.0 for k in keys}
        for (w, opts), c in sorted(events.items()):
            p = logit_probs(s, opts)
            for k in opts:
                grad[k] += c * ((1.0 if k == w else 0.0) - p[k])
        biggest = max(abs(g) / n for g in grad.values())
        for k in keys:
            s[k] += 1.0 * grad[k] / n
        if biggest < 1e-9:
            break
    m = mean(list(s.values()))
    return {k: v - m for k, v in s.items()}


def main():
    rng = random.Random(1960)        # 1960：Debreu 提出"唱片"反例的年份
    demo_red_bus(rng)
    demo_substitution()
    demo_llm_clones(rng)


if __name__ == "__main__":
    main()
