"""
=============================================================================
第 11 讲 · 代码 2：霍夫丁不等式与 UCB1——面对不确定性时保持乐观
=============================================================================

对应讲义：lectures/lecture11_bandits_ucb.md 第 4–5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
上一个文件的方法（ε-贪心、递减 ε、乐观初始化）要么遗憾线性增长，要么需要事先"猜对"问题的难度。
UCB1 的想法：**给每个臂算一个"它最多可能有多好"的上界，然后拉上界最高的那个**。

    UCB_a(t) = μ̂_a + sqrt( c · ln t / N_a )
               ↑经验均值   ↑"不确定性"：拉得越少越大

这个上界从哪里来？——**霍夫丁不等式**：n 次独立的 [0,1] 观察的平均值，
偏离真实均值超过 u 的概率不超过 exp(-2 n u²)。

本文件：
    1. 用模拟验证霍夫丁不等式，理解"置信区间的宽度 ∝ 1/sqrt(n)"
    2. 看 UCB1 在一次实验中是怎样工作的（每个臂的均值、探索奖励、上界如何变化）
    3. UCB1 与上一个文件的方法比较：在容易和困难的老虎机上都不会"认错"最好的臂
    4. 每个臂被拉了多少次？与理论上界、下界对照
    5. 探索强度 c 的影响：理论值 c = 2 过于保守；c 太小则有"卡住"的风险

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture11
    python 02_hoeffding_ucb.py      （约 55 秒）
"""

import numpy as np

from bandits import (DEFAULT_MEANS, bernoulli_kl, decaying_epsilon_greedy, empirical_means,
                     epsilon_greedy, optimistic_greedy, pad, simulate, ucb1)

np.set_printoptions(precision=3, suppress=True)

HARD_MEANS = np.array([0.2, 0.4, 0.5, 0.67, 0.7])


def hoeffding_check(mu, n, u, n_trials, rng):
    """
    用模拟估计 P(|μ̂_n - μ| ≥ u)，其中 μ̂_n 是 n 次伯努利(μ) 观察的平均值。

    【返回】(模拟得到的概率, 霍夫丁上界 2·exp(-2 n u²))
    【实现】n 次伯努利观察之和服从二项分布 Binomial(n, μ)，所以直接用 rng.binomial 一次生成 n_trials 个和。
    【边界】霍夫丁上界可能大于 1（n 或 u 很小时），这时它是一个"没有信息"的上界——概率本来就不超过 1。
    """
    means = rng.binomial(n, mu, size=n_trials) / n
    return float(np.mean(np.abs(means - mu) >= u - 1e-12)), float(2 * np.exp(-2 * n * u * u))


def trace_ucb1(means, T, snapshots, seed, c=2.0):
    """
    单独跑一次 UCB1 实验（普通的 for 循环，方便看清每一步），在 snapshots 指定的时刻记录每个臂的状态。

    【返回】列表，每个元素是 (t, counts, 经验均值, 探索奖励, 上界) 的元组。
    【为什么不用 simulate？】simulate 为了速度把很多次实验并行，只记录遗憾；
        这里只想看**一次**实验的内部细节，所以写一个最直白的版本。
    """
    rng = np.random.default_rng(seed)
    K = len(means)
    counts, sums = np.zeros(K), np.zeros(K)
    out = []
    for t in range(1, T + 1):
        if t <= K:
            a = t - 1                                     # 前 K 步：每个臂各拉一次
        else:
            ucb = sums / counts + np.sqrt(c * np.log(t) / counts)
            a = int(np.argmax(ucb))
        r = float(rng.random() < means[a])
        counts[a] += 1
        sums[a] += r
        if t in snapshots:
            bonus = np.sqrt(c * np.log(t) / counts)
            out.append((t, counts.copy(), sums / counts, bonus, sums / counts + bonus))
    return out


