"""
=============================================================================
第 7 讲 · 代码 3：多维的最优实验设计 —— 下一对该比较哪两个回答？
=============================================================================

对应讲义：lectures/lecture07_fisher_design.md 第 5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
第 2 讲的线性 BT：每个回答有一个特征向量 x，效用 = wᵀx，P(a ≻ b) = σ(wᵀ(x_a − x_b))。
现在 w 有 6 个分量，"信息"变成了一个 6 × 6 的矩阵：一次比较 (a, b) 贡献 p(1 − p) · u uᵀ，u = x_a − x_b。
从 1225 个候选对里，下一个该问哪一对？

    演示 1：五种挑法，各问 200 个问题，比较学到的 w 和"按效用排序"的准确度：
              随机 / 不确定性采样（p 最接近 0.5）/ 方差最大（uᵀΣu 最大）/ D 最优 / A 最优
            —— 以及为什么"只看 p 是否接近 0.5"会挑出一堆重复的问题。
    演示 2：一次要挑一批（例如交给标注团队 40 对）：直接取单个问题得分最高的 40 对 vs 贪心地一个一个加
            （每加一个就用 Sherman–Morrison 更新协方差，**不需要等答案**）。
    演示 3：Sherman–Morrison 为什么快。

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

需要 numpy。依赖本目录的 design_tools.py。
"""

import time

import numpy as np

from design_tools import (criteria, d_gain, fit_map_linear_bt, pad, print_header, select_next, sherman_morrison,
                          sigmoid)

D = 6
N_ITEMS = 50
SCALES = np.array([2.0, 1.5, 1.0, 0.7, 0.5, 0.3])      # 各个特征在回答之间的差异有多大（后几个特征很少变化）


def make_world(rng):
    """
    50 个回答，每个有 6 维特征；真实权重 w ~ N(0, I)。

    【为什么让特征的尺度不同】真实的特征（长度、格式、是否有例子……）在回答之间的差异大小不一。
        随机挑对时，大部分比较的差异落在"变化大"的特征上，"变化小"的特征很难学准 —— 正是最优设计能帮忙的地方。
    """
    X = rng.normal(0, 1, size=(N_ITEMS, D)) * SCALES
    w = rng.normal(0, 1, size=D)
    ci, cj = np.triu_indices(N_ITEMS, k=1)              # 所有 1225 个候选对
    return X, w, ci, cj


def pairwise_rank_accuracy(w_hat, w, X):
    """用 w_hat 给 50 个回答排序，所有 1225 对里，有多少对的先后顺序和真实效用一致。"""
    s_hat, s = X @ w_hat, X @ w
    ci, cj = np.triu_indices(len(s), k=1)
    return float(np.mean(np.sign(s_hat[ci] - s_hat[cj]) == np.sign(s[ci] - s[cj])))


def run_active(rule, X, w, ci, cj, budget, rng, warmup=6, refit_every=5, checkpoints=()):
    """
    按某个规则依次挑 budget 对去问（标注员按真实 w 和 BT 模型回答），返回各个检查点的结果。

    【思路】
        · 先随机问 warmup 个（一开始 w 的估计全是 0，所有 p 都是 0.5，按信息挑没有意义 —— "冷启动"）。
        · 维护当前的协方差 Σ = A⁻¹（A = 先验精度 I + Σ_n p_n(1 − p_n) u_n u_nᵀ）：
          每问一个，用 Sherman–Morrison 更新；每 refit_every 个重新拟合一次 MAP，并按新的 p 重新算一遍 A。
        · 问过的对不再问（同一对重复问也有信息，但这里为了看清"挑法"的差别，不允许重复）。
    【返回】dict：检查点 → (‖ŵ − w‖, 排序准确率, 已选问题的平均 |p − 0.5|（按真实 w 算）, log det(真实信息矩阵))
    """
    asked_i, asked_j, ys = [], [], []
    available = np.ones(len(ci), dtype=bool)
    w_hat = np.zeros(D)
    A_inv = np.eye(D)
    out = {}
    for t in range(budget):
        idx_av = np.flatnonzero(available)
        r = "random" if t < warmup else rule
        k = idx_av[select_next(r, X, ci[idx_av], cj[idx_av], w_hat, A_inv, rng)]
        available[k] = False
        a, b = ci[k], cj[k]
        y = int(rng.random() < sigmoid((X[a] - X[b]) @ w))
        asked_i.append(a), asked_j.append(b), ys.append(y)
        u = X[a] - X[b]
        p = float(sigmoid(u @ w_hat))
        A_inv = sherman_morrison(A_inv, u, p * (1 - p))
        if (t + 1) % refit_every == 0 or t + 1 in checkpoints:
            I_, J_, Y_ = np.array(asked_i), np.array(asked_j), np.array(ys)
            w_hat = fit_map_linear_bt(X, I_, J_, Y_, prior_var=1.0)
            U = X[I_] - X[J_]
            pp = sigmoid(U @ w_hat)
            A = np.eye(D) + (U * (pp * (1 - pp))[:, None]).T @ U
            A_inv = np.linalg.inv(A)
        if t + 1 in checkpoints:
            U = X[np.array(asked_i)] - X[np.array(asked_j)]
            p_true = sigmoid(U @ w)
            info_true = np.eye(D) + (U * (p_true * (1 - p_true))[:, None]).T @ U
            out[t + 1] = (float(np.linalg.norm(w_hat - w)), pairwise_rank_accuracy(w_hat, w, X),
                          float(np.mean(np.abs(p_true - 0.5))), criteria(info_true)["logdet"])
    return out


