"""
=============================================================================
第 8 讲 · 代码 1：行为克隆与复合误差（分布偏移）
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
行为克隆（Behavior Cloning, BC）是最简单的模仿学习：
    把专家示范中的 (状态, 动作) 当作监督学习的 (输入, 标签)，学一个分类器。

它看起来无懈可击——在专家示范的数据上几乎 100% 正确。但一上路就出问题：
    1. 学习者偶尔犯一个小错（或者被风吹偏），来到一个专家很少去的状态；
    2. 在这个状态上，学习者没有数据，更容易犯错；
    3. 犯错后离专家的轨迹更远，更加没有数据……
    错误**像滚雪球一样累积**。这叫**复合误差（compounding error）**，
    根源是**分布偏移（distribution shift）**：训练时的状态分布（专家的）≠ 测试时的状态分布（学习者自己的）。

本文件在车道保持环境（lane_env.py）上把这件事量化出来。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture08
    python 01_behavior_cloning.py      （约 15 秒）
"""

import numpy as np

from lane_env import (X_MAX, LANE, N_STATES, idx, expert_action, rollout,
                      TabularImitator, evaluate)


def collect_expert_demos(n_demos, T, rng):
    """
    让专家开 n_demos 个长度为 T 的回合，返回 (状态列表, 专家动作列表)。
    这就是行为克隆的全部训练数据。
    """
    states, actions = [], []
    for _ in range(n_demos):
        _, visited, _ = rollout(expert_action, T, rng)
        states.extend(visited)
        actions.extend(expert_action(x) for x in visited)
    return states, actions


def error_rate_on(policy, dist):
    """
    在给定的状态分布 dist 下，策略与专家动作不一致的概率：Σ_x dist(x) · 1[π(x) ≠ π*(x)]。
    这就是讲义中的 ε——分类器的"错误率"，关键在于**在哪个分布下衡量**。
    """
    xs = range(-X_MAX, X_MAX + 1)
    return float(sum(dist[idx(x)] * (policy(x) != expert_action(x)) for x in xs))


def bar(p, width=40):
    """把一个概率画成字符条。"""
    return "█" * int(round(p * width))


