"""
=============================================================================
第 7 讲 · 代码 4：度量获取 —— 只问"你更喜欢哪个分类器"，找出一个人心中的评价指标
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
一个医生要用一个诊断分类器。漏诊（把病人判成健康）和误诊（把健康人判成病人）都不好，但哪个更糟？
"请给出漏诊和误诊的相对代价"——这种问题人很难直接回答。可是给他看两个分类器的表现：

        分类器甲：真阳性率 90%，真阴性率 70%
        分类器乙：真阳性率 80%，真阴性率 85%

问"你更愿意用哪个"，他通常答得出来。**度量获取**（metric elicitation，Hiranandani、Koyejo 等 2019）
就是用这种两两比较，把他心中的评价指标找出来。

设定：医生心中的指标是线性的：φ = m · TPR + (1 − m) · TNR，m ∈ [0, 1] 未知（m 越大越怕漏诊）。
所有"阈值分类器"的 (TPR, TNR) 构成一条上凸的边界曲线（ROC 曲线换个坐标），最好的分类器在这条曲线上。

    演示 1：二分搜索：沿着边界，每次问"阈值 t 处的分类器和稍大一点的阈值处的，你更喜欢哪个"，
            根据回答把搜索区间砍掉一半。问 k 次之后，m 的误差按 2⁻ᵏ 缩小。
    演示 2：对照：随机挑两个分类器问。每个回答排除掉一部分 m，但平均每次只排除一小部分。
    演示 3：回答有噪声时（医生偶尔答错），二分搜索会"走错路"；每个问题问几遍取多数可以补救。
    演示 4：如果心中的指标是 F1（不是线性的，但沿边界仍然是"单峰"的），二分搜索仍然能找到最好的分类器。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_metric_elicitation.py        # 约 2 秒

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

import math

import numpy as np

from design_tools import pad, print_header

MU0, MU1 = -1.0, 1.0           # 健康人 / 病人的"风险分数"分别 ~ N(−1, 1)、N(1, 1)
T_LO, T_HI = -6.0, 6.0          # 阈值的搜索范围
PRIOR_POS = 0.3                 # 病人的比例（只在演示 4 的 F1 里用到）


def norm_cdf(x):
    """标准正态的累积分布函数 Φ(x)。"""
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def norm_pdf(x):
    """标准正态的密度。"""
    return math.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi)


def rates(t):
    """
    阈值分类器"分数 > t 就判为病人"的 (真阳性率 TPR, 真阴性率 TNR)。

    【思路】病人的分数 ~ N(1, 1)，被判为病人的概率 = P(分数 > t) = 1 − Φ(t − 1)；
        健康人的分数 ~ N(−1, 1)，被判为健康的概率 = Φ(t + 1)。
        t 从小到大：TPR 从 1 降到 0，TNR 从 0 升到 1 —— 沿着边界曲线从一头走到另一头。
    【边界】只考虑阈值分类器。可以证明，对线性指标（以及 F1 这类"线性分式"指标），最好的分类器一定是
        某个阈值分类器（在真实概率 P(病 | x) 上取阈值），所以只在这条曲线上搜索不会错过最优解。
    """
    return 1.0 - norm_cdf(t - MU1), norm_cdf(t - MU0)


def linear_metric(m):
    """返回函数 φ(t) = m · TPR(t) + (1 − m) · TNR(t)。"""
    def phi(t):
        tpr, tnr = rates(t)
        return m * tpr + (1 - m) * tnr
    return phi


def f1_metric(t):
    """F1 = 2TP / (2TP + FP + FN)，TP、FP、FN 是占全体的比例（病人比例 PRIOR_POS）。"""
    tpr, tnr = rates(t)
    tp = PRIOR_POS * tpr
    fn = PRIOR_POS * (1 - tpr)
    fp = (1 - PRIOR_POS) * (1 - tnr)
    return 2 * tp / (2 * tp + fp + fn)


def m_from_threshold(t):
    """
    已知最好的阈值 t*，反推线性指标的 m。

    【思路】φ(t) 在 t* 处取最大值，导数为 0：m · TPR'(t*) + (1 − m) · TNR'(t*) = 0。
        TPR'(t) = −pdf(t − μ₁)，TNR'(t) = pdf(t − μ₀)，解出 m = pdf(t* − μ₀) / (pdf(t* − μ₀) + pdf(t* − μ₁))。
        几何意义：最好的分类器处，边界曲线的切线和指标的"等高线"平行 —— 切线的斜率就告诉了我们 m。
    """
    a, b = norm_pdf(t - MU0), norm_pdf(t - MU1)
    return a / (a + b)


def oracle(phi, t1, t2, rng, noise=0.0):
    """
    问医生："阈值 t1 的分类器和阈值 t2 的分类器，你更喜欢哪个？" 返回 True 表示更喜欢 t1。

    【参数】phi：医生心中的指标（函数）；noise：答错的概率
    """
    prefer_1 = phi(t1) > phi(t2)
    if noise > 0 and rng.random() < noise:
        return not prefer_1
    return prefer_1


def binary_search(phi, n_queries, rng, noise=0.0, repeat=1, eps=1e-4):
    """
    沿着边界二分搜索最好的阈值。

    【思路】φ(t) 沿边界是单峰的（先升后降）。在区间中点 c 处问"c − ε 和 c + ε 哪个好"：
        如果 c + ε 更好，峰在右边，丢掉左半；否则丢掉右半。每问一次，区间缩小一半。
        repeat > 1 时，每个问题问 repeat 遍取多数（对付偶尔答错）。
    【参数】n_queries：总共问多少次（含重复）
    【返回】估计的最好阈值（最后区间的中点）
    【边界】ε 要比最终想要的精度小，否则"在 c 附近比较"本身就不准。
    """
    lo, hi = T_LO, T_HI
    steps = n_queries // repeat
    for _ in range(steps):
        c = 0.5 * (lo + hi)
        votes = sum(oracle(phi, c + eps, c - eps, rng, noise) for _ in range(repeat))
        if votes * 2 > repeat:          # 多数认为 c + ε 更好：峰在右边
            lo = c
        else:
            hi = c
    return 0.5 * (lo + hi)


def random_queries(m_true, n_queries, rng):
    """
    对照：随机挑两个阈值问。每个回答是关于 m 的一个线性不等式，记下所有回答都允许的 m 的范围，最后取中点。

    【思路】更喜欢 t1 ⇔ m·ΔTPR + (1 − m)·ΔTNR > 0（Δ = t1 处 − t2 处），这是 m 的一个半直线约束。
        和"版本空间"的思路一样：所有和回答一致的 m 构成一个区间，问得越多，区间越窄。
    【返回】估计的 m
    """
    phi = linear_metric(m_true)
    lo, hi = 0.0, 1.0
    for _ in range(n_queries):
        t1, t2 = rng.uniform(-3, 3, size=2)
        r1, r2 = rates(t1), rates(t2)
        d_tpr, d_tnr = r1[0] - r2[0], r1[1] - r2[1]
        prefer_1 = oracle(phi, t1, t2, rng)
        # m·(d_tpr − d_tnr) + d_tnr > 0（更喜欢 t1 时）或 < 0（更喜欢 t2 时）
        coef, const = d_tpr - d_tnr, d_tnr
        if not prefer_1:
            coef, const = -coef, -const
        if coef > 0:
            lo = max(lo, -const / coef)
        elif coef < 0:
            hi = min(hi, -const / coef)
    return 0.5 * (lo + hi)


# =============================================================================
# 演示 1 与 2
# =============================================================================
def demo_binary_vs_random(rng):
    print_header("演示 1 与 2：二分搜索 vs 随机提问")
    ms = [0.1, 0.3, 0.5, 0.7, 0.9]
    print("医生心中的指标 φ = m·TPR + (1 − m)·TNR。对 5 个不同的 m，问 k 个问题后估计 m：\n")
    print(f"  {pad('真实 m', 7)} {pad('最好的阈值', 11, 'right')}  {pad('二分搜索：k = 5 / 10 / 20 后的 |m̂ − m|', 40)}")
    for m in ms:
        errs = [abs(m_from_threshold(binary_search(linear_metric(m), k, rng)) - m) for k in [5, 10, 20]]
        t_star = binary_search(linear_metric(m), 40, rng)
        print(f"  {m:>7.1f} {t_star:>11.3f}  " + " / ".join(f"{e:.1e}" for e in errs))
    print("\n  随机提问（每个回答是关于 m 的一个不等式，取所有回答都允许的范围的中点），100 次重复的平均 |m̂ − m|：")
    print(f"  {pad('k', 5)} {pad('随机提问', 10, 'right')} {pad('二分搜索', 10, 'right')}")
    for k in [5, 10, 20, 40]:
        rnd = np.mean([abs(random_queries(m, k, rng) - m) for m in rng.uniform(0.05, 0.95, size=100)])
        bs = np.mean([abs(m_from_threshold(binary_search(linear_metric(m), k, rng)) - m)
                      for m in rng.uniform(0.05, 0.95, size=100)])
        print(f"  {k:>5d} {rnd:>10.1e} {bs:>10.1e}")
    print("""
