"""
=============================================================================
第 8 讲 · 代码 2：DAgger——让专家标注学习者自己会遇到的状态
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
01 文件看到：行为克隆的问题不在于数据量，而在于**数据分布**——
训练数据来自专家的状态分布，测试时却在学习者自己的状态分布上运行。

DAgger（Dataset Aggregation，Ross, Gordon & Bagnell, 2011）的想法非常直接：
    **让学习者自己去开车，在它实际到达的状态上，请专家告诉它"这里应该怎么做"。**

    D ← 专家示范
    π_1 ← 在 D 上训练
    for i = 1, 2, ..., N:
        用 π_i 开车，记录它访问的状态
        请专家给这些状态打上标签（专家"会怎么做"，但**不需要**专家真的去开）
        D ← D ∪ {新的 (状态, 专家动作)}
        π_{i+1} ← 在 D 上重新训练

-----------------------------------------------------------------------------
【一个重要的建模细节：学习者的"失误率" ε】
-----------------------------------------------------------------------------
    01 文件中的表格型学习者在见过的状态上**从不犯错**——它完美地记住了专家的动作。
    真实的学习者（比如神经网络）做不到这一点：即使在见过的状态上，也会以一定概率做错。
    这里用"执行时以概率 ε 随机换成另一个动作"来模拟这种分类误差。

    这个 ε 正是讲义 2.3 节误差界中的 ε：
        行为克隆：  代价 ≤ 专家代价 + O(ε T²)
        DAgger：    代价 ≤ 专家代价 + O(ε T)（再加上一些与数据量有关的项）
    ε 越大，两者的差距越明显。

另外演示一个不需要交互式专家的替代方法：**DART / 噪声注入**。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture08
    python 02_dagger.py      （约 30 秒）
"""

import numpy as np

from lane_env import X_MAX, WIND, WIND_PROBS, expert_action, rollout, TabularImitator, evaluate

EVAL_T = 400          # 评估用的回合长度
COLLECT_T = 50        # 收集数据时每个回合的长度


class ImperfectPolicy:
    """
    带失误的策略：先按 base_policy 选动作，然后以概率 eps 随机换成另外两个动作之一。
    用来模拟"学习者在见过的状态上也会犯错"（分类误差 ε）。
    """

    def __init__(self, base_policy, eps, seed):
        self.base = base_policy
        self.eps = eps
        self.rng = np.random.default_rng(seed)

    def __call__(self, x):
        a = self.base(x)
        if self.eps > 0 and self.rng.random() < self.eps:
            return int(self.rng.choice([b for b in (-1, 0, 1) if b != a]))
        return a


def label_states(learner, states):
    """请专家给一批状态打标签，并加入学习者的数据集。返回加入的标签数。"""
    states = [x for x in states if abs(x) <= X_MAX]
    learner.add(states, [expert_action(x) for x in states])
    return len(states)


def behavior_cloning(budget, rng):
    """行为克隆：专家开车收集 budget 个 (状态, 动作) 标签。"""
    learner, n = TabularImitator(), 0
    while n < budget:
        _, visited, _ = rollout(expert_action, COLLECT_T, rng)
        n += label_states(learner, visited[:budget - n])
    return learner


def dagger(budget, n_iters, eps, rng, verbose=False):
    """
    DAgger：把 budget 个标签平均分给 n_iters 次迭代。
        第 1 次迭代：专家开车（β = 1），等同于行为克隆；
        之后每次：**带失误的学习者**开车（β = 0），专家给它访问到的状态打标签。

    【为什么收集数据时要用带失误的学习者？】
        因为测试时学习者就是带着失误在开车，DAgger 的核心就是在"测试时会遇到的分布"上收集数据。
    """
    learner = TabularImitator()
    per_iter = budget // n_iters
    for i in range(1, n_iters + 1):
        driver = expert_action if i == 1 else ImperfectPolicy(learner, eps, seed=int(rng.integers(1 << 30)))
        got = 0
        while got < per_iter:
            _, visited, _ = rollout(driver, COLLECT_T, rng)
            got += label_states(learner, visited[:per_iter - got])
        if verbose:
            seen = learner.seen_states()
            cost = evaluate(ImperfectPolicy(learner, eps, seed=1), EVAL_T, 1000, seed=7)[0]
            print(f"  第 {i} 次迭代：累计标注 {i * per_iter:5d}，覆盖 [{min(seen):+d}, {max(seen):+d}]，"
                  f"T={EVAL_T} 的代价 {cost:6.2f}")
    return learner


