"""
=============================================================================
CS230 · 第 3 讲工具模块：一个隐藏层的神经网络，用 numpy 从零实现
=============================================================================

对应讲义：lectures/lecture03_shallow_neural_networks.md（§2 表示、§3 向量化、§4 激活函数、§6 反向传播、§7 初始化）

-----------------------------------------------------------------------------
【这个模块是什么】
-----------------------------------------------------------------------------
第 1 讲把 code/lecture01/tiny_nn.py 当作"黑盒"。本模块把其中"一个隐藏层"的情形**完全摊开**：
不用循环处理任意层数，而是把两层的每一个公式都明明白白地写出来，每一行注释都对应讲义里的一个公式。
函数的划分和 Coursera 编程作业 "Planar Data Classification with One Hidden Layer" 一样：

    layer_sizes            → 读出 n_x、n_h、n_y
    initialize_parameters  → W1, b1, W2, b2（§7：为什么 W 必须随机、为什么要小）
    forward_propagation    → Z1 → A1 → Z2 → A2（§2、§3）
    compute_cost           → 交叉熵 J（第 2 讲推导过）
    backward_propagation   → dW1, db1, dW2, db2（§6：六个公式）
    update_parameters      → 梯度下降的一步
    nn_model               → 把上面串成训练循环
    predict / accuracy     → 用训练好的网络做预测

外加：
    activation / activation_derivative → sigmoid、tanh、ReLU、Leaky ReLU、linear 及其导数（§4）
    logistic_regression    → 没有隐藏层的对照组（= 第 2 讲的逻辑回归）
    gradient_check         → 双边差分的数值梯度，检查反向传播写得对不对（§6.6；第 7 讲系统讲）

-----------------------------------------------------------------------------
【网络长什么样】（讲义 §2）
-----------------------------------------------------------------------------
    输入层 x（n_x 个特征）→ 隐藏层（n_h 个单元，激活函数 g）→ 输出层（1 个单元，sigmoid）

    单个样本（小写）：                         m 个样本按列堆叠（大写）：
        z[1] = W[1] x + b[1]     (n_h, 1)           Z1 = W1 @ X + b1      (n_h, m)
        a[1] = g(z[1])           (n_h, 1)           A1 = g(Z1)            (n_h, m)
        z[2] = W[2] a[1] + b[2]  (1, 1)             Z2 = W2 @ A1 + b2     (1, m)
        a[2] = σ(z[2]) = ŷ       (1, 1)             A2 = σ(Z2)            (1, m)

    参数形状：W1 (n_h, n_x)，b1 (n_h, 1)，W2 (n_y, n_h)，b2 (n_y, 1)。这里 n_y = 1。
    "数层数"：输入层不算，所以这是一个**两层网络**（一个隐藏层 + 一个输出层）。

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

直接运行本文件会做一次自检（每种激活函数的梯度检验 + 在花朵数据上训练一次）：
    python3 shallow_nn.py          # 约 1 秒
"""

import numpy as np


# =============================================================================
# 1. 激活函数及其导数 Activation functions & derivatives（讲义 §4）
# =============================================================================
LEAKY_SLOPE = 0.01        # Leaky ReLU 在 z < 0 时的斜率（Andrew Ng 课上用的就是 0.01）

ACTIVATIONS = ("linear", "sigmoid", "tanh", "relu", "leaky_relu")


def sigmoid(z):
    """
    σ(z) = 1 / (1 + e^{−z})，把任意实数压到 (0, 1)。

    【思路】直接写 1 / (1 + np.exp(-z)) 在 z 很负时 np.exp 会溢出并发出警告。
            恒等式 σ(z) = 0.5 · (1 + tanh(z / 2)) 结果完全相同，而 tanh 永远不会溢出。
            （第 1 讲的 tiny_nn.py 也是这样写的。）
    【参数】z：任意形状的数组
    【返回】形状相同的数组，值在 [0, 1]
    """
    return 0.5 * (1.0 + np.tanh(0.5 * z))