# =============================================================================
# 演示 1
# =============================================================================
def demo_sequential(reps=20):
    print_header("演示 1：五种挑法，依次问 200 个问题")
    rules = ["random", "uncertainty", "variance", "d_opt", "a_opt"]
    names = {"random": "随机", "uncertainty": "不确定性采样", "variance": "方差最大", "d_opt": "D 最优",
             "a_opt": "A 最优"}
    checkpoints = [20, 50, 100, 200]
    print(f"{N_ITEMS} 个回答、{D} 维特征（尺度 {SCALES.tolist()}），真实 w ~ N(0, I)，先验 N(0, I)。")
    print(f"每种挑法重复 {reps} 次（每次新的回答和 w），前 6 个问题随机挑（冷启动）。\n")
    res = {r: {c: [] for c in checkpoints} for r in rules}
    for rep in range(reps):
        X, w, ci, cj = make_world(np.random.default_rng(1000 + rep))
        for r in rules:
            out = run_active(r, X, w, ci, cj, checkpoints[-1], np.random.default_rng(rep), checkpoints=checkpoints)
            for c in checkpoints:
                res[r][c].append(out[c])
    for title, col, fmt in [("权重误差 ‖ŵ − w‖（越小越好）", 0, "{:>14.3f}"),
                            ("回答排序的准确率（越大越好）", 1, "{:>14.3f}")]:
        print(f"  {title}")
        print(f"  {pad('问题数', 8)}" + "".join(pad(names[r], 14, "right") for r in rules))
        for c in checkpoints:
            print(f"  {c:>8d}" + "".join(fmt.format(np.mean([v[col] for v in res[r][c]])) for r in rules))
        print()
    print("  200 个问题时，所选问题的特点（按真实 w 计算）：")
    print(f"  {pad('', 22)}" + "".join(pad(names[r], 14, "right") for r in rules))
    print(f"  {pad('平均 |p − 0.5|', 22)}" + "".join(f"{np.mean([v[2] for v in res[r][200]]):>14.3f}" for r in rules))
    print(f"  {pad('log det(信息矩阵)', 22)}" + "".join(f"{np.mean([v[3] for v in res[r][200]]):>14.2f}" for r in rules))
    print("""
观察：
  · 方差最大（只挑模型最不确定的方向 uᵀΣu，不管 p）：最差，比随机还差。它专挑特征差异最大的对，
    这些对的结果几乎一边倒（平均 |p − 0.5| = 0.46），每个问题的信息很少（log det 最低）。
  · 不确定性采样（只挑 p 最接近 0.5 的对）：问题最“没把握”（平均 |p − 0.5| 最小），比随机略好，
    但权重误差远不如 D / A 最优。p ≈ 0.5 意味着 wᵀu ≈ 0：它总在“几乎和 w 垂直”的方向上提问，
    这些问题对“边界在哪”很有用（排序准确率不错），对“沿着 w 的方向差距有多大”帮助有限，信息也不够分散。
  · D 最优和 A 最优把两者乘在一起（“这个方向还不确定” × “这个问题的答案没把握”），学得最快。
    要达到随机挑 200 个问题的精度，它们大约只需要一半的问题。
  · 【边界】这里的 w 是“局部”的：信息量依赖于当前估计，估计错了，挑的问题也会偏（所以要先随机问几个）。
    真实的奖励模型是神经网络，“信息矩阵”只能用最后一层的特征近似（第 10 讲）。""")


