"""
=============================================================================
CS230 · 第 9 讲工具模块：两个多分类（multi-class）的合成数据集
=============================================================================

对应讲义：lectures/lecture09_tuning_batchnorm_frameworks.md（§3 softmax、§4 框架、§5 综合练习）

-----------------------------------------------------------------------------
【为什么需要新的数据】
-----------------------------------------------------------------------------
第 1–8 讲的任务几乎都是二分类（猫 / 非猫、花朵的红 / 蓝、老师网络的 0 / 1）。本讲开始讲 softmax，
需要"C 选 1"的多分类任务。这个模块提供两个：

    make_spirals    ：二维平面上 C 条交错的螺旋臂，每条臂一类（默认 C = 3）。
                      二维的好处和第 3 讲的 planar_data 一样：决策边界能在终端里画出来。
                      softmax 回归（没有隐藏层）只能画直线边界，在螺旋上会很差；加一层隐藏层才能绕弯。
    make_hand_signs ：16×16 的灰度"手势"小图，伸出 0–5 根手指，一共 6 类。
                      这是 Coursera 编程作业 "TensorFlow Introduction" 里 SIGNS 数据集（64×64 彩色照片，
                      手势表示数字 0–5）的一个**合成替身**：我们拿不到原始照片，就用几行代码"画"手。

-----------------------------------------------------------------------------
【手势图是怎么画的】
-----------------------------------------------------------------------------
在 16×16 的画布上（先在四周各留 2 像素的边，方便平移）：
    · 手掌：第 10–14 行、第 3–13 列的一块矩形；
    · 四根手指（食指、中指、无名指、小指）：每根 2 像素宽，竖在手掌上方。伸直时从第 2–4 行一直画到第 9 行
      （中指最长、小指最短，再加 ±1 行的随机长短）；弯曲时只剩手掌上方 2 行的"指节"；
    · 大拇指：伸出时是手掌左边一段横着的短条；
    · 类别 k（0–5）：0 = 握拳；1 = 食指；2 = 食指 + 中指；3 = 再加无名指；4 = 四指；5 = 四指 + 大拇指。
然后加四种"干扰"，让同一类的图长得不一样：
    · 平移：上下左右各最多 2 像素（shift）；
    · 左右手：一半的图左右翻转（大拇指跑到右边）；
    · 光照：手的亮度在 0.5–1.0 之间、背景亮度在 0–0.3 之间随机；
    · 噪声：每个像素加标准差 0.15 的高斯噪声（noise）。
平移是全连接网络最头疼的干扰：同一根手指平移 2 像素，就落在完全不同的输入维度上。
第 12 讲的卷积网络正是为了解决这个问题。

【诚实的说明】噪声 0.15、平移 ±2 是我在草稿里试了几组值（噪声 0.15 / 0.2 / 0.25 × 平移 ±1 / ±2）之后选的：
    平移 ±1 时连 softmax 回归都有约 0.94 的测试准确率，超参数几乎无关紧要；噪声 0.25、平移 ±2 时网络严重过拟合。
    选 0.15 / ±2，是为了让"没有隐藏层 < 小网络 < 大一点的网络"这个层次清楚，同时留出调参的余地。

-----------------------------------------------------------------------------
【形状约定】注意：本讲起换成 PyTorch 的习惯，**每一行一个样本**（讲义 §4.3）
-----------------------------------------------------------------------------
    make_spirals    → X: (m, 2)，      y: (m,) 整数类别 0..C−1
    make_hand_signs → X: (m, 16, 16)， y: (m,) 整数类别 0..5      （喂给全连接网络前用 X.reshape(m, -1) 拉平成 (m, 256)）
如果要和第 1–8 讲的 numpy 代码（每一列一个样本）对接，转置一下：X.T 的形状是 (n_x, m)。

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

直接运行本文件会打印两个数据集的基本统计和每类一张手势图：
    python3 multiclass_data.py
"""