观察：
  · 二分搜索：每问一次，最好阈值所在的区间砍掉一半，m 的误差按 2⁻ᵏ 缩小。问 20 次，误差就到了 10⁻⁶ 量级。
  · 随机提问也能学：每个回答排除一部分 m。但大部分随机的问题“答案早就知道”（两个分类器差得很远，
    几乎任何 m 都会选同一个），只排除很小的一部分；k 翻倍，误差只减少约 40%，比 2⁻ᵏ 慢得多。
  · 这和 Fisher 信息是同一个道理：一个“是 / 否”问题最多带来 1 比特信息，只有在两个答案的可能性各占一半时
    才能达到。二分搜索的每个问题都恰好是“五五开”的：在当前的不确定范围内，一半的 m 会选甲，一半会选乙。""")


# =============================================================================
# 演示 3
# =============================================================================
def demo_noise(rng, reps=400):
    print_header("演示 3：医生偶尔答错 —— 问几遍取多数")
    print(f"每次实验 m 在 [0.05, 0.95] 里随机取，总共允许问 30 次（含重复）。重复 {reps} 次，报告 |m̂ − m| 的中位数和 90% 分位数：\n")
    print(f"  {pad('答错的概率', 11)}" + "".join(pad(f"每题问 {r} 遍", 22, "right") for r in [1, 3, 5]))
    for noise in [0.0, 0.05, 0.1, 0.2]:
        row = f"  {noise:>11.2f}"
        for repeat in [1, 3, 5]:
            errs = []
            for _ in range(reps):
                m = rng.uniform(0.05, 0.95)
                t_hat = binary_search(linear_metric(m), 30, rng, noise=noise, repeat=repeat)
                errs.append(abs(m_from_threshold(t_hat) - m))
            row += pad(f"{np.median(errs):.1e} / {np.quantile(errs, 0.9):.1e}", 22, "right")
        print(row)
    print("""
