"""
=============================================================================
第 4 讲 · 代码 1：蒙特卡洛控制与 ε-贪心探索（MC Control, GLIE）
=============================================================================

对应讲义：lectures/lecture04_model_free_control.md 第 1、2 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
第 3 讲学会了在不知道模型时**评估**一个策略。现在要**找到最优策略**。

最自然的想法是模仿第 2 讲的策略迭代（评估 → 改进 → 评估 → ……），只是把
"用模型评估"换成"用 MC 从数据中评估"。但马上会遇到两个问题：

    问题 1：策略改进 π(s) = argmax_a [R(s,a) + γ Σ P(s'|s,a) V(s')] 需要模型。
            解决：直接估计 Q(s,a)，改进时只需 argmax_a Q(s,a)（第 2 讲 5.4 节）。

    问题 2：如果策略是贪心（确定性）的，它在每个状态只会做一个动作，
            其他动作的 Q 值永远得不到数据，永远无法知道它们是不是更好。
            解决：让策略保持一定的随机性——ε-贪心。

本文件演示这两点，以及 GLIE（"最终变贪心，但始终在探索"）条件。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture04        （必须在这个目录下运行，才能 import gridworld）
    python 01_mc_control.py  （约 40 秒）
"""

import numpy as np

from gridworld import (GridWorldEnv, optimal_q, evaluate_q, epsilon_greedy_probs,
                       greedy_policy, render_policy, render_values, policy_agreement)
# 【Python 小知识】括号里可以换行写很长的 import 列表。

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


# =============================================================================
# 第 1 部分：ε-贪心选择动作
# =============================================================================
def epsilon_greedy_action(Q_s, epsilon, rng):
    """
    ε-贪心地选择动作：以概率 ε 随机选，否则选 Q 最大的（并列时随机打破平局）。

    【参数】
        Q_s     : 长度 A 的数组，当前状态下各动作的 Q 值。
        epsilon : float，[0, 1]。
        rng     : 随机数生成器。

    【为什么平局要随机打破？】
        初始时 Q 全为 0，如果总选编号最小的动作，智能体会系统性地偏爱"上"。
        （第 1 讲 04 文件讲过这个坑。）
    """
    if rng.random() < epsilon:
        return int(rng.integers(len(Q_s)))
    best = np.flatnonzero(Q_s == Q_s.max())
    return int(rng.choice(best))


