"""
=============================================================================
CS486 · 第 2 讲模块：judge_sim.py —— "LLM 当裁判"的偏差与校正（numpy 模拟）
=============================================================================

【这个模块要解决什么问题】
    开放式任务（写作、对话、研究报告、多智能体的输出……）没有标准答案，很多论文用一个强 LLM 当裁判
    （LLM-as-a-judge）：给它两个回答，让它判断哪个更好，再统计"我们的系统赢了百分之几"。
    这很便宜，但裁判有系统性偏差（Zheng et al. 2023 的 MT-Bench 论文总结了几种）：
        - 位置偏差（position bias）：倾向于选排在前面（或后面）的回答；
        - 冗长偏差（verbosity bias）：倾向于选更长的回答，即使它不更好；
        - 自我偏好（self-enhancement）：倾向于选和自己同一家族的模型写的回答。
    这些偏差会让研究结论翻转：真实更好的系统，在裁判眼里反而输了。

    这里我们不调用任何真实的 LLM，而是构造一个**知道真相**的模拟世界：
        每个提示 i 有一个难度 u_i；系统 A、B 的回答有潜在质量 q 和长度 ℓ；
        "人类"按质量差判断（这是我们定义的真相）；
        "裁判"按质量差判断，但额外受长度和位置的影响。
    然后比较几种估计"A 的真实胜率"的方法：
        单次判（A 在前 / B 在前）、交换位置取平均、长度控制回归（AlpacaEval LC 的思路）、
        只用少量人类标注、以及**预测驱动推断**（prediction-powered inference, PPI；Angelopoulos et al. 2023）：
        用大量裁判标注 + 少量人类标注，得到**无偏**且比"只用人类标注"更窄的置信区间。

【这个世界简化了什么】
    真实裁判的偏差大小因模型、任务、提示词而异；人类标注也有噪声和分歧（这里人类按概率判断，
    已经包含了一部分分歧）。我们演示的是**机制**：哪种校正能去掉哪种偏差、需要什么假设。

【运行】
    python3 judge_sim.py      # 自检：真实胜率的计算、PPI 在"裁判无偏"时退化为普通估计、逻辑回归收敛

【依赖】numpy。
"""

import math

import numpy as np


def sigmoid(x):
    """逻辑函数 1 / (1 + e^(−x))，把任意实数压到 (0, 1)。【numpy 小知识】对数组逐元素计算。"""
    return 1.0 / (1.0 + np.exp(-x))


# ---------------------------------------------------------------------------
# 世界的参数
# ---------------------------------------------------------------------------
DEFAULT_WORLD = {
    "n_prompts": 800,        # 被评测的提示数
    "mu_b": -0.25,           # 系统 B 的平均质量（A 是 0）：B 稍差一点
    "noise_q": 1.0,          # 每个回答质量的随机波动
    "len_a": 300.0,          # A 回答的典型长度（token）
    "len_b": 480.0,          # B 回答的典型长度：B 更啰嗦
    "len_sd": 0.3,           # 长度的对数标准差
    "human_beta": 1.0,       # 人类对质量差的敏感度
    "human_len": 0.0,        # 人类对长度的偏好（0 = 长度本身不影响人类判断）
    "judge_beta": 0.8,       # 裁判对质量差的敏感度
    "judge_len": 1.0,        # 裁判的冗长偏差：每多一倍长度，logit 加 ln2 × 1.0
    "judge_pos": 0.6,        # 裁判的位置偏差：排在前面的回答 logit 加 0.6
}


def make_world(rng, w=None):
    """
    生成一次评测：每个提示上 A、B 的回答（质量、长度）和人类偏好的真实概率。

    【参数】rng：np.random.Generator；w：参数字典（缺省用 DEFAULT_WORLD）
    【返回】dict：
        q_a, q_b      ：两个系统回答的潜在质量（数组，长度 n_prompts）
        log_ratio     ：ln(ℓ_A / ℓ_B)，长度比的对数
        p_human       ：人类认为 A 更好的概率（逐提示）
        theta         ：我们要估计的真值 = p_human 的平均（"人类眼中 A 的胜率"）
    """
    w = {**DEFAULT_WORLD, **(w or {})}
    n = w["n_prompts"]
    u = rng.normal(0, 1, n)                                   # 提示难度：两个系统共享
    q_a = u + rng.normal(0, w["noise_q"], n)
    q_b = u + w["mu_b"] + rng.normal(0, w["noise_q"], n)
    log_ratio = (math.log(w["len_a"]) + rng.normal(0, w["len_sd"], n)) - \
                (math.log(w["len_b"]) + rng.normal(0, w["len_sd"], n))
    p_human = sigmoid(w["human_beta"] * (q_a - q_b) + w["human_len"] * log_ratio)
    return {"q_a": q_a, "q_b": q_b, "log_ratio": log_ratio, "p_human": p_human,
            "theta": float(p_human.mean()), "w": w}