import numpy as np


# =============================================================================
# 1. 螺旋数据 Spirals
# =============================================================================
def make_spirals(m_per_class=200, C=3, rng=None, noise=0.2):
    """
    C 条交错的螺旋臂，每条臂是一类。

    【思路】第 j 类的第 i 个点（i = 0..N−1）：
                半径 r = i / (N − 1)                         （从圆心 0 走到半径 1）
                角度 t = 4j + 4 · r + 噪声                    （每条臂转 4 弧度；不同类错开 4 弧度）
                点   = (r · sin t, r · cos t)
            这个生成方式是 Stanford CS231n 课程笔记里常用的螺旋数据（"一类一条臂"）。C = 3 时三条臂一共转了
            12 弧度（接近两圈），彼此缠绕 —— 任何一条直线都分不开它们。
            噪声加在**角度**上，所以越靠外的点（r 大）偏离得越远，臂的外端会和邻居重叠一些。
    【参数】
        m_per_class : 每类的点数 N
        C           : 类别数（≥ 2）
        rng         : np.random.Generator；None 时用种子 0
        noise       : 角度噪声的标准差（弧度）
    【返回】(X, y)：X 形状 (C·N, 2)，y 形状 (C·N,)，dtype int64，值是 0..C−1（同一类的点排在一起）
    【边界】N = 1 时 r 的分母为 0；这里要求 m_per_class ≥ 2。
    【DL 联系】§3.7 用它比较 softmax 回归（线性边界）和带一个隐藏层的 softmax 网络；第 3 讲的花朵数据是二分类版本的类似问题。
    【numpy 小知识】np.linspace(0, 1, N) 在 [0, 1] 上均匀取 N 个点（含两端）；
            np.column_stack([a, b]) 把两个长度 N 的一维数组并排拼成 (N, 2) 的矩阵。
    """
    if m_per_class < 2:
        raise ValueError("m_per_class 至少是 2")
    if rng is None:
        rng = np.random.default_rng(0)
    N = m_per_class
    X = np.zeros((C * N, 2))
    y = np.zeros(C * N, dtype=np.int64)
    for j in range(C):
        rows = slice(j * N, (j + 1) * N)                     # 第 j 类占的行
        r = np.linspace(0.0, 1.0, N)
        t = 4.0 * j + 4.0 * r + rng.normal(0.0, noise, N)
        X[rows] = np.column_stack([r * np.sin(t), r * np.cos(t)])
        y[rows] = j
    return X, y


# =============================================================================
# 2. 合成"手势"图 Hand signs（SIGNS 的替身）
# =============================================================================
IMG = 16                     # 图像边长（像素）
PAD = 2                      # 画布四周预留的边，平移时手不会被"卷"到另一边
N_CLASSES = 6
# 四根手指：(左边那一列, 伸直时最上面一行)。顺序：食指、中指、无名指、小指。每根 2 像素宽，中间隔 1 像素。
FINGERS = [(3, 3), (6, 2), (9, 3), (12, 4)]
# 类别 k 伸直哪几根手指（下标对应 FINGERS）；类别 5 另外伸出大拇指
RAISED = {0: [], 1: [0], 2: [0, 1], 3: [0, 1, 2], 4: [0, 1, 2, 3], 5: [0, 1, 2, 3]}
CLASS_NAMES = ["0 握拳", "1 食指", "2 两指", "3 三指", "4 四指", "5 五指"]


