"""
=============================================================================
前置讲 P3 · 代码 02：比较两个提示 —— 为什么一定要"配对"
=============================================================================

对应讲义：lectures/prereq3_statistics_for_prompts.md §3–§4

【这个脚本要回答的问题】
    1. 两个提示在同一组 200 道题上跑（B 的真实准确率比 A 高 3 个百分点），能看出 B 更好吗？不同的检验方法怎么说？（演示 1）
    2. 要多少道题，才有把握发现一个 3 个百分点的真实差别？配对和不配对差多少？（演示 2：功效与样本量）
    3. 两个提示其实一样好时，检验会不会"冤枉"出一个差别？（演示 3：假阳性率）

【怎么读输出】
    模拟世界（stats_kit.ItemBank）：提示 A 的真实准确率 70%，B 是 73%。同一道题上两个提示的结果正相关
    （都受题目难度影响），但又不完全一样（每个提示有自己的"手感"）。

【运行】python3 02_paired.py     （约 5 秒）
【依赖】只用 Python 标准库；import 同目录的 stats_kit.py 和第 1 讲的 console.py。
"""

import math
import os
import random
import sys

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
from console import pct, print_header, print_observations, print_table  # noqa: E402
from stats_kit import (Z, ItemBank, mcnemar_exact, n_paired_mcnemar, n_unpaired, paired_bootstrap_ci,  # noqa: E402
                       paired_diff_ci, two_proportion_test, z_for)

BANK = ItemBank()
TA, TB = BANK.theta_for(0.70), BANK.theta_for(0.73)


def one_experiment(n, rng, tb=TB):
    """抽 n 道题，返回 (A 的 0/1 结果, B 的 0/1 结果)。"""
    items = BANK.draw_items(n, rng)
    return BANK.greedy(TA, items, rng), BANK.greedy(tb, items, rng)


def demo1_one(rng):
    """演示 1：一次实验的几种分析。"""
    print_header("演示 1：同一组 200 道题，B 比 A 高——几种分析方法怎么说？")
    a, b = one_experiment(200, rng)
    n = len(a)
    both = sum(1 for x, y in zip(a, b) if x and y)
    none = sum(1 for x, y in zip(a, b) if not x and not y)
    only_a = sum(1 for x, y in zip(a, b) if x and not y)
    only_b = sum(1 for x, y in zip(a, b) if y and not x)
    print_table(["", "B 对", "B 错"], [["A 对", both, only_a], ["A 错", only_b, none]])
    ka, kb = sum(a), sum(b)
    ma, mb = ka / n, kb / n
    cov = math.fsum((x - ma) * (y - mb) for x, y in zip(a, b)) / n
    corr = cov / math.sqrt(ma * (1 - ma) * mb * (1 - mb))
    print(f"  A 答对 {ka}/{n} = {ma:.1%}，B 答对 {kb}/{n} = {mb:.1%}；同一道题上两者结果的相关系数 {corr:.2f}")
    diff, z, p_un = two_proportion_test(ka, n, kb, n)
    se_un = math.sqrt(ma * (1 - ma) / n + mb * (1 - mb) / n)
    n01, n10, p_mc = mcnemar_exact(a, b)
    m, lo, hi = paired_diff_ci(a, b)
    blo, bhi = paired_bootstrap_ci(a, b, B=4000, seed=7)
    h = z_for() * se_un
    rows = [
        ["不配对：两比例 z 检验", f"{diff:+.1%}", f"[{diff - h:+.1%}, {diff + h:+.1%}]", f"{p_un:.3f}"],
        ["配对：McNemar 精确检验", f"{diff:+.1%}", "-", f"{p_mc:.3f}"],
        ["配对：差值的正态区间", f"{m:+.1%}", f"[{lo:+.1%}, {hi:+.1%}]", "-"],
        ["配对：bootstrap（按题重抽）", f"{m:+.1%}", f"[{blo:+.1%}, {bhi:+.1%}]", "-"],
    ]
    print()
    print_table(["分析方法", "B − A", "95% 区间", "p 值"], rows)
    print_observations([
        f"200 道题里，两个提示在 {both + none} 道题上结果一样（都对或都错），只在 {only_a + only_b} 道题上'意见不同'。"
        "都对、都错的题对'谁更好'没有任何信息——McNemar 检验只看那些不一致的题。",
        f"不配对的区间宽 ±{100 * h:.1f} 个百分点；配对的区间只有 ±{100 * (hi - lo) / 2:.1f}，窄了约 {1 - (hi - lo) / 2 / h:.0%}——"
        f"相当于不配对时要多用 {(2 * h / (hi - lo)) ** 2:.1f} 倍的题。"
        f"原因是相关系数 {corr:.2f}——难题两个提示都容易错、简单题都容易对，这部分共同的波动在作差时被抵消了。",
        "不配对的 z 检验假设两组结果相互独立。两个提示用的是**同一组题**，这个假设不成立，它就白白浪费了这份相关性。"
        "同一组题上比较两个提示时，总是用配对的方法。",
    ])
    return (only_a + only_b) / n


