"""
=============================================================================
第 3 讲 · 代码 4：批量学习与确定性等价（Batch MC / Batch TD / Certainty Equivalence）
=============================================================================

对应讲义：lectures/lecture03_model_free_evaluation.md 第 5、6 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
前面的实验都是"在线"的：来一个回合学一个回合，学完就扔掉。
如果数据很贵（比如医疗记录），我们会希望**把手头有限的数据反复利用，直到榨干**。
这叫**批量（batch）**设定：给定一个固定的数据集，反复在上面运行 MC 或 TD，直到收敛。

一个令人惊讶的事实：在同一批数据上，**批量 MC 和批量 TD 会收敛到不同的答案**。
    - 批量 MC 收敛到：使"训练数据上观察到的回报"的均方误差最小的估计（就是样本平均）
    - 批量 TD 收敛到：**确定性等价（certainty equivalence）** 估计——
      先用数据估计一个最大似然的 MRP 模型（数数），再精确求解这个模型的价值

哪个更好？如果环境真的是马尔可夫的，TD 的答案通常对**未来的数据**预测得更好。

-----------------------------------------------------------------------------
【本文件的实验】
-----------------------------------------------------------------------------
    实验 1：Sutton & Barto 例 6.4 的"AB 例子"——8 个回合，批量 MC 与批量 TD 给出不同答案
    实验 2：验证"批量 TD 的结果 = 确定性等价的结果"
    实验 3：随机游走上，批量 MC vs 批量 TD（确定性等价）的学习曲线（S&B 图 6.2 批量版）

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 04_batch_and_certainty_equivalence.py      （约 2 秒）
"""

import numpy as np

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


# =============================================================================
# 第 1 部分：批量 MC
# =============================================================================
def batch_mc(episodes, n_states, gamma=1.0):
    """
    批量 MC（每次访问版）：在整个数据集上，每个状态的价值 = 该状态所有回报的平均。

    【为什么"反复运行常数 α 的 MC 直到收敛"就等于"取平均"？】
        MC 的更新 V(s) ← V(s) + α(G - V(s)) 在一批固定数据上反复进行，
        收敛点满足"所有更新量之和为 0"：Σ_i (G_i - V(s)) = 0，即 V(s) = 平均值。
        所以批量 MC 可以直接用平均值计算，不需要真的迭代。

    【返回】
        长度 n_states 的数组；没有数据的状态为 NaN。
    """
    total = np.zeros(n_states)
    counts = np.zeros(n_states)
    for episode in episodes:
        G = 0.0
        for s, r, _ in reversed(episode):
            G = r + gamma * G
            total[s] += G
            counts[s] += 1
    return np.where(counts > 0, total / np.maximum(counts, 1), np.nan)


# =============================================================================
# 第 2 部分：批量 TD（真的去迭代）
# =============================================================================
def batch_td(episodes, n_states, gamma=1.0, alpha=0.01, tol=1e-10, max_sweeps=1_000_000):
    """
    批量 TD(0)：在整个数据集上反复扫描，**累积**所有转移的 TD 更新量，一次性应用，直到收敛。

    【为什么要"累积后一次性应用"？】
        如果每处理一个转移就立刻更新，结果会依赖于数据的顺序。
        Sutton & Barto 的批量 TD 定义是：用当前 V 计算整批数据的所有增量，加起来再更新 V。
        这样每一"扫"的结果与数据顺序无关，收敛点唯一。

    【参数】
        alpha      : float，步长。要足够小才能保证收敛（太大会振荡发散）。
        tol        : float，一次扫描中 V 的最大变化量小于它时停止。
        max_sweeps : int，最多扫描次数。

    【返回】
        (V, n_sweeps)

    【边界】
        - 没出现在数据中的状态保持初始值 0，没有意义；调用者应当自己知道哪些状态有数据。
        - alpha 太大 → 可能不收敛，达到 max_sweeps 后抛出 RuntimeError。
    """
    V = np.zeros(n_states)
    transitions = [t for episode in episodes for t in episode]   # 把所有回合的转移"摊平"成一个列表
    for sweep in range(1, max_sweeps + 1):
        increment = np.zeros(n_states)
        for s, r, s_next in transitions:
            target = r if s_next is None else r + gamma * V[s_next]
            increment[s] += alpha * (target - V[s])
        V += increment
        if np.abs(increment).max() < tol:
            return V, sweep
    raise RuntimeError(f"批量 TD 在 {max_sweeps} 次扫描内没有收敛，试试更小的 alpha")