def draw_hand(k, rng, shift=2):
    """
    画一只"干净"的手（还没有加光照和噪声）：手的像素是 1，背景是 0。

    【思路】先在 (16 + 2·PAD) × (16 + 2·PAD) 的大画布上，按模块开头说明的位置画手掌、手指、大拇指；
            再整体平移 (dy, dx)，最后裁出中间 16×16。有了四周的留白，平移 ≤ PAD 时手不会越界。
    【参数】k：类别 0–5；rng：随机数生成器；shift：最大平移像素数（0 ≤ shift ≤ PAD）
    【返回】(16, 16) 的 float 数组，值是 0.0 或 1.0
    【边界】shift > PAD 时 np.roll 会把手"卷"到画布另一边，所以这里直接报错。
    【numpy 小知识】
        img[r0:r1, c0:c1] = 1.0  ：把第 r0..r1−1 行、第 c0..c1−1 列的矩形整块设成 1（切片是左闭右开）。
        np.roll(a, (dy, dx), axis=(0, 1))：把数组沿行方向循环移动 dy 格、沿列方向移动 dx 格。
        rng.integers(lo, hi)     ：[lo, hi) 之间的随机整数（不含 hi）。
    """
    if shift > PAD:
        raise ValueError(f"shift 不能超过 PAD = {PAD}")
    o = PAD
    canvas = np.zeros((IMG + 2 * PAD, IMG + 2 * PAD))
    canvas[o + 10:o + 15, o + 3:o + 14] = 1.0                       # 手掌：第 10–14 行、第 3–13 列
    for f, (col, top) in enumerate(FINGERS):
        if f in RAISED[k]:
            t = top + rng.integers(-1, 2)                           # 手指长短 ±1 行
            canvas[o + t:o + 10, o + col:o + col + 2] = 1.0          # 伸直：从 t 行一直画到第 9 行
        else:
            canvas[o + 8:o + 10, o + col:o + col + 2] = 1.0          # 弯曲：只剩 2 行"指节"
    if k == 5:
        r = 10 + rng.integers(-1, 1)                                # 大拇指的高度 ±1 行
        canvas[o + r:o + r + 2, o + 0:o + 3] = 1.0                   # 手掌左边一段横条
    dy, dx = rng.integers(-shift, shift + 1, size=2)
    canvas = np.roll(canvas, (dy, dx), axis=(0, 1))
    return canvas[o:o + IMG, o:o + IMG]


def make_hand_signs(m, rng=None, shift=2, noise=0.15, mirror=True):
    """
    生成 m 张 16×16 的合成手势图和标签（类别 0–5 均匀随机）。

    【思路】每张图：draw_hand 画出干净的手 → 一半概率左右翻转（左手 / 右手）→
            像素 = 背景亮度 + 手的亮度 × 手 + 高斯噪声。
            手的亮度 ∈ [0.5, 1.0]、背景亮度 ∈ [0, 0.3] 每张图各抽一次，模拟光照和肤色的变化。
    【参数】
        m      : 样本数
        rng    : np.random.Generator；None 时用种子 0
        shift  : 最大平移像素（默认 2）
        noise  : 像素噪声的标准差（默认 0.15）
        mirror : 是否随机左右翻转
    【返回】(X, y)：X 形状 (m, 16, 16)，float64；y 形状 (m,)，int64，值 0..5
    【边界】像素值没有裁剪到 [0, 1]（噪声会让少数像素略小于 0 或略大于 1），这对网络没有影响。
    【DL 联系】Coursera 的 SIGNS 训练集有 1080 张、测试集 120 张；§5 用 1080 张训练、500 张 dev、1000 张 test
            （test 取得比原作业大，是为了让准确率本身的随机误差小一些：1000 张时约 ±1 个百分点，120 张时约 ±3–4 个）。
    【numpy 小知识】img[:, ::-1]：列的顺序倒过来，也就是左右翻转；rng.random() 是 [0, 1) 上的均匀随机数。
    """
    if rng is None:
        rng = np.random.default_rng(0)
    X = np.zeros((m, IMG, IMG))
    y = rng.integers(0, N_CLASSES, size=m)
    for i in range(m):
        hand = draw_hand(int(y[i]), rng, shift)
        if mirror and rng.random() < 0.5:
            hand = hand[:, ::-1]
        bright = rng.uniform(0.5, 1.0)
        background = rng.uniform(0.0, 0.3)
        X[i] = background + bright * hand + rng.normal(0.0, noise, (IMG, IMG))
    return X, y.astype(np.int64)


