"""
=============================================================================
CS230 · 第 4 讲共享模块：L 层神经网络的"积木"（numpy 从零实现，和 Coursera 作业同构）
=============================================================================

对应讲义：lectures/lecture04_deep_neural_networks.md（§2 前向、§3 维度、§4 积木与反向传播）

-----------------------------------------------------------------------------
【这个模块是什么】
-----------------------------------------------------------------------------
第 1 讲把 code/lecture01/tiny_nn.py 当作黑盒，说"第 4 讲会拆开 L 层的通用前向 / 反向"。
这个模块就是拆开后的样子。它和 Coursera 编程作业 "Building your Deep Neural Network: Step by Step"
用同样的函数名和同样的分工，方便你对照：

    初始化     initialize_parameters_deep      每层一个 W[l]（随机）、b[l]（0）
    前向积木   linear_forward                  Z = W A_prev + b            （存 cache = (A_prev, W, b)）
               linear_activation_forward       A = g(Z)                     （再存 cache = Z）
               L_model_forward                 把 L 块积木串起来：X → A[1] → ... → A[L]
    损失       compute_cost                    二元交叉熵（binary cross-entropy）
    反向积木   linear_backward                 已知 dZ，用 cache 算 dW、db、dA_prev
               linear_activation_backward      已知 dA，先穿过激活函数得到 dZ，再调 linear_backward
               L_model_backward                从输出层往回，把 L 块反向积木串起来
    更新       update_parameters               梯度下降 W ← W − α dW
    训练 / 预测 L_layer_model、predict、accuracy
    检查工具   check_dimensions（维度检查）、gradient_check（双边差分梯度检验）、count_parameters

-----------------------------------------------------------------------------
【一张图看懂"积木"】（讲义 §4.1 有更详细的版本）
-----------------------------------------------------------------------------
    前向：  A[0]=X ─┬─[第1层 forward]─┬─ A[1] ─┬─[第2层 forward]─┬─ ... ─→ A[L] ─→ 损失 J
                    │                 │        │                 │
                    │  cache[1]=(A[0],W[1],b[1]),Z[1]  cache[2]=...              （每层把"反向时要用的东西"存起来）
                    ↓                          ↓
    反向：  dW[1],db[1] ←─[第1层 backward]←─ dA[1] ←─[第2层 backward]←─ ... ←─ dA[L]（由损失函数给出）

每一层的前向积木：输入 A[l−1]，输出 A[l] 和 cache[l]；
每一层的反向积木：输入 dA[l] 和 cache[l]，输出 dA[l−1]、dW[l]、db[l]。
有了这两块积木，L 层网络就是"把同一块积木重复 L 次"。

-----------------------------------------------------------------------------
【形状约定】（Andrew Ng 的记号，全课统一；讲义 §3）
-----------------------------------------------------------------------------
    layer_dims = [n[0], n[1], ..., n[L]]，n[0] = n_x（输入特征数），n[L] = 1（二分类输出）
    X、A[l−1]  : (n[l−1], m)        每一列是一个样本
    W[l]       : (n[l], n[l−1])     "输出个数 × 输入个数"
    b[l]       : (n[l], 1)          列向量，加到 (n[l], m) 上时被广播到每一列
    Z[l]、A[l] : (n[l], m)
    dW[l]、db[l]、dZ[l]、dA[l]：和 W[l]、b[l]、Z[l]、A[l] 的形状**完全相同**

【注释标签】
    【思路】为什么这样写   【参数】【返回】输入输出（包括形状）   【边界】什么时候会出问题
    【DL 联系】这个函数和深度学习的哪个概念有关、后面哪一讲会深入
    【numpy 小知识】/【Python 小知识】第一次出现的写法

直接运行本文件会做一次自检（约 1 秒）：
    python3 deep_nn.py
"""

import os
import sys

import numpy as np

# 让本模块能找到第 1 讲的目录（只读地导入 tiny_nn 做对照、导入 Adam 优化器）
_LECTURE01 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01")
if _LECTURE01 not in sys.path:
    sys.path.insert(0, _LECTURE01)