# =============================================================================
# 第 3 部分：确定性等价（先估计模型，再求解）
# =============================================================================
def certainty_equivalence(episodes, n_states, gamma=1.0):
    """
    确定性等价方法：
        1. 用数据估计一个 MRP 模型（最大似然 = 数数，第 1 讲 7.7 节）：
               P_hat(s'|s) = N(s → s') / N(s)
               R_hat(s)    = 从 s 出发获得的奖励的平均值
           回合结束视为转移到一个"终止状态"，其价值为 0。
        2. 把估计的模型"当真"（这就是"确定性等价"这个名字的含义：假装估计就是真相），
           用第 1 讲的解析解精确求出价值：V = (I - γ P_hat)^{-1} R_hat

    【优点】数据利用率极高：每条转移都贡献给模型，模型再把信息传播到所有状态。
    【缺点】需要存储模型（|S|² 个数），求解要 O(|S|³)；状态多时不现实。

    【边界】
        - 数据中从未出发过的状态：没有转移信息。这里让它"自己转移到终止"、奖励 0，
          即 V = 0 的占位值。调用者应当知道这些值不可信。
        - γ = 1 时，只要数据里的每个状态最终都能到达终止，I - P_hat（只含非终止状态）就可逆。

    【返回】
        (V, P_hat, R_hat)
    """
    counts = np.zeros((n_states, n_states))   # 非终止状态之间的转移次数
    to_terminal = np.zeros(n_states)          # 转移到终止状态的次数
    reward_sum = np.zeros(n_states)
    visits = np.zeros(n_states)
    for episode in episodes:
        for s, r, s_next in episode:
            visits[s] += 1
            reward_sum[s] += r
            if s_next is None:
                to_terminal[s] += 1
            else:
                counts[s, s_next] += 1

    seen = visits > 0
    P_hat = np.zeros((n_states, n_states))
    R_hat = np.zeros(n_states)
    P_hat[seen] = counts[seen] / visits[seen][:, None]
    R_hat[seen] = reward_sum[seen] / visits[seen]
    # 注意：P_hat 只包含非终止状态之间的转移，每行和 = 1 - P(到终止)。
    # 终止状态价值为 0，所以不需要出现在方程里。
    V = np.linalg.solve(np.eye(n_states) - gamma * P_hat, R_hat)
    return V, P_hat, R_hat


# =============================================================================
# 第 4 部分：随机游走（用于实验 3）
# =============================================================================
def generate_random_walk_episode(rng, n_states=5):
    """随机游走回合 [(s, r, s_next), ...]，从中间出发；右端出去 +1。"""
    s = n_states // 2
    episode = []
    while True:
        s_next = s + (1 if rng.random() < 0.5 else -1)
        if s_next < 0:
            episode.append((s, 0.0, None))
            return episode
        if s_next >= n_states:
            episode.append((s, 1.0, None))
            return episode
        episode.append((s, 0.0, s_next))
        s = s_next


