"""
=============================================================================
第 18 讲 · 代码 2：二维混合高斯上的 GAN —— 博弈、饱和、振荡与评估
=============================================================================

对应讲义：lectures/lecture18_adversarial_gan_diffusion.md §5（GAN 的博弈）、§6（训练技巧、mode collapse、评估）

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
GAN（Goodfellow et al. 2014）让两个网络互相较劲：生成器 G 把随机噪声 z 变成"假样本" G(z)，
判别器 D 判断一个样本是真是假。数据是 ring_world.py 的"圆环上 8 个高斯"，G、D 都是小 MLP。

    演示 1：生成器损失为什么要"换一个写法"？原论文的 minimax 写法让 G 最小化 log(1 − D(G(z)))；
            当 D 很自信（D(G(z)) ≈ 0）时，这个损失**饱和**，梯度几乎为 0。换成 −log D(G(z))
            （non-saturating）就不会。先看公式，再在一个"先把 D 训练得很自信"的局面里实测两种损失的梯度，
            最后从这个局面出发，用 SGD 和 Adam 各训练一次，看谁能爬出来。
    演示 2：一次标准的 GAN 训练（non-saturating 损失、Adam）：每 250 步记录 D / G 的损失、D 的输出、
            覆盖了几个模式、高质量样本比例。看三件事：损失值不会"一路下降"；样本质量会振荡；
            有的模式会暂时被丢掉再找回来。字符画显示样本怎样从一团噪声变成 8 个点。
    演示 3：怎样评估一个 GAN？(a) 一个只比较均值和协方差的指标（FID 的公式用在二维坐标上）会被
            "形状完全不对"的分布骗过；(b) 同一个配置换 3 个学习率 × 2 个种子，GAN 的结果有多稳定
            （代码 03 对扩散模型做同样的网格，讲义 §8.4 把两者放在一起比较）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_gan_2d.py          # 约 45 秒（单线程 CPU）
    python3 02_gan_2d.py --quick  # 冒烟测试，约 3 秒（步数很少，数字不能用）

依赖：numpy、PyTorch；matplotlib 可选（装了会保存 figures/lecture18_gan_training.png）。
依赖本目录的 ring_world.py（数据、指标、字符画），以及第 1 讲 console.py、第 2 讲 text_plot.py（经由 ring_world 导入）、
第 9 讲 torch_utils.py 的 setup_torch。

【可复现】torch.set_num_threads(1)；每次训练用自己的 np.random.default_rng（真实数据）和 torch.Generator（噪声 z），
          建网络前 torch.manual_seed(种子)。同一台机器上重复运行，输出逐字相同。
"""

import copy
import os
import sys

import numpy as np
import torch
from torch import nn
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "lecture09"))

from ring_world import (ascii_density, figure_path, format_report, frechet_2d, mode_report, pad,  # noqa: E402
                        print_header, sample_ring, save_scatter_grid, side_by_side, CENTERS, SIGMA)
from torch_utils import setup_torch  # noqa: E402  （第 9 讲：固定线程数）

QUICK = "--quick" in sys.argv
Z_DIM = 2                       # 噪声 z 的维数（二维玩具里常用 2；真实图像的 GAN 常用 100–512）
BATCH = 256                     # 每一步用多少个真样本、多少个假样本
STEPS = 500 if QUICK else 5000  # 一次完整训练的步数（每一步 = D 更新一次 + G 更新一次）
N_EVAL = 2000                   # 评估时生成多少个样本