# =============================================================================
# 1. 激活函数：前向返回 (A, cache)，反向用 cache 算 dZ（Coursera 作业的写法）
# =============================================================================
def sigmoid(Z):
    """
    sigmoid(Z) = 1 / (1 + e^{−Z})，把任意实数压到 (0, 1)。

    【思路】直接写 1 / (1 + np.exp(-Z)) 在 Z 很负时 np.exp 会溢出并发出警告；
            用恒等式 sigmoid(z) = 0.5 · (1 + tanh(z / 2)) 计算，tanh 永远不会溢出，数学上完全相同。
    【参数】Z：任意形状的数组（通常是 (n[l], m)）
    【返回】(A, cache)：A 形状和 Z 相同；cache 就是 Z 本身（反向时要用 Z 重新算导数）
    【边界】|z| 大于约 37 时，float64 里 sigmoid(z) 会**恰好**等于 1.0 或 0.0（精度的极限）。
            这会让后面的 log(1 − A) 或 1 / (1 − A) 出问题，见 compute_cost 和 L_model_backward。
    """
    A = 0.5 * (1.0 + np.tanh(0.5 * Z))
    return A, Z


def relu(Z):
    """
    ReLU(Z) = max(0, Z)。

    【返回】(A, cache)，cache = Z
    【numpy 小知识】np.maximum(0, Z) 是**逐元素**取较大值；np.max(Z) 则是整个数组的最大值（一个数），别混了。
    """
    return np.maximum(0.0, Z), Z


def tanh(Z):
    """tanh(Z)，值域 (−1, 1)。【返回】(A, cache)，cache = Z。"""
    return np.tanh(Z), Z


def linear(Z):
    """
    恒等激活 g(Z) = Z。

    【DL 联系】回归任务的输出层用它；本讲代码 02 手工构造 parity 网络时，输出层也用它，
            这样输出恰好等于 0 或 1，方便检查"网络是否精确地算出了 parity"。
    """
    return Z, Z


def sigmoid_backward(dA, cache):
    """
    sigmoid 的反向：dZ = dA · σ'(Z)，其中 σ'(Z) = σ(Z)(1 − σ(Z))。

    【思路】链式法则：Z 变一点 → A 变 σ'(Z) 倍 → 损失变 dA 倍。所以 dZ = dA * σ'(Z)（逐元素相乘）。
    【参数】dA：损失对 A 的梯度，形状 (n[l], m)；cache：前向时存下的 Z，形状相同
    【返回】dZ，形状 (n[l], m)
    """
    s = 0.5 * (1.0 + np.tanh(0.5 * cache))
    return dA * s * (1.0 - s)


def relu_backward(dA, cache):
    """
    ReLU 的反向：dZ = dA · 1{Z > 0}（Z > 0 处导数为 1，Z ≤ 0 处为 0）。

    【边界】Z 恰好等于 0 时 ReLU 不可导，这里按惯例取 0（和 Coursera、PyTorch 一样）。
    【numpy 小知识】(cache > 0) 是一个布尔数组；它和浮点数相乘时 True 当作 1、False 当作 0。
    """
    return dA * (cache > 0)


def tanh_backward(dA, cache):
    """tanh 的反向：dZ = dA · (1 − tanh²(Z))。"""
    t = np.tanh(cache)
    return dA * (1.0 - t ** 2)


def linear_backward_act(dA, cache):
    """恒等激活的反向：dZ = dA（导数恒为 1）。"""
    return dA


# 名字 → (前向函数, 反向函数)。用字典"按名字查函数"，比写一长串 if / elif 更不容易漏改。
# 【Python 小知识】函数在 Python 里也是普通的对象，可以放进字典、当作参数传来传去。
ACTIVATIONS = {
    "sigmoid": (sigmoid, sigmoid_backward),
    "relu": (relu, relu_backward),
    "tanh": (tanh, tanh_backward),
    "linear": (linear, linear_backward_act),
}


def _act(name):
    """按名字取出 (forward, backward)；名字写错时给出清楚的错误，而不是 KeyError。"""
    if name not in ACTIVATIONS:
        raise ValueError(f"unknown activation: {name}（可选：{sorted(ACTIVATIONS)}）")
    return ACTIVATIONS[name]