def judge_votes(rng, world):
    """
    裁判在两种顺序下各判一次。

    【思路】裁判认为"排在前面的那个更好"的 logit =
            judge_beta × (质量差) + judge_len × ln(长度比) + judge_pos
        A 在前时，质量差和长度比都是"A − B"；B 在前时都是"B − A"。
    【返回】(a_first, b_first)：两个 0/1 数组
        a_first[i] = 1 表示 A 在前时裁判选了 A；b_first[i] = 1 表示 B 在前时裁判选了 **A**（已经换算成"选 A"）
    """
    w = world["w"]
    dq = world["q_a"] - world["q_b"]
    lr = world["log_ratio"]
    p_a_first = sigmoid(w["judge_beta"] * dq + w["judge_len"] * lr + w["judge_pos"])
    p_b_first = sigmoid(-w["judge_beta"] * dq - w["judge_len"] * lr + w["judge_pos"])  # 选 B 的概率
    a_first = (rng.random(len(dq)) < p_a_first).astype(float)
    b_first = 1.0 - (rng.random(len(dq)) < p_b_first).astype(float)
    return a_first, b_first


def judge_probs(world):
    """
    裁判的"概率分数"：两种顺序下裁判选 A 的概率的平均（不抽样）。

    【思路】真实的 LLM 裁判可以读出"判决 token"的对数概率，而不只是一个 0/1 判决。
        连续的分数比 0/1 投票噪声小，和人类判断的相关性更高——对 PPI 很重要（演示 2、4）。
    【返回】数组，长度 n_prompts，每个值在 (0, 1) 之间
    """
    w = world["w"]
    dq = world["q_a"] - world["q_b"]
    lr = world["log_ratio"]
    p_a_first = sigmoid(w["judge_beta"] * dq + w["judge_len"] * lr + w["judge_pos"])
    p_b_first_choose_a = 1.0 - sigmoid(-w["judge_beta"] * dq - w["judge_len"] * lr + w["judge_pos"])
    return (p_a_first + p_b_first_choose_a) / 2


def human_labels(rng, world, n_labels):
    """
    随机挑 n_labels 个提示请人类标注（每个提示一次 0/1 判断）。

    【返回】(idx, h)：被标注的提示下标、人类的 0/1 判断（1 = A 更好）
    【边界】n_labels 不能超过提示总数（不放回抽样）。
    """
    idx = rng.choice(len(world["p_human"]), size=n_labels, replace=False)
    h = (rng.random(n_labels) < world["p_human"][idx]).astype(float)
    return idx, h


# ---------------------------------------------------------------------------
# 估计方法
# ---------------------------------------------------------------------------
def length_controlled(a_first, b_first, log_ratio, iters=25):
    """
    长度控制的胜率（AlpacaEval LC 的思路，Dubois et al. 2024 的简化版）。

    【思路】把裁判的 2N 次判断当成数据，拟合逻辑回归：
            logit P(裁判选 A) = θ0 + θ1 × ln(ℓ_A/ℓ_B) + θ2 × [A 在前 ? +1 : −1]
        然后回答一个反事实问题："如果两个回答一样长、位置也平衡，裁判选 A 的概率是多少？"
        —— 就是 σ(θ0)。它去掉了"长度"和"位置"这两个我们认为**不该**影响判断的因素。
        重要假设：长度本身不是质量的一部分。如果更长的回答确实更好（更完整），这个校正会**过度**扣分（演示 3）。
    【参数】a_first、b_first：judge_votes 的输出；log_ratio：长度比的对数；iters：牛顿法迭代次数
    【返回】(lc_win_rate, theta)：σ(θ0) 和三个系数
    【numpy 小知识】np.linalg.solve(H, g) 解线性方程组 H x = g；牛顿法每步用它算更新量。
    """
    n = len(a_first)
    X = np.ones((2 * n, 3))
    X[:n, 1] = log_ratio
    X[n:, 1] = log_ratio
    X[:n, 2] = 1.0           # A 在前
    X[n:, 2] = -1.0          # B 在前
    y = np.concatenate([a_first, b_first])
    theta = np.zeros(3)
    for _ in range(iters):
        p = sigmoid(X @ theta)
        grad = X.T @ (y - p)
        H = (X * (p * (1 - p))[:, None]).T @ X + 1e-9 * np.eye(3)
        theta = theta + np.linalg.solve(H, grad)
    return float(sigmoid(theta[0])), theta