# =============================================================================
# 第 2 部分：MC 控制
# =============================================================================
def mc_control(env, n_episodes, gamma=1.0, epsilon_schedule="glie", alpha=None, seed=0):
    """
    同策略首次访问 MC 控制（on-policy first-visit MC control）。

    【算法】（讲义 2.5 节）
        Q(s,a) = 0，N(s,a) = 0
        对第 k 个回合：
            ε ← 根据 epsilon_schedule 决定（GLIE：ε = 1/k）
            用"对当前 Q 的 ε-贪心策略"生成一个回合
            对回合中每个 (s,a) 的首次出现，计算回报 G：
                N(s,a) += 1
                Q(s,a) += α (G - Q(s,a))              （α = 1/N(s,a) 时就是增量平均）
        策略改进是**隐式的**：下一个回合直接用新的 Q 做 ε-贪心，就等于完成了改进。

    【参数】
        env              : 环境（reset/step 接口）。
        n_episodes       : 回合数。
        gamma            : 折扣因子。
        epsilon_schedule : 探索率如何随回合数 k 变化：
                           "glie"      ：ε_k = 1/k（课本上的标准 GLIE 例子，衰减很快）
                           "glie_sqrt" ：ε_k = 1/sqrt(k)（也满足 GLIE，但衰减慢得多）
                           常数（如 0.1）：固定 ε；传 0.0 表示纯贪心（演示"不探索会怎样"）。
        alpha            : None 表示 Q(s,a) 取所有回报的精确平均（步长 1/N(s,a)）；
                           一个常数（如 0.02）表示常数步长。
                           【为什么控制问题里常数步长更好？】策略在不断改进，早期回合的回报来自
                           早期那个很差的策略。1/N 平均会把这些"过时"的回报永远算进去；
                           常数步长会逐渐"忘掉"它们，让 Q 跟上当前策略（第 3 讲 2.3 节）。
        seed             : 智能体自己的随机种子（用于 ε-贪心的随机选择）。

    【返回】
        (Q, returns, N)：学到的 Q、每个回合的回报（衡量"学习过程中的表现"）、
        每个 (s,a) 被计入的次数 N（看哪些动作从没被试过）。

    【边界】
        - 回合被时间限制截断时（env.max_steps），回报只算到截断为止。
        - 某些 (s,a) 可能从未被尝试，它们的 Q 保持 0。
    """
    rng = np.random.default_rng(seed)
    Q = np.zeros((env.n_states, env.n_actions))
    N = np.zeros((env.n_states, env.n_actions))
    episode_returns = []

    for k in range(1, n_episodes + 1):
        if epsilon_schedule == "glie":
            epsilon = 1.0 / k
        elif epsilon_schedule == "glie_sqrt":
            epsilon = 1.0 / np.sqrt(k)
        else:
            epsilon = float(epsilon_schedule)

        # ---- 用 ε-贪心策略生成一个回合 ----
        trajectory = []                     # [(s, a, r), ...]
        s = env.reset()
        done = False
        while not done:
            a = epsilon_greedy_action(Q[s], epsilon, rng)
            s_next, r, done = env.step(a)
            trajectory.append((s, a, r))
            s = s_next

        # ---- 从后往前计算回报，并记录每个 (s,a) 首次出现的回报 ----
        G = 0.0
        first_return = {}                   # (s, a) -> 首次出现时刻的回报
        for s, a, r in reversed(trajectory):
            G = r + gamma * G
            first_return[(s, a)] = G        # 倒序遍历，最后留下的就是"最早一次"的回报
        for (s, a), G_sa in first_return.items():
            N[s, a] += 1
            step = 1.0 / N[s, a] if alpha is None else alpha
            Q[s, a] += step * (G_sa - Q[s, a])

        episode_returns.append(sum(r for _, _, r in trajectory))
    return Q, np.array(episode_returns), N


# =============================================================================
# 第 3 部分：演示
# =============================================================================
def greedy_value_at_start(Q, P, R, info):
    """用真实模型精确计算"对 Q 贪心的策略"从起点出发的价值（只用于给学习结果打分）。"""
    return float(evaluate_q(P, R, epsilon_greedy_probs(Q, 0.0), info["end"]).max(axis=1)[info["start"]])


def summarize(name, Q, P, R, info, Q_star):
    """打印一次训练结果：学到的贪心策略、与最优策略的一致程度、贪心策略的真实价值。"""
    policy = greedy_policy(Q)
    ok, total = policy_agreement(policy, Q_star, info)
    # 精确评估"学到的贪心策略"的真实价值（用真实模型，只为打分）
    pi_greedy = epsilon_greedy_probs(Q, 0.0)
    V_policy = evaluate_q(P, R, pi_greedy, info["end"]).max(axis=1)
    start = info["start"]
    print(f"  【{name}】")
    print(render_policy(policy, info))
    print(f"    与最优动作一致的格子：{ok}/{total}；"
          f"该贪心策略从起点出发的真实价值 {V_policy[start]:.3f}（最优 {Q_star.max(axis=1)[start]:.3f}）")