# =============================================================================
# 2. 初始化
# =============================================================================
def initialize_parameters_deep(layer_dims, rng, init="he"):
    """
    为 L 层网络初始化参数：W[l] 随机、b[l] 为 0。

    【思路】
        · W 必须随机（第 3 讲：全 0 初始化会让同一层的单元永远一模一样，"对称性"问题）；b 可以是 0。
        · 随机数的**尺度**决定信号穿过很多层之后是爆炸还是消失。本函数提供三种选择：
              init="he"     ：W ~ N(0, 2 / n[l−1])      ReLU 网络的常用选择（He et al. 2015）
              init="xavier" ：W ~ N(0, 1 / n[l−1])      tanh / sigmoid 网络的常用选择（Glorot & Bengio 2010 的简化版）
              init=0.01 等数：W ~ N(0, 1) × 0.01         Coursera "Step by Step" 作业里的写法
          Coursera "Deep Neural Network Application" 作业里用的是 randn / sqrt(n[l−1])，也就是这里的 "xavier"。
          为什么是这几个数，第 7 讲推导；本讲只需要知道"深网的初始化尺度很要紧"。
    【参数】
        layer_dims : list[int]，[n_x, n[1], ..., n[L]]，长度 L + 1 ≥ 2
        rng        : np.random.Generator（由调用者传入，保证可复现）
        init       : "he" | "xavier" | 一个正数（固定的缩放系数）
    【返回】dict：{"W1": (n[1], n[0]), "b1": (n[1], 1), ..., "WL": (n[L], n[L−1]), "bL": (n[L], 1)}
    【边界】每一层都 assert 形状正确；layer_dims 里有 0 或负数时 numpy 会报错。

    【numpy 小知识】rng.standard_normal((a, b)) 生成 a 行 b 列的标准正态随机数（均值 0、标准差 1）。
            np.zeros((n, 1)) 生成 n 行 1 列的全 0 列向量 —— 注意要传一个元组 (n, 1)，
            写成 np.zeros(n) 会得到形状 (n,) 的一维数组（rank-1 数组，第 1 讲 §3.5 的陷阱）。
    【Python 小知识】isinstance(x, str) 判断 x 是不是字符串；f"W{l}" 是 f-string，l = 2 时得到 "W2"。
    """
    params = {}
    L = len(layer_dims) - 1                         # 层数：输入层不算
    for l in range(1, L + 1):
        n_prev, n_cur = layer_dims[l - 1], layer_dims[l]
        if isinstance(init, str):
            if init == "he":
                scale = np.sqrt(2.0 / n_prev)
            elif init == "xavier":
                scale = np.sqrt(1.0 / n_prev)
            else:
                raise ValueError(f"unknown init: {init}")
        else:
            scale = float(init)
        params[f"W{l}"] = rng.standard_normal((n_cur, n_prev)) * scale
        params[f"b{l}"] = np.zeros((n_cur, 1))
        assert params[f"W{l}"].shape == (n_cur, n_prev)
        assert params[f"b{l}"].shape == (n_cur, 1)
    return params


def count_parameters(layer_dims):
    """
    参数总数 = Σ_l (n[l] · n[l−1] + n[l])：每层权重矩阵的元素个数 + 偏置个数。

    【例子】[2, 3, 5, 4, 2, 1]：(6+3) + (15+5) + (20+4) + (8+2) + (2+1) = 66
    【Python 小知识】sum(... for l in ...) 是"生成器表达式"：边算边加，不需要先建一个列表。
    """
    return int(sum(layer_dims[l] * layer_dims[l - 1] + layer_dims[l] for l in range(1, len(layer_dims))))


# =============================================================================
# 3. 前向传播的积木
# =============================================================================
def linear_forward(A_prev, W, b):
    """
    一层的线性部分：Z = W A_prev + b。

    【思路】对 m 个样本一次算完：A_prev 的每一列是一个样本，W @ A_prev 的每一列就是 W 乘这个样本；
            b 是 (n[l], 1) 的列向量，numpy 会把它"复制"到 m 列上再相加（broadcasting，广播）。
    【参数】A_prev：(n[l−1], m)；W：(n[l], n[l−1])；b：(n[l], 1)
    【返回】(Z, cache)：Z 形状 (n[l], m)；cache = (A_prev, W, b)，反向时 linear_backward 要用
    【边界】W 的列数必须等于 A_prev 的行数，否则 @ 直接报错（这是"好"的错误，立刻暴露）；
            但 b 写成 (n[l],) 时可能**不报错、悄悄算错**（代码 01 演示 3），所以这里 assert 一下 Z 的形状。
    【numpy 小知识】@ 是矩阵乘法（等价于 np.dot / np.matmul）；* 是逐元素乘法。两者千万别混。
    """
    Z = W @ A_prev + b
    assert Z.shape == (W.shape[0], A_prev.shape[1]), f"Z 的形状 {Z.shape} 不对，应为 {(W.shape[0], A_prev.shape[1])}"
    return Z, (A_prev, W, b)


def linear_activation_forward(A_prev, W, b, activation):
    """
    一整层的前向积木：先线性、再激活。A = g(W A_prev + b)。

    【参数】A_prev：(n[l−1], m)；W、b：这一层的参数；activation："relu" | "sigmoid" | "tanh" | "linear"
    【返回】(A, cache)：A 形状 (n[l], m)；cache = (linear_cache, activation_cache)
                       = ((A_prev, W, b), Z)。反向时 linear_activation_backward 把它拆开用。
    【DL 联系】"一层 = 一块积木"：输入 A[l−1]，输出 A[l]，顺手把反向要用的东西存进 cache。
            框架（PyTorch，第 9 讲）里每个层的 forward 也会自动保存类似的东西（"saved tensors"）。
    """
    fwd, _ = _act(activation)
    Z, linear_cache = linear_forward(A_prev, W, b)
    A, activation_cache = fwd(Z)
    return A, (linear_cache, activation_cache)