def ppi_mean(judge_all, idx, h, lam=1.0, z=1.96):
    """
    预测驱动推断（PPI）估计平均胜率，以及它的置信区间。

    【思路】我们有 N 个提示的裁判分数 ĵ_i（便宜、有偏），和其中 n 个提示的人类判断 h_i（贵、无偏）。
            θ̂ = λ · mean_{全部 N}(ĵ) + mean_{n 个标注}(h − λ ĵ)
        λ = 1 是经典的 PPI（Angelopoulos et al. 2023）：第一项用大量数据压低方差，第二项（"校正项"）
        估计并减掉裁判的平均偏差，所以整体无偏。
        方差 ≈ λ² Var(ĵ)/N + Var(h − λĵ)/n。裁判和人类越一致，h − ĵ 的方差越小，区间越窄；
        但如果裁判和人类不太一致，λ = 1 的区间可能比"只用人类标注"还**宽**（演示 2 会看到）。
        PPI++（Angelopoulos, Duchi & Zrnic 2023）用数据估计最优的 λ：
            λ̂ = Cov(h, ĵ) / ((1 + n/N) · Var(ĵ))
        λ̂ 接近 0 时退化为"只用人类标注"，所以它（渐近地）不会比只用人类标注更差。
    【参数】judge_all：长度 N 的裁判分数；idx：被人类标注的下标；h：人类 0/1 判断；
           lam：1.0（经典 PPI）、None（用 PPI++ 估计 λ）或任意数字
    【返回】(estimate, lo, hi, lam)
    【边界】λ 用同一批标注数据估计，会带来很小的偏差（n 很小时要注意）；这里 n = 100，可以忽略。
    """
    N, n = len(judge_all), len(idx)
    j_lab = judge_all[idx]
    if lam is None:
        cov = float(np.cov(h, j_lab, ddof=1)[0, 1])
        lam = cov / ((1 + n / N) * float(judge_all.var(ddof=1)))
    rect = h - lam * j_lab
    est = lam * judge_all.mean() + rect.mean()
    se = math.sqrt(lam ** 2 * judge_all.var(ddof=1) / N + rect.var(ddof=1) / n)
    return float(est), float(est - z * se), float(est + z * se), float(lam)


def human_only(h, z=1.96):
    """只用人类标注：样本均值和 Wilson 区间。【返回】(estimate, lo, hi)"""
    n = len(h)
    p = float(h.mean())
    denom = 1 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return p, center - half, center + half


def judge_only_ci(scores, z=1.96):
    """只用裁判分数：均值 ± z × 标准误。它的区间只反映'抽样噪声'，完全不反映裁判的偏差。"""
    m = float(scores.mean())
    se = float(scores.std(ddof=1) / math.sqrt(len(scores)))
    return m, m - z * se, m + z * se


# ---------------------------------------------------------------------------
# 自检
# ---------------------------------------------------------------------------
def _self_test():
    rng = np.random.default_rng(0)
    world = make_world(rng)
    assert 0.0 < world["theta"] < 1.0
    # 一个"完美裁判"（判断 = 人类判断）时，PPI 的校正项期望为 0
    perfect = (rng.random(len(world["p_human"])) < world["p_human"]).astype(float)
    idx = rng.choice(len(perfect), size=100, replace=False)
    est, lo, hi, _ = ppi_mean(perfect, idx, perfect[idx])
    assert abs(est - perfect.mean()) < 1e-12 and lo < est < hi
    est0, _, _, lam0 = ppi_mean(np.zeros(len(perfect)) + 0.5, idx, perfect[idx], lam=0.0)
    assert lam0 == 0.0 and abs(est0 - perfect[idx].mean()) < 1e-12      # λ = 0：只用人类标注
    # 逻辑回归：没有长度偏差和位置偏差的裁判，θ1、θ2 应该接近 0
    w0 = {**DEFAULT_WORLD, "judge_len": 0.0, "judge_pos": 0.0, "n_prompts": 20000}
    big = make_world(np.random.default_rng(1), w0)
    a, b = judge_votes(np.random.default_rng(2), big)
    _, theta = length_controlled(a, b, big["log_ratio"])
    assert abs(theta[1]) < 0.1 and abs(theta[2]) < 0.05, theta
    print(f"自检通过：真实胜率 θ = {world['theta']:.3f}；完美裁判时 PPI = 裁判均值；"
          f"无偏裁判的回归系数 θ1 = {theta[1]:+.3f}、θ2 = {theta[2]:+.3f}（≈ 0）")


if __name__ == "__main__":
    _self_test()