# =============================================================================
# 0. 两个网络
# =============================================================================
def make_generator(hidden=128):
    """
    生成器 G：z（2 维标准正态噪声）→ 一个二维点。三个隐藏层，ReLU。

    【思路】G 本身只是一个普通的 MLP —— 它"会生成"，是因为输入是随机的：每抽一个新的 z，就得到一个新的点。
            整个分布 p_G 就是"标准正态分布被 G 这个函数推过去之后的样子"。
    【参数】hidden：每个隐藏层的单元数
    【返回】nn.Sequential，输入 (n, 2)，输出 (n, 2)
    【DL 联系】图像 GAN 的生成器（DCGAN，Radford et al. 2016）把 z 先变成一个很小的特征图（如 4×4×1024），
            再用**转置卷积**（第 14 讲 §9.2）一级级放大到 64×64×3。二维玩具不需要卷积，MLP 就够了。
    """
    return nn.Sequential(nn.Linear(Z_DIM, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(),
                         nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 2))


def make_discriminator(hidden=128):
    """
    判别器 D：一个二维点 → 一个 logit a（D(x) = sigmoid(a) 是"它是真样本"的概率）。三个隐藏层，LeakyReLU(0.2)。

    【思路】D 就是一个二分类器（第 2 讲的逻辑回归 + 隐藏层）。输出 logit 而不是概率，是为了数值稳定：
            log D = log σ(a) = −softplus(−a)，log(1 − D) = −softplus(a)，都可以直接从 a 算，不会出现 log(0)。
    【参数】hidden：每个隐藏层的单元数
    【返回】nn.Sequential，输入 (n, 2)，输出 (n, 1) 的 logit
    【DL 联系】DCGAN 的建议之一：判别器里用 LeakyReLU（负半轴有一点斜率），让梯度在 D 的"负区"也能传回给 G。
    """
    return nn.Sequential(nn.Linear(2, hidden), nn.LeakyReLU(0.2), nn.Linear(hidden, hidden), nn.LeakyReLU(0.2),
                         nn.Linear(hidden, hidden), nn.LeakyReLU(0.2), nn.Linear(hidden, 1))


# =============================================================================
# 1. 损失函数（全部用 logit a 写）
# =============================================================================
def d_loss_fn(a_real, a_fake):
    """
    判别器的损失：真样本要判成 1、假样本要判成 0 的二分类交叉熵。

    【公式】L_D = −E[log D(x)] − E[log(1 − D(G(z)))] = E[softplus(−a_real)] + E[softplus(a_fake)]
            最小化 L_D 就是最大化原论文的 V(D, G) = E[log D(x)] + E[log(1 − D(G(z)))]。
    【参数】a_real：(n, 1) 真样本的 logit；a_fake：(n, 1) 假样本的 logit
    【返回】标量张量
    【PyTorch 小知识】F.softplus(a) = log(1 + e^a)，内部做了数值稳定处理，a 很大或很小都不会溢出。
    """
    return F.softplus(-a_real).mean() + F.softplus(a_fake).mean()


def g_loss_fn(a_fake, kind):
    """
    生成器的损失，两种写法。

    【公式】
        kind = "sat"（saturating，原论文的 minimax 写法）：L_G = E[log(1 − D(G(z)))] = −E[softplus(a)]
                对 logit 求导：∂L_G/∂a = −σ(a) = −D(G(z))。D 很自信时 D(G(z)) ≈ 0，梯度 ≈ 0 —— 饱和。
        kind = "ns"（non-saturating，原论文建议的实际做法）：L_G = −E[log D(G(z))] = E[softplus(−a)]
                ∂L_G/∂a = −(1 − σ(a)) = −(1 − D(G(z)))。D 越自信，梯度越接近 −1，不会消失。
        两者的"方向"相同（都想让 a 变大、让 D 把假样本当真），区别只在梯度的**大小**怎样随 D(G(z)) 变化。
    【参数】a_fake：(n, 1) 假样本的 logit（要带着梯度，能一路传回 G）；kind："sat" 或 "ns"
    【返回】标量张量
    """
    if kind == "sat":
        return -F.softplus(a_fake).mean()
    if kind == "ns":
        return F.softplus(-a_fake).mean()
    raise ValueError(f"unknown generator loss: {kind}")