def activation(z, kind):
    """
    按名字计算激活函数 g(z)。

    【思路】五个候选（讲义 §4.1）：
                linear     ：g(z) = z                       （= 没有激活函数）
                sigmoid    ：g(z) = 1 / (1 + e^{−z})         输出 (0, 1)
                tanh       ：g(z) = (e^z − e^{−z}) / (e^z + e^{−z})   输出 (−1, 1)，以 0 为中心
                relu       ：g(z) = max(0, z)
                leaky_relu ：g(z) = max(0.01 z, z)
    【参数】z：任意形状的数组；kind：ACTIVATIONS 里的一个名字
    【返回】形状和 z 相同的数组
    【边界】名字写错时抛出 ValueError，而不是悄悄用错的函数。
    【numpy 小知识】np.where(条件, A, B)：逐个元素地"条件为真取 A，否则取 B"。
    """
    if kind == "linear":
        return z
    if kind == "sigmoid":
        return sigmoid(z)
    if kind == "tanh":
        return np.tanh(z)
    if kind == "relu":
        return np.maximum(0.0, z)
    if kind == "leaky_relu":
        return np.where(z > 0, z, LEAKY_SLOPE * z)
    raise ValueError(f"unknown activation: {kind}")


def activation_derivative(z, a, kind):
    """
    激活函数的导数 g'(z)，逐个元素计算。

    【思路】讲义 §4.2 的推导结果：
                linear     ：g'(z) = 1
                sigmoid    ：g'(z) = σ(z)(1 − σ(z)) = a(1 − a)       最大 0.25（z = 0 处）
                tanh       ：g'(z) = 1 − tanh²(z) = 1 − a²            最大 1（z = 0 处）
                relu       ：g'(z) = 1（z > 0），0（z < 0）
                leaky_relu ：g'(z) = 1（z > 0），0.01（z < 0）
            sigmoid 和 tanh 的导数可以直接用前向时算好的 a 表示 —— 反向传播时不用再算一遍 e^z，
            这是它们"好用"的一个原因。
            ReLU 在 z = 0 处严格来说不可导。代码里把 z = 0 归到"导数为 0"那一边（Leaky ReLU 归到 0.01）；
            浮点数恰好等于 0 的概率几乎为零，选哪一边对训练没有影响（讲义 §4.2）。
    【参数】z：前向时的 Z；a：前向时的 A = g(Z)（形状和 z 相同）；kind：激活函数名字
    【返回】形状和 z 相同的数组
    【numpy 小知识】(z > 0) 是布尔数组；.astype(float) 把 True / False 变成 1.0 / 0.0。
            np.ones_like(z) 生成和 z 形状相同的全 1 数组。
    """
    if kind == "linear":
        return np.ones_like(z)
    if kind == "sigmoid":
        return a * (1.0 - a)
    if kind == "tanh":
        return 1.0 - a ** 2
    if kind == "relu":
        return (z > 0).astype(float)
    if kind == "leaky_relu":
        return np.where(z > 0, 1.0, LEAKY_SLOPE)
    raise ValueError(f"unknown activation: {kind}")


# =============================================================================
# 2. 网络的大小与初始化（讲义 §2.3、§7）
# =============================================================================
def layer_sizes(X, Y, n_h):
    """
    读出三层的大小：n_x = 输入特征数，n_h = 隐藏单元数（你自己选的超参数），n_y = 输出单元数。

    【参数】X：(n_x, m)；Y：(n_y, m)；n_h：int
    【返回】(n_x, n_h, n_y)
    """
    return X.shape[0], n_h, Y.shape[0]