观察：
  · 没有噪声时，每题只问一遍最好：30 次全用来砍区间，误差最小。
  · 有噪声时，二分搜索很“脆弱”：某一步答错，峰就被丢到了错误的那一半，后面再怎么问也回不来。
    90% 分位数一下子变得很大 —— 一部分实验彻底失败。
  · 每题问 3 遍或 5 遍取多数，每一步答错的概率大幅下降，代价是总步数变少（精度上限变低）。
    噪声越大，越值得重复。更系统的做法是“带噪声的二分搜索”（概率二分法），按后验分布决定下一个问题。
  · 真实的人：回答不只是偶尔出错，还可能依赖于问题的呈现方式（第 3 讲的情境效应、第 15 讲的有限理性）。""")


# =============================================================================
# 演示 4
# =============================================================================
def demo_f1(rng):
    print_header("演示 4：指标不是线性的（F1）—— 沿边界单峰，二分搜索仍然能找到最好的分类器")
    grid = np.linspace(T_LO, T_HI, 120001)
    f1_vals = np.array([f1_metric(t) for t in grid])
    t_best = float(grid[np.argmax(f1_vals)])
    print(f"病人比例 {PRIOR_POS}。在细网格上穷举，F1 最好的阈值是 {t_best:.4f}（F1 = {f1_vals.max():.4f}）。\n")
    print(f"  {pad('问题数', 7)} {pad('二分搜索找到的阈值', 19, 'right')} {pad('它的 F1', 9, 'right')} {pad('和最好的差距', 13, 'right')}")
    for k in [5, 10, 15, 20, 30]:
        t_hat = binary_search(f1_metric, k, rng)
        print(f"  {k:>7d} {t_hat:>19.4f} {f1_metric(t_hat):>9.4f} {f1_vals.max() - f1_metric(t_hat):>13.1e}")
    print("""
观察：
  · 5 个问题之后，F1 就和最好的几乎一样：F1 在最优点附近很平，阈值差一点点，F1 几乎不变。
    （差距出现负数，是因为“穷举”用的网格本身也有间隔，二分搜索找到的点比网格上最好的点还好一点点。）
  · F1 不是 TPR、TNR 的线性函数，而是“线性分式”函数（分子分母都是线性的）。它沿着边界曲线仍然是单峰的
    （拟凹），所以“比较相邻两点、丢掉一半”的二分搜索照样有效。
  · 找到最好的分类器，不等于知道了指标的全部参数：线性分式指标有更多参数，需要在边界的不同位置多搜几次
    才能确定（Hiranandani 等 2019 的论文给出了完整的算法）。
  · 课程的视角：这是“从偏好学习”的另一种形式 —— 被学习的不是“哪个回答更好”，而是“一个人用什么标准评价”。
    同样的思想可以用来获取公平性指标的权衡（第 17 讲）。""")


def main():
    rng = np.random.default_rng(29)
    demo_binary_vs_random(rng)
    demo_noise(rng)
    demo_f1(rng)


if __name__ == "__main__":
    main()