def L_model_forward(X, parameters, hidden="relu", output="sigmoid"):
    """
    L 层网络的前向传播：[LINEAR → hidden] × (L − 1) → LINEAR → output。

    【思路】对 l = 1, ..., L 调用同一块积木 linear_activation_forward。这里有一个**对层的 for 循环**——
            Andrew Ng 在课上特别说明：对样本的循环可以用矩阵运算消掉（向量化），但**对层的循环消不掉**，
            因为第 l 层必须等第 l − 1 层算完。所以这个 for 循环是完全正当的。
    【参数】
        X          : (n_x, m) 输入
        parameters : initialize_parameters_deep 返回的 dict
        hidden     : 隐藏层激活函数名（默认 ReLU）
        output     : 输出层激活函数名（默认 sigmoid：二分类，输出 P(y = 1 | x)）
    【返回】
        AL     : (n[L], m) 网络输出
        caches : list，长度 L；caches[l − 1] 是第 l 层的 cache = ((A[l−1], W[l], b[l]), Z[l])
    【边界】层数 L = len(parameters) // 2（每层一个 W、一个 b）。
    【Python 小知识】// 是整除：7 // 2 = 3。
    """
    caches = []
    A = X
    L = len(parameters) // 2
    for l in range(1, L + 1):
        act = hidden if l < L else output
        A, cache = linear_activation_forward(A, parameters[f"W{l}"], parameters[f"b{l}"], act)
        caches.append(cache)
    return A, caches


def compute_cost(AL, Y):
    """
    二元交叉熵 cost：J = −(1/m) Σ [ y log a + (1 − y) log(1 − a) ]。

    【参数】AL：(1, m) 预测概率；Y：(1, m) 标签（0 或 1）
    【返回】float
    【边界】a 恰好等于 0 或 1 时 log 得到 −inf（并发出警告）。先把 a 裁剪到 [1e−12, 1 − 1e−12]。
    【numpy 小知识】np.clip(x, lo, hi) 把小于 lo 的值改成 lo、大于 hi 的改成 hi。
    """
    m = Y.shape[1]
    P = np.clip(AL, 1e-12, 1.0 - 1e-12)
    cost = -np.sum(Y * np.log(P) + (1.0 - Y) * np.log(1.0 - P)) / m
    return float(cost)


# =============================================================================
# 4. 反向传播的积木
# =============================================================================
def linear_backward(dZ, cache):
    """
    一层线性部分的反向：已知 dZ[l]，算 dW[l]、db[l]、dA[l−1]。

    【思路】Z = W A_prev + b，对每个量求偏导（讲义 §4.2 有逐项推导）：
                dW      = (1/m) dZ A_prevᵀ           形状 (n[l], m)(m, n[l−1]) = (n[l], n[l−1])  —— 和 W 一样
                db      = (1/m) Σ_样本 dZ             形状 (n[l], 1)                            —— 和 b 一样
                dA_prev = Wᵀ dZ                       形状 (n[l−1], n[l])(n[l], m) = (n[l−1], m) —— 和 A_prev 一样
            1/m 来自 cost 是 m 个样本损失的**平均**；dA_prev 没有 1/m，因为它是"逐样本"的量，平均在 dW、db 里做一次就够了。
            一个记形状的窍门：结果必须和对应的量同形状，而能把形状凑对的乘法顺序往往只有一种。
    【参数】dZ：(n[l], m)；cache：linear_forward 存下的 (A_prev, W, b)
    【返回】(dA_prev, dW, db)，形状分别是 (n[l−1], m)、(n[l], n[l−1])、(n[l], 1)
    【numpy 小知识】np.sum(dZ, axis=1, keepdims=True)：沿 axis=1（样本那一维）求和；keepdims=True 让结果保持
            (n[l], 1) 的二维形状。忘了 keepdims 会得到 (n[l],) 的一维数组，之后 b − α·db 会被广播成 (n[l], n[l])
            的矩阵 —— 不报错，但 b 从此就错了（代码 01 演示 3 的 bug C）。
    """
    A_prev, W, b = cache
    m = A_prev.shape[1]
    dW = (dZ @ A_prev.T) / m
    db = np.sum(dZ, axis=1, keepdims=True) / m
    dA_prev = W.T @ dZ
    assert dW.shape == W.shape and db.shape == b.shape and dA_prev.shape == A_prev.shape
    return dA_prev, dW, db