def dart(budget, noise, rng):
    """
    噪声注入（DART 的简化版，Laskey et al., 2017）：
        专家开车时，以概率 noise 执行一个**随机**动作（故意失误），
        但记录的标签永远是专家**本来想做**的动作。
    专家会被自己的"失误"带到偏离中心更远的地方，并示范如何恢复——不需要学习者参与。
    """
    learner, n = TabularImitator(), 0
    while n < budget:
        x, winds, states = 0, rng.choice(WIND, size=COLLECT_T, p=WIND_PROBS), []
        for t in range(COLLECT_T):
            states.append(x)
            a = int(rng.integers(-1, 2)) if rng.random() < noise else expert_action(x)
            x = int(x + a + winds[t])
            if abs(x) > X_MAX:
                break
        n += label_states(learner, states[:budget - n])
    return learner


def main():
    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：DAgger 的迭代过程（学习者失误率 ε = 0.05，共 2500 个标注，分 5 次迭代）")
    print("=" * 78)
    floor = evaluate(ImperfectPolicy(expert_action, 0.05, seed=1), EVAL_T, 3000, seed=7)[0]
    print(f"  参照：同样有 5% 失误率的\"专家\"的代价 = {floor:.2f}（这是任何学习者能达到的下限）")
    dagger(2500, 5, 0.05, np.random.default_rng(0), verbose=True)
    print("  → 第 1 次迭代就是行为克隆；之后由学习者开车，它（因失误和风）到达的偏远状态")
    print("    都得到了专家的标签，覆盖范围扩大，代价快速下降。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：同样的标注预算，行为克隆 vs DAgger（8 次独立实验的平均）")
    print("=" * 78)
    print("  学习者失误率 ε   标注预算    行为克隆    DAgger    （下限：带同样失误率的专家）")
    for eps in [0.0, 0.05, 0.1]:
        floor = evaluate(ImperfectPolicy(expert_action, eps, seed=1), EVAL_T, 2000, seed=8)[0]
        for budget in [500, 1000, 2500]:
            bc_costs, dg_costs = [], []
            for rep in range(8):
                bc = behavior_cloning(budget, np.random.default_rng(rep))
                dg = dagger(budget, 5, eps, np.random.default_rng(100 + rep))
                bc_costs.append(evaluate(ImperfectPolicy(bc, eps, seed=2), EVAL_T, 300, seed=8)[0])
                dg_costs.append(evaluate(ImperfectPolicy(dg, eps, seed=2), EVAL_T, 300, seed=8)[0])
            print(f"       {eps:4.2f}          {budget:5d}     {np.mean(bc_costs):7.1f}    {np.mean(dg_costs):7.1f}"
                  f"          {floor:6.1f}")
    print("  观察：")
    print("  - ε = 0（学习者完美记忆）：DAgger 比行为克隆好一些，但差距不大——")
    print("    学习者只在罕见的\"被风吹远\"时才会偏离专家的分布。")
    print("  - ε > 0（学习者会犯错，这才是真实情况）：行为克隆急剧变差，")
    print("    因为学习者自己的失误不断把车带到专家没示范过的地方；")
    print("    DAgger 则在这些地方收集了标签，代价大幅降低（常常只有行为克隆的一半），")
    print("    并随预算增加逐渐接近\"带同样失误率的专家\"这个下限。")
    print("  - 这正是理论的预言：行为克隆 O(εT²) vs DAgger O(εT)。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：不需要交互式专家的替代——DART 噪声注入（ε = 0.05，1000 个标注，8 次实验）")
    print("=" * 78)
    eps = 0.05
    for noise in [0.0, 0.1, 0.3, 0.5]:
        costs = []
        for rep in range(8):
            learner = dart(1000, noise, np.random.default_rng(200 + rep))
            costs.append(evaluate(ImperfectPolicy(learner, eps, seed=2), EVAL_T, 300, seed=8)[0])
        label = "（= 行为克隆）" if noise == 0 else ""
        print(f"  示范时的噪声 {noise:.1f}：学习者代价 {np.mean(costs):6.1f} {label}")
    print("  → 适量的噪声让专家的示范覆盖了\"偏离后如何恢复\"。在这个例子里它甚至比 DAgger 更好")
    print("    （1000 个标注时 DAgger 约为 52）：噪声正好模拟了学习者会犯的那种错误，")
    print("    专家直接示范了\"犯错之后怎么恢复\"。它只需要离线收集专家示范——实践中常常更容易实现")
    print("    （让人类专家实时给机器人访问的每个状态打标签，既昂贵又不自然）。")
    print("    代价：噪声太大时示范者自己也可能陷入危险；在这个例子里标签总是正确的，所以没有体现出来。")


if __name__ == "__main__":
    main()