def main():
    rng = np.random.default_rng(0)

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：专家开车时，车都在哪里？（2000 个长度 100 的回合）")
    print("=" * 78)
    cost_e, crash_e, dist_expert = evaluate(expert_action, 100, 2000, seed=0)
    for x in range(-X_MAX, X_MAX + 1):
        tag = "车道" if abs(x) <= LANE else "    "
        print(f"  x = {x:+d} {tag} {dist_expert[idx(x)]:6.3f} {bar(dist_expert[idx(x)])}")
    print(f"  专家：平均代价 {cost_e:.2f}（100 步中不在车道内的步数），撞车率 {crash_e * 100:.2f}%")
    print("  → 专家几乎只出现在 |x| ≤ 3 的地方。它的示范里，\"车被吹到 x = ±5 该怎么办\"的数据几乎为零。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：行为克隆（5 个专家回合，每个 50 步）")
    print("=" * 78)
    states, actions = collect_expert_demos(5, 50, rng)
    bc = TabularImitator(default_action=0)
    bc.add(states, actions)
    print(f"  训练数据：{len(states)} 个 (状态, 动作) 对，覆盖的状态：{bc.seen_states()}")
    print("  学到的策略（? 表示没见过，默认不打方向）：")
    row = ""
    for x in range(-X_MAX, X_MAX + 1):
        seen = bc.counts[idx(x)].sum() > 0
        sym = {-1: "←", 0: "·", 1: "→"}[bc(x)] if seen else "?"
        row += f" {x:+d}:{sym}"
    print("   " + row)
    print(f"  在专家的状态分布上的错误率 ε = {error_rate_on(bc, dist_expert) * 100:.2f}%   ← 看起来几乎完美！")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：复合误差——回合越长，行为克隆比专家差得越多（每个 T 跑 3000 个回合）")
    print("=" * 78)
    print("     T     专家代价   BC 代价   BC/专家   专家撞车率   BC 撞车率")
    for T in [25, 50, 100, 200, 400]:
        ce, cre, _ = evaluate(expert_action, T, 3000, seed=1)
        cb, crb, _ = evaluate(bc, T, 3000, seed=1)
        print(f"  {T:4d}    {ce:7.2f}   {cb:7.2f}    {cb / ce:5.2f}      {cre * 100:5.2f}%     {crb * 100:6.2f}%")
    print("  观察：")
    print("  - 专家的代价与 T 成正比（每步约 0.03，偶尔被风吹出车道再回来）。")
    print("  - BC 的代价增长得**比 T 更快**：BC/专家的比值随 T 增大而增大。")
    print("    原因：每一步都有一点点概率被风吹到 |x| ≥ 4（没见过的状态），此后 BC 不再修正方向，")
    print("    车就随风漂移，接下来的每一步都在付出代价，最后常常冲出路面。")
    print("    \"犯错的概率\"随 T 线性增长，\"犯错之后付出的代价\"也随 T 线性增长——两者相乘，")
    print("    就是讲义中的 O(εT²) 复合误差界。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：分布偏移——BC 自己开车时到了哪里？（T = 400）")
    print("=" * 78)
    _, _, dist_bc = evaluate(bc, 400, 2000, seed=2)
    _, _, dist_e400 = evaluate(expert_action, 400, 2000, seed=2)
    print("    x     专家的分布   BC 的分布")
    for x in range(-X_MAX, X_MAX + 1):
        seen = "" if bc.counts[idx(x)].sum() > 0 else "（没见过）"
        print(f"  {x:+3d}     {dist_e400[idx(x)]:7.4f}    {dist_bc[idx(x)]:7.4f}  {bar(dist_bc[idx(x)])} {seen}")
    unseen = [x for x in range(-X_MAX, X_MAX + 1) if bc.counts[idx(x)].sum() == 0]
    unseen_mass = sum(dist_bc[idx(x)] for x in unseen)
    print(f"\n  BC 在\"没见过的状态\"上度过了 {unseen_mass * 100:.1f}% 的时间"
          f"（专家只有 {sum(dist_e400[idx(x)] for x in unseen) * 100:.1f}%）；")
    print(f"  在 BC 自己的状态分布上，它的错误率 ε = {error_rate_on(bc, dist_bc) * 100:.2f}%"
          f"（在专家分布上是 {error_rate_on(bc, dist_expert) * 100:.2f}%），高了约 3 倍。")
    print("  （这些数字还被低估了：一旦冲出路面，回合就结束了，之后的时间没有被计入分布。）")

    # 同一个"第一次失误"之后，专家和 BC 的命运
    print("\n  更直接的对比：只看那些\"曾经被吹到没见过的状态（|x| ≥ 4）\"的回合（T = 400）：")
    for name, policy in [("专家", expert_action), ("BC", bc)]:
        rng_c = np.random.default_rng(5)
        reached, crashed_after = 0, 0
        for _ in range(3000):
            _, visited, crashed = rollout(policy, 400, rng_c)
            if any(abs(x) >= 4 for x in visited):
                reached += 1
                crashed_after += crashed
        print(f"    {name}：{reached} 个回合到过 |x| ≥ 4，其中 {crashed_after / max(reached, 1) * 100:5.1f}% 最终冲出路面")
    print("  → 被吹偏的机会两者差不多（在 |x| ≤ 3 时两者的行为完全相同），")
    print("    区别在于被吹偏之后：专家会把车拉回来，BC 不知道该怎么办。这就是分布偏移的代价。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 5：多收集一些专家数据有用吗？（T = 400 时的代价；每种设置 5 次独立收集）")
    print("=" * 78)
    ce400, _, _ = evaluate(expert_action, 400, 3000, seed=3)
    print(f"  专家的代价：{ce400:.2f}")
    print("  专家回合数   数据量    覆盖的状态范围       BC 代价（5 次的平均）")
    for n_demos in [1, 5, 20, 100, 500]:
        costs, ranges = [], []
        for rep in range(5):
            s, a = collect_expert_demos(n_demos, 50, np.random.default_rng(100 + rep))
            learner = TabularImitator()
            learner.add(s, a)
            seen = learner.seen_states()
            ranges.append((min(seen), max(seen)))
            costs.append(evaluate(learner, 400, 1000, seed=4)[0])
        lo = min(r[0] for r in ranges)
        hi = max(r[1] for r in ranges)
        print(f"  {n_demos:6d}      {n_demos * 50:6d}       [{lo:+d}, {hi:+d}] 之间          {np.mean(costs):7.2f}")
    print("  → 数据越多越好，但代价很大：要接近专家的水平，需要约 500 个回合、25000 个标注。")
    print("    原因是专家本来就很少去 |x| ≥ 5 的地方，绝大部分数据都是在车道中心附近的重复示范，")
    print("    真正有用的\"恢复\"数据只占极小一部分。")
    print("    问题不在于数据**量**，而在于数据的**分布**。下一个文件的 DAgger 用少得多的标注解决这个问题。")


if __name__ == "__main__":
    main()