def linear_activation_backward(dA, cache, activation):
    """
    一整层的反向积木：dA[l] → （穿过激活函数）dZ[l] → （穿过线性部分）dA[l−1]、dW[l]、db[l]。

    【思路】dZ[l] = dA[l] * g'(Z[l])（逐元素），然后交给 linear_backward。
            这里用到了 cache 里的两样东西：Z[l]（算 g'）和 (A[l−1], W[l], b[l])（算 dW、dA_prev）。
            **这就是 cache 存在的理由**：反向时需要前向的中间结果，存下来就不用重算。
    【参数】dA：(n[l], m)；cache：linear_activation_forward 返回的 ((A_prev, W, b), Z)；activation：这一层的激活函数名
    【返回】(dA_prev, dW, db)
    """
    _, bwd = _act(activation)
    linear_cache, activation_cache = cache
    dZ = bwd(dA, activation_cache)
    return linear_backward(dZ, linear_cache)


def L_model_backward(AL, Y, caches, hidden="relu", output="sigmoid", output_grad="chain"):
    """
    L 层网络的反向传播：从输出层往回，把每层的反向积木串起来。

    【思路】
        第一步：损失对输出 A[L] 的梯度（交叉熵对 a 求导）：
                dA[L] = −( Y / A[L] − (1 − Y) / (1 − A[L]) )
        然后 l = L, L−1, ..., 1：dA[l] --(第 l 层反向积木)--> dA[l−1], dW[l], db[l]

        output_grad 控制输出层怎么起步：
            "chain"（默认，Coursera 作业的写法）：先算上面的 dA[L]，再穿过 sigmoid 的反向。
                    好处：通用 —— 换一个损失函数，只要改 dA[L] 这一行。
            "fused"：直接用 dZ[L] = A[L] − Y（第 2 讲推导的结果：σ'(Z) 和 dA[L] 的分母恰好约掉）。
                    数学上两者相同；数值上 fused 更稳：当 |Z[L]| > 约 37 时 A[L] 恰好是 1.0 或 0.0，
                    chain 写法要把 1 / (1 − A) 裁剪掉，再乘以 σ'(Z) = 0，梯度变成 0（"自信地错了却学不动"），
                    而 fused 仍给出正确的 A − Y。代码 01 演示 2 展示这个差别。
                    PyTorch 的 BCEWithLogitsLoss 也是这样"合并"计算的（第 9 讲）。
    【参数】
        AL     : (1, m) 前向的输出
        Y      : (1, m) 标签
        caches : L_model_forward 返回的 caches
        hidden : 隐藏层激活函数名；output：输出层激活函数名（"chain" 模式下任意；"fused" 只支持 "sigmoid"）
        output_grad : "chain" | "fused"
    【返回】grads：dict，和 Coursera 一样的键：
                "dA{l−1}"（l = 1..L；dA0 是对输入 X 的梯度，一般用不上）、"dW{l}"、"db{l}"
    【边界】损失固定为二元交叉熵；"chain" 模式里 A[L] 被裁剪到 [1e−12, 1 − 1e−12] 以免除以 0。
    """
    grads = {}
    L = len(caches)
    Y = Y.reshape(AL.shape)                       # 保险：确保 Y 和 AL 同形状，避免 (m,) 与 (1, m) 的广播陷阱
    if output_grad == "chain":
        P = np.clip(AL, 1e-12, 1.0 - 1e-12)
        dAL = -(Y / P - (1.0 - Y) / (1.0 - P))
        dA_prev, dW, db = linear_activation_backward(dAL, caches[L - 1], output)
    elif output_grad == "fused":
        if output != "sigmoid":
            raise ValueError("fused 模式只适用于 sigmoid 输出 + 交叉熵")
        dZL = AL - Y
        dA_prev, dW, db = linear_backward(dZL, caches[L - 1][0])
    else:
        raise ValueError(f"unknown output_grad: {output_grad}")
    grads[f"dA{L - 1}"], grads[f"dW{L}"], grads[f"db{L}"] = dA_prev, dW, db

    for l in range(L - 1, 0, -1):                 # l = L−1, ..., 1（range 的第三个参数 −1 表示倒着数）
        dA_prev, dW, db = linear_activation_backward(grads[f"dA{l}"], caches[l - 1], hidden)
        grads[f"dA{l - 1}"], grads[f"dW{l}"], grads[f"db{l}"] = dA_prev, dW, db
    return grads


