"""
=============================================================================
第 18 讲 · 代码 1：对抗样本 —— 为什么存在、怎样攻击（FGSM / PGD）、怎样防御（对抗训练）、防御的陷阱
=============================================================================

对应讲义：lectures/lecture18_adversarial_gan_diffusion.md §1–§4

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
第 5 讲 §4.5 演示过：把白天照片的每个像素沿梯度的符号方向改 0.02，62.8% 的照片就被判成"夜晚"。
这个文件回答第 5 讲留下的问题：它为什么会发生、能做到多狠、怎样防御、防御怎样会自欺欺人。

    演示 1：一个线性模型 wᵀx 就足以产生对抗样本。每一维只改 ε，沿 sign(w) 改时输出变化 ε‖w‖₁，
            随维度 n 线性增长（权重按 1/√n 缩放时是 √n 增长）；同样大小的**随机**符号噪声只改变约 ε‖w‖₂，
            不随维度增长。这是 Goodfellow et al. (2015) 的"线性解释"。（纯 numpy）
    演示 2：在一个训练好的小 CNN（第 9 讲的合成手势，6 类，16×16 灰度）上做攻击：
            (a) 准确率随 ε 的变化：随机噪声 vs FGSM vs PGD；一个线性的 softmax 回归作对照；
            (b) 这个 CNN 在 ε 的尺度上有多"线性"？一阶近似预测的 logit 变化 vs 实际变化；
            (c) 一张对抗样本长什么样；
            (d) 黑盒迁移：在 CNN-A 上造的对抗样本，拿去攻击另外独立训练的 CNN-B、MLP、softmax 回归。
    演示 3：对抗训练（Madry et al. 2018）：用 FGSM / PGD 造的样本来训练。干净准确率掉了多少、鲁棒准确率涨了多少、
            训练贵了多少；再用更强的攻击（100 步 + 3 次随机重启）检查鲁棒性是不是真的。
    演示 4：一个"假防御"：在网络前面加一个把像素量化成 8 档的预处理。基于梯度的攻击全部失效，看上去"完全鲁棒"；
            但换成 BPDA（Athalye, Carlini & Wagner 2018）或迁移攻击，准确率立刻掉下去 —— 这叫 gradient masking。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_adversarial_examples.py          # 约 70 秒（单线程 CPU；要训练 7 个小模型，其中两个做 PGD 对抗训练）
    python3 01_adversarial_examples.py --quick  # 冒烟测试，约 5 秒（数据和 epoch 都很少，数字不能用）

依赖：numpy、PyTorch。只读地导入前几讲的模块：
    第 1 讲 console.py            pad、print_header（对齐表格）
    第 9 讲 multiclass_data.py    make_hand_signs（合成手势）、ascii_image（字符画）
    第 9 讲 torch_utils.py        setup_torch、make_loader（打乱、分 batch）
    第 12 讲 cnn_tools.py         SmallCNN（CONV-POOL-CONV-POOL-FC）、make_mlp（拉平 + 第 9 讲的 MLP）

【可复现】torch.set_num_threads(1)；建模型前 torch.manual_seed(种子)；DataLoader 和攻击里的随机起点各用自己的
          torch.Generator。同一台机器上重复运行，输出逐字相同。

【注释标签】【思路】【参数】【返回】【边界】【DL 联系】【numpy 小知识】【PyTorch 小知识】【Python 小知识】
"""

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__))
for _d in ("lecture01", "lecture09", "lecture12"):
    sys.path.insert(0, os.path.join(HERE, "..", _d))

from console import pad, print_header  # noqa: E402  （第 1 讲）
from multiclass_data import CLASS_NAMES, ascii_image, make_hand_signs  # noqa: E402  （第 9 讲）
from torch_utils import make_loader, setup_torch  # noqa: E402  （第 9 讲）
from cnn_tools import SmallCNN, make_mlp  # noqa: E402  （第 12 讲）

QUICK = "--quick" in sys.argv
N_TRAIN = 600 if QUICK else 4000
N_TEST = 300 if QUICK else 1000
EPOCHS = 1 if QUICK else 10
EPS_LIST = (0.01, 0.02, 0.03, 0.05, 0.1)       # 像素值在 [0, 1]：0.01 ≈ 0–255 里的 2.6 个灰度级，0.1 ≈ 25.5 个
EPS_TRAIN = 0.1                                 # 对抗训练用的预算（比数据本身每像素 0.15 的噪声还小）
PGD_STEPS = 5 if QUICK else 20                  # 评估用的 PGD 步数（步长 ε/4）
STRONG_STEPS, STRONG_RESTARTS = (5, 1) if QUICK else (50, 2)   # "强攻击"：更多步、更小步长（ε/8）、多次随机重启