def main():
    env = GridWorldEnv(seed=0)
    P, R, info = env.P, env.R, env.info
    Q_star = optimal_q(P, R)

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：标准答案（用真实模型计算，智能体看不到）")
    print("=" * 78)
    print(render_policy(greedy_policy(Q_star), info))

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：不探索会怎样？——纯贪心的 MC 控制（ε = 0）")
    print("=" * 78)
    Q0, ret0, N0 = mc_control(GridWorldEnv(seed=1), n_episodes=3000, epsilon_schedule=0.0)
    summarize("纯贪心 ε=0，3000 回合", Q0, P, R, info, Q_star)
    normal = [s for s in range(env.n_states) if s != info["end"] and s not in info["terminal"]]
    counts = N0[normal]                                   # 只看 9 个普通格子
    print(f"    9 个普通格子 × 4 个动作 = 36 个 (状态, 动作) 对中，"
          f"有 {(counts == 0).sum()} 个一次都没被试过，有 {(counts < 5).sum()} 个被试过不到 5 次")
    print("  → 贪心策略只会反复做\"目前看起来最好\"的动作。一旦某个动作的估计碰巧偏高，")
    print("    其他动作就再也得不到数据，错误的估计永远不会被纠正——停在了次优策略上。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：不同的探索方案和步长（每种设置用 5 个不同的随机种子各训练 20000 个回合）")
    print("=" * 78)
    print("  为什么要用多个种子？单次运行的结果受运气影响很大——同样的算法，")
    print("  换一个种子可能从\"学到最优\"变成\"学得很差\"。只看一次运行很容易得出错误结论。\n")
    V_opt = Q_star.max(axis=1)[info["start"]]
    configs = [
        (0.1, None, "固定 ε=0.1，   α=1/N"),
        ("glie", None, "GLIE ε=1/k，   α=1/N"),
        ("glie_sqrt", None, "GLIE ε=1/√k，  α=1/N"),
        ("glie_sqrt", 0.02, "GLIE ε=1/√k，  α=0.02"),
    ]
    print(f"  最优策略从起点出发的价值 = {V_opt:.3f}")
    print("  设置                        5 个种子学到的贪心策略的真实价值          训练最后1000回合的平均回报")
    for schedule, alpha, label in configs:
        values, train = [], []
        for seed in range(5):
            Q, rets, _ = mc_control(GridWorldEnv(seed=seed + 10), 20_000,
                                    epsilon_schedule=schedule, alpha=alpha, seed=seed)
            values.append(greedy_value_at_start(Q, P, R, info))
            train.append(rets[-1000:].mean())
        shown = " ".join(f"{v:6.3f}" for v in values)
        print(f"  {label:<24s}  {shown}      {np.mean(train):6.3f}")
    print("\n  观察：")
    print("  - 固定 ε = 0.1：大多数种子学到了（接近）最优策略，但训练时始终有 10% 的概率乱走。")
    print("    而且它学到的 Q 是\"ε-贪心策略\"的价值，不是 Q*（讲义 2.4 节）。")
    print("  - GLIE ε = 1/k：理论上保证收敛，实际表现却最差，有的种子甚至学会了走进 -1！")
    print("    因为它衰减得太快：第 100 个回合时 ε 就只剩 0.01，很多动作还没被充分尝试，")
    print("    探索就几乎停止了。\"理论上最终收敛\"不等于\"在有限的数据下表现好\"。")
    print("  - GLIE ε = 1/√k、α = 1/N：衰减慢了，但仍不稳定。原因在步长：1/N 平均把早期")
    print("    很差的策略产生的回报永远算在 Q 里，策略改进了，Q 却跟不上。")
    print("  - GLIE ε = 1/√k、α = 0.02：5 个种子全部学到最优策略，训练回报也最高。")
    print("    常数步长让 Q 能\"忘掉\"过时的数据，跟上不断改进的策略。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：学到的 Q 与 Q* 的比较（GLIE ε = 1/√k，α = 0.02，20000 回合）")
    print("=" * 78)
    Q_glie, _, _ = mc_control(GridWorldEnv(seed=10), n_episodes=20_000,
                              epsilon_schedule="glie_sqrt", alpha=0.02, seed=0)
    summarize("学到的贪心策略", Q_glie, P, R, info, Q_star)
    print("  学到的 V(s) = max_a Q(s,a)：")
    print(render_values(Q_glie.max(axis=1), info))
    print("  最优 V*(s)：")
    print(render_values(Q_star.max(axis=1), info))
    print("  → 智能体经常走的状态（起点附近、最优路线上）估计得很准；")
    print("    很少去的状态（-1 附近、右下角）数据少，估计偏差很大：")
    print("    - 右下角 (2,3) 的贪心动作甚至是 ↑（直接走进 -1）！因为学到的策略几乎从不经过这里。")
    print("      这不影响从起点出发的价值，但提醒我们：学到的策略只在\"常去的地方\"可靠。")
    print("    - -1 格子的估计只有 -0.79 而不是 -1：常数步长 α=0.02 从初始值 0 出发，")
    print("      访问次数不够多，还没\"走完\"到 -1 的路（每次只挪 2%）。")


if __name__ == "__main__":
    main()
