"""
=============================================================================
第 1 讲 · 代码 4：探索 vs 利用（Exploration vs Exploitation）——餐厅问题
=============================================================================

对应讲义：lectures/lecture01_intro_to_rl.md 第 2.3 节（探索）、第 6.2 节（探索 vs 利用）
（这是第 11–12 讲"多臂老虎机"的一个预览，这里只求建立直觉，不追求理论。）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
你家附近有 3 家餐厅，每家的"平均好吃程度"不同，但你事先不知道。
每次去一家，你会得到一个带随机波动的满意度分数（今天厨师状态好不好、你饿不饿……）。
你要吃 1000 顿饭，目标是总满意度最高。

    策略 A（纯贪心）：每家先试一次，之后永远去"目前平均分最高"的那家。
    策略 B（ε-贪心）：大部分时候去平均分最高的那家，但以 ε 的小概率随机试一家。

直觉上 A 很"理性"，但你会看到：A 经常被一次偶然的差评误导，永远错过最好的餐厅。
这就是讲义说的"数据是被审查的"——你不去 B 家，就永远不知道 B 家其实更好。

-----------------------------------------------------------------------------
【这个问题和完整 RL 的关系】
-----------------------------------------------------------------------------
这个问题叫**多臂老虎机（multi-armed bandit）**：每家餐厅是一个"臂"。
它是**没有状态转移**的 RL：今天去哪家不影响明天面对的情况（讲义 3.7 节）。
所以它只保留了"探索 vs 利用"这一个难点，去掉了"延迟后果"，是研究探索的理想模型。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 04_exploration_vs_exploitation.py
"""

import numpy as np


# =============================================================================
# 第 1 部分：环境 —— 去一家餐厅吃饭
# =============================================================================
def visit_restaurant(true_means, arm, rng, noise_std=2.0):
    """
    去第 arm 家餐厅吃一顿饭，返回这顿饭的满意度（带随机波动）。

    【思路】
        满意度 = 这家店的真实平均水平 + 随机噪声，
        噪声服从均值为 0、标准差为 noise_std 的正态分布。
        所以同一家店，每次去的体验都不一样；但去得够多，平均下来就是 true_means[arm]。

    【参数】
        true_means : 一维数组，每家店的真实平均满意度。智能体**看不到**它！
                     只有"环境"知道。
        arm        : int，去哪家店，范围 0 到 len(true_means)-1。
        rng        : numpy 随机数生成器。
        noise_std  : float，噪声的标准差，必须 >= 0。
                     越大表示单次体验越"不靠谱"，越容易被一次偶然的体验误导。
                     noise_std = 0 时没有随机性，试一次就知道真实水平，探索就几乎不需要了。

    【返回】
        float，本次满意度。

    【边界】
        - arm 越界 → IndexError（numpy 取下标时自动抛出）。
    """
    # 【Python 小知识】rng.normal(loc, scale) 从均值为 loc、标准差为 scale 的正态分布中抽一个数。
    return float(rng.normal(loc=true_means[arm], scale=noise_std))


# =============================================================================
# 第 2 部分：智能体的两个基本操作 —— 选择与更新
# =============================================================================
def argmax_random_tiebreak(values, rng):
    """
    返回最大值所在的下标；如果有多个并列最大值，随机选一个。

    【为什么不直接用 np.argmax？】
        np.argmax 遇到并列时**总是返回第一个**。比如 values = [0, 0, 0]，
        np.argmax 永远返回 0。这会让智能体系统性地偏爱排在前面的选项——一个隐蔽的 bug。
        在 RL 中，初始时所有估计值常常相等（都是 0），这个问题尤其常见。

    【参数】
        values : 一维数组。
        rng    : numpy 随机数生成器。

    【返回】
        int，被选中的下标。

    【边界】
        - values 为空 → ValueError（空数组没有最大值）。
        - 浮点数"恰好相等"在计算中其实不常见，但初始值、整数奖励等情况下经常出现。
    """
    values = np.asarray(values)
    if values.size == 0:
        raise ValueError("values 不能为空")
    # values == values.max() 得到一个布尔数组，标出所有等于最大值的位置；
    # np.flatnonzero 返回这些位置的下标，例如 [0, 2]；rng.choice 从中随机选一个。
    best = np.flatnonzero(values == values.max())
    return int(rng.choice(best))


