"""
=============================================================================
第 6 讲 · 代码 3：连续状态 —— 离散化、维数灾难与 fitted value iteration
=============================================================================

对应讲义：lectures/lecture06_rl_control.md 第 3 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
状态是连续的（位置、速度、角度……）时，表格 V(s) 存不下。CS229 讲义给了两条路：

    演示 1：离散化（discretization）。把 mountain car 的二维状态空间切成 N×N 个格子，
            用模拟器在每个格子里采样估计转移概率，再做价值迭代。N 要多大才够？
            同样的分辨率放到 4 维、6 维、12 维（直升机）要多少个格子？—— 维数灾难（curse of dimensionality）。
    演示 2：Fitted value iteration（FVI）。在采样的状态上做一次 Bellman backup 得到目标值 y⁽ⁱ⁾，
            再用第 1 讲的最小二乘把 V(s) ≈ θᵀφ(s) 拟合到这些目标上，反复迭代。
            比较多项式特征和 RBF 特征。
    演示 3：FVI 不一定收敛：不加正则化时 RBF 特征的 θ 一直在跳，V 甚至变成正数（真值一定 ≤ 0）。
            加一点 ridge（第 3 讲）就稳定了。为什么？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_continuous_fitted_vi.py          # 约 20 秒

需要 numpy。依赖本目录的 rl_tools.py（用到第 1 讲的 fit_least_squares）。
"""

import numpy as np

from rl_tools import fit_least_squares, print_header

# =============================================================================
# Mountain car 模拟器（Moore 1990；Sutton & Barto 的版本）
# =============================================================================
# 一辆动力不足的小车在两座山之间的谷底，要开到右边山顶（位置 ≥ 0.5）。发动机太弱，不能直接开上去：
# 必须先往左边山坡倒车，借势冲上右边 —— "先远离目标"才能到达目标，这需要向前看很多步（规划）。
# 状态 s = (位置 p, 速度 v)，动作 a ∈ {−1（向左推）, 0（不推）, +1（向右推）}，每一步奖励 −1，到达山顶结束。
P_MIN, P_MAX, V_MAX, GOAL = -1.2, 0.5, 0.07, 0.5
ACTIONS = np.array([-1, 0, 1])
GAMMA = 0.99


def mc_step(p, v, a):
    """
    Mountain car 的一步（确定性），对数组逐个元素计算（一次模拟很多辆车）。

        v' = clip(v + 0.001·a − 0.0025·cos(3p), −0.07, 0.07)
        p' = clip(p + v', −1.2, 0.5)；撞到左墙（p' = −1.2）时速度清零
    【参数】p, v：形状相同的数组；a：动作（−1 / 0 / +1），标量或同形状数组
    【返回】(p', v', done)，done 表示到达山顶（p' ≥ 0.5）
    【ML 联系】这就是 CS229 讲义说的"模拟器"（simulator）：一个函数 s_{t+1} = f(s_t, a_t)（这里没有噪声）。
        讲义 §3.1：模拟器可以来自物理定律，也可以从数据里学（例如用第 1 讲的线性回归拟合 s_{t+1} ≈ A s_t + B a_t）。
    """
    v2 = np.clip(v + 0.001 * a - 0.0025 * np.cos(3 * p), -V_MAX, V_MAX)
    p2 = np.clip(p + v2, P_MIN, P_MAX)
    v2 = np.where(p2 <= P_MIN, 0.0, v2)
    return p2, v2, p2 >= GOAL


def evaluate_policy(act_fn, starts, max_steps=1000):
    """
    从一组起点出发执行策略，返回每辆车到达山顶用了多少步（没到达记为 max_steps）。

    【参数】act_fn：函数 act_fn(p, v) → 动作数组（−1/0/+1），对一批状态同时计算；
        starts：(p0, v0) 两个数组；max_steps：上限
    【返回】steps：整数数组
    【思路】所有车同时模拟（向量化）；已经到达的车不再移动。
    """
    p, v = starts[0].copy(), starts[1].copy()
    done = np.zeros(len(p), dtype=bool)
    steps = np.full(len(p), max_steps)
    for t in range(max_steps):
        p2, v2, reached = mc_step(p, v, act_fn(p, v))
        p, v = np.where(done, p, p2), np.where(done, v, v2)
        newly = reached & ~done
        steps[newly] = t + 1
        done |= reached
        if done.all():
            break
    return steps