# =============================================================================
# 演示 1：线性模型里的"累积"
# =============================================================================
def linear_accumulation(n, eps, rng, n_random=2000):
    """
    一个 n 维线性模型 f(x) = wᵀx：比较"沿 sign(w) 改"和"随机符号噪声"对输出的影响。

    【思路】扰动 η 的每一维只能是 ±ε（L∞ 范数 = ε，"每个像素最多改 ε"）。
            · 对抗方向 η = ε · sign(w)：wᵀη = ε Σ|w_i| = ε‖w‖₁ —— 每一维的小贡献**全部同号**，一起累加。
            · 随机方向 η = ε · (随机 ±1)：wᵀη = ε Σ ±w_i，正负互相抵消。它近似服从 N(0, ε²‖w‖₂²)，
              所以 |wᵀη| 的平均值是 ε‖w‖₂ · √(2/π)（正态分布绝对值的期望）。
            权重按 w_i ~ N(0, 1/n) 初始化（第 7 讲的方差保持：这样 wᵀx 的尺度不随 n 变化，"信号"始终是 1 左右），
            则 ‖w‖₁ ≈ n · E|w_i| = √(2n/π)，‖w‖₂ ≈ 1。对抗 / 随机之比 ≈ √(2n/π) / √(2/π) = √n：
            维度越高，对抗方向越"占便宜"。
    【参数】n：维度；eps：每一维的扰动上限；rng：np.random.Generator；n_random：随机符号向量抽多少个
    【返回】dict：l1、l2（‖w‖₁、‖w‖₂）、adv（ε‖w‖₁）、rand（随机扰动下 |wᵀη| 的平均）、eta_l2（扰动本身的 L2 长度 ε√n）
    【numpy 小知识】rng.choice([-1.0, 1.0], size=(k, n))：每个元素独立地等概率取 −1 或 1。
            随机向量分块生成（每块最多 200 个），避免 n = 150528 时一次生成 2000 × 150528 的大矩阵占太多内存。
    """
    w = rng.standard_normal(n) / np.sqrt(n)
    l1, l2 = float(np.abs(w).sum()), float(np.sqrt((w ** 2).sum()))
    vals = []
    for start in range(0, n_random, 200):
        k = min(200, n_random - start)
        signs = rng.choice([-1.0, 1.0], size=(k, n))
        vals.append(np.abs(signs @ w) * eps)
    return {"l1": l1, "l2": l2, "adv": eps * l1, "rand": float(np.concatenate(vals).mean()), "eta_l2": eps * np.sqrt(n)}


def demo_linear():
    print_header("演示 1：线性模型就够了 —— 每一维只改 ε，输出的变化随维度增长")
    eps = 1.0 / 255.0
    dims = [(16, "4×4 灰度"), (256, "16×16 灰度（本讲的手势图）"), (3072, "32×32×3（CIFAR-10）"),
            (150528, "224×224×3（ImageNet）")]
    if QUICK:
        dims = dims[:2]
    print(f"ε = 1/255 ≈ {eps:.4f}（8 位图像里最小的一档亮度）。w_i ~ N(0, 1/n)，所以 wᵀx 对正常输入的尺度约为 1。")
    print(pad("维度 n", 9) + pad("图像", 28) + pad("‖w‖₁", 9, ">") + pad("√(2n/π)", 9, ">") + pad("‖w‖₂", 7, ">")
          + pad("对抗：ε‖w‖₁", 13, ">") + pad("随机：|wᵀη| 平均", 18, ">") + pad("之比", 8, ">") + pad("√n", 8, ">")
          + pad("‖η‖₂ = ε√n", 13, ">"))
    rows = []
    for n, name in dims:
        r = linear_accumulation(n, eps, np.random.default_rng(n))
        rows.append((n, r))
        print(pad(n, 9) + pad(name, 28) + f"{r['l1']:>9.1f}{np.sqrt(2 * n / np.pi):>9.1f}{r['l2']:>7.3f}{r['adv']:>13.4f}"
              f"{r['rand']:>18.5f}{r['adv'] / r['rand']:>8.1f}{np.sqrt(n):>8.1f}{r['eta_l2']:>13.3f}")
    big = rows[-1][1]
    print(f"""
观察 Observations：
  · 每一维只改 1/255，在 {rows[-1][0]} 维时沿 sign(w) 改能让输出变化 {big['adv']:.2f} —— 和"正常输入下输出的尺度"（约 1）一样大，
    足以把一个分类器的 logit 推过决策边界。同样大小的随机符号噪声只让输出变化约 {big['rand']:.4f}，几乎没有影响。
  · ‖w‖₁ 和 √(2n/π) 几乎一样，对抗 / 随机之比和 √n 几乎一样：每一维的贡献都很小，但对抗方向让它们**全部同号**，
    n 个小量一起累加，总和 ≈ n · E|w_i|；随机方向的贡献有正有负，互相抵消，总和只有 √n · E|w_i| 的量级。
    这就是 Goodfellow et al. (2015) 的解释："高维 + 线性"就够了，不需要什么"深度网络的奇怪非线性"。
  · 最后一列提醒一个容易混淆的地方：每个像素最多改 ε（L∞ 范数小），但整个扰动向量的 L2 长度 ε√n 随维度增长。
    "人眼看不出"说的是每个像素的变化小，而不是扰动向量短。
  · 演示 2 会检查：一个真正的 CNN 在这么小的尺度上，是不是也表现得像个线性函数。""")