def update_parameters(params, grads, learning_rate):
    """
    梯度下降的一步：W[l] ← W[l] − α dW[l]，b[l] ← b[l] − α db[l]。

    【思路】返回一份**新的** dict，不改动传进来的 params（和 Coursera 作业里 copy.deepcopy 的用意相同），
            这样调用者手里"更新前的参数"不会被悄悄改掉。
    【参数】params、grads：dict；learning_rate：学习率 α（超参数，§6）
    【返回】新的 params dict
    【边界】更新后再 assert 一次形状：如果 db 的形状错了，b 会被广播成矩阵，这里能立刻抓住。
    【Python 小知识】{k: v for k, v in d.items()} 是"字典推导式"：遍历 d 的每个 (键, 值) 生成新字典。
    """
    new = {k: v.copy() for k, v in params.items()}
    L = len(params) // 2
    for l in range(1, L + 1):
        new[f"W{l}"] = new[f"W{l}"] - learning_rate * grads[f"dW{l}"]
        new[f"b{l}"] = new[f"b{l}"] - learning_rate * grads[f"db{l}"]
        assert new[f"W{l}"].shape == params[f"W{l}"].shape and new[f"b{l}"].shape == params[f"b{l}"].shape, \
            f"第 {l} 层更新后形状变了：W {new[f'W{l}'].shape}，b {new[f'b{l}'].shape}"
    return new


# =============================================================================
# 5. 训练与预测
# =============================================================================
def L_layer_model(X, Y, layer_dims, rng, learning_rate=0.0075, num_iterations=3000, hidden="relu", init="he",
                  optimizer="gd", record_every=100, params=None):
    """
    训练一个 L 层网络（全批量）：重复"前向 → cost → 反向 → 更新"num_iterations 次。

    【思路】和 Coursera "Deep Neural Network Application" 作业里的 L_layer_model 一样，只多了三样东西：
            · optimizer="adam"：改用第 1 讲 tiny_nn.py 里的 Adam（第 8 讲推导）。普通梯度下降在
              parity 这类难任务上要几十万步，演示里等不起，所以 03、04 用 Adam，并在输出里写明。
            · 发散检测：每一步检查输出和 cost 是否都是有限的数（np.isfinite）。BLAS 矩阵乘法里产生的 inf / nan
              不会触发 numpy 的浮点警告（第 1 讲踩过的坑），必须显式检查；一旦发散就停止并标记。
            · 每 record_every 步记录一次 cost，用来画 / 打印学习曲线。
    【参数】
        X, Y          : (n_x, m)、(1, m)
        layer_dims    : [n_x, ..., 1]
        rng           : np.random.Generator（只用于初始化；全批量训练没有其他随机性）
        learning_rate : α
        num_iterations: 迭代次数（每次用全部 m 个样本）
        hidden, init  : 隐藏层激活函数、初始化方式
        optimizer     : "gd"（普通梯度下降，本讲的 update_parameters）| "adam"
        record_every  : 每隔多少步记录 cost
        params        : 可选，给定初始参数（不给就用 initialize_parameters_deep）
    【返回】
        params  : 训练后的参数（发散时是发散前最后一步的有限参数）
        history : dict，"iteration" 和 "cost" 两个等长 list（第 0 步也记录）
        info    : dict，"diverged"（bool）、"iterations_run"（实际跑了多少步）
    【边界】输出层固定为 sigmoid、损失固定为交叉熵；反向传播用 "fused" 起步（数值更稳，见 L_model_backward）。
    """
    if params is None:
        params = initialize_parameters_deep(layer_dims, rng, init)
    opt = None
    if optimizer == "adam":
        from tiny_nn import Adam                    # 第 1 讲的 Adam；只在需要时导入
        opt = Adam(params, learning_rate)
    elif optimizer != "gd":
        raise ValueError(f"unknown optimizer: {optimizer}")
    history = {"iteration": [], "cost": []}
    diverged = False
    it = 0
    for it in range(num_iterations + 1):
        AL, caches = L_model_forward(X, params, hidden, "sigmoid")
        cost = compute_cost(AL, Y)
        if not (np.all(np.isfinite(AL)) and np.isfinite(cost)):
            diverged = True
            break
        if it % record_every == 0 or it == num_iterations:
            history["iteration"].append(it)
            history["cost"].append(cost)
        if it == num_iterations:                    # 最后一次只记录、不再更新
            break
        grads = L_model_backward(AL, Y, caches, hidden, "sigmoid", output_grad="fused")
        if opt is None:
            new = update_parameters(params, grads, learning_rate)
        else:
            new = {k: v.copy() for k, v in params.items()}
            opt.step(new, grads)                     # Adam 原地修改 new
        if not all(np.all(np.isfinite(v)) for v in new.values()):
            diverged = True
            break
        params = new
    return params, history, {"diverged": diverged, "iterations_run": it}


