"""
=============================================================================
第 7 讲 · 代码 3：性能差异引理、替代目标与"信赖域"——TRPO / PPO 的理论基础
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
策略梯度只告诉我们"朝哪个方向走"，没告诉我们"走多远"。步子太大会怎样？本文件用精确计算回答：

    演示 1：性能差异引理——新旧策略的价值差，可以用**旧策略的优势函数**精确表示
    演示 2：替代目标（surrogate）——把新策略的状态分布换成旧策略的，得到一个可以用旧数据估计的近似；
            步子小时它很准，步子大时它会严重高估
    演示 3：用**带噪声的**梯度估计走大步——有相当大的概率让策略**变差**，甚至灾难性地变差
    演示 4：自然梯度——同样的"策略变化量"（KL 散度），能换来更多的改进
    演示 5：PPO 的裁剪目标长什么样

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture07
    python 03_trust_region.py      （约 2 秒）
"""

import numpy as np

from chain_mdp import (N_STATES, GAMMA, policy_probs, exact, exact_gradient, J,
                       sample_episode, grad_log_pi)

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


# =============================================================================
# 第 1 部分：基本量
# =============================================================================
def avg_kl(pi_old, pi_new, d):
    """
    按状态分布 d 加权的平均 KL 散度：Σ_s d(s) KL(π_old(·|s) ‖ π_new(·|s))。

    【KL 散度是什么？】KL(p ‖ q) = Σ_a p(a) log(p(a)/q(a)) 衡量两个分布"差多远"：
        - 总是 ≥ 0，当且仅当 p = q 时为 0；
        - 不对称：KL(p‖q) ≠ KL(q‖p)。
    TRPO 用它来衡量"新策略离旧策略有多远"——比"参数变了多少"更有意义，
    因为同样的参数变化，在不同的地方可能让策略改变得很多或很少。
    """
    return float(np.sum(d * np.sum(pi_old * np.log(pi_old / pi_new), axis=1)))


def surrogate_improvement(ex_old, pi_new):
    """
    替代目标（相对于 J(π_old) 的改进量）：
        L_{π_old}(π_new) - J(π_old) = Σ_s η^{π_old}(s) Σ_a π_new(a|s) A^{π_old}(s, a)

    【为什么叫"替代"？】它和性能差异引理（演示 1）只差一处：状态访问量用的是**旧**策略的 η。
        旧策略的 η 可以用旧策略收集的数据估计，所以这个目标可以不和新策略交互就算出来。
    """
    return float(np.sum(ex_old["eta"][:, None] * pi_new * ex_old["A"]))


def estimate_gradient(theta, n_episodes, rng):
    """用 n_episodes 个回合的 REINFORCE 估计梯度（带 γ^t，无基线），用来模拟"带噪声的梯度"。"""
    g = np.zeros_like(theta)
    for _ in range(n_episodes):
        ep = sample_episode(theta, rng)
        G, rets = 0.0, np.zeros(len(ep))
        for t in range(len(ep) - 1, -1, -1):
            G = ep[t][2] + GAMMA * G
            rets[t] = G
        for t, (s, a, _, _) in enumerate(ep):
            g += GAMMA ** t * rets[t] * grad_log_pi(theta, s, a)
    return g / n_episodes


def step_for_kl(theta, direction, pi_old, d, target_kl):
    """
    二分查找：沿 direction 走多远，才能让平均 KL 恰好等于 target_kl？
    （这就是"信赖域"的做法：不是固定参数步长，而是固定策略的变化量。）
    """
    unit = direction / np.linalg.norm(direction)
    lo, hi = 0.0, 1000.0
    for _ in range(80):
        mid = (lo + hi) / 2
        if avg_kl(pi_old, policy_probs(theta + mid * unit), d) < target_kl:
            lo = mid
        else:
            hi = mid
    return theta + lo * unit


