"""
=============================================================================
CS230 · 第 12 讲共享模块：用 numpy 从零实现卷积层与池化层（前向 + 反向）
=============================================================================

对应讲义：lectures/lecture12_cnn_foundations.md §2、§4–§7、§10

-----------------------------------------------------------------------------
【这个模块是什么】
-----------------------------------------------------------------------------
Coursera 编程作业 "Convolutional Model: Step by Step" 让你用 numpy 一步步写出卷积网络的零件。
这个模块按**同样的函数名、同样的形状约定**把它们写出来，每一行都加了注释：

    zero_pad                 在图像四周补 0（padding）
    conv_single_step         一个滤波器在**一个位置**上做的事：逐元素相乘再求和，加上偏置
    conv_forward             卷积层的前向传播（四重循环，照抄作业的写法，慢但一目了然）
    pool_forward             池化层的前向传播（max / average）
    conv_backward            卷积层的反向传播：dA_prev、dW、db
    create_mask_from_window  max pooling 反向时用的"谁是最大值"的蒙版
    distribute_value         average pooling 反向时把梯度平均分回去
    pool_backward            池化层的反向传播

另外补充了几样作业里没有、但讲义要用的东西：

    conv_output_size         输出尺寸公式 ⌊(n + 2p − f) / s⌋ + 1
    conv_forward_fast        同一个卷积的**向量化**写法（滑动窗口 + einsum），结果和四重循环相同，快几百倍
    pool_forward_fast        池化的向量化写法
    correlate2d / convolve2d 单通道二维的"互相关"和"真正的卷积"（数学定义，会把核翻转 180°）
    to_torch_layout / from_torch_layout   numpy 的 (m, n_H, n_W, n_C) 与 PyTorch 的 (m, C, H, W) 互换

-----------------------------------------------------------------------------
【形状约定】（和 Coursera 作业一致；注意和 PyTorch 不同）
-----------------------------------------------------------------------------
    A_prev : (m, n_H_prev, n_W_prev, n_C_prev)   m 张图，每张 高 × 宽 × 通道 —— "通道在最后"（channels-last）
    W      : (f, f, n_C_prev, n_C)                n_C 个滤波器，每个是 f × f × n_C_prev 的小方块
    b      : (1, 1, 1, n_C)                       每个滤波器一个偏置
    Z / A  : (m, n_H, n_W, n_C)                   输出：每个滤波器给出一张 n_H × n_W 的"特征图"（feature map）

    PyTorch 用的是"通道在前"（channels-first）：输入 (m, C, H, W)，nn.Conv2d 的 weight 是 (C_out, C_in, f, f)。
    两种约定之间要用 transpose（换轴的顺序），**不能**用 reshape（讲义 §6.4、代码 03 演示 1 的坑）。

-----------------------------------------------------------------------------
【为什么四重循环】
-----------------------------------------------------------------------------
conv_forward 有 for i（样本）/ for h（行）/ for w（列）/ for c（滤波器）四重循环。它很慢，
但每一行都直接对应讲义里的公式，适合"看懂"。真正干活时用 conv_forward_fast 或 PyTorch：
它们把所有位置的小方块排成一个大矩阵，一次矩阵乘法算完（第 5 讲代码 03 的 im2col 就是这个想法）。
本模块只用在小例子和"逐数字对照"上；训练网络一律用 PyTorch。

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

直接运行本文件做一个自检（和 PyTorch 逐数字对照、数值梯度检验，约 1 秒）：
    python3 conv_numpy.py
"""

import numpy as np


# =============================================================================
# 0. 输出尺寸公式
# =============================================================================
def conv_output_size(n, f, p=0, s=1):
    """
    卷积（或池化）之后的边长：⌊(n + 2p − f) / s⌋ + 1。

    【思路】补 0 之后图像边长是 n + 2p。滤波器的左边缘可以放在 0, s, 2s, …，
            只要右边缘不超出图像（左边缘 ≤ n + 2p − f）。这样的位置一共有 ⌊(n + 2p − f) / s⌋ + 1 个。
            向下取整的意思是：最后一步如果放不下一个完整的滤波器，就**不算**这个位置（不会"半个滤波器"）。
    【参数】n：输入边长；f：滤波器边长；p：每一边补几圈 0；s：步长（stride）
    【返回】int，输出边长
    【边界】n + 2p < f 时滤波器一个位置都放不下，这里抛出 ValueError，而不是返回 0 或负数。
    【Python 小知识】// 是整除（向下取整）：7 // 2 = 3。对非负数，(n + 2p − f) // s 就是公式里的 ⌊·⌋。
    """
    if n + 2 * p < f:
        raise ValueError(f"滤波器 f = {f} 比补 0 之后的图像 n + 2p = {n + 2 * p} 还大")
    return (n + 2 * p - f) // s + 1