# =============================================================================
# 数据与训练
# =============================================================================
def load_hand_signs():
    """
    第 9 讲的合成手势（6 类，16×16 灰度），整理成 PyTorch 的 (m, 1, 16, 16) 张量，像素裁剪到 [0, 1]。

    【思路】第 9 讲的生成器没有裁剪像素（噪声会让少数像素略小于 0 或略大于 1，对训练没有影响）。
            但对抗攻击要求"扰动后仍是一张合法的图"，需要明确的像素范围，所以这里统一裁剪到 [0, 1]，
            攻击时也把 x + δ 裁剪回 [0, 1]。
            训练集 4000 张（第 9 讲是 1080 张；多一点数据让 CNN 的干净准确率更高，对抗样本的现象更"纯粹"），
            测试集 1000 张，用两个新的种子生成，和第 9 讲的数据不重叠。
    【返回】(Xtr, ytr, Xte, yte)：X 是 float32 张量 (m, 1, 16, 16)，y 是 int64 张量 (m,)
    【numpy 小知识】X[:, None]：在第 1 维插入一个长度为 1 的新维度（通道），(m, 16, 16) → (m, 1, 16, 16)。
    """
    Xtr, ytr = make_hand_signs(N_TRAIN, np.random.default_rng(1801))
    Xte, yte = make_hand_signs(N_TEST, np.random.default_rng(1802))
    to_t = lambda X: torch.tensor(np.clip(X, 0.0, 1.0)[:, None], dtype=torch.float32)   # noqa: E731
    return to_t(Xtr), torch.tensor(ytr), to_t(Xte), torch.tensor(yte)


def build(kind):
    """
    造一个新模型（6 类）。

    【参数】kind："cnn"  —— 第 12 讲的 SmallCNN：CONV(16) → POOL → CONV(32) → POOL → FC(64) → 6，约 4 万个参数
                  "mlp"  —— 拉平后接 [256, 256, 256, 6] 的全连接网络（第 9 讲的 MLP）
                  "linear" —— 拉平后直接 [256, 6]：softmax 回归，一个**线性**分类器
    【返回】nn.Module，输入 (m, 1, 16, 16)，输出 (m, 6) 的 logits
    """
    if kind == "cnn":
        return SmallCNN(1, 6, c1=16, c2=32, img=16, head="fc", fc_hidden=64)
    if kind == "mlp":
        return make_mlp([256, 256, 256, 6])
    if kind == "linear":
        return make_mlp([256, 6])
    raise ValueError(kind)


def pgd_attack(model, x, y, eps, steps, alpha, gen=None, random_start=True):
    """
    PGD（projected gradient descent，Madry et al. 2018）攻击：在 L∞ 球 {δ : |δ_i| ≤ ε} 里找让损失最大的扰动。

    【思路】这是"对输入做梯度**上升**"，每一步：
                δ ← δ + α · sign(∇_δ L(f(x + δ), y))      沿梯度符号走一小步（让损失变大）
                δ ← clip(δ, −ε, ε)                        "投影"回 L∞ 球（每个像素最多改 ε）
                δ ← clip(x + δ, 0, 1) − x                 再保证 x + δ 是合法像素
            · steps = 1、alpha = ε、不随机起点 → 就是 **FGSM**（fast gradient sign method，Goodfellow et al. 2015）：
              x_adv = x + ε · sign(∇_x L)。FGSM 是"假设损失是线性的"时的一步最优解；PGD 多走几步，不再依赖这个假设。
            · random_start：从 L∞ 球里的一个随机点出发（Madry 的做法），避免一开始就卡在梯度为 0 的地方。
            和第 5 讲 §4（风格迁移、对抗样本预告）、第 14 讲 §11 的风格迁移是同一招：**固定参数，对输入求梯度，改输入**。
    【参数】
        model  : 分类器（参数不会被修改）
        x, y   : (m, 1, 16, 16) 干净图像，(m,) 标签
        eps    : L∞ 预算 ε
        steps  : 迭代次数（≥ 1）
        alpha  : 每一步的步长
        gen    : torch.Generator，随机起点用
        random_start : 是否随机起点
    【返回】(m, 1, 16, 16) 的对抗样本（不连着计算图）
    【边界】模型对输入的梯度处处为 0 时（比如演示 4 的量化），sign(0) = 0，δ 停在起点不动 —— 攻击"安静地"失败。
    【PyTorch 小知识】torch.autograd.grad(loss, delta)：只对 delta 求梯度，不往模型参数的 .grad 里写任何东西
            （和 loss.backward() 不同），所以攻击不会干扰训练时的梯度。
            model.eval() 之后再攻击：对有 BatchNorm / Dropout 的模型，攻击时要用测试模式（这里的模型没有这两种层，但养成习惯）。
    """
    was_training = model.training
    model.eval()
    if random_start:
        delta = (torch.rand(x.shape, generator=gen) * 2.0 - 1.0) * eps
    else:
        delta = torch.zeros_like(x)
    delta = (x + delta).clamp(0.0, 1.0) - x
    for _ in range(steps):
        delta.requires_grad_(True)
        loss = F.cross_entropy(model(x + delta), y)
        grad, = torch.autograd.grad(loss, delta)
        delta = (delta.detach() + alpha * grad.sign()).clamp(-eps, eps)
        delta = (x + delta).clamp(0.0, 1.0) - x
    model.train(was_training)
    return (x + delta).detach()