def epsilon_greedy_action(Q, epsilon, rng):
    """
    ε-贪心选择：以概率 ε 随机探索，以概率 1-ε 选择目前估计最好的动作。

    【思路】
        - 利用（exploitation）：选 Q 值最大的动作——根据目前的知识，这是最好的。
        - 探索（exploration）  ：随机选一个动作——可能不好，但能获得新信息。
        ε 控制两者的比例。这是最简单、也最常用的探索策略之一（第 4 讲 Q-learning、
        第 5 讲 DQN 都用它）。

    【参数】
        Q       : 一维数组，每个动作的当前估计值。
        epsilon : float，探索概率，范围 [0, 1]。
                  ε = 0 → 纯贪心，从不探索；
                  ε = 1 → 纯随机，从不利用已有知识。
        rng     : numpy 随机数生成器。

    【返回】
        int，选中的动作。

    【边界】
        - 注意：随机探索时也**可能**恰好选中最好的动作，
          所以"选中非最优动作的概率"其实是 ε × (K-1)/K，而不是 ε（K 是动作数）。
    """
    if not (0.0 <= epsilon <= 1.0):
        raise ValueError(f"epsilon 必须在 [0, 1] 之间，但收到了 {epsilon}")
    if rng.random() < epsilon:
        return int(rng.integers(len(Q)))       # 探索：均匀随机选一个
    return argmax_random_tiebreak(Q, rng)      # 利用：选当前最好的


def incremental_mean_update(old_mean, new_value, count):
    """
    增量式更新平均值：已知前 (count-1) 个数的平均值 old_mean，
    又来了第 count 个数 new_value，求全部 count 个数的平均值。

    【思路】
        朴素做法：把所有历史数据存下来，每次重新求平均——内存和时间都随数据量增长。
        聪明做法：只存当前平均值和计数。推导如下：
            新平均 = (旧总和 + 新值) / count
                   = ((count-1) × 旧平均 + 新值) / count
                   = 旧平均 + (新值 - 旧平均) / count

    【RL 联系】（非常重要！）
        最后一行的形式：
            新估计 = 旧估计 + 步长 × (目标 - 旧估计)
        会在整门课中**反复出现**：
            - 第 3 讲蒙特卡洛：V(s) ← V(s) + α (G - V(s))
            - 第 3 讲 TD 学习 ：V(s) ← V(s) + α (r + γV(s') - V(s))
            - 第 4 讲 Q-learning：Q(s,a) ← Q(s,a) + α (r + γ max Q(s',·) - Q(s,a))
        括号里的 (目标 - 旧估计) 叫"误差"或"惊讶程度"：
        结果比预期好，估计就往上调；比预期差，就往下调。
        这里步长是 1/count，恰好得到精确的平均值；后面几讲会换成固定步长 α。

    【参数】
        old_mean  : float，之前的平均值。
        new_value : float，新来的数据。
        count     : int，**包括新数据在内**的数据总个数，必须 >= 1。

    【返回】
        float，新的平均值。

    【边界】
        - count = 1 时：新平均 = old_mean + (new_value - old_mean) = new_value。
          无论 old_mean 是多少（通常初始化为 0），第一个数据会完全覆盖它，这是正确的。
        - count <= 0 → ValueError（会导致除以 0 或负数，没有意义）。
    """
    if count < 1:
        raise ValueError(f"count 必须 >= 1，但收到了 {count}")
    return old_mean + (new_value - old_mean) / count


# =============================================================================
# 第 3 部分：跑一次完整的实验
# =============================================================================
def run_bandit(true_means, n_rounds, epsilon, rng, noise_std=2.0):
    """
    用 ε-贪心策略（ε=0 即纯贪心）连续吃 n_rounds 顿饭，记录全过程。

    【思路】
        1. 初始化阶段：每家店先各去一次（轮流），得到每家店的第一个估计。
        2. 之后每一轮：
             a. 用 ε-贪心选一家店；
             b. 去吃，得到满意度；
             c. 用增量平均更新这家店的估计值 Q。

    【为什么需要"每家先去一次"？】（边界情况）
        如果 Q 全部初始化为 0，而满意度通常是正数：
        第一次随便去了店 0，得到 Q[0] = 5 > 0 = Q[1] = Q[2]，
        纯贪心从此永远只去店 0——另外两家连一次都没试过！
        所以先轮流各试一次。（另一种思路叫"乐观初始化"：把 Q 初始化成一个很大的数，
        让每家店都"看起来很好"从而都会被试到——第 11 讲会讨论。）

    【参数】
        true_means : 一维数组，每家店的真实平均满意度（智能体看不到）。
        n_rounds   : int，总共吃几顿饭，必须 >= 1。
        epsilon    : float，探索概率，范围 [0, 1]。
        rng        : numpy 随机数生成器。
        noise_std  : float，满意度噪声的标准差。

    【返回】
        dict，包含：
        - "arms"    : 长度 n_rounds 的数组，每一轮去了哪家
        - "rewards" : 长度 n_rounds 的数组，每一轮的满意度
        - "Q"       : 最终每家店的估计值
        - "counts"  : 最终每家店去了几次

    【边界】
        - n_rounds 小于餐厅数 K 时，初始化阶段都走不完，只试前 n_rounds 家。
        - n_rounds < 1 → ValueError。
    """
    if n_rounds < 1:
        raise ValueError(f"n_rounds 必须 >= 1，但收到了 {n_rounds}")
    true_means = np.asarray(true_means, dtype=float)
    K = len(true_means)

    Q = np.zeros(K)                        # 每家店的估计平均满意度
    counts = np.zeros(K, dtype=int)        # 每家店去过几次
    arms = np.zeros(n_rounds, dtype=int)
    rewards = np.zeros(n_rounds)

    for t in range(n_rounds):
        if t < K:
            arm = t                        # 初始化阶段：第 t 轮去第 t 家
        else:
            arm = epsilon_greedy_action(Q, epsilon, rng)

        r = visit_restaurant(true_means, arm, rng, noise_std)
        counts[arm] += 1
        Q[arm] = incremental_mean_update(Q[arm], r, counts[arm])

        arms[t] = arm
        rewards[t] = r

    return {"arms": arms, "rewards": rewards, "Q": Q, "counts": counts}