# =============================================================================
# 1. 卷积层的前向传播（作业版：循环写法）
# =============================================================================
def zero_pad(X, pad):
    """
    在一批图像的高、宽两个方向上各补 pad 圈 0（通道和样本两个方向不补）。

    【参数】X：(m, n_H, n_W, n_C)；pad：每一边补几圈（整数，≥ 0）
    【返回】(m, n_H + 2·pad, n_W + 2·pad, n_C)
    【numpy 小知识】np.pad(X, ((0, 0), (pad, pad), (pad, pad), (0, 0)), mode="constant", constant_values=0)：
            第二个参数是一个元组，**每一维**给出 (前面补几个, 后面补几个)。这里第 0 维（样本）和第 3 维（通道）
            补 (0, 0)，第 1、2 维（高、宽）前后各补 pad 个。
    【DL 联系】padding 的两个目的（讲义 §4.1）：不让图像越卷越小；让边缘像素被"看"的次数多一些。
    """
    return np.pad(X, ((0, 0), (pad, pad), (pad, pad), (0, 0)), mode="constant", constant_values=0)


def conv_single_step(a_slice_prev, W, b):
    """
    一个滤波器在一个位置上的计算：小方块和滤波器逐元素相乘、全部加起来、再加上偏置。

    【思路】这就是讲义 §2.1 手算的那一步："3·1 + 0·0 + 1·(−1) + …"。
            它和全连接层里一个神经元的 z = wᵀx + b 是同一件事 —— 只不过 x 只是图像的一小块，
            而且同一个 w 会在所有位置重复使用（参数共享，讲义 §9.1）。
    【参数】
        a_slice_prev : (f, f, n_C_prev) 输入里的一个小方块
        W            : (f, f, n_C_prev) 一个滤波器的权重
        b            : (1, 1, 1) 或标量，这个滤波器的偏置
    【返回】Python float，这个位置的输出 z
    【numpy 小知识】a * W 是逐元素相乘（形状相同的两个数组，对应位置相乘），np.sum 把所有元素加起来。
            np.asarray(b).item() 把只有一个元素的数组变成 Python 数（比 float(b) 更明确）。
    """
    s = a_slice_prev * W                    # 逐元素相乘，形状 (f, f, n_C_prev)
    Z = np.sum(s)                           # 全部加起来：一个数
    return float(Z + np.asarray(b).item())  # 加上偏置