# =============================================================================
# 第 2 部分：演示
# =============================================================================
def main():
    rng = np.random.default_rng(1)
    theta = rng.normal(0, 0.5, size=(N_STATES, 2))
    pi = policy_probs(theta)
    ex = exact(pi)
    d = ex["eta"] / ex["eta"].sum()             # 归一化的状态分布
    g = exact_gradient(theta)
    print(f"当前策略 π(右|s) = {pi[:, 1]}，J(π) = {ex['J']:.3f}\n")

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：性能差异引理 J(π') - J(π) = Σ_s η^{π'}(s) Σ_a π'(a|s) A^π(s,a)")
    print("=" * 78)
    for k in range(3):
        theta_new = theta + rng.normal(0, 1.0, size=theta.shape) * (k + 1)
        pi_new = policy_probs(theta_new)
        ex_new = exact(pi_new)
        true_diff = ex_new["J"] - ex["J"]
        pdl = float(np.sum(ex_new["eta"][:, None] * pi_new * ex["A"]))
        surr = surrogate_improvement(ex, pi_new)
        print(f"  随机新策略 {k + 1}：真实差 {true_diff:+8.4f}   引理（用新策略的 η）{pdl:+8.4f}   "
              f"替代目标（用旧策略的 η）{surr:+8.4f}   KL = {avg_kl(pi, pi_new, d):.3f}")
    print("  → 性能差异引理**精确**成立（前两列相同）。")
    print("    直觉：新策略比旧策略好多少 = 新策略在它会访问的每个状态上，")
    print("    \"按旧策略的标准\"平均能多拿多少优势。")
    print("  → 替代目标把 η^{π'} 换成 η^π，就不再精确；新旧策略差得越多（KL 越大），误差越大。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：沿精确梯度方向走不同的步长——替代目标 vs 真实改进")
    print("=" * 78)
    print("   参数步长     平均KL      真实改进    替代目标预测    预测误差")
    for t in [0.1, 0.3, 1, 3, 10, 30]:
        pi_t = policy_probs(theta + t * g / np.linalg.norm(g))
        true = J(theta + t * g / np.linalg.norm(g)) - ex["J"]
        surr = surrogate_improvement(ex, pi_t)
        print(f"   {t:7.1f}     {avg_kl(pi, pi_t, d):7.4f}    {true:+8.3f}      {surr:+8.3f}      {surr - true:+7.3f}")
    print("  → 步子小（KL 小）时，替代目标几乎完全准确；步子大时，它严重高估了改进。")
    print("    在替代目标上\"大胆优化\"，得到的承诺并不兑现。这就是 TRPO 要限制 KL 的原因：")
    print("    只在替代目标可信的范围内（信赖域）优化。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：用带噪声的梯度估计走大步——策略变差的概率（每种设置 300 次）")
    print("=" * 78)
    print("   每批回合数   参数步长    平均改进    变差的概率    最坏情况")
    for n in [2, 10]:
        for step in [0.3, 1, 3, 10, 30]:
            dJ = []
            for _ in range(300):
                g_hat = estimate_gradient(theta, n, rng)
                theta_new = theta + step * g_hat / (np.linalg.norm(g_hat) + 1e-12)
                dJ.append(J(theta_new) - ex["J"])
            dJ = np.array(dJ)
            print(f"   {n:6d}       {step:6.1f}     {dJ.mean():+7.3f}      {(dJ < 0).mean() * 100:6.1f}%     {dJ.min():+7.2f}")
    print(f"  （J 的下界是 {-0.5 / (1 - GAMMA):.1f}：策略变成确定性地在中间来回走、永远到不了终点。）")
    print("  观察：")
    print("  - 步子小时，几乎每次都在改进；步子越大，越可能变差，而且最坏情况是灾难性的：")
    print("    J 掉了 8 以上，接近\"永远到不了终点\"的下界。")
    print("  - 数据越少（梯度越不准），大步子越危险。")
    print("  - 在深度 RL 中，一次这样的坏更新会让策略收集到更差的数据，之后可能再也恢复不过来。")
    print("    TRPO / PPO 的核心目的，就是让每次更新\"不要走太远\"。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：自然梯度——同样的 KL 预算，谁的改进更多？")
    print("=" * 78)
    # Fisher 信息矩阵（按状态分块）：F_s = d(s) [diag(π_s) - π_s π_sᵀ]
    nat = np.zeros_like(theta)
    for s in range(N_STATES):
        F_s = d[s] * (np.diag(pi[s]) - np.outer(pi[s], pi[s]))
        nat[s] = np.linalg.pinv(F_s) @ g[s]          # 伪逆：F_s 是奇异的（softmax 加常数不变）
    # 对比：自然梯度（每个状态减去均值后）与优势函数 A（同样减去均值）是否成正比
    nat_c = nat - nat.mean(axis=1, keepdims=True)
    A_c = ex["A"] - ex["A"].mean(axis=1, keepdims=True)
    ratio = nat_c[:, 1] / A_c[:, 1]
    print(f"  自然梯度 F⁻¹∇J 与优势 A^π 的比值（每个状态）：{ratio}")
    print(f"  Σ_s η(s) = {ex['eta'].sum():.3f}")
    print("  → 对表格型 softmax，自然梯度方向恰好就是优势函数 A^π（乘以一个常数）！")
    print("    也就是说：自然梯度 = \"在每个状态，按优势的大小提高好动作的偏好\"——")
    print("    与状态被访问的频率无关。普通梯度则被 η(s) 加权：很少访问的状态几乎不更新。")
    print()
    for budget in [0.001, 0.01, 0.05]:
        th_v = step_for_kl(theta, g, pi, d, budget)
        th_n = step_for_kl(theta, nat, pi, d, budget)
        print(f"  KL 预算 {budget:5.3f}：普通梯度改进 {J(th_v) - ex['J']:+.4f}，自然梯度改进 {J(th_n) - ex['J']:+.4f}")
    print("  → KL 预算小时（信赖域的典型用法），同样的\"策略变化量\"，自然梯度带来的改进更多；")
    print("    预算大到 0.05 时两者就差不多了——自然梯度是在\"KL 的二阶近似\"下的最优方向，")
    print("    这个近似只在局部成立，离开局部就不再有保证。")
    print("    自然梯度衡量的是\"策略空间中的距离\"（KL），而不是\"参数空间中的距离\"——")
    print("    这正是 TRPO 使用的更新方向（TRPO 还会做线搜索，检查实际的 KL 和改进）。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 5：PPO 的裁剪目标 min(r·A, clip(r, 1-ε, 1+ε)·A)，ε = 0.2")
    print("=" * 78)
    eps = 0.2
    print("   概率比 r    |   A = +1：r·A   裁剪后   PPO目标   |   A = -1：r·A   裁剪后   PPO目标")
    for r in [0.5, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 2.0]:
        row = []
        for A in [1.0, -1.0]:
            unclipped = r * A
            clipped = np.clip(r, 1 - eps, 1 + eps) * A
            row.append((unclipped, clipped, min(unclipped, clipped)))
        (u1, c1, m1), (u2, c2, m2) = row
        print(f"   {r:6.2f}     |      {u1:+5.2f}    {c1:+5.2f}    {m1:+5.2f}     |      {u2:+5.2f}    {c2:+5.2f}    {m2:+5.2f}")
    print("  解读（r = π_new(a|s) / π_old(a|s)）：")
    print("  - A > 0（好动作）：想提高它的概率（增大 r）。但 r 超过 1.2 以后，目标不再增加（平了），")
    print("    梯度为 0——\"已经提高得够多了，别再推了\"。")
    print("  - A < 0（坏动作）：想降低它的概率（减小 r）。但 r 低于 0.8 以后，目标同样不再变化。")
    print("  - 取 min 保证这是一个\"悲观\"的下界：如果某个更新让目标变差（比如 A > 0 但 r 变小了），")
    print("    不会被裁剪掩盖，仍然会被纠正。")


if __name__ == "__main__":
    main()