# =============================================================================
# 2. 训练
# =============================================================================
def train_gan(G, D, steps, rng, gen, lr_g=1e-3, lr_d=1e-3, g_kind="ns", g_opt="adam", sgd_lr=0.05,
              record_every=0, z_eval=None):
    """
    交替训练 D 和 G：每一步先更新 D 一次，再更新 G 一次（原论文算法 1，k = 1）。

    【思路】一步之内：
            1. 抽 BATCH 个真样本 x 和 BATCH 个噪声 z，算假样本 G(z)。
            2. 更新 D：最小化 d_loss。注意假样本用 G(z).detach() —— 这一步只改 D，不让梯度流进 G。
            3. 更新 G：用**同一批** z 的 G(z)（这次不 detach），过一遍**刚更新过的** D，最小化 g_loss。
               梯度从 D 的输出一路穿过 D（D 的参数不更新，只是"借道"）传回 G 的参数。
            这就是 minimax 博弈的"交替梯度"近似：理论分析（§5.3）假设每一步 D 都被训练到最优，
            实践中每方只走一小步。
    【参数】
        G, D          : 两个网络（原地训练）
        steps         : 训练步数
        rng           : np.random.Generator，抽真样本
        gen           : torch.Generator，抽噪声 z
        lr_g, lr_d    : Adam 的学习率（β1 = 0.5，DCGAN 的建议）
        g_kind        : 生成器损失 "ns" 或 "sat"
        g_opt         : 生成器的优化器 "adam" 或 "sgd"（演示 1 用；D 一律用 Adam）
        sgd_lr        : g_opt = "sgd" 时的学习率（动量 0.9）
        record_every  : > 0 时每隔这么多步记录一次（用 z_eval 生成样本、打分）
        z_eval        : (N_EVAL, 2) 固定的评估噪声。每次记录都用同一组 z，这样不同时刻的样本可以直接比较
    【返回】list of dict：每个记录点的 step、这段时间里 D / G 损失的平均、D(真) / D(假) 的平均、样本和 mode_report
    【边界】损失变成 inf / nan（发散）时立刻停止，记录里标出 "diverged"。
    【PyTorch 小知识】两个优化器各管各的参数：opt_d 只登记了 D.parameters()，所以 G 的损失虽然也会给 D 的参数
            算出 .grad，opt_g.step() 并不会改 D。但这些 .grad 会留在 D 的参数上 —— 所以每次更新 D 之前
            必须 opt_d.zero_grad()，否则会把 G 那一步留下的梯度也加进来。
    """
    opt_d = torch.optim.Adam(D.parameters(), lr=lr_d, betas=(0.5, 0.999))
    if g_opt == "adam":
        opt_g = torch.optim.Adam(G.parameters(), lr=lr_g, betas=(0.5, 0.999))
    else:
        opt_g = torch.optim.SGD(G.parameters(), lr=sgd_lr, momentum=0.9)
    records = []
    sums = np.zeros(4)                         # 累计：D 损失、G 损失、D(真)、D(假)
    n_since = 0
    for step in range(1, steps + 1):
        x_real = torch.from_numpy(sample_ring(BATCH, rng))
        z = torch.randn(BATCH, Z_DIM, generator=gen)
        x_fake = G(z)

        # ---- 更新 D：真 → 1，假 → 0 ----
        a_real, a_fake_d = D(x_real), D(x_fake.detach())
        loss_d = d_loss_fn(a_real, a_fake_d)
        opt_d.zero_grad()
        loss_d.backward()
        opt_d.step()

        # ---- 更新 G：让（刚更新过的）D 把假样本当真 ----
        a_fake = D(x_fake)
        loss_g = g_loss_fn(a_fake, g_kind)
        opt_g.zero_grad()
        loss_g.backward()
        opt_g.step()

        ld, lg = loss_d.item(), loss_g.item()
        if not (np.isfinite(ld) and np.isfinite(lg)):
            records.append({"step": step, "diverged": True})
            break
        sums += [ld, lg, torch.sigmoid(a_real).mean().item(), torch.sigmoid(a_fake).mean().item()]
        n_since += 1
        if record_every and step % record_every == 0:
            with torch.no_grad():
                xs = G(z_eval).numpy()
            avg = sums / n_since
            records.append({"step": step, "d_loss": avg[0], "g_loss": avg[1], "d_real": avg[2], "d_fake": avg[3],
                            "samples": xs, "report": mode_report(xs), "diverged": False})
            sums[:] = 0.0
            n_since = 0
    return records