def conv_forward(A_prev, W, b, hparameters):
    """
    卷积层的前向传播（四重循环；照 Coursera 作业的结构写）。

    【思路】
        1. 按公式算出输出的高 n_H、宽 n_W（conv_output_size）；
        2. 给输入补 0；
        3. 对每张图 i、每个输出位置 (h, w)、每个滤波器 c：
               取出输入里以 (h·s, w·s) 为左上角的 f × f × n_C_prev 小方块，
               Z[i, h, w, c] = conv_single_step(小方块, 第 c 个滤波器, 第 c 个偏置)。
        注意：这里没有激活函数。一个完整的卷积层是 A = g(Z)，ReLU 放在外面做（讲义 §6.1）。
    【参数】
        A_prev      : (m, n_H_prev, n_W_prev, n_C_prev)
        W           : (f, f, n_C_prev, n_C) —— 第三维必须等于输入的通道数（讲义 §5.1）
        b           : (1, 1, 1, n_C)
        hparameters : dict，{"stride": s, "pad": p}
    【返回】(Z, cache)
        Z     : (m, n_H, n_W, n_C)
        cache : (A_prev, W, b, hparameters)，反向传播要用（第 4 讲 §4.4 的 cache）
    【边界】滤波器的通道数和输入的通道数不一致时立刻报错（PyTorch 也会报错）。
    【Python 小知识】for h in range(n_H) 里的 h 是**输出**的行号；它在输入（补 0 之后）里对应的起点是 h · stride。
    """
    m, n_H_prev, n_W_prev, n_C_prev = A_prev.shape
    f, f2, n_C_prev_W, n_C = W.shape
    if f != f2:
        raise ValueError("这里只处理正方形的滤波器 f × f")
    if n_C_prev_W != n_C_prev:
        raise ValueError(f"滤波器有 {n_C_prev_W} 个通道，输入有 {n_C_prev} 个：滤波器的通道数必须等于输入的通道数")
    stride, pad = hparameters["stride"], hparameters["pad"]
    n_H = conv_output_size(n_H_prev, f, pad, stride)
    n_W = conv_output_size(n_W_prev, f, pad, stride)
    Z = np.zeros((m, n_H, n_W, n_C))
    A_prev_pad = zero_pad(A_prev, pad)
    for i in range(m):                                  # 第 i 张图
        a_prev_pad = A_prev_pad[i]                      # (n_H_prev + 2p, n_W_prev + 2p, n_C_prev)
        for h in range(n_H):                            # 输出的第 h 行
            vert_start = h * stride                     # 对应输入里小方块的上边
            vert_end = vert_start + f
            for w in range(n_W):                        # 输出的第 w 列
                horiz_start = w * stride
                horiz_end = horiz_start + f
                a_slice_prev = a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :]   # (f, f, n_C_prev)
                for c in range(n_C):                    # 第 c 个滤波器
                    Z[i, h, w, c] = conv_single_step(a_slice_prev, W[:, :, :, c], b[:, :, :, c])
    cache = (A_prev, W, b, hparameters)
    return Z, cache


# =============================================================================
# 2. 同一个卷积的向量化写法
# =============================================================================
def extract_windows(A_pad, f, stride):
    """
    把（已经补过 0 的）输入里所有 f × f 的小方块一次性取出来。

    【参数】A_pad：(m, H, W, n_C)；f：方块边长；stride：步长
    【返回】(m, n_H, n_W, n_C, f, f)：windows[i, h, w] 就是 conv_forward 循环里的 a_slice_prev（轴的顺序不同）
    【numpy 小知识】np.lib.stride_tricks.sliding_window_view(A, (f, f), axis=(1, 2)) 返回所有 f × f 窗口的
            "视图"（view，不复制数据），形状 (m, H − f + 1, W − f + 1, n_C, f, f)，窗口的两维放在最后。
            再用 [:, ::stride, ::stride] 每隔 stride 取一个位置，就得到步长为 stride 的那些窗口。
    """
    windows = np.lib.stride_tricks.sliding_window_view(A_pad, (f, f), axis=(1, 2))
    return windows[:, ::stride, ::stride]


def conv_forward_fast(A_prev, W, b, hparameters):
    """
    和 conv_forward 完全相同的计算，但没有 Python 循环。

    【思路】先把所有位置的小方块取出来（extract_windows），再用一次 einsum 让每个小方块和每个滤波器做点积：
                Z[i, h, w, d] = Σ_{c, u, v} windows[i, h, w, c, u, v] · W[u, v, c, d]  +  b[d]
            这就是讲义 §6.1 的公式，只是一次算完所有 (i, h, w, d)。
    【参数】【返回】同 conv_forward（返回 (Z, cache)）
    【numpy 小知识】np.einsum("mhwcuv,uvcd->mhwd", X, Y)：用字母给每一维起名字，出现在两边、但不出现在
            箭头右边的字母（c、u、v）会被求和；optimize=True 让 numpy 自己找最快的计算顺序（内部会变成矩阵乘法）。
    【DL 联系】深度学习框架实现卷积的一种经典办法就是这样：把小方块排成矩阵（im2col），再做一次大矩阵乘法（GEMM）。
    """
    f, _, n_C_prev_W, _ = W.shape
    if n_C_prev_W != A_prev.shape[3]:
        raise ValueError("滤波器的通道数必须等于输入的通道数")
    stride, pad = hparameters["stride"], hparameters["pad"]
    windows = extract_windows(zero_pad(A_prev, pad), f, stride)
    Z = np.einsum("mhwcuv,uvcd->mhwd", windows, W, optimize=True) + b.reshape(1, 1, 1, -1)
    return Z, (A_prev, W, b, hparameters)


