"""
=============================================================================
前置讲 P3 · 代码 01：一个准确率有多准 —— 抽样分布与置信区间
=============================================================================

对应讲义：lectures/prereq3_statistics_for_prompts.md §1–§2

【这个脚本要回答的问题】
    1. 同一个提示、换一份同样难度的测试题，准确率会变多少？（演示 1：抽样分布）
    2. "95% 置信区间"到底是什么意思？最常见的公式在什么时候不可靠？（演示 2：覆盖率）
    3. 想把区间缩到 ±2 个百分点，要多少道题？（演示 3）
    4. 没有现成公式的统计量怎么办？（演示 4：bootstrap）

【怎么读输出】
    所有数字都来自模拟：我们**知道**提示的真实准确率，所以能检验每种方法说得对不对。

【运行】python3 01_intervals.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 ItemBank, bootstrap_ci, n_for_ci_halfwidth, wald_ci, wilson_ci  # noqa: E402


def demo1_sampling():
    """演示 1：抽样分布。"""
    print_header("演示 1：同一个提示，换一份测试题——准确率会变多少？")
    bank = ItemBank()
    rng = random.Random(1)
    theta = bank.theta_for(0.70)
    n, reps = 50, 10000
    accs = [math.fsum(bank.greedy(theta, bank.draw_items(n, rng), rng)) / n for _ in range(reps)]
    five = ", ".join(pct(a, 1, 0) for a in accs[:5])
    mean = math.fsum(accs) / reps
    sd = math.sqrt(math.fsum((a - mean) ** 2 for a in accs) / (reps - 1))
    print(f"一个真实准确率为 70% 的提示，在 {reps} 份各 {n} 道题的测试集上分别测一次。前 5 次：{five}")
    bins = [(lo / 100, (lo + 4) / 100) for lo in range(46, 94, 4)]
    rows = []
    for lo, hi in bins:
        c = sum(1 for a in accs if lo <= a < hi)
        rows.append([f"{lo:.0%}–{hi:.0%}", c, "█" * round(60 * c / reps)])
    print_table(["观测到的准确率", "次数", "分布"], rows, aligns=["<", ">", "<"])
    lo95 = sorted(accs)[int(0.025 * reps)]
    hi95 = sorted(accs)[int(0.975 * reps)]
    print(f"\n  均值 {mean:.3f}；标准差 {sd:.4f}（公式 sqrt(p(1−p)/n) = {math.sqrt(0.7 * 0.3 / n):.4f}）；"
          f"中间 95% 的结果落在 {lo95:.0%}–{hi95:.0%}")
    print_observations([
        f"同一个提示，换一份 50 道题的测试集，准确率可以从 {min(accs[:5]):.0%} 跳到 {max(accs[:5]):.0%}：一次测量里有大量的**抽样误差**。"
        "两个提示在 50 道题上差 6 个百分点，完全可能是同一个提示测了两次。",
        f"这些结果的标准差（{sd:.3f}）和公式 sqrt(p(1−p)/n) 几乎一样：每道题对或错可以看成一次抛硬币，"
        "准确率就是抛 n 次硬币的正面比例——这个结论只需要'题目是从同一个题库里随机抽的'。",
        "标准误随 1/sqrt(n) 缩小：题目多 4 倍，误差才减半。",
    ])


def coverage(method, p, n, reps, rng):
    """用某种区间方法、在真实准确率 p、n 道题时做 reps 次实验，区间包含真值的比例和平均宽度。"""
    hit, width = 0, 0.0
    for _ in range(reps):
        k = sum(1 for _ in range(n) if rng.random() < p)
        lo, hi = method(k, n)
        hit += lo <= p <= hi
        width += hi - lo
    return hit / reps, width / reps


def demo2_coverage():
    """演示 2：区间的覆盖率。"""
    print_header("演示 2：'95% 置信区间'说得对吗？——Wald 区间 vs Wilson 区间的覆盖率")
    rng = random.Random(2)
    rows = []
    worst = (1.0, None)
    wil = []
    for n in [10, 30, 100, 400]:
        for p in [0.5, 0.9, 0.98]:
            cw, ww = coverage(wald_ci, p, n, 4000, rng)
            cl, wl = coverage(wilson_ci, p, n, 4000, rng)
            wil.append(cl)
            if cw < worst[0]:
                worst = (cw, (n, p))
            rows.append([n, f"{p:.0%}", pct(cw, 1), f"±{50 * ww:.1f}", pct(cl, 1), f"±{50 * wl:.1f}"])
    print("每种情形模拟 4000 次实验，看区间有多少次包含了真实的准确率（应该是 95%）")
    print_table(["题数 n", "真实准确率", "Wald 覆盖率", "Wald 半宽（百分点）", "Wilson 覆盖率", "Wilson 半宽"], rows)
    print_observations([
        "'95% 置信区间'的意思是：如果反复做这个实验，**用这个方法**算出的区间有 95% 的次数会包含真值。"
        "覆盖率就是检验这句话的。",
        f"最常见的 Wald 区间（p̂ ± 1.96·SE）在题目少、准确率接近 100% 时严重失灵：最差的情形"
        f"（n = {worst[1][0]}、准确率 {worst[1][1]:.0%}）只有 {pct(worst[0], 1)} 的覆盖率。"
        "准确率 98% 时 10 道题全对很常见，Wald 区间就变成 [100%, 100%]——'绝对不会错'。",
        f"Wilson 区间的覆盖率在所有情形下都在 {min(wil):.1%}–{max(wil):.1%} 之间（小样本时略偏保守，宁宽勿窄）。"
        "评测里高准确率（> 90%）、小样本（几十道题）的情形很多，默认用 Wilson。",
    ])


def demo3_n_needed():
    """演示 3：区间宽度和题数。"""
    print_header("演示 3：想把区间缩到 ±h，要多少道题？")
    rows = []
    for n in [50, 100, 200, 500, 1000, 2000, 5000]:
        lo, hi = wilson_ci(round(0.8 * n), n)
        rows.append([n, f"[{lo:.1%}, {hi:.1%}]", f"±{50 * (hi - lo):.1f} 个百分点"])
    print("准确率 80% 时的 Wilson 95% 区间：")
    print_table(["题数", "区间", "半宽"], rows)
    print()
    rows = [[f"±{h * 100:.0f} 个百分点", n_for_ci_halfwidth(0.8, h), n_for_ci_halfwidth(0.5, h)] for h in [0.05, 0.03, 0.02, 0.01]]
    print_table(["想要的半宽", "需要的题数（准确率 80%）", "需要的题数（准确率 50%）"], rows)
    print_observations([
        "100 道题的区间是 ±8 个百分点左右；想要 ±2 个百分点，要 1500 道以上。很多论文和博客在几十、几百道题上"
        "比较提示，报告的差别常常落在这个误差里。",
        "准确率越接近 50%，需要的题越多（p(1−p) 在 0.5 时最大）。",
        "题数不够时有两条路：承认区间宽，只下'差别很大'的结论；或者用**配对**的比较（02 号脚本），"
        "它在同样的题数下灵敏得多。",
    ])


def demo4_bootstrap():
    """演示 4：bootstrap。"""
    print_header("演示 4：没有现成公式时——bootstrap")
    rng = random.Random(4)
    bank = ItemBank()
    items = bank.draw_items(200, rng)
    outs = bank.greedy(bank.theta_for(0.7), items, rng)
    k = sum(outs)
    w = wilson_ci(k, len(outs))
    b = bootstrap_ci(outs, B=4000, seed=5)
    # 一个没有简单公式的统计量："难题"（难度最高的一半）和"简单题"上准确率的差
    order = sorted(range(len(items)), key=lambda i: items[i])
    easy = set(order[:100])

    def gap(pairs):
        e = [o for d_rank, o in pairs if d_rank in easy]
        h = [o for d_rank, o in pairs if d_rank not in easy]
        return math.fsum(e) / max(1, len(e)) - math.fsum(h) / max(1, len(h))

    pairs = list(enumerate(outs))
    g = gap(pairs)
    gb = bootstrap_ci(pairs, stat=gap, B=4000, seed=6)
    print_table(["统计量", "估计", "95% 区间", "方法"], [
        ["准确率", f"{k / len(outs):.3f}", f"[{w[0]:.3f}, {w[1]:.3f}]", "Wilson 公式"],
        ["准确率", f"{k / len(outs):.3f}", f"[{b[0]:.3f}, {b[1]:.3f}]", "bootstrap（4000 次重抽）"],
        ["简单题准确率 − 难题准确率", f"{g:.3f}", f"[{gb[0]:.3f}, {gb[1]:.3f}]", "bootstrap"],
    ])
    print_observations([
        "对准确率，bootstrap 和 Wilson 公式给出几乎一样的区间：bootstrap 是一种通用的'模拟再出一份题'的办法。",
        "它的好处是**不需要公式**：F1、两组题准确率的差、按类别加权的平均……只要能写出'怎么从题目算出这个数'，"
        "就能用同样的重抽办法得到区间。重抽的单位一定要是**题目**（独立的单位），后面几个脚本会反复用到这一点。",
    ])


def main():
    demo1_sampling()
    demo2_coverage()
    demo3_n_needed()
    demo4_bootstrap()


if __name__ == "__main__":
    main()
