"""
=============================================================================
第 11 讲 · 代码 3：多臂老虎机 —— 贪心、ε-贪心、先探索后利用、UCB 的遗憾
=============================================================================

对应讲义：lectures/lecture11_assistance_bandits.md 第 4、5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
一个推荐系统有 K 个候选（"臂"），每次给用户推荐一个，用户点或不点。每个候选的点击率未知。
一边推荐一边学：试得太少，可能永远不知道哪个最好（利用过度）；试得太多，又浪费了很多次推荐（探索过度）。

    演示 1：容易的问题（5 个臂，点击率 0.3~0.7，差距大）：五种算法在 100、1000、10000 步时的累计遗憾。
    演示 2：难的问题（10 个臂，最好的 0.5，其余 0.45）：先探索后利用"试的次数"不够时会怎样？
    演示 3：贪心为什么会永远卡住：每次实验最后选中最好的臂的比例。
    演示 4：遗憾 ÷ ln T：UCB 的遗憾按 log T 增长，ε-贪心按 T 线性增长；Lai–Robbins 下界。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_bandit_regret.py        # 约 15 秒

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

import math

import numpy as np

from bandit_tools import ALGORITHMS, lai_robbins_bound, pad, print_header, run_bandit

def table(means, T, runs, rng, title, checks, extra=None):
    """跑所有算法（以及 extra 里的额外设定），打印累计遗憾的表格，返回结果。"""
    print(f"{title}\n")
    print(f"  {pad('算法', 22)}" + "".join(pad(f"T={t}", 10, "right") for t in checks)
          + pad("最后 10% 选中最好的", 20, "right"))
    results = {}
    settings = [(ALGORITHMS[a], a, {}) for a in ALGORITHMS] + (extra or [])
    for name, algo, kw in settings:
        out = run_bandit(algo, means, T, runs, rng, **kw)
        results[name] = out
        print(f"  {pad(name, 22)}" + "".join(f"{out['regret'][t - 1]:>10.1f}" for t in checks)
              + f"{out['best_frac'].mean():>20.3f}")
    print(f"  {pad('Lai–Robbins 下界', 22)}" + "".join(f"{lai_robbins_bound(list(means), t):>10.1f}" for t in checks))
    return results


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

    print_header("演示 1：容易的问题")
    easy = np.array([0.3, 0.4, 0.5, 0.6, 0.7])
    easy_checks = [100, 1000, 10000, 100000]
    res_easy = table(easy, 100000, 100, rng, f"5 个臂，点击率 {easy.tolist()}。每种算法独立跑 100 次、每次 10 万步，"
                     "报告平均累计遗憾：", easy_checks, extra=[("先探索后利用（m=5）", "explore_then_commit", {"m": 5}),
                            ("UCB（c=0.5）", "ucb1", {"c": 0.5})])
    print("""
观察：
  · 贪心：每次都选“目前看起来最好”的臂。约 44% 的实验卡在了次优的臂上（演示 3），遗憾随 T 线性增长：
    10 万步时遗憾 7300，相当于每步平均损失 0.07。
  · ε-贪心（ε = 0.1）：永远有 10% 的时间在随机探索，即使早就知道哪个最好。遗憾也是线性的：
    0.1 × 平均差距 0.2 × 10 万 ≈ 2000，和表中的 2046 吻合。
  · ε 逐渐变小（ε_t = 5K/t）：遗憾增长慢得多，但仍比 log 快 —— 探索衰减得太快时，偶尔会在还没分清之前就锁定错的臂。
    衰减速度要按问题的难度调。
  · 先探索后利用（每个臂先试 m 次）：m = 20 时前期很好，可有约 10% 的实验在试完之后选错、永远错下去，
    长期来看遗憾是线性的；m = 5 时选错得更多。
  · UCB1：“乐观地面对不确定性”——给每个臂加一个“试得越少、加得越多”的奖励。前期它探索得很多（1000 步时
    遗憾在所有算法里最大，比贪心还大），但 1 万步以后几乎只选最好的臂，10 万步时遗憾远小于其他做法。
    常数 c 调小（0.5）更激进，在这里全程都最好；UCB1 的 c = 2 来自理论证明，偏保守。""")

    print_header("演示 2：难的问题")
    hard = np.array([0.5] + [0.45] * 9)
    table(hard, 10000, 200, rng, "10 个臂，最好的点击率 0.5，其余 9 个都是 0.45（差距只有 0.05）。每种算法跑 200 次、每次 1 万步：",
          [100, 1000, 10000], extra=[("先探索后利用（m=200）", "explore_then_commit", {"m": 200}),
                 ("UCB（c=0.5）", "ucb1", {"c": 0.5})])
    print("""