# =============================================================================
# 3. 池化层的前向传播
# =============================================================================
def pool_forward(A_prev, hparameters, mode="max"):
    """
    池化层的前向传播（循环写法，照 Coursera 作业）。

    【思路】和卷积一样在图上滑动一个 f × f 的窗口，但窗口里不做加权求和，而是取最大值（max）或平均值（average）；
            而且**每个通道单独处理**：输入有 n_C 个通道，输出也是 n_C 个通道（讲义 §7.2）。
            池化层没有任何要学的参数，只有两个超参数 f 和 s。
    【参数】
        A_prev      : (m, n_H_prev, n_W_prev, n_C)
        hparameters : dict，{"f": f, "stride": s}（池化通常不补 0）
        mode        : "max" 或 "average"
    【返回】(A, cache)：A 形状 (m, n_H, n_W, n_C)；cache = (A_prev, hparameters)
    【边界】mode 拼错时报错，而不是悄悄当成 max。
    """
    if mode not in ("max", "average"):
        raise ValueError(f"unknown mode: {mode}")
    m, n_H_prev, n_W_prev, n_C = A_prev.shape
    f, stride = hparameters["f"], hparameters["stride"]
    n_H = conv_output_size(n_H_prev, f, 0, stride)
    n_W = conv_output_size(n_W_prev, f, 0, stride)
    A = np.zeros((m, n_H, n_W, n_C))
    for i in range(m):
        for h in range(n_H):
            vert_start = h * stride
            vert_end = vert_start + f
            for w in range(n_W):
                horiz_start = w * stride
                horiz_end = horiz_start + f
                for c in range(n_C):                    # 每个通道单独池化
                    a_prev_slice = A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c]   # (f, f)
                    if mode == "max":
                        A[i, h, w, c] = np.max(a_prev_slice)
                    else:
                        A[i, h, w, c] = np.mean(a_prev_slice)
    return A, (A_prev, hparameters)


def pool_forward_fast(A_prev, hparameters, mode="max"):
    """
    池化的向量化写法：取出所有窗口，再沿窗口的两维取最大值 / 平均值。

    【参数】【返回】同 pool_forward（只返回 A，不返回 cache）
    【numpy 小知识】windows.max(axis=(-2, -1))：沿最后两维（窗口的行、列）取最大值。
    """
    if mode not in ("max", "average"):
        raise ValueError(f"unknown mode: {mode}")
    windows = extract_windows(A_prev, hparameters["f"], hparameters["stride"])   # (m, n_H, n_W, n_C, f, f)
    return windows.max(axis=(-2, -1)) if mode == "max" else windows.mean(axis=(-2, -1))