# =============================================================================
# 演示 2
# =============================================================================
def demo_batch(reps=100, batch=40, warm=15):
    print_header(f"演示 2：一次挑一批 —— 取前 {batch} 名 vs 贪心地一个一个加")
    print(f"先随机问 {warm} 对、拟合出当前估计；然后一次挑 {batch} 对交给标注团队（挑的时候还没有答案）。")
    print(f"重复 {reps} 次，报告这一批的信息量和问完之后的权重误差：\n")
    names = [f"随机 {batch} 对", f"单个得分前 {batch} 名", "贪心逐个加入"]
    rows = {k: [] for k in names}
    sims = {k: [] for k in rows}
    for rep in range(reps):
        rng = np.random.default_rng(500 + rep)
        X, w, ci, cj = make_world(rng)
        first = rng.choice(len(ci), size=warm, replace=False)
        I0, J0 = ci[first], cj[first]
        Y0 = (rng.random(warm) < sigmoid((X[I0] - X[J0]) @ w)).astype(int)
        w_hat = fit_map_linear_bt(X, I0, J0, Y0)
        U0 = X[I0] - X[J0]
        p0 = sigmoid(U0 @ w_hat)
        A = np.eye(D) + (U0 * (p0 * (1 - p0))[:, None]).T @ U0
        rest = np.setdiff1d(np.arange(len(ci)), first)
        U = X[ci[rest]] - X[cj[rest]]
        p = sigmoid(U @ w_hat)
        wts = p * (1 - p)
        # 三种挑法
        choice = {}
        choice[names[0]] = rng.choice(len(rest), size=batch, replace=False)
        choice[names[1]] = np.argsort(-d_gain(np.linalg.inv(A), U, wts))[:batch]
        A_inv = np.linalg.inv(A)
        picked = []
        for _ in range(batch):
            g = d_gain(A_inv, U, wts)
            g[picked] = -np.inf
            k = int(np.argmax(g))
            picked.append(k)
            A_inv = sherman_morrison(A_inv, U[k], wts[k])     # "假装"已经问了：信息矩阵不依赖答案
        choice["贪心逐个加入"] = np.array(picked)
        for name, sel in choice.items():
            Ub = U[sel]
            info = A + (Ub * wts[sel][:, None]).T @ Ub
            Ib, Jb = ci[rest[sel]], cj[rest[sel]]
            Yb = (rng.random(batch) < sigmoid((X[Ib] - X[Jb]) @ w)).astype(int)
            w_new = fit_map_linear_bt(X, np.concatenate([I0, Ib]), np.concatenate([J0, Jb]), np.concatenate([Y0, Yb]))
            rows[name].append((criteria(info)["logdet"], float(np.linalg.norm(w_new - w))))
            Un = Ub / np.linalg.norm(Ub, axis=1, keepdims=True)
            C = np.abs(Un @ Un.T)
            sims[name].append(float(C[np.triu_indices(batch, k=1)].mean()))
    print(f"  {pad('挑法', 18)} {pad('log det(信息)', 14, 'right')} {pad('权重误差', 9, 'right')} "
          f"{pad('批内方向的平均 |余弦|', 21, 'right')}")
    for name, v in rows.items():
        v = np.array(v)
        print(f"  {pad(name, 18)} {v[:, 0].mean():>14.2f} {v[:, 1].mean():>9.3f} {np.mean(sims[name]):>21.3f}")
    print("""
观察：
  · “单个得分前 40 名”的 40 对彼此很像（批内方向的余弦相似度明显更高）：同一个“还不确定的方向”上得分高的对，
    全被选了进来。问了第一个之后，那个方向就不再那么不确定了，后面的很多是在重复。
  · 贪心逐个加入：每选一个，就假装已经问了它，用 Sherman–Morrison 更新协方差，再挑下一个。
    关键在于 **Fisher 信息不依赖答案**（只依赖 p，而 p 用当前估计），所以不用等标注结果就能做。
    选出来的批次方向和随机一样分散，信息量却最大，问完后误差最小。
  · log det 是“子模”函数（边际收益递减），贪心逐个加入有常数倍的近似保证；这也是第 10 讲批量主动学习的基础。""")


# =============================================================================
# 演示 3
# =============================================================================
def demo_sherman_morrison():
    print_header("演示 3：Sherman–Morrison 为什么快")
    rng = np.random.default_rng(3)
    for d in [50, 200, 800]:
        A = np.eye(d)
        A_inv = np.eye(d)
        us = rng.normal(size=(50, d))
        t0 = time.perf_counter()
        for u in us:
            A = A + 0.2 * np.outer(u, u)
            direct = np.linalg.inv(A)
        t_direct = time.perf_counter() - t0
        t0 = time.perf_counter()
        for u in us:
            A_inv = sherman_morrison(A_inv, u, 0.2)
        t_sm = time.perf_counter() - t0
        err = float(np.max(np.abs(direct - A_inv)))
        print(f"  d = {d:>4d}：50 次更新，每次重新求逆 {t_direct * 1000:>8.1f} 毫秒，Sherman–Morrison {t_sm * 1000:>7.1f} 毫秒，"
              f"结果最大差异 {err:.1e}")
    print("""
（时间和机器有关，只看相对大小。）

观察：
  · 每次重新求逆是 O(d³)，Sherman–Morrison 是 O(d²)。维数越高，差距越大。
  · 两者的结果在浮点误差范围内一致。在线主动学习（每来一个标注就更新一次）几乎都用这个技巧。""")


def main():
    demo_sequential()
    demo_batch()
    demo_sherman_morrison()


if __name__ == "__main__":
    main()