观察：
  · 差距只有 0.05 时，每个臂要试几百次才分得清。1 万步里，所有算法都还没能完全确定最好的臂（最后一列都明显小于 1），
    UCB1（c = 2）甚至和贪心差不多 —— 它还在“探索阶段”。
  · 先探索后利用的 m = 20 远远不够，常常选错并永远错下去；m = 200 在这里最好，但这个 m 要事先知道问题有多难
    才能选对。UCB 不需要知道，代价是在给定的时间里不一定最好。
  · 推荐系统、A/B 测试里，候选之间的差距常常很小（点击率 5.0% vs 5.2%），这正是难的问题：
    Lai–Robbins 下界（1 万步约 830）说明，即使最好的算法也要付出可观的探索代价。""")

    print_header("演示 3：贪心为什么会卡住")
    out = res_easy[ALGORITHMS["greedy"]]
    stuck = np.mean(out["best_frac"] < 0.5)
    print(f"容易的问题上，贪心在 {stuck:.0%} 的实验里最后 10% 的步数大部分时间都没选最好的臂。")
    print("""
观察：
  · 贪心的机制：每个臂先各试一次。如果最好的臂第一次运气不好（没被点击，估计值 0），而某个次优的臂
    第一次被点了（估计值 1），贪心就一直选次优的臂；次优臂的估计值慢慢降到它的真实值 0.6，
    但始终高于最好的臂“被冤枉”的 0 —— 最好的臂再也没有机会被试第二次。
  · 这就是“利用过度”：只根据当前的估计行动，不考虑估计本身有多不确定。
  · 对偏好学习的启示：一个推荐系统如果只推荐“目前点击率最高”的内容，新内容、小众内容永远得不到展示，
    它们的估计永远不会被纠正（代码 04 的选择架构会看到另一种版本）。""")

    print_header("演示 4：遗憾的增长速度")
    print("容易的问题上，累计遗憾除以 ln T（如果遗憾 ≈ 常数 × ln T，这个比值会稳定下来）：\n")
    print(f"  {pad('算法', 22)}" + "".join(pad(f"T={t}", 10, "right") for t in easy_checks))
    for name in [ALGORITHMS["epsilon_greedy"], ALGORITHMS["decaying_epsilon"], ALGORITHMS["explore_then_commit"],
                 ALGORITHMS["ucb1"], "UCB（c=0.5）"]:
        r = res_easy[name]["regret"]
        print(f"  {pad(name, 22)}" + "".join(f"{r[t - 1] / math.log(t):>10.2f}" for t in easy_checks))
    lr = lai_robbins_bound(list(easy), 10) / math.log(10)
    print(f"  {pad('Lai–Robbins 的系数', 22)}" + "".join(f"{lr:>10.2f}" for _ in easy_checks))
    print("""
观察：
  · ε-贪心的比值一直在快速变大：遗憾是线性的，比 log 增长得快得多。先探索后利用（m = 20）也一样
    （选错的那些实验贡献了线性的遗憾）。ε 逐渐变小的比值也还在涨。
  · UCB 的比值逐渐稳定下来：遗憾 ≈ 常数 × ln T。每多一个数量级的时间，只多付出一个大致固定的代价。
  · Lai–Robbins 下界说：渐近地，任何“一致”的算法的这个常数都不会小于 Σ Δ_k / KL(μ_k, μ*) ≈ 9.46。
    UCB1（c = 2）的常数大好几倍；c = 0.5 的 UCB 在这个时间范围内甚至略低于它 —— 下界是 T → ∞ 时的结论，
    有限的 T 里低一点并不矛盾（而且 c 太小的 UCB 没有理论保证，在别的问题上可能出错）。
    KL-UCB、Thompson 采样（第 12 讲）的常数能达到下界。""")


if __name__ == "__main__":
    main()