# =============================================================================
# 4. 卷积层的反向传播
# =============================================================================
def conv_backward(dZ, cache):
    """
    卷积层的反向传播：已知 dZ = ∂J/∂Z，求 dA_prev = ∂J/∂A_prev、dW、db。

    【思路】前向时，每个输出 Z[i, h, w, c] = Σ (小方块 · W[..., c]) + b[c]。它对三样东西的偏导很简单：
                ∂Z[i,h,w,c] / ∂(小方块) = W[..., c]
                ∂Z[i,h,w,c] / ∂W[..., c] = 小方块
                ∂Z[i,h,w,c] / ∂b[c]     = 1
            链式法则：把每个输出位置的 dZ[i, h, w, c] 乘上这些偏导，**累加**到对应的地方（讲义 §10.1）：
                dA_prev_pad[i, 小方块的位置] += W[..., c] · dZ[i, h, w, c]     （一个输入像素被好几个小方块用到，梯度要加起来）
                dW[..., c]                   += 小方块   · dZ[i, h, w, c]     （同一个 W 在所有位置共享，梯度要加起来）
                db[c]                        += dZ[i, h, w, c]
            "参数共享"在反向传播里的样子就是第二行的 += ：一个滤波器的梯度 = 它在所有位置、所有样本上的梯度之和。
    【参数】
        dZ    : (m, n_H, n_W, n_C)
        cache : conv_forward 返回的 (A_prev, W, b, hparameters)
    【返回】(dA_prev, dW, db)，形状分别和 A_prev、W、b 相同
    【边界】最后要把补的 0 "剪掉"：取 [pad : pad + n_H_prev]。常见的写法 [pad:-pad] 在 pad = 0 时会得到空数组
            （-0 就是 0，切片 [0:0] 是空的），这里避免了这个坑。
    """
    A_prev, W, b, hparameters = cache
    m, n_H_prev, n_W_prev, n_C_prev = A_prev.shape
    f = W.shape[0]
    stride, pad = hparameters["stride"], hparameters["pad"]
    _, n_H, n_W, n_C = dZ.shape
    dA_prev = np.zeros_like(A_prev, dtype=float)
    dW = np.zeros_like(W, dtype=float)
    db = np.zeros_like(b, dtype=float)
    A_prev_pad = zero_pad(A_prev, pad)
    dA_prev_pad = zero_pad(dA_prev, pad)
    for i in range(m):
        a_prev_pad = A_prev_pad[i]
        da_prev_pad = dA_prev_pad[i]                    # 视图：修改它就是修改 dA_prev_pad[i]
        for h in range(n_H):
            for w in range(n_W):
                for c in range(n_C):
                    vert_start, horiz_start = h * stride, w * stride
                    vert_end, horiz_end = vert_start + f, horiz_start + f
                    a_slice = a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :]
                    da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] += W[:, :, :, c] * dZ[i, h, w, c]
                    dW[:, :, :, c] += a_slice * dZ[i, h, w, c]
                    db[:, :, :, c] += dZ[i, h, w, c]
        dA_prev[i] = da_prev_pad[pad:pad + n_H_prev, pad:pad + n_W_prev, :]     # 剪掉补的 0
    return dA_prev, dW, db


# =============================================================================
# 5. 池化层的反向传播
# =============================================================================
def create_mask_from_window(x):
    """
    max pooling 反向时的蒙版：窗口里最大的那个位置为 True，其余为 False。

    【思路】max pooling 的输出只取决于窗口里最大的那个数；其他数稍微变一点，输出完全不变（导数为 0）。
            所以梯度只流回最大值所在的位置。
    【参数】x：(f, f)
    【返回】(f, f) 的布尔数组
    【边界】如果窗口里有几个数**并列**最大，这个写法会把它们都标成 True（梯度会被重复分给它们）；
            PyTorch 只选其中一个。随机的浮点数几乎不会并列，但手写的小整数例子可能会 —— 对照时要注意。
    """
    return x == np.max(x)


def distribute_value(dz, shape):
    """
    average pooling 反向时，把一个输出位置的梯度 dz 平均分给窗口里的每个输入。

    【思路】平均值 = Σ x / (f·f)，每个 x 的偏导都是 1 / (f·f)。所以每个位置分到 dz / (f·f)。
    【参数】dz：标量；shape：(f, f)
    【返回】(f, f) 的数组，每个元素都是 dz / (f·f)
    """
    n_H, n_W = shape
    return np.ones(shape) * dz / (n_H * n_W)


def pool_backward(dA, cache, mode="max"):
    """
    池化层的反向传播：已知 dA = ∂J/∂A（池化的输出），求 dA_prev。

    【思路】对每个输出位置 (i, h, w, c)：
                max     ：dA_prev 窗口 += 蒙版 · dA[i, h, w, c]          （只给最大值那一格）
                average ：dA_prev 窗口 += dA[i, h, w, c] / (f·f)         （平均分给每一格）
            窗口有重叠时（stride < f），一个输入可能属于好几个窗口，梯度要加起来。
            池化没有参数，所以只有 dA_prev，没有 dW、db。
    【参数】dA：(m, n_H, n_W, n_C)；cache：pool_forward 返回的 (A_prev, hparameters)；mode："max" / "average"
    【返回】dA_prev，形状和 A_prev 相同
    """
    A_prev, hparameters = cache
    f, stride = hparameters["f"], hparameters["stride"]
    m, n_H, n_W, n_C = dA.shape
    dA_prev = np.zeros_like(A_prev, dtype=float)
    for i in range(m):
        for h in range(n_H):
            for w in range(n_W):
                for c in range(n_C):
                    vert_start, horiz_start = h * stride, w * stride
                    vert_end, horiz_end = vert_start + f, horiz_start + f
                    if mode == "max":
                        a_prev_slice = A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c]
                        mask = create_mask_from_window(a_prev_slice)
                        dA_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] += mask * dA[i, h, w, c]
                    elif mode == "average":
                        dA_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] += distribute_value(dA[i, h, w, c], (f, f))
                    else:
                        raise ValueError(f"unknown mode: {mode}")
    return dA_prev