def eval_noise(seed=12345):
    """
    固定的评估噪声 z。

    【思路】所有演示、所有记录点都用同一组 z：样本的变化只来自 G 的变化，而不是"这次抽到的 z 不一样"。
    【参数】seed：种子
    【返回】(N_EVAL, 2) 的标准正态张量
    """
    return torch.randn(N_EVAL, Z_DIM, generator=torch.Generator().manual_seed(seed))


def grad_norm(module):
    """
    一个网络所有参数梯度的总 L2 范数 √(Σ ‖∂L/∂θ‖²)。用来比较两种损失给 G 的"推力"有多大。

    【参数】module：已经调用过 loss.backward() 的网络
    【返回】Python float
    【边界】还没有梯度的参数（p.grad is None）跳过。
    """
    return float(np.sqrt(sum(float((p.grad ** 2).sum().item()) for p in module.parameters() if p.grad is not None)))


# =============================================================================
# 演示 1：饱和
# =============================================================================
def demo_saturation():
    print_header("演示 1：log(1 − D(G(z))) 为什么会饱和？换成 −log D(G(z)) 之后呢？")
    print("(a) 公式：对 D 的 logit a 求导（D = σ(a)）。两种损失想把 a 往同一个方向推，推力大小不同：")
    print(pad("D(G(z))", 12) + pad("饱和写法 |∂/∂a| = D", 24, ">") + pad("non-saturating |∂/∂a| = 1 − D", 34, ">")
          + pad("两者之比", 12, ">"))
    for dval in (0.5, 0.1, 0.01, 1e-3, 1e-4):
        print(pad(f"{dval:g}", 12) + f"{dval:>24.4g}{1 - dval:>34.4f}{(1 - dval) / dval:>12.0f}")

    # (b) 制造一个"D 很自信"的局面：G 刚初始化（样本是原点附近的一团），先单独训练 D 300 步
    torch.manual_seed(0)
    G0, D0 = make_generator(), make_discriminator()
    rng, gen = np.random.default_rng(101), torch.Generator().manual_seed(101)
    opt_d = torch.optim.Adam(D0.parameters(), lr=1e-3, betas=(0.5, 0.999))
    for _ in range(300):
        x_real = torch.from_numpy(sample_ring(BATCH, rng))
        with torch.no_grad():
            x_fake = G0(torch.randn(BATCH, Z_DIM, generator=gen))
        loss_d = d_loss_fn(D0(x_real), D0(x_fake))
        opt_d.zero_grad()
        loss_d.backward()
        opt_d.step()
    z = eval_noise()
    with torch.no_grad():
        d_fake = torch.sigmoid(D0(G0(z))).squeeze(1).numpy()
    print(f"\n(b) 生成器刚初始化（{format_report(mode_report(G0(z).detach().numpy()))}），先只训练 D 300 步。"
          f"\n    现在 D 对 {N_EVAL} 个假样本的输出 D(G(z))：平均 {d_fake.mean():.2e}，最大 {d_fake.max():.2e} —— D 非常自信。")
    norms = {}
    for kind in ("sat", "ns"):
        G = copy.deepcopy(G0)
        loss = g_loss_fn(D0(G(z)), kind)
        G.zero_grad()
        loss.backward()
        norms[kind] = grad_norm(G)
    print(f"    G 的参数梯度的总范数：饱和写法 {norms['sat']:.3e}，non-saturating {norms['ns']:.3e}，"
          f"相差 {norms['ns'] / norms['sat']:.0f} 倍")

    # (c) 从这个局面出发继续训练（D 也继续训练），比较 2 种损失 × 3 种优化器设置
    start_dist = mode_report(G0(z).detach().numpy())["mean_dist"]
    print(f"\n(c) 从这个局面出发，D 和 G 继续交替训练（D 一律 Adam lr 1e-3；G 用 SGD（动量 0.9）或 Adam）。"
          f"\n    每格：这 100 步里 D(G(z)) 的平均 / 样本到最近模式中心的平均距离（起点 {start_dist:.2f}）")
    checkpoints = (100, 300, 1000, 3000) if not QUICK else (100, 300)
    print(pad("G 的优化器 + 损失", 30) + "".join(pad(f"第 {s} 步", 15, ">") for s in checkpoints) + "   最后一步的样本")
    settings = [("sgd", 0.01, "SGD lr 0.01"), ("sgd", 0.05, "SGD lr 0.05"), ("adam", 1e-3, "Adam lr 1e-3")]
    finals = {}
    for g_opt, lr, label in settings:
        for kind in ("sat", "ns"):
            G, D = copy.deepcopy(G0), copy.deepcopy(D0)
            recs = train_gan(G, D, max(checkpoints), np.random.default_rng(102), torch.Generator().manual_seed(102),
                             lr_g=lr, g_kind=kind, g_opt=g_opt, sgd_lr=lr, record_every=100, z_eval=z)
            by_step = {r["step"]: r for r in recs if not r["diverged"]}
            cells = []
            for s in checkpoints:
                if s in by_step:
                    cells.append(pad(f"{by_step[s]['d_fake']:.3f} / {by_step[s]['report']['mean_dist']:.2f}", 15, ">"))
                else:
                    cells.append(pad("—", 15, ">"))
            name = label + (" + 饱和写法" if kind == "sat" else " + non-saturating")
            if recs[-1]["diverged"]:
                tail = f"第 {recs[-1]['step']} 步发散（损失变成 inf / nan）"
                finals[(label, kind)] = None
            else:
                tail = format_report(recs[-1]["report"])
                finals[(label, kind)] = recs[-1]
            print(pad(name, 30) + "".join(cells) + "   " + tail)
    def last_txt(label, kind):
        r = finals[(label, kind)]
        return "发散" if r is None else f"D(G(z)) {r['d_fake']:.3f}，平均距离 {r['report']['mean_dist']:.2f}，{format_report(r['report'])}"
    print(f"""
观察 Observations：
  · (a) 两种写法的梯度方向一样，大小却相反地依赖 D(G(z))：D(G(z)) = 1e-4 时，饱和写法的推力只有 non-saturating 的
    1/{(1 - 1e-4) / 1e-4:.0f}。训练初期 G 很差、D 很容易分辨真假，恰恰是 G 最需要推力的时候 —— 饱和写法这时最没劲。
  · (b) 实测：D(G(z)) 平均 {d_fake.mean():.1e}，G 的梯度范数相差 {norms['ns'] / norms['sat']:.0f} 倍，和 (a) 的 1/D(G(z)) 同一个量级。
  · (c) SGD + 饱和写法：两个学习率下 G 都一动不动（{last_txt('SGD lr 0.01', 'sat')}）。
    梯度只有 1e-4 量级，SGD 的步长 = 学习率 × 梯度，几乎为 0。这正是原论文建议换掉这个损失的原因。
  · SGD + non-saturating：G 动起来了，但 SGD 训练 GAN 本身很不顺 —— lr 0.01 时第 100 步样本一度被甩到很远的地方，
    3000 步后 {last_txt('SGD lr 0.01', 'ns')}；lr 0.05 时梯度大、又有动量，直接发散。
  · Adam：两种写法都爬出来了。Adam 按每个参数梯度的大小做归一化（第 8 讲 §6），梯度小一万倍对它影响不大 ——
    和第 8 讲 §9.4 "Adam 部分掩盖了梯度消失"是同一个现象。这一次运行里饱和写法最后反而更好
    （饱和：{format_report(finals[('Adam lr 1e-3', 'sat')]['report'])}；non-saturating：{format_report(finals[('Adam lr 1e-3', 'ns')]['report'])}），
    单次运行的这种差别多半是 GAN 训练本身的振荡（演示 2），不说明饱和写法更好。
  · 所以 non-saturating 损失是"便宜的保险"：它不依赖优化器替你补救梯度的大小。""")