def fgsm(model, x, y, eps):
    """
    FGSM：x_adv = clip(x + ε · sign(∇_x L), 0, 1)。

    【思路】就是 pgd_attack 的特例：1 步、步长 ε、不随机起点。"一步线性近似"的最优解（讲义 §3.1）。
    【参数】model；x：(m, 1, 16, 16)；y：(m,)；eps：L∞ 预算
    【返回】(m, 1, 16, 16) 的对抗样本
    """
    return pgd_attack(model, x, y, eps, steps=1, alpha=eps, random_start=False)


def random_sign_noise(x, eps, gen):
    """
    对照组：每个像素随机 ±ε（和 FGSM 一样大的 L∞ 扰动，只是方向随机），再裁剪到 [0, 1]。

    【参数】x：(m, 1, 16, 16)；eps：幅度；gen：torch.Generator
    【返回】和 x 同形状的张量
    【PyTorch 小知识】torch.randint(0, 2, shape) 得到 0 / 1，乘 2 减 1 就是 −1 / +1。
    """
    signs = torch.randint(0, 2, x.shape, generator=gen).float() * 2.0 - 1.0
    return (x + eps * signs).clamp(0.0, 1.0)


@torch.no_grad()
def accuracy(model, x, y):
    """
    分类准确率：argmax(logits) == y 的比例。

    【参数】model；x：(m, 1, 16, 16)；y：(m,)
    【返回】Python float
    【PyTorch 小知识】@torch.no_grad() 让这个函数里不记录计算图（只做预测，不需要梯度）。
    """
    model.eval()
    return float((model(x).argmax(dim=1) == y).float().mean().item())


def train(kind, Xtr, ytr, seed, attack=None):
    """
    训练一个分类器：Adam（lr 1e-3），batch 64，EPOCHS 个 epoch。attack 不是 None 时做**对抗训练**。

    【思路】和第 9 讲 torch_utils.train_one_epoch 一样的"zero_grad → forward → loss → backward → step"，
            只多了一行：对抗训练时，先用当前的模型把这一批 xb 变成对抗样本，再用对抗样本算损失。
            这就是 Madry et al. (2018) 的 min-max 目标的直接实现：
                min_θ  E_(x,y) [ max_{‖δ‖∞ ≤ ε} L(f_θ(x + δ), y) ]
            里面的 max 用 PGD 近似（attack 函数），外面的 min 用普通的梯度下降。
            （Goodfellow et al. 2015 的 FGSM 对抗训练把干净样本和对抗样本的损失各取一半；这里按 Madry 的做法只用对抗样本。）
    【参数】kind：见 build；Xtr、ytr：训练数据；seed：初始化和打乱顺序的种子；
            attack：None，或函数 attack(model, xb, yb, gen) → 对抗样本
    【返回】训练好的模型
    【边界】对抗训练每一步要额外做 k 次前向 + 反向（PGD 的步数），训练开销是普通训练的 k + 1 倍。
    """
    torch.manual_seed(seed)
    model = build(kind)
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    loader = make_loader(Xtr, ytr, batch_size=64, shuffle=True, seed=seed, x_dtype=torch.float32)
    gen = torch.Generator().manual_seed(seed + 500)            # 攻击的随机起点用的随机数
    for _ in range(EPOCHS):
        for xb, yb in loader:
            if attack is not None:
                xb = attack(model, xb, yb, gen)                  # 对抗训练：只多这一行
            model.train()
            loss = F.cross_entropy(model(xb), yb)
            opt.zero_grad()
            loss.backward()
            opt.step()
    return model


# =============================================================================
# 演示 2：攻击一个训练好的 CNN
# =============================================================================
def margin_and_grad(model, x, y):
    """
    logit 间隔 m(x) = z_y − max_{j≠y} z_j（正确类别领先第二名多少），以及 m 对输入的梯度。

    【思路】m > 0 表示分对了，m < 0 表示分错了。ReLU + 最大池化的网络，logit 是输入的**分段线性**函数；
            在一小块区域里它就是一个线性函数，m(x + δ) = m(x) + ∇m · δ 精确成立。
            "第二名是谁"在干净输入上定下来，之后固定（这样 m 在局部就是两个 logit 之差，一个线性函数）。
    【参数】model；x：(m, 1, 16, 16)；y：(m,)
    【返回】(margin (m,), grad (m, 1, 16, 16), runner_up (m,))
    """
    model.eval()
    x = x.clone().requires_grad_(True)
    z = model(x)
    z_masked = z.detach().clone()
    z_masked[torch.arange(len(y)), y] = -float("inf")
    runner = z_masked.argmax(dim=1)
    m = z[torch.arange(len(y)), y] - z[torch.arange(len(y)), runner]
    grad, = torch.autograd.grad(m.sum(), x)
    return m.detach(), grad, runner


@torch.no_grad()
def margin_with(model, x, y, runner):
    """
    给定"第二名"的类别 runner（在干净图上定下来的），算 z_y − z_runner。

    【参数】model；x：(m, 1, 16, 16)；y、runner：(m,)
    【返回】(m,) 的张量
    """
    z = model(x)
    return z[torch.arange(len(y)), y] - z[torch.arange(len(y)), runner]