def predict(X, parameters, hidden="relu"):
    """
    预测 0/1 标签：概率 > 0.5 判为 1。

    【返回】(1, m) 的 float 数组，值是 0.0 或 1.0
    【numpy 小知识】(AL > 0.5) 是布尔数组，.astype(float) 把 True / False 变成 1.0 / 0.0。
    """
    AL, _ = L_model_forward(X, parameters, hidden, "sigmoid")
    return (AL > 0.5).astype(float)


def accuracy(X, Y, parameters, hidden="relu"):
    """准确率：预测和标签相同的比例（0 到 1 之间的 float）。"""
    return float(np.mean(predict(X, parameters, hidden) == Y))


# =============================================================================
# 6. 检查工具：维度检查、梯度检验
# =============================================================================
def check_dimensions(layer_dims, parameters, m, caches=None, AL=None, grads=None, Y=None, verbose=True):
    """
    自动维度检查：按讲义 §3 的规则逐层核对每个量的形状，打印一张表，返回发现的所有问题。

    【思路】规则只有几条，全部由 layer_dims 和 m 决定：
                W[l], dW[l] : (n[l], n[l−1])        b[l], db[l] : (n[l], 1)
                Z[l], A[l]  : (n[l], m)             dA[l−1]     : (n[l−1], m)
            把"应该是什么"和"实际是什么"一一比较。和直接 assert 不同，这里先把**所有**问题收集起来再报告，
            一次就能看到哪几层、哪几个量错了。
    【参数】
        layer_dims : [n[0], ..., n[L]]
        parameters : dict（必需）
        m          : 样本数
        caches, AL : 可选，L_model_forward 的输出；给了就检查 Z[l]、A[l]（A[l] 从下一层 cache 里的 A_prev 取，A[L] 用 AL）
        grads      : 可选，L_model_backward 的输出；给了就检查 dW、db、dA
        Y          : 可选，标签；给了就检查它是不是 (n[L], m)（写成 (m,) 或 (m, 1) 都是常见 bug）
        verbose    : 是否打印表格
    【返回】problems：list[str]，每条描述一个形状不符；空列表表示全部正确
    【边界】只检查形状，不检查数值。形状全对、数值算错（比如忘了除以 m）的 bug 它抓不到 —— 那要靠 gradient_check。
    【Python 小知识】def 里面再 def 一个函数（这里的 chk）叫"嵌套函数"，它可以直接用外层的变量（problems、row）。
    """
    from console import pad                       # 第 1 讲的对齐工具（中英文混排）
    L = len(layer_dims) - 1
    problems = []
    names = ["W", "b", "Z", "A", "dW", "db", "dA[l-1]"]
    if verbose:
        print(pad("层 l", 6) + pad("激活前后", 10) + "".join(pad(nm, 13, ">") for nm in names) + pad("  检查", 8))
    for l in range(1, L + 1):
        n_prev, n_cur = layer_dims[l - 1], layer_dims[l]
        row = []

        def chk(label, arr, expected):
            if arr is None:
                row.append("—")
                return
            ok = tuple(arr.shape) == expected
            row.append(str(tuple(arr.shape)) + ("" if ok else "✗"))
            if not ok:
                problems.append(f"第 {l} 层 {label}：形状 {tuple(arr.shape)}，应为 {expected}")

        chk("W", parameters.get(f"W{l}"), (n_cur, n_prev))
        chk("b", parameters.get(f"b{l}"), (n_cur, 1))
        Z = A = None
        if caches is not None:
            Z = caches[l - 1][1]
            A = AL if l == L else caches[l][0][0]
        chk("Z", Z, (n_cur, m))
        chk("A", A, (n_cur, m))
        g = grads if grads is not None else {}
        chk("dW", g.get(f"dW{l}"), (n_cur, n_prev))
        chk("db", g.get(f"db{l}"), (n_cur, 1))
        chk("dA[l-1]", g.get(f"dA{l - 1}"), (n_prev, m))
        if verbose:
            flag = "✓" if not any("✗" in r for r in row) else "✗"
            print(pad(str(l), 6) + pad(f"{n_prev}→{n_cur}", 10) + "".join(pad(r, 13, ">") for r in row) + "  " + flag)
    if Y is not None and tuple(Y.shape) != (layer_dims[-1], m):
        problems.append(f"标签 Y：形状 {tuple(Y.shape)}，应为 {(layer_dims[-1], m)}")
    return problems