def power_paired(n, delta, psi, alpha=0.05):
    """McNemar 检验的近似功效：Φ((δ·sqrt(n) − z_{α/2}·sqrt(ψ)) / sqrt(ψ − δ²))（n_paired_mcnemar 的反函数）。"""
    za = z_for(1 - alpha)
    return Z.cdf((delta * math.sqrt(n) - za * math.sqrt(psi)) / math.sqrt(psi - delta * delta))


def power_unpaired(n, p1, p2, alpha=0.05):
    """不配对的两比例检验的近似功效（n_unpaired 的反函数）。"""
    za = z_for(1 - alpha)
    pbar = (p1 + p2) / 2
    num = abs(p2 - p1) * math.sqrt(n) - za * math.sqrt(2 * pbar * (1 - pbar))
    return Z.cdf(num / math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)))


def demo2_power(rng, psi):
    """演示 2：功效与样本量。"""
    print_header("演示 2：要多少道题才能发现 3 个百分点的真实差别？——功效（power）")
    reps = 1000
    rows = []
    for n in [100, 200, 500, 1000, 2000]:
        sig_un = sig_mc = 0
        for _ in range(reps):
            a, b = one_experiment(n, rng)
            sig_un += two_proportion_test(sum(a), n, sum(b), n)[2] < 0.05
            sig_mc += mcnemar_exact(a, b)[2] < 0.05
        rows.append([n, pct(sig_un, reps), f"{power_unpaired(n, 0.70, 0.73):.0%}", pct(sig_mc, reps),
                     f"{power_paired(n, 0.03, psi):.0%}"])
    print(f"真实差别 3 个百分点（70% vs 73%）；每种题数模拟 {reps} 次实验，看有多少次检验结果显著（p < 0.05）")
    print_table(["题数", "不配对 z 检验：显著的比例", "（公式预测）", "McNemar：显著的比例", "（公式预测）"], rows)
    nu, npd = n_unpaired(0.70, 0.73), n_paired_mcnemar(0.03, psi)
    print(f"\n  达到 80% 功效需要的题数：不配对 {nu}，配对 {npd}（用实测的不一致比例 ψ = {psi:.3f}）")
    print_observations([
        f"功效（power）= 真实存在差别时，实验能检测出它的概率。题目少时，一个真实的 3 个百分点的差别大多数时候**测不出来**："
        f"200 道题时配对检验只有 {rows[1][3]} 的把握，500 道题时也只有 {rows[2][3]}。",
        f"配对检验在每种题数下都比不配对的灵敏得多；达到 80% 功效，配对需要约 {npd} 道题，不配对需要约 {nu} 道——"
        f"差了 {nu / npd:.1f} 倍。题目和 API 调用都是钱。",
        "McNemar 的公式预测（Connor 1987 的样本量公式）和模拟结果基本吻合，可以用来在实验**之前**规划题数："
        "先用几十道题的小规模预实验估计不一致的比例 ψ，再代入公式。不配对的公式假设两组题相互独立，"
        "用在同一组题上时，题少的时候实际功效远低于它的预测（100 道题时预测 7%、实际不到 1%）。",
        "反过来说：在 200 道题上'没有发现显著差别'，**不**等于'两个提示一样好'——可能只是题太少，测不出来。",
    ])


def demo3_false_positive(rng):
    """演示 3：两个提示一样好时的假阳性率。"""
    print_header("演示 3：两个提示其实一样好——检验会不会'冤枉'出一个差别？")
    reps, n = 2000, 500
    sig_un = sig_mc = 0
    for _ in range(reps):
        a, b = one_experiment(n, rng, tb=TA)          # B 的真实能力和 A 完全相同
        sig_un += two_proportion_test(sum(a), n, sum(b), n)[2] < 0.05
        sig_mc += mcnemar_exact(a, b)[2] < 0.05
    print_table(["检验", f"p < 0.05 的比例（{reps} 次实验，每次 {n} 道题）"],
                [["不配对 z 检验", pct(sig_un, reps)], ["McNemar（配对）", pct(sig_mc, reps)]])
    print_observations([
        "两个提示一样好时，'p < 0.05'的比例就是假阳性率。McNemar 检验接近名义上的 5%（精确检验略偏保守）：每做 20 次这样的比较，"
        "大约有 1 次会'冤枉'出一个差别——这是 α = 0.05 的定义，不是 bug。",
        "不配对的检验假阳性率远低于 5%：它把两个提示的结果当成独立的，高估了差值的方差，于是过于保守——"
        "这和演示 2 里它功效低是同一枚硬币的两面。",
        "如果你比较了很多对提示，假阳性会累积起来。03 号脚本讲怎样处理。",
    ])


def main():
    rng = random.Random(20)
    psi = demo1_one(rng)
    # 用一个大样本估计这个世界里两个提示的不一致比例 ψ（真实实验里用预实验估计）
    a, b = one_experiment(100000, random.Random(99))
    psi = sum(1 for x, y in zip(a, b) if x != y) / len(a)
    demo2_power(rng, psi)
    demo3_false_positive(rng)


if __name__ == "__main__":
    main()