def demo_attacks(Xtr, ytr, Xte, yte):
    print_header("演示 2：攻击一个训练好的小 CNN —— 随机噪声 vs FGSM vs PGD；它有多线性；能不能迁移")
    cnn = train("cnn", Xtr, ytr, seed=0)
    lin = train("linear", Xtr, ytr, seed=0)
    print(f"训练集 {len(ytr)} 张、测试集 {len(yte)} 张合成手势（16×16，6 类；数据本身每个像素有标准差 0.15 的噪声）。")
    print(f"CNN（SmallCNN，{sum(p.numel() for p in cnn.parameters())} 个参数）干净准确率 {accuracy(cnn, Xte, yte):.3f}；"
          f"softmax 回归（线性，{sum(p.numel() for p in lin.parameters())} 个参数）{accuracy(lin, Xte, yte):.3f}")

    # (a) 准确率随 ε 的变化
    print("\n(a) 测试集准确率随 ε 的变化（PGD：20 步，步长 ε/4，随机起点）：")
    print(pad("ε", 7) + pad("≈ /255", 8, ">") + pad("CNN 随机噪声", 14, ">") + pad("CNN FGSM", 10, ">") + pad("CNN PGD", 10, ">")
          + pad("线性 随机噪声", 15, ">") + pad("线性 FGSM", 11, ">"))
    gen = torch.Generator().manual_seed(21)
    table = {}
    for eps in EPS_LIST:
        row = (accuracy(cnn, random_sign_noise(Xte, eps, gen), yte), accuracy(cnn, fgsm(cnn, Xte, yte, eps), yte),
               accuracy(cnn, pgd_attack(cnn, Xte, yte, eps, PGD_STEPS, eps / 4, gen), yte),
               accuracy(lin, random_sign_noise(Xte, eps, gen), yte), accuracy(lin, fgsm(lin, Xte, yte, eps), yte))
        table[eps] = row
        print(pad(f"{eps:g}", 7) + f"{eps * 255:>8.1f}" + "".join(f"{v:>{w}.3f}" for v, w in zip(row, (14, 10, 10, 15, 11))))

    # (b) 线性程度
    m0, g, runner = margin_and_grad(cnn, Xte, yte)
    ok = m0 > 0
    g1 = g.abs().flatten(1).sum(dim=1)                         # ‖∇m‖₁，每张图一个数
    print(f"\n(b) 这个 CNN 有多线性？对分对的 {int(ok.sum())} 张图，沿 δ = −ε · sign(∇m) 改（不裁剪），"
          f"\n    一阶近似预测 m 下降 ε‖∇m‖₁；实际下降多少？（m = 正确类别的 logit − 第二名的 logit）")
    print(pad("ε", 7) + pad("预测的下降（中位数）", 22, ">") + pad("实际的下降（中位数）", 22, ">") + pad("实际 / 预测（中位数）", 22, ">"))
    lin_rows = []
    for eps in (0.005, 0.02, 0.05, 0.1):
        x_adv = Xte[ok] - eps * g[ok].sign()
        actual = m0[ok] - margin_with(cnn, x_adv, yte[ok], runner[ok])
        pred = eps * g1[ok]
        ratio = float((actual / pred).median().item())
        lin_rows.append((eps, ratio))
        print(pad(f"{eps:g}", 7) + f"{float(pred.median().item()):>22.3f}{float(actual.median().item()):>22.3f}{ratio:>22.3f}")
    eps_lin = (m0[ok] / g1[ok])                                 # 线性近似下，把 m 推到 0 需要的 ε
    print(f"    ‖∇m‖₁ 的中位数 {float(g1[ok].median().item()):.1f} = 256 个像素 × 每个像素平均 |∂m/∂x_i| "
          f"{float(g1[ok].median().item()) / 256:.3f}；干净图的间隔 m 的中位数 {float(m0[ok].median().item()):.2f}"
          f"\n    → 按线性近似，翻转一半的图只需要 ε ≈ m / ‖∇m‖₁ 的中位数 = {float(eps_lin.median().item()):.4f}")

    # (c) 一张对抗样本
    i = int(torch.nonzero(ok).flatten()[0].item())
    eps_show = 0.05
    x1 = Xte[i:i + 1]
    x1_adv = pgd_attack(cnn, x1, yte[i:i + 1], eps_show, PGD_STEPS, eps_show / 4, torch.Generator().manual_seed(3))
    with torch.no_grad():
        p_clean = F.softmax(cnn(x1), dim=1)[0]
        p_adv = F.softmax(cnn(x1_adv), dim=1)[0]
    c0, c1 = int(p_clean.argmax().item()), int(p_adv.argmax().item())
    arts = [ascii_image(x1[0, 0].numpy()), ascii_image((x1_adv - x1)[0, 0].numpy()), ascii_image(x1_adv[0, 0].numpy())]
    titles = [f"干净：{CLASS_NAMES[c0]} {p_clean[c0].item():.2f}", f"扰动 δ（±{eps_show}）",
              f"对抗：{CLASS_NAMES[c1]} {p_adv[c1].item():.2f}"]
    print(f"\n(c) 测试集第 {i} 张（真实类别 {CLASS_NAMES[int(yte[i].item())]}），PGD，ε = {eps_show}（≈ {eps_show * 255:.0f}/255）。"
          f"每张字符画按自己的最小 / 最大值缩放：")
    print("    " + "".join(pad(t, 26) for t in titles))
    for r in range(16):
        print("    " + "".join(pad(a[r], 26) for a in arts))
    print(f"    最大像素变化 {float((x1_adv - x1).abs().max().item()):.3f}，扰动的 L2 长度 {float((x1_adv - x1).norm().item()):.3f}"
          f"（干净图本身的 L2 长度 {float(x1.norm().item()):.2f}）")

    # (d) 迁移
    cnn_b = train("cnn", Xtr, ytr, seed=1)
    mlp = train("mlp", Xtr, ytr, seed=2)
    targets = [("CNN-A 自己（白盒）", cnn), ("CNN-B（同结构、另一个种子）", cnn_b), ("MLP（不同结构）", mlp),
               ("softmax 回归（线性）", lin)]
    print(f"\n(d) 黑盒迁移：只用 CNN-A 的梯度造对抗样本，拿去攻击其他模型（它们的梯度攻击者看不到）：")
    print(pad("被攻击的模型", 30) + pad("干净", 7, ">") + "".join(pad(f"{name} ε={e:g}", 13, ">")
                                                              for e in (0.05, 0.1) for name in ("FGSM", "PGD")))
    adv_sets = []
    for e in (0.05, 0.1):
        adv_sets.append(fgsm(cnn, Xte, yte, e))
        adv_sets.append(pgd_attack(cnn, Xte, yte, e, PGD_STEPS, e / 4, torch.Generator().manual_seed(22)))
    transfer = {}
    for name, model in targets:
        vals = [accuracy(model, xa, yte) for xa in adv_sets]
        transfer[name] = (accuracy(model, Xte, yte), vals)
        print(pad(name, 30) + f"{transfer[name][0]:>7.3f}" + "".join(f"{v:>13.3f}" for v in vals))

    r005, r01 = table[0.05], table[0.1]
    tb = transfer["CNN-B（同结构、另一个种子）"]
    print(f"""
观察 Observations：
  · (a) 随机噪声几乎无害：ε = 0.1 时 CNN 还有 {r01[0]:.3f}（数据里本来就有每像素 0.15 的噪声，网络早就习惯了）。
    同样大小、但沿梯度符号方向的扰动：ε = 0.05 时 FGSM {r005[1]:.3f}、PGD {r005[2]:.3f}；ε = 0.1 时只剩 {r01[1]:.3f} / {r01[2]:.3f}。
    **决定伤害的是方向，不是大小。** PGD 只比 FGSM 强一点 —— 对这个 CNN 来说，一步线性近似已经找到了大部分的"坏方向"。
  · 线性的 softmax 回归一样被攻破（ε = 0.05 时 FGSM 后 {r005[4]:.3f}）：对抗样本不是深度网络独有的"怪病"，
    这和演示 1 的线性解释一致。
  · (b) 实际下降 / 一阶预测的中位数：{', '.join(f'ε = {e:g} 时 {r:.2f}' for e, r in lin_rows)}。
    在 ε ≤ 0.02 的尺度上这个 CNN 几乎就是一个线性函数；ε 再大，ReLU 的开关状态开始变化，实际下降小于线性预测，
    但仍是同一个量级。这正是 Goodfellow 说的"网络在局部太线性了"。
  · (c) 字符画里干净图和对抗图几乎看不出区别（扰动是均匀的 ±{eps_show} 斑点），网络却改变了答案。
  · (d) 迁移性很强：攻击者完全不知道 CNN-B 的参数，ε = 0.1 的 PGD 样本仍让它掉到 {tb[1][3]:.3f}；不同结构的 MLP、
    线性模型也一样被骗。这就是**黑盒攻击**的基础：自己训练一个替身模型，在替身上造样本（Papernot et al. 2017）。
    （迁移过去的攻击比白盒弱一些：同样 ε = 0.05 的 PGD，CNN-A 自己 {transfer['CNN-A 自己（白盒）'][1][1]:.3f}，CNN-B {tb[1][1]:.3f}。）""")
    return cnn, table