# =============================================================================
# 6. 单通道二维的小工具：互相关 vs 真正的卷积
# =============================================================================
def correlate2d(image, kernel, pad=0, stride=1):
    """
    一张单通道图像和一个二维核的**互相关**（cross-correlation）：深度学习里所说的"卷积"就是它。

    【公式】out[i, j] = Σ_u Σ_v image[i·s + u, j·s + v] · kernel[u, v]     （核不翻转，直接对上去）
    【思路】把二维的 image、kernel 包装成 conv_forward 要的四维形状（m = 1，通道数 1），调用 conv_forward_fast。
    【参数】image：(n_H, n_W)；kernel：(f, f)；pad、stride：同上
    【返回】(n_H_out, n_W_out) 的数组
    【numpy 小知识】image[None, :, :, None] 在最前面和最后面各加一个长度为 1 的维度：(n_H, n_W) → (1, n_H, n_W, 1)。
    """
    A = np.asarray(image, dtype=float)[None, :, :, None]
    W = np.asarray(kernel, dtype=float)[:, :, None, None]
    Z, _ = conv_forward_fast(A, W, np.zeros((1, 1, 1, 1)), {"stride": stride, "pad": pad})
    return Z[0, :, :, 0]


def convolve2d(image, kernel):
    """
    数学 / 信号处理里**真正的卷积**（只取"核完全落在图像里"的部分，即 valid）。

    【公式】按教科书的定义直接写（不借助翻转）：
                out[i, j] = Σ_u Σ_v image[i + f − 1 − u, j + f − 1 − v] · kernel[u, v]
            u 越大，取的像素越靠上 —— 核相当于被"倒过来"对上去。可以证明它等于"把核旋转 180° 之后做互相关"
            （讲义 §2.3；代码 01 演示 3 用数字验证）。
    【参数】image：(n_H, n_W)；kernel：(f, f)
    【返回】(n_H − f + 1, n_W − f + 1)
    【边界】为了让公式一眼可读，这里用了两重 Python 循环（只用于小图）。
    """
    image = np.asarray(image, dtype=float)
    kernel = np.asarray(kernel, dtype=float)
    f = kernel.shape[0]
    n_H, n_W = image.shape[0] - f + 1, image.shape[1] - f + 1
    out = np.zeros((n_H, n_W))
    for i in range(n_H):
        for j in range(n_W):
            total = 0.0
            for u in range(f):
                for v in range(f):
                    total += image[i + f - 1 - u, j + f - 1 - v] * kernel[u, v]
            out[i, j] = total
    return out


# =============================================================================
# 7. 和 PyTorch 之间换形状
# =============================================================================
def to_torch_layout(A=None, W=None):
    """
    numpy（Coursera）的形状 → PyTorch 的形状。

    【参数】A：(m, n_H, n_W, n_C) 或 None；W：(f, f, n_C_prev, n_C) 或 None
    【返回】(A_t, W_t)：A_t 形状 (m, n_C, n_H, n_W)，W_t 形状 (n_C, n_C_prev, f, f)（numpy 数组；传 None 的返回 None）
    【numpy 小知识】A.transpose(0, 3, 1, 2)：新数组的第 0、1、2、3 维分别是原来的第 0、3、1、2 维。
            transpose 只改变"怎样读"这些数，每个数仍然和它原来的 (样本, 行, 列, 通道) 对应。
            reshape 则不同：它按内存里的顺序重新切分，(m, H, W, C) 直接 reshape 成 (m, C, H, W) 会把数字放错位置。
            np.ascontiguousarray 把转置后的数组在内存里重新排好（torch.from_numpy 需要）。
    """
    A_t = None if A is None else np.ascontiguousarray(A.transpose(0, 3, 1, 2))
    W_t = None if W is None else np.ascontiguousarray(W.transpose(3, 2, 0, 1))
    return A_t, W_t


def from_torch_layout(A_t):
    """PyTorch 的 (m, C, H, W) → numpy 的 (m, H, W, C)。参数是 numpy 数组。"""
    return np.ascontiguousarray(A_t.transpose(0, 2, 3, 1))