def run_experiment(true_means, epsilons, n_runs=2000, n_rounds=1000, noise_std=2.0, seed=0):
    """
    对每个 ε，重复实验 n_runs 次，统计平均表现。

    【为什么要重复很多次？】
        单次实验受运气影响太大：纯贪心有时运气好一开始就锁定了最好的店。
        要看出策略**本身**的好坏，必须对大量独立实验取平均。

    【统计的三个指标】
        1. 平均每顿满意度：越高越好。
        2. 最后 100 顿中去最好那家的比例：衡量"最终有没有找到最好的店"。
        3. 累积遗憾（regret）：Σ_t (μ* - μ_{a_t})，其中 μ* 是最好那家的真实均值，
           μ_{a_t} 是第 t 轮所选那家的真实均值。
           含义："和一开始就知道哪家最好的'先知'相比，我总共少吃了多少满意度"。
           遗憾是衡量探索策略的标准指标，第 11 讲会正式定义。
           （这里用真实均值而不是实际拿到的分数来算，可以排除噪声的干扰。）

    【参数】
        true_means : 每家店真实均值。
        epsilons   : list[float]，要比较的 ε 值。
        n_runs     : int，每个 ε 重复实验的次数。
        n_rounds   : int，每次实验吃几顿饭，应当 >= 100（因为要统计最后 100 顿）。
        noise_std  : float，噪声标准差。
        seed       : int，随机种子。

    【返回】
        dict：{epsilon: (平均满意度, 最后100顿选最优比例, 平均累积遗憾)}
    """
    true_means = np.asarray(true_means, dtype=float)
    best_arm = int(np.argmax(true_means))
    best_mean = true_means[best_arm]
    last = min(100, n_rounds)

    results = {}
    for eps in epsilons:
        # 每个 ε 用同一个种子，让不同 ε 面对"相同的运气"，比较更公平
        rng = np.random.default_rng(seed)
        avg_rewards, pct_best, regrets = [], [], []
        for _ in range(n_runs):
            out = run_bandit(true_means, n_rounds, eps, rng, noise_std)
            avg_rewards.append(out["rewards"].mean())
            # out["arms"][-last:] 取最后 last 轮；== best_arm 得到布尔数组；.mean() 就是 True 的比例
            pct_best.append((out["arms"][-last:] == best_arm).mean())
            # true_means[out["arms"]]：用数组做下标（"花式索引"），一次取出每一轮所选店的真实均值
            regrets.append(np.sum(best_mean - true_means[out["arms"]]))
        results[eps] = (np.mean(avg_rewards), np.mean(pct_best), np.mean(regrets))
    return results