def initialize_parameters(n_x, n_h, n_y, rng, scale=0.01, method="random"):
    """
    初始化 W1, b1, W2, b2。

    【思路】（讲义 §7）
        · method = "random"（默认，Coursera 作业的做法）：
              W[l] = 标准正态随机数 × scale，b[l] = 0。scale 默认 0.01（"小随机数"）。
          为什么 W 要**随机**：如果同一层的所有隐藏单元初始值相同，它们的输出相同、梯度相同、
          更新后仍然相同 —— 永远是同一个单元的 n_h 份复制品（"对称性"，§7.1）。随机数打破了这种对称。
          为什么 b 可以是 0：只要 W 是随机的，各单元已经不同了，b 一样也没关系。
          为什么要**小**：tanh / sigmoid 在 |z| 大的地方几乎是平的（饱和），导数接近 0，学得很慢（§7.4）。
        · method = "zeros"：全部为 0（演示"零初始化为什么不行"，代码 04）。
        · method = "constant"：W 的每个元素都等于 scale，b = 0（"不为 0 但处处相同"，同样对称）。
    【参数】
        n_x, n_h, n_y : 三层的大小
        rng           : np.random.Generator（method 为 "zeros" / "constant" 时不使用）
        scale         : 随机权重的标准差（"random"）或常数值（"constant"）
        method        : "random" | "zeros" | "constant"
    【返回】dict：{"W1": (n_h, n_x), "b1": (n_h, 1), "W2": (n_y, n_h), "b2": (n_y, 1)}
    【边界】method 写错时抛出 ValueError。
    【DL 联系】0.01 这个尺度对一个隐藏层够用；层数一多就不行了（信号一层层缩小），
            第 7 讲推导 Xavier / He 初始化：标准差取 √(1/n_{l−1}) 或 √(2/n_{l−1})。
    【numpy 小知识】rng.normal(size=(a, b)) 生成 a 行 b 列的标准正态随机数。
            np.full((a, b), c) 生成 a 行 b 列、每个元素都是 c 的数组。
    """
    if method == "random":
        W1 = rng.normal(size=(n_h, n_x)) * scale
        W2 = rng.normal(size=(n_y, n_h)) * scale
    elif method == "zeros":
        W1 = np.zeros((n_h, n_x))
        W2 = np.zeros((n_y, n_h))
    elif method == "constant":
        W1 = np.full((n_h, n_x), float(scale))
        W2 = np.full((n_y, n_h), float(scale))
    else:
        raise ValueError(f"unknown init method: {method}")
    return {"W1": W1, "b1": np.zeros((n_h, 1)), "W2": W2, "b2": np.zeros((n_y, 1))}