def standard_starts(rng, n=100):
    """标准的起点分布：p ~ U(−0.6, −0.4)（谷底附近），v = 0。所有方法用同一组起点比较。"""
    return rng.uniform(-0.6, -0.4, size=n), np.zeros(n)


# =============================================================================
# 演示 1：离散化
# =============================================================================
def cell_index(p, v, N):
    """
    连续状态 → 格子编号（0 … N²−1）。位置和速度各切成 N 段，编号 = i·N + j。

    【边界】右端点 p = P_MAX 会算出 i = N，用 clip 截到 N − 1。
    【numpy 小知识】.astype(int) 向 0 取整；这里的量都 ≥ 0，所以等于 floor。
    """
    i = np.clip(((p - P_MIN) / (P_MAX - P_MIN) * N).astype(int), 0, N - 1)
    j = np.clip(((v + V_MAX) / (2 * V_MAX) * N).astype(int), 0, N - 1)
    return i * N + j


def discretize_and_solve(N, k, rng, tol=1e-6):
    """
    把状态空间切成 N×N 个格子，用模拟器估计格子之间的转移概率，再做价值迭代。

    【思路】（CS229 main notes 第 15 章 continuous state MDPs 一节的离散化 + 代码 02 的"从样本学模型"）
        1. 在每个格子里均匀采样 k 个连续状态，对每个动作用模拟器走一步，记下落到哪个格子（或到达终点）。
           于是 P̂(格子' | 格子, a) = 落到格子' 的比例。（比"只模拟格子中心"好：车在格子里的位置不同，
           去的地方也不同，这些差别被平均进了概率里。）
        2. 在这个 N² 个状态的有限 MDP 上做价值迭代：V(c) = max_a [−1 + γ · mean_k V(next[c, a, k])]。
        3. 策略：π(s) = 格子 cell(s) 的最优动作（同一个格子里所有状态用同一个动作 —— 分段常数）。
    【参数】N：每个维度切几段；k：每个格子、每个动作采样几个点；rng；tol：VI 停止阈值
    【返回】(pi, n_iters, n_sim)：pi 形状 (N²,) 的动作下标（0/1/2）；n_sim = 模拟器调用次数
    【边界】"到达终点"用一个额外的下标 N² 表示，它的价值固定为 0（终止状态）。
        转移不存成 (N², 3, N²) 的稠密矩阵（N = 160 时要 50 GB），而是存"每个样本落到哪"：(N², 3, k) 的下标数组。
    【numpy 小知识】V[nxt] 用一个整数数组去索引另一个数组，结果的形状和 nxt 一样 —— 一次取出所有样本的下一状态价值。
    """
    C = N * N
    ii, jj = np.divmod(np.arange(C), N)                       # 格子编号 → (位置段, 速度段)
    p = P_MIN + (ii[:, None] + rng.uniform(size=(C, k))) / N * (P_MAX - P_MIN)
    v = -V_MAX + (jj[:, None] + rng.uniform(size=(C, k))) / N * (2 * V_MAX)
    nxt = np.zeros((C, len(ACTIONS), k), dtype=int)
    for ai, a in enumerate(ACTIONS):
        p2, v2, done = mc_step(p, v, a)
        nxt[:, ai, :] = np.where(done, C, cell_index(p2, v2, N))
    V = np.zeros(C + 1)                                       # 最后一个是"终点"，价值恒为 0
    for it in range(1, 100001):
        Q = -1.0 + GAMMA * V[nxt].mean(axis=2)                # (C, 3)
        V_new = np.append(Q.max(axis=1), 0.0)
        if np.max(np.abs(V_new - V)) < tol:
            break
        V = V_new
    return Q.argmax(axis=1), it, C * len(ACTIONS) * k