def main():
    rng = np.random.default_rng(0)

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：霍夫丁不等式 P(|μ̂_n - μ| ≥ u) ≤ 2·exp(-2 n u²)（每种情况模拟 20 万次）")
    print("=" * 78)
    print("   真实 μ     n      u     模拟的概率     霍夫丁上界")
    for mu in [0.5, 0.1]:
        for n in [10, 100, 1000]:
            for u in [0.05, 0.1]:
                p, bound = hoeffding_check(mu, n, u, 200000, rng)
                note = "（>1，没有提供信息）" if bound > 1 else ""
                # 【Python 小知识】:9.2e 表示科学计数法，例如 4.12e-09 = 4.12 × 10⁻⁹，适合显示很小的概率
                print(f"    {mu:.1f}   {n:5d}   {u:.2f}     {p:9.2e}     {min(bound, 1):9.2e}{note}")
    print("  观察：")
    print("  - 模拟的概率总是不超过霍夫丁上界——不等式成立。它对**任何** [0,1] 上的分布都成立，不需要知道分布的形状。")
    print("  - 代价是它偏保守：μ = 0.1 时（方差 μ(1-μ) = 0.09，比 μ = 0.5 时的 0.25 小得多），实际概率远小于上界。")
    print("  - 反过来用：令 2·exp(-2 n u²) = δ，解出 u = sqrt(ln(2/δ) / (2n))。")
    print("    即以至少 1 - δ 的概率，真实均值落在 μ̂ ± sqrt(ln(2/δ)/(2n)) 之内。宽度 ∝ 1/sqrt(n)：")
    for n in [10, 100, 1000, 10000]:
        print(f"      n = {n:5d}：95% 置信区间半宽 = {np.sqrt(np.log(2 / 0.05) / (2 * n)):.3f}")
    print("    数据量增加 100 倍，不确定性只缩小 10 倍。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：UCB1 在一次实验中如何工作（c = 2，即上界 = μ̂ + sqrt(2 ln t / N)）")
    print("=" * 78)
    print(f"  真实 μ = {DEFAULT_MEANS}")
    for t, counts, mean, bonus, ucb in trace_ucb1(DEFAULT_MEANS, 10000, {10, 100, 1000, 10000}, seed=4):
        print(f"\n  t = {t}：")
        print("     臂    拉的次数 N   经验均值 μ̂   探索奖励   上界 UCB")
        for a in range(len(DEFAULT_MEANS)):
            mark = "  ← 上界最高，下一步拉它" if a == int(np.argmax(ucb)) else ""
            print(f"     {a}     {int(counts[a]):6d}       {mean[a]:.3f}       {bonus[a]:.3f}     {ucb[a]:.3f}{mark}")
    print("\n  观察：")
    print("  - 最好的臂（臂 4）拉得越来越多，它的探索奖励越来越小，上界逐渐贴近真实值 0.7。")
    print("  - 差的臂拉得少，探索奖励大，但它们的均值太低，上界仍然比臂 4 低，所以很少被拉。")
    print("  - 各臂的上界大致\"齐平\"：UCB 让每个次优臂恰好被拉到\"它的上界降到 μ* 附近\"为止——")
    print("    差距 Δ 越小，需要拉越多次才能把上界降下来（约 ∝ ln t / Δ²）。")
    print("  - 探索奖励中的 ln t 缓慢增长：长期不被拉的臂，上界会慢慢升高，最终被再试一次——永不\"永久放弃\"。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：UCB1 与上一个文件的方法比较（T = 10⁵，重复 200 次）")
    print("=" * 78)
    agents = [
        ("ε = 0.01", epsilon_greedy(0.01)),
        ("递减 ε：c=1, d=0.3", decaying_epsilon_greedy(1, 0.3)),
        ("乐观初始化：伪计数 10", optimistic_greedy(1.0, 10)),
        ("UCB1（c = 2，理论值）", ucb1(2.0)),
        ("UCB1（c = 0.5）", ucb1(0.5)),
    ]
    for label, m in [("容易的老虎机（最小差距 0.10）", DEFAULT_MEANS), ("困难的老虎机（最小差距 0.03）", HARD_MEANS)]:
        print(f"\n  {label}：μ = {m}")
        print("   " + pad("策略", 24) + "  T=1000  T=10000 T=100000  R(10⁵)/R(10⁴)  认错的比例")
        for name, pol in agents:
            res = simulate(pol, m, T=100000, n_runs=200, seed=2, checkpoints=[1000, 10000, 100000])
            r = res["regret"].mean(axis=0)
            wrong = np.mean(res["counts"].argmax(axis=1) != np.argmax(m))
            print("   " + pad(name, 24) + "".join(f"{x:9.1f}" for x in r) + f"      {r[2] / r[1]:6.2f}       {wrong * 100:5.1f}%")
    print("\n  观察：")
    print("  - UCB1 在两个老虎机上都没有\"认错\"过最好的臂；从 T = 10⁴ 到 10⁵，遗憾只增长了 1.5～2.5 倍——")
    print("    远小于线性增长的 10 倍。这就是**对数遗憾**。增长比仍大于 ln(10⁵)/ln(10⁴) = 1.25，")
    print("    因为有限的 T 下还有低阶项（比如差距小的臂在 T = 10⁴ 时还没被充分探索）；T 越大，越接近对数的预测。")
    print("  - 其他方法在容易的老虎机上可能比 UCB1（c = 2）还好，但换到困难的老虎机上就出现了认错的实验。")
    print("    UCB1 用**同一组参数**适应了两个难度不同的问题——它不需要知道差距是多少。")
    print("  - c = 2 是理论分析用的值，实际表现偏保守；c = 0.5 在两个问题上都好得多（见演示 5）。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：每个次优臂被拉了多少次？（UCB1，T = 10⁵，重复 200 次）")
    print("=" * 78)
    T = 100000
    m = DEFAULT_MEANS
    res2 = simulate(ucb1(2.0), m, T=T, n_runs=200, seed=5)
    res05 = simulate(ucb1(0.5), m, T=T, n_runs=200, seed=5)
    best = m.max()
    print(f"   臂   μ     Δ     UCB1(c=2)   UCB1(c=0.5)   UCB1 定理上界       Lai–Robbins 下界     遗憾贡献（c=2）")
    print(f"                   实际次数     实际次数     8 ln T/Δ² + 4.3    ln T / kl(μ_a, μ*)     Δ × 次数")
    for a in range(len(m) - 1):
        gap = best - m[a]
        n2 = res2["counts"][:, a].mean()
        n05 = res05["counts"][:, a].mean()
        upper = 8 * np.log(T) / gap ** 2 + 1 + np.pi ** 2 / 3
        lower = np.log(T) / bernoulli_kl(m[a], best)
        print(f"   {a}   {m[a]:.1f}   {gap:.1f}   {n2:9.0f}     {n05:9.0f}      {upper:9.0f}           {lower:9.0f}           {gap * n2:7.1f}")
    print("  观察：")
    print("  - 差距越小的臂被拉得越多：大约与 1/Δ² 成正比。要区分两个很接近的臂，需要很多样本。")
    print("  - UCB1 定理（Auer et al., 2002）保证 E[N_a(T)] ≤ 8 ln T / Δ² + 1 + π²/3，实际次数远低于这个上界：")
    print("    证明中的每一步放缩都比较宽松。")
    print("  - Lai–Robbins 下界（03 文件）：任何\"合理\"的算法都至少要拉 ≈ ln T / kl(μ_a, μ*) 次。")
    print("    UCB1（c = 2）拉的次数是下界的 3～4 倍；c = 0.5 的次数与下界几乎相同（臂 3 甚至略低于下界——")
    print("    下界是 T → ∞ 时的渐近结论，有限的 T 下可以略低于它）。")
    print("    c = 0.5 能这么好，是因为这个老虎机的成功率都在 0.5 附近，此时霍夫丁不等式几乎是紧的（kl ≈ 2Δ²）。")
    print("    成功率接近 0 或 1 时（比如广告点击率只有百分之几），霍夫丁上界会变得很松，")
    print("    这时需要用 KL 散度构造上界的 KL-UCB（03 文件）。")
    print("  - 遗憾贡献 = Δ × 次数 ∝ Δ × (1/Δ²) = 1/Δ：**差距最小的臂贡献的遗憾最多**。")
    print("    这有点反直觉：最差的臂（Δ = 0.5）很快就被识破了，真正\"费钱\"的是那些和最好的臂难以区分的臂。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 5：探索强度 c 的影响（T = 10⁵，重复 200 次）")
    print("=" * 78)
    for label, m in [("容易的老虎机", DEFAULT_MEANS), ("困难的老虎机", HARD_MEANS)]:
        print(f"\n  {label}：μ = {m}")
        print("      c      平均遗憾   最差 1% 实验的遗憾   认错的比例")
        for c in [0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0]:
            res = simulate(ucb1(c), m, T=100000, n_runs=200, seed=3)
            reg = res["regret"][:, -1]
            wrong = np.mean(res["counts"].argmax(axis=1) != np.argmax(m))
            print(f"    {c:5.2f}   {reg.mean():9.1f}      {np.percentile(reg, 99):9.1f}          {wrong * 100:5.1f}%")
    print("\n  观察：")
    print("  - c 太大（2、4）：探索过多，遗憾偏大，但非常稳定（最差的实验也不比平均差多少）。")
    print("  - c 太小（0.05、0.1）：平均遗憾可能还行，但**最差 1% 的实验遗憾极大**，还有实验认错了最好的臂——")
    print("    上界太窄，最好的臂早期运气不好时，它的上界可能长时间低于某个次优臂的上界，又退化成了\"贪心卡住\"。")
    print("  - 中间的 c（0.25～0.5）最好。c = 0.5 恰好对应霍夫丁不等式本身给出的宽度 sqrt(ln t / (2N))，")
    print("    即\"以 1 - 1/t 的概率成立\"的置信上界；c = 2 则对应 1 - 1/t⁴，是为了让证明更容易而取的保守值。")
    print("  - c = 0.25 比 c = 0.5 还好：上界更窄，花在次优臂上的探索更少；而在这两个问题上，")
    print("    \"最好的臂被上界低估\"的风险还很小，净效果是赚的。但 c 降到 0.1 以下，这个风险就开始占主导。")
    print("    \"更窄\"是在拿\"最坏情况\"冒险——理论保证和实际表现之间的取舍，是探索算法设计中永恒的主题。")


if __name__ == "__main__":
    main()