def hand_sign_splits(m_train=1080, m_dev=500, m_test=1000):
    """
    §5 用的三份数据：训练 / dev / test，用三个不同的种子独立生成（同一个分布，没有共享的图）。

    【返回】dict：{"train": (X, y), "dev": (X, y), "test": (X, y)}，X 形状 (m, 16, 16)
    【DL 联系】dev（开发集）用来选超参数，test 只在最后用一次（第 6 讲讲数据划分）。
    """
    return {
        "train": make_hand_signs(m_train, np.random.default_rng(901)),
        "dev": make_hand_signs(m_dev, np.random.default_rng(902)),
        "test": make_hand_signs(m_test, np.random.default_rng(903)),
    }


def ascii_image(img, chars=" .:-=+*#%@"):
    """
    把一张灰度图画成字符画：越亮的像素用越"浓"的字符。

    【参数】img：(H, W) 数组；chars：从暗到亮的字符表
    【返回】list[str]，每个元素是一行字符（长度 W）
    【思路】先把像素线性映射到 0..len(chars)−1 的整数，再查表。每张图按自己的最小值 / 最大值缩放。
    【Python 小知识】"".join(列表) 把一串字符拼成一个字符串。
    """
    lo, hi = float(img.min()), float(img.max())
    n = len(chars)
    idx = np.clip(((img - lo) / (hi - lo + 1e-12) * n).astype(int), 0, n - 1)
    return ["".join(chars[v] for v in row) for row in idx]


def one_hot(y, C):
    """
    把整数类别变成 one-hot 矩阵（每一行一个样本，行约定）。

    【参数】y：(m,) 整数数组，值在 0..C−1；C：类别数
    【返回】(m, C) 的 float 数组：第 i 行只有第 y[i] 个位置是 1
    【边界】y 里有超出 0..C−1 的值时，numpy 的花式索引会报 IndexError。
    【numpy 小知识】Y[np.arange(m), y] = 1.0：花式索引，同时把 (0, y[0]), (1, y[1]), … 这 m 个位置设成 1。
    【DL 联系】Ng 的列约定下 one-hot 标签 Y 的形状是 (C, m)，就是这个结果的转置（讲义 §3.4）。
            PyTorch 的 nn.CrossEntropyLoss 直接收整数类别 y，不需要 one-hot（讲义 §4.6）。
    """
    m = len(y)
    Y = np.zeros((m, C))
    Y[np.arange(m), y] = 1.0
    return Y


if __name__ == "__main__":
    import os
    import sys
    sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
    from console import pad                       # 第 1 讲的小工具：按显示宽度补空格（汉字占 2 格）
    X, y = make_spirals()
    print(f"螺旋：X {X.shape}，y {y.shape}，每类 {np.bincount(y).tolist()} 个；半径范围 "
          f"[{np.hypot(X[:, 0], X[:, 1]).min():.2f}, {np.hypot(X[:, 0], X[:, 1]).max():.2f}]")
    d = hand_sign_splits()
    for name, (Xs, ys) in d.items():
        print(f"手势 {name:<5}：X {Xs.shape}，y {ys.shape}，各类个数 {np.bincount(ys, minlength=6).tolist()}，"
              f"像素均值 {Xs.mean():.3f}、标准差 {Xs.std():.3f}")
    Xs, ys = make_hand_signs(60, np.random.default_rng(5))
    first = [int(np.flatnonzero(ys == k)[0]) for k in range(N_CLASSES)]      # 每类第一张
    panels = [ascii_image(Xs[i]) for i in first]
    print("\n每类一张（字符越浓越亮）：")
    print("   ".join(pad(CLASS_NAMES[k], IMG) for k in range(N_CLASSES)))
    for r in range(IMG):
        print("   ".join(p[r] for p in panels))