def demo_discretization():
    print_header("演示 1：离散化 —— N×N 个格子 + 从模拟器采样估计转移概率 + 价值迭代")
    rng = np.random.default_rng(31)
    starts = standard_starts(rng)
    print("Mountain car：s = (位置, 速度)，3 个动作，每步奖励 −1，γ = 0.99。")
    print("评估：100 个起点 p ~ U(−0.6, −0.4)、v = 0，记录到达山顶的步数（上限 1000，没到达记为 1000）。\n")
    print(f"{'N':>5}{'cells':>8}{'sim calls':>11}{'VI iters':>10}{'mean steps':>12}{'failed':>8}")
    results = {}
    for N in [5, 10, 20, 40, 80, 160]:
        pi, it, n_sim = discretize_and_solve(N, k=20, rng=rng)
        steps = evaluate_policy(lambda p, v, pi=pi, N=N: ACTIONS[pi[cell_index(p, v, N)]], starts)
        results[N] = steps
        print(f"{N:>5}{N * N:>8}{n_sim:>11}{it:>10}{steps.mean():>12.1f}{np.mean(steps >= 1000):>8.2f}")

    print("\n维数灾难：每个维度 N 段，d 维状态需要 N^d 个格子（表格 V 的大小；转移模型还要再乘以 |A|·k）：\n")
    dims = [(2, "mountain car"), (4, "cart-pole"), (6, "rigid body pose"), (12, "helicopter (Ng)")]
    print(f"{'d':>4}  {'example':<18}" + "".join(f"{'N = ' + str(N):>12}" for N in [10, 20, 80]))
    for d, name in dims:
        print(f"{d:>4}  {name:<18}" + "".join(f"{float(N) ** d:>12.1e}" for N in [10, 20, 80]))
    best = min(results, key=lambda n: results[n].mean())
    print(f"""
观察 Observations：
  · N = 5 时格子太粗，"往左倒车蓄势"这种精细的策略表示不出来，{np.mean(results[5] >= 1000):.0%} 的车到不了山顶。
    N = 10、20 能到，但绕远路（平均 {results[10].mean():.0f}、{results[20].mean():.0f} 步）；N = 40 降到 {results[40].mean():.0f} 步，
    N ≥ 80 基本饱和（N = {best} 时 {results[best].mean():.1f} 步）。分辨率不够是离散化的第一个代价：
    V 和 π 在一个格子内是常数（piecewise constant），而真实的 V 在格子内部也在变化。
  · 第二个代价是维数灾难：二维时 160² = 25600 个格子毫无压力；同样每维 20 段，
    4 维（cart-pole）是 16 万个格子，6 维是 6400 万，12 维的直升机是 4×10¹⁵。
    CS229 讲义的经验：1–2 维离散化很好用，3–4 维要小心地设计格子，6 维以上基本不行。
  · 这就是为什么连续问题需要函数逼近（演示 2）：参数个数不随 N^d 增长。""")
    return results


# =============================================================================
# 演示 2：Fitted value iteration
# =============================================================================
def normalize(p, v):
    """把 (p, v) 线性映射到 [0, 1]²，让两个维度的尺度一样（第 1 讲的 feature scaling）。"""
    return np.column_stack([(p - P_MIN) / (P_MAX - P_MIN), (v + V_MAX) / (2 * V_MAX)])


def poly2d_features(Z, degree):
    """
    二维多项式特征：所有 z₁ⁱ z₂ʲ（i + j ≤ degree），包括常数项 1。

    【参数】Z：(n, 2)，已经归一化到 [0, 1]²；degree：最高次数
    【返回】(n, (degree+1)(degree+2)/2)。degree = 2 → 6 个特征，4 → 15，6 → 28。
    【ML 联系】第 1 讲 §2.5 的多项式特征的二维版本（第 1 讲的 poly_features 只处理一维输入）。
    """
    return np.column_stack([Z[:, 0] ** i * Z[:, 1] ** j for i in range(degree + 1) for j in range(degree + 1 - i)])