# =============================================================================
# 8. 打印小矩阵
# =============================================================================
def format_matrix(M, fmt="{:>5.0f}", indent="    "):
    """
    把一个二维数组格式化成若干行文字（每个数按 fmt 格式化）。

    【参数】M：(h, w)；fmt：每个数的格式；indent：每行前面的空格
    【返回】list[str]
    【边界】−0.0 或 −1e−17 这样的数会被打印成 "-0"、"-0.00"。这里先 round 到小数点后 12 位再加 0.0
            （round(−1e−17, 12) = −0.0，而浮点数里 −0.0 + 0.0 = 0.0），这样舍入误差不会多出一个负号。
    """
    return [indent + "".join(fmt.format(clean(v)) for v in row) for row in np.asarray(M)]


def clean(x):
    """把一个数的舍入误差级别的尾巴去掉（|x| < 5e−13 时变成 0.0），方便打印。返回 Python float。"""
    return round(float(x), 12) + 0.0


# =============================================================================
# 自检：直接运行本文件
# =============================================================================
def _numerical_grad(fn, X, eps=1e-6):
    """双边差分（第 7 讲 §4.1）：对 X 的每个元素推 ±eps，看标量 fn() 变多少。X 会被原地修改再还原。"""
    grad = np.zeros_like(X)
    it = np.nditer(X, flags=["multi_index"])
    for _ in it:
        idx = it.multi_index
        old = X[idx]
        X[idx] = old + eps
        plus = fn()
        X[idx] = old - eps
        minus = fn()
        X[idx] = old
        grad[idx] = (plus - minus) / (2 * eps)
    return grad


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    A = rng.normal(size=(2, 5, 7, 3))
    W = rng.normal(size=(3, 3, 3, 4))
    b = rng.normal(size=(1, 1, 1, 4))
    hp = {"stride": 2, "pad": 1}
    Z, cache = conv_forward(A, W, b, hp)
    Zf, _ = conv_forward_fast(A, W, b, hp)
    print(f"conv_forward：输入 {A.shape}，滤波器 {W.shape}，pad 1，stride 2 → 输出 {Z.shape}"
          f"（公式：{conv_output_size(5, 3, 1, 2)} × {conv_output_size(7, 3, 1, 2)}）")
    print(f"    循环写法 vs 向量化写法 最大差 {np.max(np.abs(Z - Zf)):.1e}")
    # 数值梯度检验：J = Σ Z ⊙ G（G 是随机的"上游梯度"），所以 dZ = G
    G = rng.normal(size=Z.shape)
    dA, dW, db = conv_backward(G, cache)
    J = lambda: float(np.sum(conv_forward(A, W, b, hp)[0] * G))
    for name, mine, X in (("dA_prev", dA, A), ("dW", dW, W), ("db", db, b)):
        num = _numerical_grad(J, X)
        rel = np.linalg.norm(mine - num) / (np.linalg.norm(mine) + np.linalg.norm(num))
        print(f"    数值梯度检验 {name:<8} 相对误差 {rel:.1e}")
    Ap, pcache = pool_forward(A, {"f": 2, "stride": 1}, "max")
    Gp = rng.normal(size=Ap.shape)
    dAp = pool_backward(Gp, pcache, "max")
    Jp = lambda: float(np.sum(pool_forward(A, {"f": 2, "stride": 1}, "max")[0] * Gp))
    num = _numerical_grad(Jp, A)
    print(f"    max pool（f 2，stride 1，窗口有重叠）反向：数值梯度相对误差 "
          f"{np.linalg.norm(dAp - num) / (np.linalg.norm(dAp) + np.linalg.norm(num)):.1e}")
    try:
        import torch
        import torch.nn.functional as F
        A_t, W_t = to_torch_layout(A, W)
        Zt = F.conv2d(torch.from_numpy(A_t), torch.from_numpy(W_t), torch.from_numpy(b.reshape(-1)), stride=2, padding=1)
        print(f"    和 torch.nn.functional.conv2d（float64）的最大差 "
              f"{np.max(np.abs(from_torch_layout(Zt.numpy()) - Z)):.1e}")
    except ImportError:
        print("    （没装 PyTorch，跳过和 PyTorch 的对照）")