def gradient_check(parameters, X, Y, hidden="relu", eps=1e-7, output_grad="chain", grads=None):
    """
    双边差分梯度检验：用 (J(θ + ε) − J(θ − ε)) / (2ε) 近似每个参数的偏导数，和反向传播的结果比较。

    【思路】反向传播很容易写错一个转置、漏一个 1/m，而且**不报错**。数值近似虽然慢（每个参数要做两次前向），
            但几乎不可能写错，所以拿它当"标准答案"。比较用相对误差：
                diff = ‖g_num − g_back‖ / (‖g_num‖ + ‖g_back‖)
            经验上 diff < 1e−7 说明没问题，1e−5 左右要警惕，> 1e−3 基本肯定有 bug。
    【参数】parameters、X、Y、hidden：同 L_model_forward；eps：差分步长；output_grad：传给 L_model_backward；
            grads：可选。给了就拿它和数值梯度比（用来检验"别人写的 / 故意写错的"反向传播，代码 01 演示 3），
            不给就用本模块的 L_model_backward 现算
    【返回】(diff, n_checked)：相对误差和检查了多少个参数
    【边界】只适合小网络（参数个数 × 2 次前向传播）。ReLU 在 0 处不可导，如果某个 Z 恰好落在 ±ε 以内，
            差分会跨过折点，个别项会不准 —— 这是梯度检验本身的局限，不是反向传播的错。
    【DL 联系】第 7 讲系统地讲梯度检验（为什么用双边差分、怎样和正则化 / dropout 配合）；这里只当工具用。
    【numpy 小知识】np.ndindex(shape) 依次给出矩阵的每个下标 (i, j)，用来逐个"拧"每个参数。
    """
    if grads is None:
        AL, caches = L_model_forward(X, parameters, hidden)
        grads = L_model_backward(AL, Y, caches, hidden, output_grad=output_grad)
    num, back = [], []
    for k in sorted(parameters):                  # sorted：固定遍历顺序，结果可复现
        P = parameters[k]
        for idx in np.ndindex(P.shape):
            old = P[idx]
            P[idx] = old + eps
            jp = compute_cost(L_model_forward(X, parameters, hidden)[0], Y)
            P[idx] = old - eps
            jm = compute_cost(L_model_forward(X, parameters, hidden)[0], Y)
            P[idx] = old                              # 一定要还原！否则后面的检查都在错误的参数上做
            num.append((jp - jm) / (2 * eps))
            back.append(grads["d" + k][idx])
    num, back = np.array(num), np.array(back)
    diff = np.linalg.norm(num - back) / (np.linalg.norm(num) + np.linalg.norm(back))
    return float(diff), len(num)


# =============================================================================
# 自检：梯度检验 + 和第 1 讲 tiny_nn 的结果对照
# =============================================================================
if __name__ == "__main__":
    import tiny_nn

    rng = np.random.default_rng(0)
    dims = [4, 5, 3, 1]
    X = rng.standard_normal((4, 9))
    Y = (rng.random((1, 9)) > 0.5).astype(float)
    for hidden in ["relu", "tanh"]:
        p = initialize_parameters_deep(dims, np.random.default_rng(1), "he" if hidden == "relu" else "xavier")
        # 把 b 换成小随机数再检验：b = 0 时，若某个样本在第 l 层的单元全部 Z ≤ 0，下一层的 Z 就恰好等于 b = 0，
        # 正好落在 ReLU 的折点上，双边差分会跨过折点而失准（讲义 §4.5、代码 01 演示 2）
        for l in range(1, len(dims)):
            p[f"b{l}"] = 0.1 * rng.standard_normal(p[f"b{l}"].shape)
        d, n = gradient_check(p, X, Y, hidden)
        AL, caches = L_model_forward(X, p, hidden)
        grads = L_model_backward(AL, Y, caches, hidden)
        AL1, caches1 = tiny_nn.forward(p, X, hidden, "sigmoid")
        g1 = tiny_nn.backward(p, caches1, Y, hidden, "sigmoid", "bce")
        gdiff = max(float(np.max(np.abs(grads[k] - g1[k]))) for k in g1)
        print(f"{hidden:<5} 梯度检验相对误差 {d:.2e}（{n} 个参数）；和 tiny_nn 对照：输出最大差 "
              f"{float(np.max(np.abs(AL - AL1))):.1e}，梯度最大差 {gdiff:.1e}")
    probs = check_dimensions(dims, p, 9, caches, AL, grads, verbose=False)
    print("维度检查：" + ("全部通过" if not probs else "; ".join(probs)))