# =============================================================================
# 第 4 部分：演示
# =============================================================================
def main():
    # 三家餐厅的真实平均满意度。店 2 最好，但差距不算大，单次体验的噪声（标准差 2）比差距还大。
    true_means = np.array([5.0, 6.0, 7.0])
    names = ["A 家", "B 家", "C 家"]

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：纯贪心被\"一次差评\"困住的真实案例")
    print("=" * 78)
    print(f"  真实平均满意度（智能体看不到）：A={true_means[0]}, B={true_means[1]}, C={true_means[2]}\n")

    # 找一个纯贪心"被困住"的随机种子来展示（不是每次都会被困住，这正是危险之处：
    # 你永远不知道这一次自己是不是被困住的那个）
    for seed in range(100):
        out = run_bandit(true_means, n_rounds=1000, epsilon=0.0, rng=np.random.default_rng(seed))
        if out["counts"][2] == 1:          # C 家（最好的）只在初始化时去过 1 次
            break
    print(f"  （随机种子 {seed}）前 3 轮的初始化体验：")
    for t in range(3):
        print(f"    第 {t + 1} 顿去 {names[out['arms'][t]]}，满意度 {out['rewards'][t]:.2f}")
    print("\n  1000 顿之后：")
    for k in range(3):
        print(f"    {names[k]}：去了 {out['counts'][k]:4d} 次，估计平均 {out['Q'][k]:.2f}（真实 {true_means[k]}）")
    print("\n  → C 家第一次恰好体验很差，估计值很低，纯贪心从此再也没去过。")
    print("    它对 C 家的错误印象永远得不到纠正——因为纠正需要再去一次，而贪心不会再去。")
    print("    这就是讲义 2.3 节说的：你只能看到自己所选之事的后果。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：不同 ε 的平均表现（每个 ε 重复 500 次实验，每次吃 1000 顿）")
    print("=" * 78)
    print("  正在运行，大约需要 10 秒……\n")
    # 想要更平滑、更可信的结果，可以把 n_runs 调大（比如 2000），代价是运行时间成比例增加。
    epsilons = [0.0, 0.01, 0.1, 0.3, 1.0]
    results = run_experiment(true_means, epsilons, n_runs=500, n_rounds=1000)
    print("     ε      平均每顿满意度   最后100顿去最好那家的比例   累积遗憾")
    for eps, (avg_r, pct, reg) in results.items():
        tag = {0.0: "纯贪心", 1.0: "纯随机"}.get(eps, "")
        # 【Python 小知识】dict.get(键, 默认值)：键存在就返回对应值，否则返回默认值。
        print(f"  {eps:5.2f}      {avg_r:6.3f}            {pct * 100:5.1f}%               {reg:7.1f}   {tag}")

    print(f"\n  参照：\"先知\"（一开始就知道 C 最好）每顿平均 {true_means.max()}，累积遗憾 0。")
    print(
        "\n  解读：\n"
        "  - 纯贪心（ε=0）：相当一部分实验被困在次优餐厅，平均表现并不好。\n"
        "  - 适量探索（ε=0.1 左右）：偶尔的随机尝试让错误印象有机会被纠正，表现最好。\n"
        "  - 探索太多（ε=0.3、1.0）：已经知道 C 最好了，还在大量浪费机会去试 A 和 B。\n"
        "  - 结论：探索和利用之间存在真实的权衡，太少、太多都不好。\n"
        "    更进一步的问题是：能否\"聪明地\"探索——多试那些不确定的，少试那些已经确定不好的？\n"
        "    这就是第 11–13 讲的 UCB、汤普森采样等方法要回答的问题。"
    )

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：边界情况")
    print("=" * 78)
    rng = np.random.default_rng(0)
    print(f"  [并列最大值] np.argmax([0,0,0]) 永远是 {np.argmax([0, 0, 0])}；"
          f"随机打破平局 10 次：{[argmax_random_tiebreak([0, 0, 0], rng) for _ in range(10)]}")
    print(f"  [增量平均]   对 [4, 6, 8] 逐个增量更新：", end="")
    # 【Python 小知识】print(..., end="") 打印后不换行，下一次 print 会接在同一行后面。
    m = 0.0
    for i, x in enumerate([4, 6, 8], start=1):
        m = incremental_mean_update(m, x, i)
        print(f" {m:.1f}", end="")
    print("   （与 np.mean 结果一致：", np.mean([4, 6, 8]), "）")

    out = run_bandit(true_means, n_rounds=2, epsilon=0.1, rng=rng)
    print(f"  [轮数 < 店数] n_rounds=2 时各店访问次数：{out['counts']}（C 家一次都没试到）")

    out = run_bandit(true_means, n_rounds=1000, epsilon=0.0, rng=rng, noise_std=0.0)
    print(f"  [无噪声]     noise_std=0 时纯贪心各店访问次数：{out['counts']}"
          "（试一次就知道真实水平，不需要探索）")

    try:
        epsilon_greedy_action(np.zeros(3), epsilon=1.5, rng=rng)
    except ValueError as e:
        print(f"  [ε 越界]     ValueError: {e}")


if __name__ == "__main__":
    main()