# =============================================================================
# 第 5 部分：演示
# =============================================================================
def main():
    A, B = 0, 1

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("实验 1：AB 例子（Sutton & Barto 例 6.4）")
    print("=" * 78)
    ab_data = (
        [[(A, 0.0, B), (B, 0.0, None)]]          # A --0--> B --0--> 结束
        + [[(B, 1.0, None)]] * 6                 # B --1--> 结束（6 次）
        + [[(B, 0.0, None)]]                     # B --0--> 结束（1 次）
    )
    # 【Python 小知识】[x] * 6 得到包含 6 个 x 的列表；两个列表用 + 拼接。
    print("  数据（8 个回合，γ = 1）：")
    print("    A --0--> B --0--> 结束")
    print("    B --1--> 结束   （× 6）")
    print("    B --0--> 结束   （× 1）\n")

    V_mc = batch_mc(ab_data, 2)
    V_td, sweeps = batch_td(ab_data, 2, alpha=0.05)
    V_ce, P_hat, R_hat = certainty_equivalence(ab_data, 2)
    print(f"  批量 MC        ：V(A) = {V_mc[A]:.3f}，V(B) = {V_mc[B]:.3f}")
    print(f"  批量 TD        ：V(A) = {V_td[A]:.3f}，V(B) = {V_td[B]:.3f}   （扫描 {sweeps} 次收敛）")
    print(f"  确定性等价     ：V(A) = {V_ce[A]:.3f}，V(B) = {V_ce[B]:.3f}")
    print("\n  两种方法都同意 V(B) = 6/8 = 0.75，但对 V(A) 意见不同：")
    print("  - 批量 MC 说 V(A) = 0：A 只出现过一次，那一次的回报是 0，平均就是 0。")
    print("  - 批量 TD 说 V(A) = 0.75：数据显示 A 总是（100%）转移到 B，而 B 的价值是 0.75，")
    print("    所以 A 的价值也应该是 0.75。")
    print("  哪个对？如果过程是马尔可夫的——A 之后发生什么只取决于到了 B——那么那一次 B 之后的 0")
    print("  只是运气不好，TD 的答案更合理，对未来的数据预测得更好。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("实验 2：确定性等价——TD 隐含地做了什么？")
    print("=" * 78)
    print("  从数据中\"数\"出来的模型：")
    print(f"    P_hat(B | A) = {P_hat[A, B]:.2f}，P_hat(结束 | A) = {1 - P_hat[A].sum():.2f}，R_hat(A) = {R_hat[A]:.2f}")
    print(f"    P_hat(结束 | B) = {1 - P_hat[B].sum():.2f}，R_hat(B) = {R_hat[B]:.3f}")
    print(f"  在这个模型上精确求解：V = {V_ce}")
    print(f"  与批量 TD 的结果最大差：{np.abs(V_ce - V_td).max():.1e}")
    print("  → 批量 TD 收敛到的，正是\"把最大似然模型当作真相\"后求出的价值。")
    print("    TD 虽然从不显式地建模，却隐含地利用了马尔可夫结构；MC 完全不用这个结构。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("实验 3：随机游走上的批量学习曲线（100 次独立运行的平均 RMS 误差）")
    print("=" * 78)
    V_true = np.arange(1, 6) / 6
    n_runs, n_episodes = 100, 100
    checkpoints = [1, 5, 10, 25, 50, 100]
    mc_err = np.zeros((n_runs, n_episodes))
    td_err = np.zeros((n_runs, n_episodes))
    rng = np.random.default_rng(0)
    for run in range(n_runs):
        data = []
        for k in range(n_episodes):
            data.append(generate_random_walk_episode(rng))
            # 每来一个新回合，就在"迄今为止的全部数据"上重新求批量解
            V_mc = batch_mc(data, 5)
            V_mc = np.where(np.isnan(V_mc), 0.5, V_mc)   # 没访问过的状态按初始值 0.5 计
            V_ce, _, _ = certainty_equivalence(data, 5)  # = 批量 TD 的收敛点（实验 2 已验证）
            seen = np.zeros(5, dtype=bool)
            for ep in data:
                for s, _, _ in ep:
                    seen[s] = True
            V_ce = np.where(seen, V_ce, 0.5)
            mc_err[run, k] = np.sqrt(np.mean((V_mc - V_true) ** 2))
            td_err[run, k] = np.sqrt(np.mean((V_ce - V_true) ** 2))
    # 表头用英文标签，避免中文宽度导致对不齐
    print("  回合数              " + "".join(f"{c:>8d}" for c in checkpoints))
    print("  batch MC            " + "".join(f"{mc_err[:, c - 1].mean():8.3f}" for c in checkpoints))
    print("  batch TD (= CE)     " + "".join(f"{td_err[:, c - 1].mean():8.3f}" for c in checkpoints))
    print("  → 只有 1 个回合时两者相同：这条回合经过的所有状态，在两种方法下都被估计为")
    print("    该回合的最终结果（0 或 1），因为数据里只见过一种结局；")
    print("    之后在每一个数据量下，批量 TD 的误差都低于批量 MC。")
    print("    这说明 TD 的优势不只是\"更新得早\"，更在于它更好地利用了马尔可夫结构，")
    print("    从同样的数据中榨取出了更多信息。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：边界情况")
    print("=" * 78)
    # α 太大时数值会发散到无穷大，numpy 会打印"溢出"警告。
    # 【Python 小知识】with np.errstate(...): 在这个代码块内临时关闭这些警告（因为我们是故意的）。
    with np.errstate(over="ignore", invalid="ignore"):
        try:
            batch_td(ab_data, 2, alpha=0.5, max_sweeps=2000)
        except RuntimeError as e:
            print(f"  [α 太大]   RuntimeError: {e}")
            print("             （8 个转移的增量累加后一起更新，α=0.5 相当于一步迈出 8×0.5 倍，越调越离谱）")
    V_one = batch_mc([[(B, 1.0, None)]], 2)
    print(f"  [数据不全] 只有一个 B 的回合时，批量 MC：V = {V_one}（A 没有数据，为 nan）")


if __name__ == "__main__":
    main()