def rbf_features(Z, g):
    """
    高斯径向基函数（RBF）特征：在 [0, 1]² 上放 g×g 个中心，φ_c(z) = exp(−‖z − c‖² / (2w²))，w = 中心间距。

    【参数】Z：(n, 2)；g：每个维度的中心数（≥ 2）
    【返回】(n, g² + 1)，第一列是截距 1
    【边界】w 取中心间距时，相邻的 RBF 重叠很多：特征之间高度相关，设计矩阵 Φ 的条件数很大（演示 3 打印了）。
        这对"拟合一次"影响不大，但 FVI 要把拟合结果反复喂回去，问题就被放大了。
    【numpy 小知识】Z[:, None, :] − C[None, :, :] 的形状是 (n, g², 2)：每个样本减去每个中心（broadcasting）。
    """
    c = np.linspace(0.0, 1.0, g)
    centers = np.array([(a, b) for a in c for b in c])
    w = 1.0 / (g - 1)
    d2 = ((Z[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
    return np.column_stack([np.ones(len(Z)), np.exp(-d2 / (2 * w ** 2))])


def regression_operator(Phi, ridge):
    """
    返回矩阵 M（形状 (d, m)），使得 θ = M y 就是"用特征 Phi 对目标 y 做（ridge）最小二乘"的解。

    【思路】FVI 每一轮都要对**同一组**样本状态做一次回归，只是目标 y 变了。所以先把"回归"这个线性映射算出来，
        之后每轮只做一次矩阵乘法。用第 1 讲的 fit_least_squares（lstsq）实现两个技巧：
          · lstsq 的右边可以是矩阵：fit_least_squares(Φ, I) 一次解出 m 个最小二乘问题，结果就是伪逆 Φ⁺。
          · Ridge = 在数据下面加 d 行"假数据" √(λm)·I、目标 0：min ‖Φθ − y‖² + λm‖θ‖²。
            （λ 乘以 m，这样 λ 的含义和样本数无关：等价于最小化"平均"平方误差 + λ‖θ‖²。第 3 讲会讲 ridge。）
    【参数】Phi：(m, d)；ridge：λ ≥ 0（0 表示普通最小二乘）
    【返回】M：(d, m)
    """
    m, d = Phi.shape
    if ridge > 0:
        A = np.vstack([Phi, np.sqrt(ridge * m) * np.eye(d)])
        rhs = np.vstack([np.eye(m), np.zeros((d, m))])
    else:
        A, rhs = Phi, np.eye(m)
    return fit_least_squares(A, rhs)


def fitted_value_iteration(feat, rng, m=2000, iters=1000, ridge=0.0, checkpoints=(), starts=None):
    """
    Fitted value iteration（CS229 main notes 第 15 章 value function approximation 一节）：V(s) ≈ θᵀφ(s)。

    【思路】
        0. 均匀采样 m 个状态 s⁽¹⁾ … s⁽ᵐ⁾（固定不变）。对每个状态和每个动作，用模拟器得到下一状态 s'⁽ⁱ⁾_a。
           （讲义里每个动作采样 k 个下一状态取平均来近似期望；我们的模拟器没有噪声，k = 1 就是精确的。）
        1. 重复：
              y⁽ⁱ⁾ = max_a [ R(s⁽ⁱ⁾) + γ V(s'⁽ⁱ⁾_a) ]      ← 在样本上做一次 Bellman backup（终点的 V = 0）
              θ ← argmin Σᵢ (θᵀφ(s⁽ⁱ⁾) − y⁽ⁱ⁾)²         ← 第 1 讲的最小二乘（可选 ridge）
        2. 策略：π(s) = argmax_a [R(s) + γ V(f(s, a))]，用模拟器向前看一步。
    【参数】feat：函数 feat(Z) → 特征矩阵；m：样本状态数；iters：迭代次数；ridge：λ
        checkpoints：在这些迭代次数上记录诊断信息（用于演示 3）；starts：评估用的起点
    【返回】(theta, log)，log 是 list of dict：it、dtheta（这一轮 θ 的最大变化）、theta_max（max |θ_j|）、
        V_min、V_max（样本上的 V 的范围）、argmax_pv（V 最大的样本的 (p, v)）、y_at_argmax（那里的回归目标）、
        steps（贪心策略的平均步数）、failed（失败比例）、cond（Φ 的条件数）
    【边界】**没有收敛保证**。精确的 VI 是 ∞-范数下的收缩；最小二乘投影在 2-范数下"最优"，却可能在某些状态上
        放大误差（外推、振荡），两者复合不一定收缩（Gordon 1995；Tsitsiklis & Van Roy 1996）。演示 3 就是这个现象。
    """
    p = rng.uniform(P_MIN, P_MAX, size=m)
    v = rng.uniform(-V_MAX, V_MAX, size=m)
    Phi = feat(normalize(p, v))
    Phi_next, done_next = [], []
    for a in ACTIONS:
        p2, v2, done = mc_step(p, v, a)
        Phi_next.append(feat(normalize(p2, v2)))
        done_next.append(done)
    M = regression_operator(Phi, ridge)
    theta = np.zeros(Phi.shape[1])
    log = []
    for it in range(1, iters + 1):
        Q = np.column_stack([-1.0 + GAMMA * np.where(done_next[ai], 0.0, Phi_next[ai] @ theta)
                             for ai in range(len(ACTIONS))])
        y = Q.max(axis=1)                                     # Bellman backup 的目标值
        theta_new = M @ y                                     # 最小二乘拟合
        dtheta = float(np.max(np.abs(theta_new - theta)))
        theta = theta_new
        if it in checkpoints or it == iters:
            steps = evaluate_policy(lambda pp, vv, th=theta: greedy_action(feat, th, pp, vv), starts)
            V_s = Phi @ theta
            top = int(np.argmax(V_s))                         # V 最大的那个样本状态在哪里
            log.append({"it": it, "dtheta": dtheta, "theta_max": float(np.max(np.abs(theta))),
                        "V_min": float(V_s.min()), "V_max": float(V_s.max()), "argmax_pv": (p[top], v[top]),
                        "y_at_argmax": float(y[top]),                # 这一轮的回归目标在同一个状态上是多少
                        "steps": float(steps.mean()), "failed": float(np.mean(steps >= 1000)),
                        "cond": float(np.linalg.cond(Phi))})
    return theta, log


def greedy_action(feat, theta, p, v):
    """向前看一步的贪心策略：对每个动作模拟一步，选 R + γV(s') 最大的动作（终点的 V = 0）。"""
    q = []
    for a in ACTIONS:
        p2, v2, done = mc_step(p, v, a)
        q.append(-1.0 + GAMMA * np.where(done, 0.0, feat(normalize(p2, v2)) @ theta))
    return ACTIONS[np.argmax(np.column_stack(q), axis=1)]


FEATURE_SETS = [
    ("poly deg 2", lambda Z: poly2d_features(Z, 2)),
    ("poly deg 4", lambda Z: poly2d_features(Z, 4)),
    ("poly deg 6", lambda Z: poly2d_features(Z, 6)),
    ("RBF 5x5", lambda Z: rbf_features(Z, 5)),
    ("RBF 9x9", lambda Z: rbf_features(Z, 9)),
    ("RBF 15x15", lambda Z: rbf_features(Z, 15)),
]


def demo_fitted_vi(disc_results):
    print_header("演示 2：Fitted value iteration —— 采样状态 + Bellman backup + 最小二乘（第 1 讲）")
    starts = standard_starts(np.random.default_rng(31))       # 和演示 1 同一组起点
    print("m = 2000 个均匀采样的状态，1000 轮 FVI，没有正则化。真实的 V 一定在 [−100, −1] 之间（每步 −1，γ = 0.99）。\n")
    print(f"{'features':<12}{'#params':>8}{'cond(Phi)':>11}{'last dtheta':>13}{'V range on samples':>22}"
          f"{'mean steps':>12}{'failed':>8}")
    out = {}
    for name, feat in FEATURE_SETS:
        theta, log = fitted_value_iteration(feat, np.random.default_rng(32), iters=1000, starts=starts)
        r = log[-1]
        out[name] = r
        v_range = f"[{r['V_min']:.1f}, {r['V_max']:.1f}]"
        print(f"{name:<12}{len(theta):>8}{r['cond']:>11.1e}{r['dtheta']:>13.1e}{v_range:>22}"
              f"{r['steps']:>12.1f}{r['failed']:>8.2f}")
    d80 = disc_results[80]
    print(f"\n（对照：演示 1 的离散化 N = 80，6400 个格子 → {d80.mean():.1f} 步。）")
    n_pos = sum(r["V_max"] > 0 for r in out.values())
    print(f"""
观察 Observations：
  · 低次多项式太"僵硬"：二次多项式只有 6 个参数，表示不出 V 的形状，{out['poly deg 2']['failed']:.0%} 的车到不了山顶；
    4 次还有 {out['poly deg 4']['failed']:.0%} 失败；6 次（28 个参数）才全部到达（{out['poly deg 6']['steps']:.0f} 步）。
  · RBF 是局部的特征（每个只管一小块区域）：5x5 只有 26 个参数就到了 {out['RBF 5x5']['steps']:.0f} 步，
    9x9 到 {out['RBF 9x9']['steps']:.0f} 步，接近离散化 N = 80 的 {d80.mean():.0f} 步，而参数少得多（82 vs 6400 个格子）。
    但 15x15 反而是 {out['RBF 15x15']['steps']:.0f} 步：特征更多并没有带来更好的策略。
  · 看 last dtheta 一列：多项式和 RBF 5x5 收敛了（θ 不再变），RBF 9x9 和 15x15 在 1000 轮之后 θ 每轮还在变
    {out['RBF 9x9']['dtheta']:.2g} 和 {out['RBF 15x15']['dtheta']:.2g}。精确的 VI 一定收敛；FVI 却可能停不下来。演示 3 仔细看。
  · V range 一列：{n_pos} 组特征的 V 都有正值（多项式 6 次最高到 {out['poly deg 6']['V_max']:.0f}），而真值 ≤ −1。
    函数逼近有误差，而 greedy 策略只看"哪个动作的 V(s') 更大"，所以 V 有误差时策略仍可能不错 ——
    这也意味着 V 的数值本身不能直接当成"还要多少步"来读。""")
    return out


# =============================================================================
# 演示 3：FVI 不一定收敛；ridge 让它稳定
# =============================================================================
def demo_instability():
    print_header("演示 3：FVI 没有收敛保证 —— RBF 15x15，不加正则化 vs 加一点 ridge")
    starts = standard_starts(np.random.default_rng(31))
    feat = dict(FEATURE_SETS)["RBF 15x15"]
    cps = (10, 30, 100, 300, 1000, 2000)
    configs = [("m=2000, ridge 0", 2000, 0.0), ("m=10000, ridge 0", 10000, 0.0), ("m=2000, ridge 1e-4", 2000, 1e-4)]
    logs = {}
    for name, m, lam in configs:
        _, log = fitted_value_iteration(feat, np.random.default_rng(32), m=m, iters=2000, ridge=lam,
                                        checkpoints=cps, starts=starts)
        logs[name] = log
        print(f"\n{name}   (cond(Phi) = {log[0]['cond']:.1e})")
        print(f"{'iter':>6}{'dtheta':>11}{'max|theta|':>12}{'V min':>9}{'V max':>9}{'mean steps':>12}{'failed':>8}")
        for r in log:
            print(f"{r['it']:>6}{r['dtheta']:>11.1e}{r['theta_max']:>12.1f}{r['V_min']:>9.1f}{r['V_max']:>9.1f}"
                  f"{r['steps']:>12.1f}{r['failed']:>8.2f}")
        pv = log[-1]["argmax_pv"]
        print(f"   V 最大的样本状态：p = {pv[0]:.3f}, v = {pv[1]:+.4f}（山顶在 p = 0.5）；"
              f"拟合值 V = {log[-1]['V_max']:.2f}，回归目标 y = {log[-1]['y_at_argmax']:.2f}")
    a, b, c = (logs[n][-1] for n, _, _ in configs)
    la, lc = logs["m=2000, ridge 0"], logs["m=2000, ridge 1e-4"]
    early_a = "、".join(f"{r['dtheta']:.2g}" for r in la)
    early_c = "、".join(f"{r['dtheta']:.2g}" for r in lc)
    th_a = max(r["theta_max"] for r in la)
    th_c = max(r["theta_max"] for r in lc)
    print(f"""
观察 Observations：
  · 不加正则化（m = 2000）：各记录点上 θ 的变化量是 {early_a} —— 忽大忽小，几百轮都没有稳定的下降趋势，
    第 100、300 轮的贪心策略 {la[2]['failed']:.0%}、{la[3]['failed']:.0%} 失败；系数大到 {th_a:.0f}（相邻的 RBF 高度重叠，
    大的正负系数互相抵消，cond(Phi) ≈ {a['cond']:.0e}）。最后在 2000 轮附近慢慢停下来，策略 {a['steps']:.0f} 步。
  · 多采样（m = 10000）**没有**治好它：同样乱跳了几百轮，最后停在一个更差的地方（{b['failed']:.0%} 失败，V 最大 {b['V_max']:.0f}）。
    问题不在样本少（方差），而在算法本身：精确 VI 是 ∞-范数下的收缩，最小二乘拟合却可能在某些状态上放大误差
    （外推、互相抵消的大系数），再经过 max 和 γ 喂回下一轮。"拟合 + backup"复合起来不一定收缩，
    所以 FVI 可能振荡、发散，或者停在一个依赖于细节（样本、特征、初值）的地方。
  · 加一点 ridge（λ = 1e-4）：θ 的变化量 {early_c} —— 稳定地下降，像一个收缩映射；系数始终不超过 {th_c:.0f}；
    第 100 轮的策略已经 {lc[2]['failed']:.0%} 失败、{lc[2]['steps']:.0f} 步。Ridge 惩罚大系数，拟合不再在没有数据的方向上乱外推。
  · Ridge 修好的是"不稳定"，不是"高估"：三种设置的 V 最大值都 > 0（真值 ≤ −1），位置都在山顶旁边
    （p ≈ 0.45–0.5），而且那里的**回归目标 y 本身**也是正的（{a['y_at_argmax']:.2f}、{b['y_at_argmax']:.2f}、{c['y_at_argmax']:.2f}）。
    机制：山顶是一个"悬崖"（再走一步就到山顶的状态 V = −1，山顶本身 = 0、不在回归样本里），光滑的特征在悬崖边
    拟合不好、局部冲过头；bootstrapping 把这个高估喂进邻居的目标 y = −1 + γV(s')，max 又专挑被高估的那个动作 ——
    高估就这样一轮轮传开（maximization bias。Double DQN 就是为它设计的，见 CS234 第 4–5 讲）。
  · 实践教训：FVI（以及它的后代 DQN，见 CS234 第 5 讲的"致命三角" deadly triad）要监控 V 的范围和 θ 的变化；
    特征要"温和"（局部、有界、或者加正则化）。Gordon（1995）证明了：如果回归器是 averager
    （预测 = 训练目标的加权平均，例如 k 近邻、离散化），FVI 一定收敛 —— 演示 1 的离散化就属于这一类。""")


def main():
    disc = demo_discretization()
    demo_fitted_vi(disc)
    demo_instability()


if __name__ == "__main__":
    main()