# =============================================================================
# 演示 3：对抗训练
# =============================================================================
def robust_eval(model, Xte, yte, eps, gen, strong=False):
    """
    在预算 ε 下评估一个模型：FGSM 后的准确率、PGD-20 后的准确率，以及（strong=True 时）强攻击后的准确率。

    【思路】"强攻击" = PGD 50 步（步长 ε/8）× 2 次随机重启：一张图只要在任何一次重启里被攻破，就算错。
            如果一个防御在 PGD-20 下看起来鲁棒，在更强的攻击下却大幅下降，说明 PGD-20 不够强 —— 报告鲁棒性时
            要用尽量强的攻击（Carlini et al. 2019 "On Evaluating Adversarial Robustness" 的建议之一）。
    【返回】(fgsm_acc, pgd_acc, strong_acc)；strong=False 时 strong_acc 是 nan
    【边界】强攻击要做 100 次前向 + 反向，是这个文件里最花时间的部分之一，所以只在 ε = 0.1 时做。
    """
    a_fgsm = accuracy(model, fgsm(model, Xte, yte, eps), yte)
    a_pgd = accuracy(model, pgd_attack(model, Xte, yte, eps, PGD_STEPS, eps / 4, gen), yte)
    if not strong:
        return a_fgsm, a_pgd, float("nan")
    still_right = torch.ones(len(yte), dtype=torch.bool)
    for _ in range(STRONG_RESTARTS):
        xa = pgd_attack(model, Xte, yte, eps, STRONG_STEPS, eps / 8, gen)
        with torch.no_grad():
            still_right &= model(xa).argmax(dim=1) == yte
    return a_fgsm, a_pgd, float(still_right.float().mean().item())


