"""
=============================================================================
CS329T · 第 7 讲数据与模型：程序生成的"七段数码管"数字 + 小 CNN / MLP
=============================================================================

对应讲义：lectures/lecture07_adversarial_attacks.md（代码 01–05 都导入本文件）

-----------------------------------------------------------------------------
【这个世界是怎么生成的 The recipe】
-----------------------------------------------------------------------------
对抗样本的经典舞台是 MNIST 手写数字。我们不下载任何数据集，而是用程序"画"数字：每个数字由
七段数码管（seven-segment display）的若干段组成，再加上随机的字体变化和噪声。

        a
      ─────
     f│   │b          0: abcdef   1: bc      2: abged   3: abgcd   4: fgbc
      ──g──           5: afgcd    6: afgedc  7: abc     8: abcdefg 9: abcdfg
     e│   │c
      ─────
        d

    · 图像：16 × 16 灰度，像素值在 [0, 1]（0 = 黑背景，1 = 最亮）。
    · 每张图的"字体"随机：宽 5–7 像素、高 9–11 像素、笔画粗 1–2 像素、随机位置、
      斜体程度（shear）U(−0.2, 0.2)、亮度 U(0.6, 1.0)（每个像素再乘 U(0.85, 1.0)）。
    · 最后加高斯噪声 N(0, 0.08²)，裁剪到 [0, 1]。

为什么用七段数码管？因为**类别之间的"语义距离"是已知的**：7 和 1 只差顶上一横（a 段），8 和 0 只差中间一横
（g 段）。这让我们能回答讲义里反复出现的问题："小的 Lp 扰动"和"人眼看来不同的图"是一回事吗？
（代码 02 演示 5 真的去掉一段，测量这个"语义编辑"有多大。）

可选的"非鲁棒特征"（代码 05）：给每个类别 k 配一个固定的、很淡的随机斑点纹理 P_k ∈ {0, amp}^{16×16}，
加到这一类的每张图上。它对分类非常有用（几乎完美地预测类别），但幅度很小（amp = 0.03–0.05），
扰动几个百分点的亮度就能把它改成另一类的纹理 —— 这正是 Ilyas et al.（2019）所说的
"useful but non-robust feature"。因为是我们自己放进去的，所以知道真相。

-----------------------------------------------------------------------------
【模型 Models】
-----------------------------------------------------------------------------
    SmallCNN(c1, c2)：conv 3×3（c1 通道）→ ReLU → maxpool → conv 3×3（c2 通道）→ ReLU → maxpool → 全连接 → 10 个 logit
                       默认 c1 = 16、c2 = 32，约 10k 个参数。
    MLP(hidden)      ：把 256 个像素拉平 → 全连接（hidden 个单元）→ ReLU → 全连接 → 10 个 logit。
    两者都输出 **logit**（softmax 之前的分数 Z(x)），C&W 攻击直接作用在 logit 上（讲义 §6）。

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

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

# 【PyTorch 小知识】单线程计算：多线程时浮点加法的顺序可能变，最后几位数字就可能不同；
#     设成 1 个线程，同一台机器上重复运行的输出逐字相同。
torch.set_num_threads(1)

IMG = 16                     # 图像边长
N_CLASSES = 10               # 数字 0–9
NOISE_SD = 0.08              # 背景噪声标准差
DIGIT_AMP = (0.6, 1.0)       # 笔画亮度范围
SEGMENTS = {0: "abcdef", 1: "bc", 2: "abged", 3: "abgcd", 4: "fgbc",
            5: "afgcd", 6: "afgedc", 7: "abc", 8: "abcdefg", 9: "abcdfg"}

# "去掉一段就变成另一个数字"的六对（代码 02 演示 5 用）：(原数字, 去掉的段, 新数字)
ONE_SEGMENT_EDITS = [(7, "a", 1), (8, "g", 0), (8, "b", 6), (8, "e", 9), (9, "f", 3), (6, "e", 5)]


# =============================================================================
# 1. 画一个数字 Drawing one glyph
# =============================================================================
def sample_style(rng):
    """
    随机抽一种"字体"：大小、粗细、位置、斜体、亮度。

    【思路】把"字体参数"和"画哪几段"分开：同一个 style 可以画成不同的数字。代码 02 演示 5 用这一点
        制造"同一张图、只少了一段"的反事实版本（比如同一个 7 去掉顶上一横变成 1）。
    【参数】rng：np.random.Generator
    【返回】dict，键 W、H、t、r0、c0、shear、amp，以及 jitter（16 × 16 的逐像素亮度系数）
    【numpy 小知识】rng.integers(a, b) 返回 [a, b) 里的整数（不含 b）；rng.uniform(a, b) 返回 [a, b) 里的实数。
    """
    W = int(rng.integers(5, 8))            # 宽 5–7
    H = int(rng.integers(9, 12))           # 高 9–11
    return {
        "W": W, "H": H,
        "t": int(rng.integers(1, 3)),                      # 笔画粗细 1 或 2
        "r0": int(rng.integers(1, IMG - H)),               # 左上角的行
        "c0": int(rng.integers(2, IMG - W - 1)),           # 左上角的列（左右各留出斜体的空间）
        "shear": float(rng.uniform(-0.2, 0.2)),            # 斜体：每往上一行，列偏移 shear 个像素
        "amp": float(rng.uniform(*DIGIT_AMP)),             # 【Python 小知识】*DIGIT_AMP 把 (a, b) 拆成两个参数
        "jitter": rng.uniform(0.85, 1.0, size=(IMG, IMG)),  # 每个像素的亮度再乘一个 0.85–1.0 的系数
    }


def draw_glyph(segments, style):
    """
    按 style 把若干段画到一张 16 × 16 的黑底图上（还没有噪声）。

    【思路】先在"正体"坐标里列出每一段覆盖的 (行, 列)，再按 shear 做斜体偏移：
        第 r 行的列坐标加上 shear × (中线行 − r)，于是上半部分往右（或左）倾斜。
    【参数】segments：字符串，比如 "abc"（= 数字 7）；style：sample_style 的返回值
    【返回】float32 数组，16 × 16，值在 [0, 1]
    【边界】斜体偏移后落到图外的像素直接丢掉（sample_style 留了边距，实际上很少发生）。
    """
    W, H, t = style["W"], style["H"], style["t"]
    top, left = style["r0"], style["c0"]
    bot, right = top + H - 1, left + W - 1
    mid = top + H // 2
    pts = []
    for s in segments:
        if s == "a":       # 顶横
            pts += [(top + k, c) for c in range(left, right + 1) for k in range(t)]
        elif s == "d":     # 底横
            pts += [(bot - k, c) for c in range(left, right + 1) for k in range(t)]
        elif s == "g":     # 中横
            pts += [(mid + k - t // 2, c) for c in range(left, right + 1) for k in range(t)]
        elif s == "f":     # 左上竖
            pts += [(r, left + k) for r in range(top, mid + 1) for k in range(t)]
        elif s == "e":     # 左下竖
            pts += [(r, left + k) for r in range(mid, bot + 1) for k in range(t)]
        elif s == "b":     # 右上竖
            pts += [(r, right - k) for r in range(top, mid + 1) for k in range(t)]
        elif s == "c":     # 右下竖
            pts += [(r, right - k) for r in range(mid, bot + 1) for k in range(t)]
    img = np.zeros((IMG, IMG), dtype=np.float32)
    for r, c in pts:
        cc = int(round(c + style["shear"] * (mid - r)))
        if 0 <= r < IMG and 0 <= cc < IMG:
            img[r, cc] = style["amp"] * style["jitter"][r, cc]
    return img


def make_textures(rng, amp, density=0.5):
    """
    为 10 个类别各生成一个固定的淡纹理 P_k ∈ {0, amp}^{16×16}（代码 05 的"非鲁棒特征"）。

    【思路】每个像素以概率 density 取 amp、否则取 0。不同类别的纹理互相独立，所以任意两类的纹理
        大约在一半的像素上相差 amp。用非负纹理（而不是 ±amp）是因为背景接近 0，负值会被裁剪掉。
    【参数】rng；amp：纹理亮度（比如 0.04）；density：亮点比例
    【返回】float32 数组，10 × 16 × 16
    【可信联系】amp 很小 ⇒ 人眼几乎看不见、L∞ 扰动 ε ≈ amp 就能改写它；但 256 个像素合起来，
        它对类别的预测力很强 —— "有用但不鲁棒"（讲义 §11）。
    """
    return (amp * (rng.random((N_CLASSES, IMG, IMG)) < density)).astype(np.float32)


# =============================================================================
# 2. 生成数据集 Making a dataset
# =============================================================================
def make_digits(n, rng, textures=None, noise_sd=NOISE_SD, keep_parts=False, occlude=0, texture_shift=0):
    """
    生成 n 张数字图像。

    【思路】标签均匀随机 → 每张图抽一个 style、画出对应的段 →（可选）随机遮挡一块 →（可选）加上该类的纹理
        → 加噪声 → 裁剪到 [0, 1]。所有随机性都来自传入的 rng，同一个种子永远得到同一批图像。
    【参数】
        n             图像张数
        rng           np.random.Generator
        textures      None（默认：只有数字形状），或 make_textures 的返回值（10 × 16 × 16）
        noise_sd      噪声标准差
        keep_parts    True 时额外返回每张图的 style 和噪声（用来制造"只改一段"的反事实图像）
        occlude       > 0 时，在每张图的随机位置把一块 occlude × occlude 的区域涂黑（只遮数字，不遮纹理）。
                      这让"只看形状"不再能 100% 判对（比如 8 的中间一横被遮住就像 0），代码 05 用它。
        texture_shift 纹理用第 (y + texture_shift) mod 10 类的（默认 0 = 自己类的纹理；1 = "张冠李戴"的测试集）
    【返回】dict：
        X       torch.float32，n × 1 × 16 × 16（"1"是通道数：灰度图只有一个通道）
        y       torch.int64，n 个类别标签（F.cross_entropy 要求整数标签）
        styles  （keep_parts 时）长度 n 的 style 列表
        noise   （keep_parts 时）numpy 数组 n × 16 × 16
    【边界】n = 0 时返回空张量。纹理加在噪声之前，所以纹理也会被裁剪（只在笔画像素上可能超过 1）。
    【PyTorch 小知识】torch.from_numpy 与 numpy 数组共享内存、不复制；torch.tensor 会复制一份。
    """
    y = rng.integers(0, N_CLASSES, size=n)
    styles = [sample_style(rng) for _ in range(n)]
    X = np.zeros((n, IMG, IMG), dtype=np.float32)
    for i in range(n):
        X[i] = draw_glyph(SEGMENTS[int(y[i])], styles[i])
    if occlude > 0:                            # 只有 occlude > 0 时才多抽随机数，所以不影响其他数据集
        corners = rng.integers(0, IMG - occlude + 1, size=(n, 2))
        for i in range(n):
            r, c = corners[i]
            X[i, r:r + occlude, c:c + occlude] = 0.0
    if textures is not None:
        X = X + textures[(y + texture_shift) % N_CLASSES]   # 【numpy 小知识】textures[idx]：按下标取出每张图的纹理，n × 16 × 16
    noise = rng.normal(0.0, noise_sd, size=X.shape).astype(np.float32)
    Xc = np.clip(X + noise, 0.0, 1.0)
    out = {"X": torch.from_numpy(Xc[:, None]), "y": torch.from_numpy(y.astype(np.int64))}
    if keep_parts:
        out["styles"] = styles
        out["noise"] = noise
    return out


def render_with_segments(data, i, segments, textures=None):
    """
    用第 i 张图的 style 和噪声，重新画出一个只包含 segments 的版本（"同一张图、改了几段"）。

    【参数】data：make_digits(..., keep_parts=True) 的返回值；i：下标；segments：比如 "bc"
    【返回】torch.float32，1 × 16 × 16
    【可信联系】这是一次**语义编辑**：真实类别变了（7 → 1），但像素改动可能并不大（代码 02 演示 5）。
    """
    img = draw_glyph(segments, data["styles"][i])
    if textures is not None:
        img = img + textures[int(data["y"][i])]
    img = np.clip(img + data["noise"][i], 0.0, 1.0)
    return torch.from_numpy(img[None].astype(np.float32))


# =============================================================================
# 3. 模型 Models
# =============================================================================
class SmallCNN(nn.Module):
    """
    小卷积网络：x (1×16×16) → conv(c1) → ReLU → maxpool → conv(c2) → ReLU → maxpool → flatten → fc → 10 个 logit。

    【思路】和第 3 讲的 SmallCNN 是同一类结构，只是输出 10 类。默认 16 / 32 通道（约 10k 参数），
        在这个世界上干净准确率约 99.8%；代码 03 用 8 / 16 通道的版本当作"不同架构"的替身模型。
    【PyTorch 小知识】
        · nn.Module 是所有网络的基类：在 __init__ 里定义层，在 forward 里写前向计算；model(x) 会调用 forward。
        · F.max_pool2d(h, 2)：每个 2 × 2 的格子取最大值，边长减半。
        · h.flatten(1)：保留第 0 维（样本），把其余维度拉成一行。
    """

    def __init__(self, c1=16, c2=32):
        super().__init__()
        self.conv1 = nn.Conv2d(1, c1, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(c1, c2, kernel_size=3, padding=1)
        self.fc = nn.Linear(c2 * 4 * 4, N_CLASSES)

    def forward(self, x):
        h = F.max_pool2d(F.relu(self.conv1(x)), 2)
        h = F.max_pool2d(F.relu(self.conv2(h)), 2)
        return self.fc(h.flatten(1))


class MLP(nn.Module):
    """
    两层全连接网络（没有卷积）：256 → hidden → ReLU → 10。代码 03 用它当作"完全不同架构"的替身模型。

    【PyTorch 小知识】nn.Sequential 把几层按顺序串起来；nn.Flatten() 把 n × 1 × 16 × 16 拉成 n × 256。
    """

    def __init__(self, hidden=256):
        super().__init__()
        self.net = nn.Sequential(nn.Flatten(), nn.Linear(IMG * IMG, hidden), nn.ReLU(),
                                 nn.Linear(hidden, N_CLASSES))

    def forward(self, x):
        return self.net(x)


def train_model(data, seed, arch="cnn", epochs=8, lr=3e-3, batch=64, **kw):
    """
    用 Adam + cross-entropy 训练一个模型（标准训练，不做任何对抗防御）。

    【思路】每个 epoch 把训练集打乱，按 64 张一批更新参数。初始化用 torch.manual_seed(seed)，
        打乱顺序用 np.random.default_rng(seed)，所以同一个 (data, seed, arch) 永远得到同一个模型。
    【参数】
        data   make_digits 的返回值（或任何含 X、y 的 dict）
        seed   整数；arch："cnn" 或 "mlp"；kw：传给模型构造函数（比如 c1=8, c2=16 或 hidden=128）
    【返回】训练好的模型（已切换到 eval 模式）
    【边界】6000 张图、8 个 epoch，单线程 CPU 约 2 秒。没有早停、没有验证集 —— 演示用，够了。
    【PyTorch 小知识】
        · opt.zero_grad() 清掉上一步的梯度（PyTorch 默认累加梯度）；loss.backward() 反向传播；opt.step() 更新参数。
        · F.cross_entropy(logits, y) 内部先做 log-softmax 再取真实类别的负对数，数值稳定。
        · model.eval() 切换到推理模式（本模型没有 Dropout / BatchNorm，所以只是一个好习惯）。
    【可信联系】第 8 讲的 adversarial training 只改这个函数里的一行：把 X[idx] 换成"对当前模型的 PGD 对抗样本"。
    """
    torch.manual_seed(seed)
    model = SmallCNN(**kw) if arch == "cnn" else MLP(**kw)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    rng = np.random.default_rng(seed)
    X, y = data["X"], data["y"]
    n = len(y)
    for _ in range(epochs):
        perm = torch.from_numpy(rng.permutation(n))
        for s in range(0, n, batch):
            idx = perm[s:s + batch]
            loss = F.cross_entropy(model(X[idx]), y[idx])
            opt.zero_grad()
            loss.backward()
            opt.step()
    return model.eval()


def predict(model, X, batch=2000):
    """
    返回预测类别（logit 最大的那一类），torch.int64，长度 n。

    【思路】分批计算，避免一次把太多图片送进网络（这里 batch 很大，实际上通常一批就够了）。
    【PyTorch 小知识】with torch.no_grad(): 只算数值、不建计算图，更快更省内存；.argmax(1) 取每行最大值的下标。
    """
    with torch.no_grad():
        return torch.cat([model(X[s:s + batch]).argmax(1) for s in range(0, len(X), batch)])


def accuracy(model, X, y):
    """准确率（Python float）。【PyTorch 小知识】.item() 把只有一个元素的张量变成 Python 数字。"""
    return (predict(model, X) == y).float().mean().item()


def build_world(n_train=6000, n_test=1000):
    """
    本讲图像实验共用的数据集和"被攻击的模型" Model A（代码 01–04 都调用它，所以它们攻击的是同一个模型）。

    【返回】(train, test, model_A)
        train  6000 张训练图（种子 701）；test：1000 张测试图（种子 702，keep_parts=True）
        model_A：SmallCNN(16, 32)，seed 0
    【边界】每个数据集用自己的种子；改其中一个不影响其他。
    """
    train = make_digits(n_train, np.random.default_rng(701))
    test = make_digits(n_test, np.random.default_rng(702), keep_parts=True)
    model_A = train_model(train, seed=0)
    return train, test, model_A


# =============================================================================
# 4. 字符画 ASCII rendering
# =============================================================================
def ascii_images(images, titles, signed=None):
    """
    把几张 16 × 16 的图并排打印成字符画（亮度越高字符越"重"）。

    【思路】图像：亮度 [0, 1] 映射到 " .:-=+*#%@" 这 10 个字符。扰动（signed[k] = True 时）：
        按 |δ| 相对于本图最大 |δ| 的大小，正的用 "+"（大）/ "'"（小），负的用 "-"（大）/ ","（小），接近 0 的用空格。
    【参数】images：若干个 16 × 16 的张量或数组；titles：每张图的标题；signed：bool 列表，哪些是"扰动图"
    【返回】None（直接打印）
    """
    ramp = " .:-=+*#%@"
    signed = signed or [False] * len(images)
    arrs = [np.asarray(im, dtype=np.float64).reshape(IMG, IMG) for im in images]
    print("   ".join(f"{t:<{IMG}}" for t in titles))
    for r in range(IMG):
        parts = []
        for a, sg in zip(arrs, signed):
            if sg:
                m = float(np.abs(a).max()) + 1e-12
                row = ""
                for v in a[r]:
                    q = v / m
                    row += "+" if q > 0.5 else "'" if q > 0.15 else "-" if q < -0.5 else "," if q < -0.15 else " "
            else:
                row = "".join(ramp[min(9, int(np.clip(v, 0, 1) * 10))] for v in a[r])
            parts.append(row)
        print("   ".join(parts))


if __name__ == "__main__":
    import time

    t0 = time.time()
    train, test, model_A = build_world()
    print(f"数据 + Model A：{time.time() - t0:.1f} 秒；Model A 干净准确率 = {accuracy(model_A, test['X'], test['y']):.3f}")
    print("每类测试样本数：", np.bincount(test["y"].numpy(), minlength=10).tolist())
    ascii_images([test["X"][i, 0] for i in range(4)], [f"y = {int(test['y'][i])}" for i in range(4)])