# =============================================================================
# 3. 前向传播与损失（讲义 §2、§3）
# =============================================================================
def forward_propagation(X, params, hidden="tanh"):
    """
    前向传播：X → Z1 → A1 → Z2 → A2。

    【思路】四行公式（讲义 §3.2），每一行都同时处理 m 个样本（样本是列）：
                Z1 = W1 X + b1        (n_h, n_x) @ (n_x, m) = (n_h, m)；b1 (n_h, 1) 被广播到 m 列
                A1 = g(Z1)            逐元素，形状不变 (n_h, m)
                Z2 = W2 A1 + b2       (1, n_h) @ (n_h, m) = (1, m)
                A2 = σ(Z2)            (1, m)：每个样本"是第 1 类"的概率
            Z1、A1、Z2、A2 都要存进 cache：反向传播时要用（§6.3）。
    【参数】X：(n_x, m)；params：initialize_parameters 返回的字典；hidden：隐藏层激活函数名字
    【返回】(A2, cache)：A2 形状 (1, m)；cache 是字典 {"Z1", "A1", "Z2", "A2"}
    【DL 联系】矩阵 Z1 的**第 i 行**是第 i 个隐藏单元对所有样本的输出，**第 j 列**是第 j 个样本在所有
            隐藏单元上的输出（"横着是样本，竖着是单元"，§3.2）。
    【numpy 小知识】W1 @ X 是矩阵乘法（等价于 np.dot(W1, X)）；+ b1 时 numpy 的广播（broadcasting）
            自动把 (n_h, 1) 的 b1 复制成 m 列。
    """
    W1, b1, W2, b2 = params["W1"], params["b1"], params["W2"], params["b2"]
    Z1 = W1 @ X + b1
    A1 = activation(Z1, hidden)
    Z2 = W2 @ A1 + b2
    A2 = sigmoid(Z2)                                   # 二分类：输出层固定用 sigmoid（§4.5）
    return A2, {"Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}


def compute_cost(A2, Y):
    """
    交叉熵损失（cross-entropy cost）：J = −(1/m) Σ [ y log a + (1 − y) log(1 − a) ]。

    【思路】第 2 讲推导过：它是"把输出当作概率"时的负对数似然，和 sigmoid 输出层配合时
            梯度恰好是 A2 − Y（§6.2）。
    【参数】A2：(1, m) 预测概率；Y：(1, m) 0/1 标签
    【返回】float
    【边界】A2 恰好等于 0 或 1（大的初始化下 sigmoid 会饱和到浮点数的极限）时 log(0) = −∞。
            先把 A2 裁剪到 [1e−12, 1 − 1e−12]：被裁剪的样本最多贡献 −log(1e−12) ≈ 27.6 的损失。
    【numpy 小知识】np.clip(A, lo, hi)：把小于 lo 的值改成 lo、大于 hi 的值改成 hi。
    """
    P = np.clip(A2, 1e-12, 1.0 - 1e-12)
    return float(-np.mean(Y * np.log(P) + (1.0 - Y) * np.log(1.0 - P)))


# =============================================================================
# 4. 反向传播（讲义 §6）—— 本讲最重要的函数
# =============================================================================
def backward_propagation(params, cache, X, Y, hidden="tanh"):
    """
    反向传播：算出 cost J 对四个参数的梯度。

    【思路】从输出往回走，每一行对应讲义 §6.3 的一个公式（m 是样本数，* 是逐元素乘法）：
                dZ2 = A2 − Y                          (1, m)      sigmoid + 交叉熵的"神奇简化"（第 2 讲）
                dW2 = (1/m) dZ2 A1ᵀ                   (1, n_h)    和 W2 同形状
                db2 = (1/m) Σ_样本 dZ2                (1, 1)      和 b2 同形状
                dZ1 = (W2ᵀ dZ2) * g'(Z1)              (n_h, m)    先把误差"分回"每个隐藏单元，再乘上激活函数的斜率
                dW1 = (1/m) dZ1 Xᵀ                    (n_h, n_x)  和 W1 同形状
                db1 = (1/m) Σ_样本 dZ1                (n_h, 1)    和 b1 同形状
            规律：每一层都是 "dW = (1/m) · 本层的 dZ · 本层的输入ᵀ"、"db = (1/m) · dZ 按样本求和"。
            1/m 来自 J 是 m 个样本损失的**平均**。
    【参数】params：当前参数；cache：forward_propagation 返回的 cache；X：(n_x, m)；Y：(1, m)；hidden：激活函数名字
    【返回】grads：dict {"dW1", "db1", "dW2", "db2"}，形状和对应的参数完全相同
    【边界】只支持"sigmoid 输出 + 交叉熵"这个组合（dZ2 = A2 − Y 只对这个组合成立）。
    【numpy 小知识】
        A.T                           ：转置，(n_h, m) → (m, n_h)
        dZ.sum(axis=1, keepdims=True) ：沿第 1 维（样本那一维）求和。keepdims=True 让结果保持二维
                                        (n, 1)，否则会变成形状 (n,) 的 rank-1 数组（第 1 讲 §3.5 的陷阱）。
        W2.T @ dZ2                    ：(n_h, 1) @ (1, m) = (n_h, m)
        (...) * g'(Z1)                ：两个 (n_h, m) 的数组**逐元素**相乘，不是矩阵乘法
    """
    m = X.shape[1]
    W2 = params["W2"]
    A1, A2, Z1 = cache["A1"], cache["A2"], cache["Z1"]

    dZ2 = A2 - Y                                                   # (1, m)       公式 (1)
    dW2 = (1.0 / m) * (dZ2 @ A1.T)                                 # (1, n_h)     公式 (2)
    db2 = (1.0 / m) * np.sum(dZ2, axis=1, keepdims=True)           # (1, 1)       公式 (3)
    dZ1 = (W2.T @ dZ2) * activation_derivative(Z1, A1, hidden)     # (n_h, m)     公式 (4)：逐元素乘 g'(Z1)
    dW1 = (1.0 / m) * (dZ1 @ X.T)                                  # (n_h, n_x)   公式 (5)
    db1 = (1.0 / m) * np.sum(dZ1, axis=1, keepdims=True)           # (n_h, 1)     公式 (6)
    return {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}


def update_parameters(params, grads, learning_rate=1.2):
    """
    梯度下降的一步：θ ← θ − α · dθ，对 W1, b1, W2, b2 各做一次。

    【参数】params、grads：同名的两个字典；learning_rate：学习率 α（Coursera 作业里是 1.2）
    【返回】新的参数字典（不修改传进来的那个）
    【Python 小知识】{k: ... for k in params} 是**字典推导式**：对 params 的每个键 k 算出一个值，组成新字典。
            "d" + k 把字符串拼起来："W1" → "dW1"。
    """
    return {k: params[k] - learning_rate * grads["d" + k] for k in params}


# =============================================================================
# 5. 训练循环（Coursera: nn_model）
# =============================================================================
def nn_model(X, Y, n_h, rng, num_iterations=10000, learning_rate=1.2, hidden="tanh",
             init_scale=0.01, init_method="random", record_every=100, X_test=None, Y_test=None):
    """
    训练一个 [n_x, n_h, 1] 的网络：重复"前向 → 算 cost → 反向 → 更新" num_iterations 次（全批量梯度下降）。

    【思路】和 Coursera 作业的 nn_model 一样，每一步用**全部** m 个训练样本（花朵数据只有 400 个，
            一次算完很便宜；mini-batch 是第 8 讲的内容）。每隔 record_every 步记录一次 cost 和准确率，
            用来画学习曲线、找"第几步达到某个准确率"。
            发散检测：学习率太大时数值会爆炸。矩阵乘法里产生的 inf / nan **不会**触发 numpy 的浮点警告
            （第 1 讲踩过的坑），所以每一步显式检查 cost 是否是有限的数，一旦不是就停止并标记 diverged。
    【参数】
        X, Y           : 训练数据 (n_x, m)、(1, m)
        n_h            : 隐藏单元个数
        rng            : 随机数生成器（只用于初始化；全批量训练没有其他随机性）
        num_iterations : 梯度下降的步数（默认 10000，和作业一样）
        learning_rate  : 学习率（默认 1.2，和作业一样）
        hidden         : 隐藏层激活函数
        init_scale, init_method : 传给 initialize_parameters
        record_every   : 每隔多少步记录一次
        X_test, Y_test : 可选的测试集（只用来记录，不参与训练）
    【返回】(params, history)
        params  : 训练好的参数
        history : dict，"iter"、"cost"、"train_acc"、"test_acc"（没有测试集时为空 list）等长；
                  另有 "diverged"（bool）和 "init"（初始参数的副本，代码 04 要看"训练前后"）
    【边界】diverged 时返回发散前最后一步的参数（cost 仍是有限值的那一步）。
    """
    n_x, n_h, n_y = layer_sizes(X, Y, n_h)
    params = initialize_parameters(n_x, n_h, n_y, rng, init_scale, init_method)
    history = {"iter": [], "cost": [], "train_acc": [], "test_acc": [], "diverged": False,
               "init": {k: v.copy() for k, v in params.items()}}

    def record(i, A2, cost):
        history["iter"].append(i)
        history["cost"].append(cost)
        history["train_acc"].append(float(np.mean((A2 > 0.5) == Y)))
        if X_test is not None:
            history["test_acc"].append(accuracy(params, X_test, Y_test, hidden))

    for i in range(num_iterations + 1):
        A2, cache = forward_propagation(X, params, hidden)
        cost = compute_cost(A2, Y)
        if not (np.isfinite(cost) and np.all(np.isfinite(A2))):
            history["diverged"] = True
            break
        if i % record_every == 0 or i == num_iterations:
            record(i, A2, cost)
        if i == num_iterations:                    # 最后一次只记录、不再更新
            break
        grads = backward_propagation(params, cache, X, Y, hidden)
        new_params = update_parameters(params, grads, learning_rate)
        if not all(np.all(np.isfinite(v)) for v in new_params.values()):
            history["diverged"] = True
            break
        params = new_params
    return params, history


def iterations_to_reach(history, threshold, key="train_acc"):
    """
    从训练历史里读出"第一次达到 threshold 是第几步"。

    【参数】history：nn_model 返回的 history；threshold：比如 0.85；key：看哪一列
    【返回】int（步数），或者 None（整个训练过程都没达到）
    【边界】只在记录点上检查（每 record_every 步一次），所以结果是 record_every 的整数倍。
    【Python 小知识】zip(a, b) 把两个列表"拉链式"配对；for ... in zip(...) 一次取一对。
    """
    for it, v in zip(history["iter"], history[key]):
        if v >= threshold:
            return it
    return None


# =============================================================================
# 6. 预测
# =============================================================================
def predict(params, X, hidden="tanh"):
    """
    用训练好的网络预测类别：概率 A2 > 0.5 判为 1，否则判为 0。

    【参数】params；X：(n_x, m)；hidden：训练时用的激活函数（必须一致！）
    【返回】(1, m) 的 0.0 / 1.0 数组
    【边界】hidden 和训练时不一致不会报错，但预测是错的 —— 这是一类"不报错的 bug"。
    """
    A2, _ = forward_propagation(X, params, hidden)
    return (A2 > 0.5).astype(float)


def accuracy(params, X, Y, hidden="tanh"):
    """准确率：预测类别和真实标签相同的样本比例（0 到 1 之间的 float）。"""
    return float(np.mean(predict(params, X, hidden) == Y))


# =============================================================================
# 7. 对照组：逻辑回归（= 没有隐藏层的网络，第 2 讲）
# =============================================================================
def logistic_regression(X, Y, num_iterations=10000, learning_rate=1.2):
    """
    用梯度下降训练逻辑回归：ŷ = σ(wᵀx + b)。

    【思路】这是第 2 讲的内容，这里只是拿来当对照组。公式（第 2 讲推导）：
                Z = wᵀ X + b         (1, m)
                A = σ(Z)
                dZ = A − Y
                dw = (1/m) X dZᵀ     (n_x, 1)
                db = (1/m) Σ dZ
            从 w = 0、b = 0 开始：逻辑回归只有一个"单元"，没有对称性问题，零初始化完全没问题（§7.1）。
            逻辑回归的 cost 是凸函数，梯度下降最终会走到唯一的最优点附近 —— 结果和初始化无关。
    【参数】X：(n_x, m)；Y：(1, m)；num_iterations、learning_rate：同 nn_model
    【返回】dict {"w": (n_x, 1), "b": (1, 1)}
    """
    n_x, m = X.shape
    w = np.zeros((n_x, 1))
    b = np.zeros((1, 1))
    for _ in range(num_iterations):
        A = sigmoid(w.T @ X + b)
        dZ = A - Y
        w = w - learning_rate * (X @ dZ.T) / m
        b = b - learning_rate * np.sum(dZ, axis=1, keepdims=True) / m
    return {"w": w, "b": b}


def predict_logistic(lr_params, X):
    """逻辑回归的预测：σ(wᵀX + b) > 0.5 判为 1。返回 (1, m) 的 0.0 / 1.0 数组。"""
    return (sigmoid(lr_params["w"].T @ X + lr_params["b"]) > 0.5).astype(float)


# =============================================================================
# 8. 梯度检验（讲义 §6.6；第 7 讲系统讲）
# =============================================================================
def gradient_check(params, X, Y, hidden="tanh", eps=1e-7, backward_fn=None):
    """
    用双边差分（two-sided difference）的数值梯度检查反向传播算出的梯度。

    【思路】导数的定义：J 对某个参数 θ 的偏导 ≈ [J(θ + ε) − J(θ − ε)] / (2ε)。
            对每一个参数元素（W1 的每个格子、b1 的每个格子……）都这样"推一下、量一下"，
            得到数值梯度 g_num；再和反向传播给出的 g_ana 比较：
                相对误差 = ‖g_num − g_ana‖ / (‖g_num‖ + ‖g_ana‖)
            经验（Andrew Ng 的建议）：≈ 1e−7 或更小 → 很好；≈ 1e−5 → 要小心；≥ 1e−3 → 几乎肯定有 bug。
            为什么用**双边**差分而不是单边 [J(θ + ε) − J(θ)] / ε：双边的误差是 O(ε²)，单边是 O(ε)，准得多。
    【参数】
        params      : 要在哪一组参数上检查（不会被修改）
        X, Y        : 数据（用很少几个样本就够了）
        hidden      : 激活函数名字
        eps         : 推一下的幅度 ε
        backward_fn : 要检查的反向传播函数，签名同 backward_propagation；None 时用本模块的那个。
                      代码 02 用它检查一个"故意写错"的版本。
    【返回】(rel_error, per_param)：总体相对误差（float），以及每个参数各自的相对误差（dict）
    【边界】· 很慢：每个参数元素要做两次前向传播。只在调试时、用小网络和少量样本做，训练时关掉。
            · ReLU 在 z = 0 处不可导：如果某个 z 恰好落在 ±ε 之内，那一项的数值梯度会不准。随机数据上几乎不会发生。
    【numpy 小知识】np.ndindex(shape) 依次给出数组里每个位置的下标元组，比如 (0, 0), (0, 1), ...
            np.linalg.norm(v)：向量的长度（各元素平方和开根号）。
    """
    if backward_fn is None:
        backward_fn = backward_propagation
    work = {k: v.copy() for k, v in params.items()}               # 复制一份，"推一下"只改副本
    _, cache = forward_propagation(X, work, hidden)
    grads = backward_fn(work, cache, X, Y, hidden)
    num_all, ana_all, per_param = [], [], {}
    for k in ("W1", "b1", "W2", "b2"):
        num_k = np.zeros_like(work[k])
        for idx in np.ndindex(work[k].shape):
            old = work[k][idx]
            work[k][idx] = old + eps
            j_plus = compute_cost(forward_propagation(X, work, hidden)[0], Y)
            work[k][idx] = old - eps
            j_minus = compute_cost(forward_propagation(X, work, hidden)[0], Y)
            work[k][idx] = old                                      # 一定要还原！
            num_k[idx] = (j_plus - j_minus) / (2 * eps)
        ana_k = grads["d" + k]
        denom = np.linalg.norm(num_k) + np.linalg.norm(ana_k)
        per_param[k] = float(np.linalg.norm(num_k - ana_k) / denom) if denom > 0 else 0.0
        num_all.append(num_k.ravel())
        ana_all.append(ana_k.ravel())
    num_v, ana_v = np.concatenate(num_all), np.concatenate(ana_all)
    rel = float(np.linalg.norm(num_v - ana_v) / (np.linalg.norm(num_v) + np.linalg.norm(ana_v)))
    return rel, per_param


if __name__ == "__main__":
    from planar_data import get_dataset

    X_tr, Y_tr, X_te, Y_te = get_dataset("flower")
    rng = np.random.default_rng(0)
    p = initialize_parameters(2, 4, 1, rng, scale=1.0)       # 用较大的随机权重，让 z 不全挤在 0 附近
    for kind in ACTIVATIONS:
        rel, _ = gradient_check(p, X_tr[:, :10], Y_tr[:, :10], kind)
        print(f"梯度检验  {kind:<11} 相对误差 = {rel:.1e}")
    params, hist = nn_model(X_tr, Y_tr, 4, np.random.default_rng(2), X_test=X_te, Y_test=Y_te)
    print(f"花朵数据，4 个 tanh 隐藏单元，10000 步：cost {hist['cost'][0]:.4f} → {hist['cost'][-1]:.4f}，"
          f"训练准确率 {hist['train_acc'][-1]:.3f}，测试准确率 {hist['test_acc'][-1]:.3f}")