def demo_adversarial_training(Xtr, ytr, Xte, yte, cnn_std):
    print_header("演示 3：对抗训练 —— 用攻击造出来的样本训练（min-max 鲁棒优化）")
    pgd7 = lambda e: (lambda m, xb, yb, g: pgd_attack(m, xb, yb, e, 7, e / 4, g))      # noqa: E731
    fgsm_t = lambda e: (lambda m, xb, yb, g: fgsm(m, xb, yb, e))                       # noqa: E731
    configs = [("普通训练", None, 1), (f"FGSM 对抗训练 ε={EPS_TRAIN:g}", fgsm_t(EPS_TRAIN), 2),
               ("PGD-7 对抗训练 ε=0.05", pgd7(0.05), 8), (f"PGD-7 对抗训练 ε={EPS_TRAIN:g}", pgd7(EPS_TRAIN), 8)]
    print("所有模型：同样的 CNN、同样的数据、同样的 10 个 epoch。对抗训练时每个 batch 先用当前模型造对抗样本，只用对抗样本更新。")
    print("鲁棒准确率：FGSM / PGD-20 / 强攻击（PGD-50，步长 ε/8，2 次随机重启，任一次攻破就算错；只在 ε = 0.1 时做）。")
    print(pad("训练方式", 26) + pad("每步前向+反向", 14, ">") + pad("干净", 7, ">")
          + pad("ε=0.05：FGSM / PGD", 20, ">") + pad("ε=0.1：FGSM / PGD / 强", 26, ">"))
    results = {}
    for name, attack, cost in configs:
        model = cnn_std if attack is None else train("cnn", Xtr, ytr, seed=0, attack=attack)
        gen = torch.Generator().manual_seed(31)
        clean = accuracy(model, Xte, yte)
        r05 = robust_eval(model, Xte, yte, 0.05, gen)
        r10 = robust_eval(model, Xte, yte, 0.1, gen, strong=True)
        results[name] = (clean, r05, r10)
        print(pad(name, 26) + pad(cost, 14, ">") + f"{clean:>7.3f}" + pad(" / ".join(f"{v:.3f}" for v in r05[:2]), 20, ">")
              + pad(" / ".join(f"{v:.3f}" for v in r10), 26, ">"))
    std = results["普通训练"]
    fg = results[f"FGSM 对抗训练 ε={EPS_TRAIN:g}"]
    p05 = results["PGD-7 对抗训练 ε=0.05"]
    p10 = results[f"PGD-7 对抗训练 ε={EPS_TRAIN:g}"]
    print(f"""
观察 Observations：
  · 对抗训练确实买到了鲁棒性：ε = 0.1 的强攻击下，普通训练 {std[2][2]:.3f}，PGD 对抗训练（ε = 0.1）{p10[2][2]:.3f}。
    而且 PGD-20 和强攻击的结果很接近（{p10[2][1]:.3f} vs {p10[2][2]:.3f}）：这份鲁棒性不是"攻击太弱"造成的假象。
  · 代价一：干净准确率从 {std[0]:.3f} 掉到 {p10[0]:.3f}（ε = 0.05 训练时 {p05[0]:.3f}）。预算越大，干净准确率掉得越多 ——
    鲁棒性和准确率之间有取舍（Tsipras et al. 2019）。（这么小的模型、只训练 10 个 epoch，代价可能被放大了；这里没有专门验证。）
  · 代价二：训练贵 8 倍（PGD-7 每一步要额外做 7 次前向 + 反向）。FGSM 对抗训练只贵 2 倍，鲁棒性介于普通训练和
    PGD 对抗训练之间（ε = 0.1 强攻击下 {fg[2][2]:.3f}）。这里没有出现文献里 FGSM 对抗训练的"灾难性过拟合"
    （catastrophic overfitting：对 FGSM 很鲁棒、对 PGD 几乎为 0，Wong et al. 2020）—— 它对 PGD 仍有 {fg[2][1]:.3f}。
  · 用 ε = 0.05 训练的模型，面对 ε = 0.1 的强攻击只剩 {p05[2][2]:.3f}：鲁棒性只在训练时的预算以内成立，威胁模型要事先定好。""")