# =============================================================================
# 演示 2：一次标准的训练
# =============================================================================
def run_gan(lr, seed, record_every=250):
    """
    用默认配置（non-saturating、Adam、β1 = 0.5）从头训练一个 GAN，返回记录。

    【参数】lr：G 和 D 共用的学习率；seed：网络初始化和数据 / 噪声的种子；record_every：记录间隔
    【返回】train_gan 的记录列表
    """
    torch.manual_seed(seed)
    G, D = make_generator(), make_discriminator()
    return train_gan(G, D, STEPS, np.random.default_rng(1000 + seed), torch.Generator().manual_seed(1000 + seed),
                     lr_g=lr, lr_d=lr, g_kind="ns", record_every=record_every, z_eval=eval_noise())


def demo_training(recs):
    print_header("演示 2：一次标准的 GAN 训练（non-saturating 损失，Adam lr 1e-3，β1 = 0.5，种子 0）")
    print(f"每 250 步记录一次：D、G 损失是这 250 步的平均；D(真)、D(假) 是 D 输出的平均；样本指标用固定的 {N_EVAL} 个 z 生成。")
    print(pad("步数", 7) + pad("D 损失", 9, ">") + pad("G 损失", 9, ">") + pad("D(真)", 8, ">") + pad("D(假)", 8, ">")
          + pad("覆盖", 6, ">") + pad("高质量", 8, ">") + pad("最少模式", 10, ">") + "   各模式的高质量样本数")
    for r in recs:
        rep = r["report"]
        print(pad(r["step"], 7) + f"{r['d_loss']:>9.3f}{r['g_loss']:>9.3f}{r['d_real']:>8.3f}{r['d_fake']:>8.3f}"
              f"{rep['covered']:>6}{rep['hq_frac']:>8.3f}{rep['min_share']:>10.2f}   " + " ".join(f"{c:>3}" for c in rep["counts"]))
    by_step = {r["step"]: r for r in recs}
    show = [s for s in (250, 1000, 2500, STEPS) if s in by_step]
    real_show = sample_ring(N_EVAL, np.random.default_rng(7))
    blocks = [ascii_density(by_step[s]["samples"], width=31, height=15) for s in show] + \
        [ascii_density(real_show, width=31, height=15)]
    titles = [f"第 {s} 步" for s in show] + ["真实数据"]
    for start in range(0, len(blocks), 3):                     # 每行最多并排 3 张，免得终端太窄
        print()
        for line in side_by_side(blocks[start:start + 3], titles[start:start + 3]):
            print(line)
    path = figure_path(__file__, "lecture18_gan_training.png")
    saved = (not QUICK) and save_scatter_grid([by_step[s]["samples"] for s in show] + [real_show],
                                              [f"GAN 第 {s} 步" for s in show] + ["真实数据"], path)   # 冒烟测试不覆盖图
    if saved:
        print(f"\n（散点图已保存：figures/{os.path.basename(path)}）")

    hq = np.array([r["report"]["hq_frac"] for r in recs])
    gl = np.array([r["g_loss"] for r in recs])
    corr = float(np.corrcoef(gl, hq)[0, 1]) if len(recs) > 2 else float("nan")
    late = [r for r in recs if r["step"] > STEPS // 2]
    late_hq = np.array([r["report"]["hq_frac"] for r in late])
    # 质量掉得最厉害的一次（相邻两个记录点之间），以及那时 G 损失变了多少
    k = int(np.argmin(np.diff(hq))) + 1 if len(hq) > 1 else 0
    # 找"被丢掉最久"的模式：第 1000 步以后，连续多少个记录点它的高质量样本 < 1%
    longest = (0, -1, 0, 0)                                    # (长度, 模式编号, 开始步数, 结束步数)
    for m in range(len(recs[0]["report"]["counts"])):
        run = 0
        for i, r in enumerate(recs):
            lost = r["step"] >= 1000 and r["report"]["counts"][m] < 0.01 * N_EVAL
            run = run + 1 if lost else 0
            if run > longest[0]:
                longest = (run, m, recs[i - run + 1]["step"], r["step"])
    back = [r["step"] for r in recs if r["step"] > longest[3] and r["report"]["counts"][longest[1]] >= 0.01 * N_EVAL]
    print(f"""
观察 Observations：
  · 损失不是进度条：D 损失在 {min(r['d_loss'] for r in recs):.2f}–{max(r['d_loss'] for r in recs):.2f}、G 损失在 {gl.min():.2f}–{gl.max():.2f} 之间，
    最后慢慢靠近理论上的均衡点（D(真) = D(假) = 0.5，D 损失 = 2 ln 2 ≈ 1.386，G 损失 = ln 2 ≈ 0.693）。
    整体趋势上 G 损失越低、质量越高（相关系数 {corr:+.2f}），但逐点看很不可靠：第 {recs[k]['step']} 步高质量比例从
    {hq[k - 1]:.3f} 掉到 {hq[k]:.3f}，G 损失却只从 {gl[k - 1]:.3f} 变到 {gl[k]:.3f}。这是博弈，不是单纯的最小化：G 变好，D 的任务就变难。
  · 样本质量会振荡：后一半训练（第 {STEPS // 2} 步以后）高质量比例在 {late_hq.min():.3f}–{late_hq.max():.3f} 之间来回。
    什么时候停、存哪一步的模型，本身就是 GAN 训练的一个难题（实践中常存多个检查点、用指标挑）。
  · 模式会被丢掉很久再找回来：第 {longest[1]} 号模式从第 {longest[2]} 步到第 {longest[3]} 步（连续 {longest[0]} 个记录点）
    几乎没有样本（< 1%），{'第 ' + str(back[0]) + ' 步才回来' if back else '直到最后也没回来'}。这段时间里覆盖数最多只有 7，
    其余几团的质量却在变好 —— 只看"高质量比例"，你不会发现少了一团。这是 mode collapse 的温和版本（讲义 §6.3）。
  · 字符画：第 250 步样本还挤在左下角（画框外还有一些），之后逐渐被"拉"到 8 个模式上。""")


# =============================================================================
# 演示 3：评估与稳定性
# =============================================================================
def demo_evaluation(main_recs):
    print_header("演示 3：怎样评估一个 GAN？指标会被骗；结果对超参数和种子有多敏感？")
    rng = np.random.default_rng(301)
    real = sample_ring(N_EVAL, rng)
    real2 = sample_ring(N_EVAL, rng)
    cov = np.cov(real, rowvar=False)
    blob = rng.multivariate_normal(real.mean(axis=0), cov, size=N_EVAL)             # 均值、协方差和真实数据一样的高斯
    theta = rng.uniform(0, 2 * np.pi, N_EVAL)
    ring = np.stack([2 * np.cos(theta), 2 * np.sin(theta)], axis=1) + SIGMA * rng.standard_normal((N_EVAL, 2))
    one = CENTERS[0] + SIGMA * rng.standard_normal((N_EVAL, 2))
    final = main_recs[-1]["samples"]
    rows = [("真实数据（另一批）", real2), ("GAN 最后一步（演示 2）", final),
            ("同均值、同协方差的高斯", blob), ("均匀的圆环（没有模式）", ring), ("只有第 0 团（完全坍缩）", one)]
    print("(a) 五组样本，和一批真实数据比较。FD = 二维 Fréchet 距离（FID 的公式，用在 (x, y) 坐标上）：")
    print(pad("样本", 26) + pad("FD", 8, ">") + pad("覆盖", 6, ">") + pad("高质量", 8, ">") + pad("最少模式", 10, ">"))
    fd = {}
    for name, x in rows:
        r = mode_report(x)
        fd[name] = frechet_2d(real, x)
        print(pad(name, 26) + f"{fd[name]:>8.3f}{r['covered']:>6}{r['hq_frac']:>8.3f}{r['min_share']:>10.2f}")

    # (b) 学习率 × 种子 的网格
    lrs = (3e-4, 1e-3, 3e-3)
    seeds = (0, 1)
    print(f"\n(b) 同一个配置，学习率 × 种子 = {len(lrs)} × {len(seeds)} 次训练，每次 {STEPS} 步，每 250 步记录一次。"
          f"\n    \"后一半\"= 第 {STEPS // 2} 步以后的记录点：看最少覆盖了几个模式、高质量比例在什么范围里摆动。")
    print(pad("学习率", 9) + pad("种子", 6, ">") + pad("最后：覆盖 / 高质量 / 最少模式", 34, ">")
          + pad("后一半：最少覆盖", 18, ">") + pad("后一半：高质量范围", 22, ">"))
    grid = {}
    for lr in lrs:
        for seed in seeds:
            recs = main_recs if (lr == 1e-3 and seed == 0) else run_gan(lr, seed)
            if recs[-1].get("diverged"):
                print(pad(f"{lr:g}", 9) + pad(seed, 6, ">") + pad(f"第 {recs[-1]['step']} 步发散", 34, ">"))
                continue
            late = [r["report"] for r in recs if r["step"] > STEPS // 2] or [recs[-1]["report"]]
            last = recs[-1]["report"]
            grid[(lr, seed)] = (last, min(r["covered"] for r in late), min(r["hq_frac"] for r in late),
                                max(r["hq_frac"] for r in late))
            print(pad(f"{lr:g}", 9) + pad(seed, 6, ">")
                  + pad(f"{last['covered']} / {last['hq_frac']:.3f} / {last['min_share']:.2f}", 34, ">")
                  + pad(grid[(lr, seed)][1], 18, ">")
                  + pad(f"{grid[(lr, seed)][2]:.3f} – {grid[(lr, seed)][3]:.3f}", 22, ">"))
    all8 = sum(1 for v in grid.values() if v[1] == 8)
    widest = max(grid.items(), key=lambda kv: kv[1][3] - kv[1][2]) if grid else None
    print(f"""
观察 Observations：
  · (a) "同均值、同协方差的高斯"的 FD 只有 {fd['同均值、同协方差的高斯']:.3f}，和 GAN 的 {fd['GAN 最后一步（演示 2）']:.3f} 差不多，
    但它一个模式也没覆盖、几乎没有高质量样本；"均匀的圆环"也一样。完全坍缩到一团的生成器，样本个个"高质量"，FD 却最大。
    只比较前两阶统计量的指标看不出"有没有 8 团"。FID 之所以在 Inception 特征上算，是指望深层特征把
    "像不像一张真实的图"变成均值和协方差能捕捉的差别 —— 这个指望大体成立，但不总是成立（讲义 §6.5）。
  · (b) {len(grid)} 次训练里，后一半训练始终保持 8 个模式的有 {all8} 次。
    高质量比例摆动最大的是学习率 {widest[0][0]:g}、种子 {widest[0][1]}：{widest[1][2]:.3f} – {widest[1][3]:.3f}。
    这张表要和代码 03 演示 3 的同一张表（扩散模型）对照着看（讲义 §8.4）。""")


def main():
    setup_torch(1)
    demo_saturation()
    main_recs = run_gan(1e-3, 0)
    demo_training(main_recs)
    demo_evaluation(main_recs)


if __name__ == "__main__":
    main()