# =============================================================================
# 演示 4：gradient masking
# =============================================================================
class Quantize(nn.Module):
    """
    一个"防御"：把输入像素量化成 levels 档（0, 1/7, 2/7, …, 1），再交给原来的模型。

    【思路】直觉：小于半档（1/14 ≈ 0.071）的扰动会被"四舍五入"掉。看起来很有道理 —— 这类做法（位深压缩、
            thermometer encoding 等输入变换）在 2017–2018 年确实被当作防御提出过。
            问题在于：round() 的导数处处为 0，攻击者用 autograd 算出的梯度全是 0，梯度攻击直接失效。
            这只是让梯度"失灵"（gradient masking / obfuscated gradients），不是让模型变鲁棒。
    【参数】base：原来的模型；levels：量化档数；bpda：True 时用 BPDA（backward pass differentiable approximation）：
            前向照常量化，反向时假装量化是恒等函数 —— 攻击者"绕过"不可导的那一步。
    【PyTorch 小知识】x + (q − x).detach()：前向时值等于 q（x 和 −x 抵消），反向时 detach 的那一项没有梯度，
            于是梯度原样穿过去，就像这一步是恒等函数。这叫 straight-through estimator，BPDA 是它在攻击中的用法。
    """

    def __init__(self, base, levels=8, bpda=False):
        super().__init__()
        self.base, self.k, self.bpda = base, levels - 1, bpda

    def forward(self, x):
        q = torch.round(x * self.k) / self.k
        if self.bpda:
            q = x + (q - x).detach()
        return self.base(q)


def demo_gradient_masking(cnn, Xte, yte):
    print_header("演示 4：一个\"假防御\"：输入量化成 8 档 —— 梯度攻击失效 ≠ 鲁棒（gradient masking）")
    defended = Quantize(cnn)
    bpda_view = Quantize(cnn, bpda=True)                       # 同一个模型，只是攻击者换了一种求梯度的办法
    x = Xte[:50].clone().requires_grad_(True)
    loss = F.cross_entropy(defended(x), yte[:50])
    grad, = torch.autograd.grad(loss, x)
    print(f"量化后的模型：干净准确率 {accuracy(defended, Xte, yte):.3f}（原模型 {accuracy(cnn, Xte, yte):.3f}）；"
          f"autograd 算出的输入梯度的最大绝对值 = {float(grad.abs().max().item()):.1f}")
    print(pad("攻击方式", 44) + pad("ε = 0.05", 10, ">") + pad("ε = 0.1", 10, ">"))
    gen = torch.Generator().manual_seed(41)
    rows = [("原模型（不防御）：PGD-20", lambda e: (cnn, pgd_attack(cnn, Xte, yte, e, PGD_STEPS, e / 4, gen))),
            ("量化防御：FGSM（autograd 梯度）", lambda e: (defended, fgsm(defended, Xte, yte, e))),
            ("量化防御：PGD-20（autograd 梯度）", lambda e: (defended, pgd_attack(defended, Xte, yte, e, PGD_STEPS, e / 4, gen))),
            ("量化防御：随机符号噪声", lambda e: (defended, random_sign_noise(Xte, e, gen))),
            ("量化防御：PGD-20 + BPDA", lambda e: (defended, pgd_attack(bpda_view, Xte, yte, e, PGD_STEPS, e / 4, gen))),
            ("量化防御：从原模型迁移来的 PGD-20 样本", lambda e: (defended, pgd_attack(cnn, Xte, yte, e, PGD_STEPS, e / 4, gen)))]
    res = {}
    for name, fn in rows:
        accs = []
        for e in (0.05, 0.1):
            model, xa = fn(e)
            accs.append(accuracy(model, xa, yte))
        res[name] = accs
        print(pad(name, 44) + f"{accs[0]:>10.3f}{accs[1]:>10.3f}")
    print(f"""
观察 Observations：
  · 只看前两行"量化防御"的结果，你会以为它几乎完美：FGSM 和 PGD 之后准确率还是 {res['量化防御：FGSM（autograd 梯度）'][1]:.3f} / {res['量化防御：PGD-20（autograd 梯度）'][1]:.3f}（ε = 0.1）。
    原因很简单：round() 的梯度是 0，攻击者拿到的梯度全是 0（上面打印的最大值），sign(0) = 0，攻击根本没动。
    这比随机噪声对照（{res['量化防御：随机符号噪声'][1]:.3f}）还"好" —— **一个防御在梯度攻击下比随机噪声还强，本身就是危险信号。**
  · BPDA：反向时把量化当成恒等函数，攻击立刻生效：ε = 0.05 时 {res['量化防御：PGD-20 + BPDA'][0]:.3f}，ε = 0.1 时 {res['量化防御：PGD-20 + BPDA'][1]:.3f}，
    和不防御的原模型（{res['原模型（不防御）：PGD-20'][0]:.3f} / {res['原模型（不防御）：PGD-20'][1]:.3f}）差不多。直接拿原模型上造的样本（迁移攻击）也一样：
    {res['量化防御：从原模型迁移来的 PGD-20 样本'][0]:.3f} / {res['量化防御：从原模型迁移来的 PGD-20 样本'][1]:.3f}。
  · Athalye, Carlini & Wagner (2018) 用 BPDA 等方法检查了 ICLR 2018 上的一批防御，发现大部分依赖这种"梯度失灵"，
    换一种求梯度的办法就被攻破。教训：评估防御时要假设攻击者知道你的防御（白盒）、并且会想办法绕过它；
    黑盒迁移攻击和"比随机噪声还鲁棒"的检查是两个便宜的体检项目。""")


def main():
    setup_torch(1)
    demo_linear()
    Xtr, ytr, Xte, yte = load_hand_signs()
    cnn, _ = demo_attacks(Xtr, ytr, Xte, yte)
    demo_adversarial_training(Xtr, ytr, Xte, yte, cnn)
    demo_gradient_masking(cnn, Xte, yte)


if __name__ == "__main__":
    main()
