Skip to content

s05 前向传播与计算图 — demo.py 代码详解

Download demo.py

运行方式

bash
cd s05_forward_computation_graph/code
python demo.py

代码逐段详解

第1步:导入库 — 每个库是做什么的

python
import os
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
import matplotlib.patches as mpatches
from typing import Dict, List, Tuple, Callable
  • os:文件路径操作,创建 images/ 目录
  • numpy:数值计算核心。关键用法:np.random.randn() 生成随机数据,np.maximum() 实现 ReLU,np.exp() 实现 Sigmoid,np.tanh() 实现 Tanh,@ 运算符矩阵乘法
  • matplotlib:绘图,包括网络结构图、激活函数对比图、激活值分布直方图
  • matplotlib.patches:提供绘图元素(如 Circle 用于绘制神经元节点,Patch 用于图例)
  • typing:Python 类型提示(Dict, List, Tuple, Callable),让函数签名更清晰,便于理解参数和返回值的类型

第2步:激活函数及其导数 — 神经网络的非线性来源

这是前向传播中最关键的概念之一。激活函数在每个线性变换之后引入非线性,使得多层网络能够学习复杂函数。

2.1 ReLU(Rectified Linear Unit)

python
def relu(z: np.ndarray) -> np.ndarray:
    return np.maximum(0, z)

def relu_derivative(z: np.ndarray) -> np.ndarray:
    return (z > 0).astype(np.float64)

数学定义:

ReLU(z)=max(0,z),ReLU(z)={0z<01z>0
  • np.maximum(0, z) 是逐元素操作:对数组的每个元素,保留大于 0 的值,小于 0 的值替换为 0
  • (z > 0).astype(np.float64) 利用布尔索引:z > 0 生成一个布尔数组,.astype(np.float64)True 转为 1.0False 转为 0.0

为什么 ReLU 是深度学习革命的英雄? 正区间的导数恒为 1,这意味着在反向传播中梯度可以无损传播——20 层 ReLU 网络连乘梯度后仍是 1,而 Sigmoid 网络连乘 20 个最大 0.25 的导数后梯度只剩 0.25209×1013(消失殆尽)。

"死亡 ReLU"问题:如果某个神经元的输出对所有输入都 0,则该神经元的梯度永远为 0,参数不再更新——这个神经元"死亡"了。这是 ReLU 的主要缺点,Leaky ReLU 设计了一个小斜率(0.01)在负区间来缓解这个问题。

2.2 Sigmoid

python
def sigmoid(z: np.ndarray) -> np.ndarray:
    z_clipped = np.clip(z, -500, 500)
    return 1.0 / (1.0 + np.exp(-z_clipped))

def sigmoid_derivative(z: np.ndarray) -> np.ndarray:
    s = sigmoid(z)
    return s * (1 - s)

数学定义:

σ(z)=11+ez,σ(z)=σ(z)(1σ(z))

np.clip(z, -500, 500)z 限制在 [500,500] 内,防止 e500 上溢出(e5001.4×10217,远超 float64 的最大值)。裁剪到 ±500 对 Sigmoid 值几乎没有影响——σ(500)σ() 在浮点精度下不可区分。

Sigmoid 导数可以用自身表达(σ(1σ)),这在代码上非常简洁。

当前用途:曾经是隐藏层的标准激活,现在因为梯度消失问题仅在二分类输出层使用(配合 BCE 损失)。

2.3 Tanh

python
def tanh(z: np.ndarray) -> np.ndarray:
    return np.tanh(z)

def tanh_derivative(z: np.ndarray) -> np.ndarray:
    t = np.tanh(z)
    return 1 - t ** 2

数学定义:

tanh(z)=ezezez+ez,tanh(z)=1tanh2(z)

np.tanh() 是 NumPy 内置的数值稳定实现,直接调用即可。相比 Sigmoid,Tanh 的输出是零中心的((1,1) vs (0,1)),这对优化有帮助——零中心的数据使得梯度更新方向更一致。

2.4 GELU(Gaussian Error Linear Unit)

python
def gelu_approximate(z: np.ndarray) -> np.ndarray:
    sqrt_2_over_pi = np.sqrt(2.0 / np.pi)
    return 0.5 * z * (1.0 + np.tanh(sqrt_2_over_pi * (z + 0.044715 * z ** 3)))

数学定义(精确版):

GELU(z)=zΦ(z),Φ(z)=12[1+erf(z2)]

代码中使用的是 tanh 近似(高精度,广泛使用):

GELU(z)0.5z[1+tanh(2π(z+0.044715z3))]

GELU 的核心思想:不像 ReLU 那样"一刀切"地决定通过还是丢弃信息,而是根据 z 的大小概率性地让信息通过。当 z 很大时通过概率接近 1,z 接近 0 时"是否通过"具有不确定性。这引入了类似 Dropout 的随机正则化效果,但是确定性的(不需要采样)。

GELU 是 Transformer 架构的标准激活函数——BERT、GPT、ViT 等全部使用它。

第3步:参数初始化 — He 初始化

python
def initialize_parameters(layer_dims, seed=42):
    parameters = {}
    L = len(layer_dims)
    for l in range(1, L):
        n_in = layer_dims[l - 1]
        n_out = layer_dims[l]
        parameters[f"W{l}"] = np.random.randn(n_out, n_in) * np.sqrt(2.0 / n_in)
        parameters[f"b{l}"] = np.zeros((n_out, 1))
    return parameters

为什么需要特殊的初始化策略?

权重初始化对深层网络的训练至关重要。如果初始化不当:

  • 太大:前向传播时激活值爆炸,梯度也爆炸
  • 太小:前向传播时激活值消失,梯度也消失

He 初始化(Kaiming He, 2015)专为配合 ReLU 设计:

WN(0,2nin)
  • 2/nin 是标准差:nin 是当前层的输入维度(前一层神经元数)
  • 因子 2 是为了补偿 ReLU 将一半输入置零造成的方差减半效应
  • 这个初始化使得每层输出的方差保持稳定,无论网络有多深

Xavier 初始化(配合 Tanh/Sigmoid)使用 1/nin 作为标准差,因为 Sigmoid/Tanh 不会像 ReLU 那样丢弃负半轴。

偏置初始化:偏置向量通常初始化为全零。因为权重的随机初始化已经打破了对称性,偏置从零开始学习是合理且常见的做法。

第4步:前向传播 — 数据在网络中的旅程

python
def forward_pass(X, parameters, activations, verbose=True):
    a = X  # a^{[0]} = X
    caches = []
    L = len(parameters) // 2

    for l in range(1, L + 1):
        W = parameters[f"W{l}"]
        b = parameters[f"b{l}"]
        z = W @ a + b                           # ① 线性变换
        a_new = activations[l - 1](z)            # ② 非线性激活
        cache = {"z": z, "a_prev": a, "a": a_new}  # ③ 缓存中间值
        caches.append(cache)
        a = a_new                               # ④ 更新当前激活,传给下一层
    return a, caches

这是前向传播的核心循环。每一层执行完全相同的两步操作:

子步骤 1:线性变换 z[l]=W[l]a[l1]+b[l]

数学上,W[l] 是一个 n[l]×n[l1] 的矩阵,它将 n[l1] 维的输入映射到 n[l] 维的中间表示。

代码中 W @ a 是矩阵乘法:W(nout×nin)a(nin×m)=z(nout×m)。然后 + b 利用 NumPy 的**广播(broadcasting)**机制自动将偏置向量加到每一列。

子步骤 2:非线性激活 a[l]=ϕ[l](z[l])

ϕ[l] 是激活函数(ReLU、Sigmoid 等),逐元素作用于 z[l] 的每个元素。这是神经网络非线性能力的来源——没有它,多层网络退化为单层线性模型。

子步骤 3:存储中间值(cache)

python
cache = {
    "z": z,           # z^{[l]} — 反向传播中计算激活函数导数 φ'(z) 时需要
    "a_prev": a,      # a^{[l-1]} — 反向传播中计算 dW = δ · (a_prev)^T 时需要
    "a": a_new,       # a^{[l]} — 作为下一层的输入
}

这三个值是反向传播的"燃料"——没有它们,梯度无法从后往前传递。详细用途见下文的"为什么必须存储中间值"。

张量形状追踪:verbose 模式下,代码打印每层的 a[l1], W[l], b[l], z[l], a[l] 的形状以及激活值统计信息(min/max/mean/std)。这对于理解和调试神经网络至关重要——形状不匹配是最常见的错误来源。

第5步:为什么必须存储中间值?

反向传播需要以下信息来计算每个参数的梯度:

存储的值反向传播中的用途对应的梯度公式
z[l]计算激活函数的导数δ[l]=δ[l+1]W[l+1]Tϕ(z[l])
a[l1]计算权重梯度LW[l]=δ[l](a[l1])T
δ[l+1]递推计算前一层误差链式法则逐层传递

这就是为什么训练需要比推理更多的显存——前向传播的所有中间结果必须保留到反向传播完成。如果显存不够,有一个权衡技巧叫 Checkpointing/Re-materialization:不存储中间值,在反向传播时重新计算前向传播。这节省了显存但增加了计算量。

第6步:可视化

6.1 网络结构图

plot_network_structure() 用 matplotlib 绘制神经网络的计算图视角:

  • 蓝色圆点:输入层神经元(x1,x2,x3
  • 橙色圆点:隐藏层神经元(h1,1,h1,2,
  • 红色圆点:输出层神经元(y^1
  • 灰色连线:连接权重,每对前后层神经元之间都有
  • 绿色标注:两柱之间的 W[l] 矩阵形状

神经元在 y 轴上的位置通过 np.linspace() 均匀分布。网络使用 plt.Circle() 绘制圆形节点,ax.plot() 绘制连接线。

6.2 激活函数对比图

plot_activation_functions() 绘制 4 种激活函数(ReLU、Sigmoid、Tanh、Leaky ReLU)的函数值曲线和导数曲线:

  • 蓝色实线:f(z)(函数值)
  • 红色虚线:f(z)(导数值)
  • 灰色虚线:y=0y=1 参考线

从图上可以直观看到:

  • Sigmoid 的导数值域是 (0,0.25],远小于 1——梯度消失的根源
  • Tanh 的导数值域是 (0,1],最大值 1 但两端饱和
  • ReLU 的导数在正区间恒为 1——这就是为什么它解决了梯度消失
  • Leaky ReLU 在负区间有微小斜率 0.01——防止神经元"死亡"

6.3 前向传播数据流

plot_forward_data_flow() 绘制每层激活值的分布直方图:

  • 第一列:输入数据的分布(期望:标准正态分布 N(0,1)
  • 后续列:每层激活输出的分布
  • 红色虚线标注 x=0 参考线

观察要点:如果激活值的分布(均值和方差)在层间保持稳定,说明初始化参数设置合理。如果激活值越来越集中在 0(消失),或越来越发散(爆炸),说明初始化或网络结构有问题。

第7步:主程序 — 完整的 3 层 MLP 前向传播

python
def main():
    # 1. 生成合成数据: 32 个样本,3 个特征 (3, 32)
    X = np.random.randn(3, 32)

    # 2. 定义网络结构: [3] → [4] → [4] → [1]
    layer_dims = [3, 4, 4, 1]

    # 3. He 初始化参数
    parameters = initialize_parameters(layer_dims)

    # 4. 选择激活函数: 隐藏层用 ReLU,输出层用 Sigmoid
    activations = [relu, relu, sigmoid]

    # 5. 执行前向传播
    y_pred, caches = forward_pass(X, parameters, activations)

这个 3 层 MLP 的网络结构为:

  • 输入层:3 个神经元(对应 3 个特征)
  • 隐藏层 1:4 个神经元,ReLU 激活
  • 隐藏层 2:4 个神经元,ReLU 激活
  • 输出层:1 个神经元,Sigmoid 激活(输出一个概率值,适合二分类)

总参数量:3×4+4 (偏置)+4×4+4 (偏置)+4×1+1 (偏置)=41 个参数。

注意代码中的形状约定:输入 X(n_features, n_samples)(3,32),而不是常见的 (n_samples, n_features)。这种约定在数学上等价,只是矩阵乘法的顺序不同。反向传播的推导通常使用这个约定。

第8步:张量形状总览

print_tensor_shape_table() 将前向传播中所有张量的形状以表格形式打印出来:

步骤       名称         形状                   说明
--------------------------------------------------------------------------------
输入       X (a[0])     (3, 32)               输入数据(特征数 × 样本数)
权重       W[1]         (4, 3)                第 1 层权重矩阵
偏置       b[1]         (4, 1)                第 1 层偏置向量
第 1 层    z[1]         (4, 32)               线性变换输出(W·a_prev + b)
第 1 层    a[1]         (4, 32)               激活函数输出(下一层输入)
...

这个表格是理解网络数据流的关键参考。每一层的输出维度由 W[l] 的行数决定,输入维度由 W[l] 的列数决定。循着形状追踪,可以验证整个网络结构的一致性。

关键概念速查表

概念数学形式代码位置关键说明
线性变换z=Wa+bforward_pass()W @ a + b,广播加法
ReLUmax(0,z)relu()正区间导数=1,解决梯度消失
Sigmoid1/(1+ez)sigmoid()输出范围 (0,1),用于二分类输出层
Tanh(ezez)/(ez+ez)tanh()输出零中心 (-1,1),用于 RNN
GELUzΦ(z)gelu_approximate()Transformer 标配,概率性通过
He 初始化WN(0,2/nin)initialize_parameters()配合 ReLU 使用,保持方差稳定
中间值缓存{z, a_prev, a}forward_pass() cache反向传播的"燃料"
计算图DAG 节点=操作,边=数据概念层前向/反向传播的基础抽象
Batch 处理(d,m) 形状约定主程序32 个样本并行处理

完整代码

py
# -*- coding: utf-8 -*-
"""
s05 计算图与前向传播 — 演示代码
==================================
功能:用纯 NumPy 构建一个 3 层 MLP,展示完整的前向传播过程,
      包括中间值存储、计算图可视化(打印张量形状)、
      以及不同激活函数的对比。

每个函数都有中文 docstring,每行逻辑代码都有中文注释。
运行方式:在 s05_forward_computation_graph/ 目录下执行 python code/demo.py
"""

import os
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['axes.unicode_minus'] = False
import matplotlib.patches as mpatches
from typing import Dict, List, Tuple, Callable

_HERE = os.path.dirname(os.path.abspath(__file__))
_IMAGES = os.path.join(_HERE, '..', 'images')
os.makedirs(_IMAGES, exist_ok=True)

# ============================================================================
# 第一部分:激活函数及其导数
# ============================================================================

def relu(z: np.ndarray) -> np.ndarray:
    """
    ReLU 激活函数:f(z) = max(0, z)

    参数:
        z: 输入数组,任意形状
    返回:
        逐元素应用的 ReLU 结果
    """
    return np.maximum(0, z)  # max(0, z) 的向量化实现


def relu_derivative(z: np.ndarray) -> np.ndarray:
    """
    ReLU 的导数:f'(z) = 1 if z > 0 else 0

    参数:
        z: 输入数组(前向传播时存储的 z 值)
    返回:
        逐元素的导数,形状与 z 相同
    """
    return (z > 0).astype(np.float64)  # z>0 处导数为 1,其余为 0


def sigmoid(z: np.ndarray) -> np.ndarray:
    """
    Sigmoid 激活函数:f(z) = 1 / (1 + e^{-z})

    参数:
        z: 输入数组,任意形状
    返回:
        逐元素应用的 sigmoid 结果,范围 (0, 1)
    """
    # 为防止数值溢出,对 z 进行裁剪
    z_clipped = np.clip(z, -500, 500)  # 限制 z 的范围,避免 exp 溢出
    return 1.0 / (1.0 + np.exp(-z_clipped))  # sigmoid 公式


def sigmoid_derivative(z: np.ndarray) -> np.ndarray:
    """
    Sigmoid 的导数:f'(z) = f(z) * (1 - f(z))

    参数:
        z: 输入数组(前向传播时存储的 z 值)
    返回:
        逐元素的导数
    """
    s = sigmoid(z)  # 先计算 sigmoid(z)
    return s * (1 - s)  # 利用 f(z) 直接计算导数


def tanh(z: np.ndarray) -> np.ndarray:
    """
    Tanh 激活函数:f(z) = (e^z - e^{-z}) / (e^z + e^{-z})

    参数:
        z: 输入数组,任意形状
    返回:
        逐元素应用的 tanh 结果,范围 (-1, 1)
    """
    return np.tanh(z)  # NumPy 内置的 tanh 已经数值稳定


def tanh_derivative(z: np.ndarray) -> np.ndarray:
    """
    Tanh 的导数:f'(z) = 1 - f(z)^2 = 1 - tanh^2(z)

    参数:
        z: 输入数组(前向传播时存储的 z 值)
    返回:
        逐元素的导数
    """
    t = np.tanh(z)  # 计算 tanh(z)
    return 1 - t ** 2  # tanh 导数的简洁形式


def gelu_approximate(z: np.ndarray) -> np.ndarray:
    """
    GELU 激活函数的近似实现:f(z) ≈ 0.5 * z * (1 + tanh(√(2/π) * (z + 0.044715 * z^3)))

    这是 GELU 的高精度近似,被广泛使用。

    参数:
        z: 输入数组,任意形状
    返回:
        逐元素应用的 GELU 近似结果
    """
    # GELU tanh 近似公式的系数
    sqrt_2_over_pi = np.sqrt(2.0 / np.pi)  # √(2/π) ≈ 0.7979
    return 0.5 * z * (1.0 + np.tanh(sqrt_2_over_pi * (z + 0.044715 * z ** 3)))


# ============================================================================
# 第二部分:参数初始化
# ============================================================================

def initialize_parameters(layer_dims: List[int], seed: int = 42) -> Dict[str, np.ndarray]:
    """
    使用 He 初始化方法为每一层创建权重和偏置。

    He 初始化(Kaiming He, 2015):W 服从 N(0, sqrt(2/n_in)),
    特别适合配合 ReLU 激活函数使用,可以有效缓解梯度消失/爆炸。

    参数:
        layer_dims: 每层神经元数量的列表,如 [3, 4, 4, 1]
        seed: 随机种子,保证结果可复现

    返回:
        parameters: 字典,包含每一层的 W{layer} 和 b{layer}
            - W1, b1: 第 1 层(输入→隐藏层1)
            - W2, b2: 第 2 层(隐藏层1→隐藏层2)
            - W3, b3: 第 3 层(隐藏层2→输出层)
    """
    np.random.seed(seed)  # 固定随机种子,保证每次运行结果一致
    parameters = {}  # 初始化参数字典
    L = len(layer_dims)  # 总层数(包含输入层)

    for l in range(1, L):  # 遍历每一层(跳过输入层 l=0)
        n_in = layer_dims[l - 1]   # 当前层的输入维度
        n_out = layer_dims[l]      # 当前层的输出维度
        # He 初始化:标准差为 sqrt(2/n_in)
        parameters[f"W{l}"] = np.random.randn(n_out, n_in) * np.sqrt(2.0 / n_in)  # 权重矩阵 (n_out x n_in)
        parameters[f"b{l}"] = np.zeros((n_out, 1))  # 偏置向量初始化为 0 (n_out x 1)
        print(f"  初始化 W{l}: shape={parameters[f'W{l}'].shape}, He init (std={np.sqrt(2.0/n_in):.4f})")
        print(f"  初始化 b{l}: shape={parameters[f'b{l}'].shape}, 全零初始化")

    return parameters


# ============================================================================
# 第三部分:前向传播
# ============================================================================

def forward_pass(
    X: np.ndarray,
    parameters: Dict[str, np.ndarray],
    activations: List[Callable],
    verbose: bool = True
) -> Tuple[np.ndarray, List[Dict[str, np.ndarray]]]:
    """
    执行完整的前向传播,并存储所有中间值(cache)。

    数学过程:
      对于第 l 层:
        z^{[l]} = W^{[l]} @ a^{[l-1]} + b^{[l]}
        a^{[l]} = φ^{[l]}(z^{[l]})

    参数:
        X: 输入数据,shape (n_features, m_samples)
        parameters: 参数字典,包含 W1,b1, W2,b2, ...
        activations: 每层的激活函数列表,长度 = L-1
        verbose: 是否打印每层的张量形状

    返回:
        aL: 最后一层的输出(模型的预测值)
        caches: 列表,每个元素是一个字典,存储了该层的 z, a, a_prev
    """
    a = X  # a^{[0]} = X,当前激活值初始化为输入
    caches = []  # 缓存列表,存储每层的中间值以供反向传播使用
    L = len(parameters) // 2  # 网络的层数(W 和 b 成对出现)

    if verbose:
        print("\n" + "=" * 70)
        print("【前向传播开始】输入 shape: {}".format(X.shape))
        print("=" * 70)

    for l in range(1, L + 1):  # 逐层前向传播
        # ---- 步骤 1: 线性变换 z^{[l]} = W^{[l]} @ a^{[l-1]} + b^{[l]} ----
        W = parameters[f"W{l}"]  # 获取第 l 层的权重矩阵
        b = parameters[f"b{l}"]  # 获取第 l 层的偏置向量
        z = W @ a + b            # 线性变换:矩阵乘法 + 广播加法

        # ---- 步骤 2: 非线性激活 a^{[l]} = φ^{[l]}(z^{[l]}) ----
        activation_fn = activations[l - 1]  # 获取第 l 层的激活函数
        a_new = activation_fn(z)            # 应用激活函数

        # ---- 步骤 3: 存储中间值(cache) ----
        cache = {
            "z": z,           # 预激活值 z^{[l]} —— 反向传播中算 φ' 时需要
            "a_prev": a,      # 上一层的激活 a^{[l-1]} —— 反向传播中算 dW 时需要
            "a": a_new,       # 当前层的激活 a^{[l]} —— 作为下一层的输入
            "W_shape": W.shape,  # 权重矩阵形状(便于调试)
        }
        caches.append(cache)

        # ---- 步骤 4: 打印该层的张量形状 ----
        if verbose:
            act_name = activation_fn.__name__  # 获取激活函数名称
            print(f"  第 {l} 层:")
            print(f"    a^{{{l-1}}}.shape = {cache['a_prev'].shape}  ← 输入")
            print(f"    W^{{{l}}}.shape   = {W.shape}        ← 权重矩阵")
            print(f"    b^{{{l}}}.shape   = {b.shape}        ← 偏置向量")
            print(f"    z^{{{l}}}.shape   = {z.shape}        ← 线性输出 (W·a + b)")
            print(f"    a^{{{l}}}.shape   = {a_new.shape}        ← 激活输出 ({act_name})")
            # 打印该层激活值的统计信息
            print(f"    a^{{{l}}} 统计: min={a_new.min():.4f}, max={a_new.max():.4f}, "
                  f"mean={a_new.mean():.4f}, std={a_new.std():.4f}")

        a = a_new  # 更新当前激活值,作为下一层的输入

    if verbose:
        print("=" * 70)
        print(f"【前向传播完成】最终输出 shape: {a.shape}")
        print(f"  输出值范围: [{a.min():.4f}, {a.max():.4f}]")
        print(f"  共缓存 {len(caches)} 层的中间值(供反向传播使用)")
        print("=" * 70)

    return a, caches


# ============================================================================
# 第四部分:可视化
# ============================================================================

def plot_network_structure(parameters: Dict[str, np.ndarray], X_sample: np.ndarray):
    """
    绘制网络结构图,显示每层的神经元数量和连接关系。
    左侧显示网络架构,右侧标注对应的数据维度。

    参数:
        parameters: 参数字典
        X_sample: 单个样本输入 (n_features, 1),用于确定输入维度
    """
    L = len(parameters) // 2  # 层数
    layer_sizes = [X_sample.shape[0]]  # 输入层神经元数
    for l in range(1, L + 1):
        layer_sizes.append(parameters[f"W{l}"].shape[0])  # 第 l 层的输出神经元数

    fig, ax = plt.subplots(1, 1, figsize=(10, 6))
    ax.set_xlim(-0.5, L + 0.5)  # x 轴范围:层索引
    max_neurons = max(layer_sizes)  # 最大神经元数量,用于确定 y 轴范围
    ax.set_ylim(-max_neurons - 0.5, max_neurons + 0.5)

    # 存储每层神经元的位置
    neuron_positions = []

    # ---- 绘制神经元和连接 ----
    for l_idx, n_neurons in enumerate(layer_sizes):  # 遍历每一层
        # 计算该层神经元在 y 轴上的均匀分布位置
        y_positions = np.linspace(max_neurons / 2 - n_neurons / 2,
                                   -max_neurons / 2 + n_neurons / 2,
                                   max(n_neurons, 1))
        positions = []
        for n_idx, y in enumerate(y_positions):  # 遍历该层每个神经元
            # 确定颜色:输入层=蓝,隐藏层=橙,输出层=红
            if l_idx == 0:
                color = '#4A90D9'  # 蓝色:输入层
                label = f'x{n_idx+1}'  # 标签:x1, x2, ...
            elif l_idx == L:
                color = '#E74C3C'  # 红色:输出层
                label = f{n_idx+1}'  # 标签:ŷ1, ŷ2, ...
            else:
                color = '#F39C12'  # 橙色:隐藏层
                label = f'h{l_idx},{n_idx+1}'

            # 绘制神经元(圆点)
            circle = plt.Circle((l_idx, y), 0.25, color=color, ec='white', linewidth=1.5, zorder=5)
            ax.add_patch(circle)
            # 标注神经元名称
            ax.text(l_idx, y, label, ha='center', va='center', fontsize=7,
                    color='white', fontweight='bold', zorder=6)
            positions.append(y)

        neuron_positions.append((l_idx, positions))

        # ---- 绘制层之间的连接线 ----
        if l_idx > 0:  # 非第一层需要绘制入边
            prev_positions = neuron_positions[l_idx - 1][1]  # 上一层神经元位置
            for prev_y in prev_positions:  # 遍历前一层每个神经元
                for curr_y in positions:  # 遍历当前层每个神经元
                    ax.plot([l_idx - 1, l_idx], [prev_y, curr_y],
                            color='gray', alpha=0.2, linewidth=0.5, zorder=1)

        # ---- 标注层名 ----
        if l_idx == 0:
            layer_name = f'Input Layer\n({n_neurons} neurons)'
        elif l_idx == L:
            layer_name = f'Output Layer\n({n_neurons} neurons)'
        else:
            layer_name = f'Hidden Layer {l_idx}\n({n_neurons} neurons)'
        ax.text(l_idx, max_neurons / 2 + 0.8, layer_name,
                ha='center', fontsize=9, fontweight='bold')

    # ---- 绘制权重矩阵标注 ----
    for l in range(1, L + 1):
        W = parameters[f"W{l}"]
        x_pos = l - 0.5  # 标注在两层之间的位置
        ax.annotate(f'W[{l}]\n{W.shape[0]}×{W.shape[1]}',
                    xy=(x_pos, -max_neurons / 2 - 0.3),
                    fontsize=7, ha='center', color='#2C3E50',
                    bbox=dict(boxstyle='round,pad=0.3', facecolor='#E8F8F5', alpha=0.8))

    ax.set_title('Neural Network Structure - Computation Graph View', fontsize=14, fontweight='bold')
    ax.axis('equal')
    ax.axis('off')

    # 图例
    legend_elements = [
        mpatches.Patch(color='#4A90D9', label='Input Layer'),
        mpatches.Patch(color='#F39C12', label='Hidden Layer'),
        mpatches.Patch(color='#E74C3C', label='Output Layer'),
    ]
    ax.legend(handles=legend_elements, loc='lower right', fontsize=9)

    plt.tight_layout()
    plt.savefig(os.path.join(_IMAGES, 'network_structure.png'), dpi=150, bbox_inches='tight')
    plt.close()
    print("\n[可视化] 网络结构图已保存至 " + os.path.join(_IMAGES, 'network_structure.png'))


def plot_activation_functions():
    """
    绘制四种常见激活函数及其导数的对比图。
    包含:ReLU, Sigmoid, Tanh, Leaky ReLU
    """
    z = np.linspace(-5, 5, 1000)  # 在 [-5, 5] 区间生成 1000 个点

    fig, axes = plt.subplots(2, 2, figsize=(12, 10))
    axes = axes.flatten()  # 展平为 1D 数组,方便索引

    # ---- 定义要绘制的激活函数 ----
    funcs = [
        ("ReLU", relu, relu_derivative, "max(0, z)", "#2E86AB"),
        ("Sigmoid", sigmoid, sigmoid_derivative, "1/(1+e^{-z})", "#A23B72"),
        ("Tanh", tanh, tanh_derivative, "tanh(z)", "#F18F01"),
        ("Leaky ReLU (α=0.01)", lambda z: np.maximum(0, z) + 0.01 * np.minimum(0, z),
         lambda z: np.where(z > 0, 1.0, 0.01), "max(0,z)+0.01*min(0,z)", "#C73E1D"),
    ]

    for ax, (name, fn, fn_prime, formula, color) in zip(axes, funcs):
        y = fn(z)        # 计算函数值
        dy = fn_prime(z) # 计算导数值

        # 绘制函数曲线(蓝色实线)
        ax.plot(z, y, 'b-', linewidth=2.5, label=f'{name}: f(z)')
        # 绘制导数曲线(红色虚线)
        ax.plot(z, dy, 'r--', linewidth=2, label=f"{name}: f'(z)")

        # 标记饱和区(导数接近 0 的区域)
        ax.axhline(y=0, color='gray', linestyle=':', alpha=0.5)  # y=0 参考线
        ax.axhline(y=1, color='gray', linestyle=':', alpha=0.5)  # y=1 参考线

        # 设置坐标轴和标题
        ax.set_xlim(-5, 5)
        ax.set_title(f'{name}\n{formula}', fontsize=12, fontweight='bold')
        ax.set_xlabel('z', fontsize=10)
        ax.set_ylabel('f(z) / f\'(z)', fontsize=10)
        ax.legend(loc='best', fontsize=8)
        ax.grid(True, alpha=0.3)

    plt.suptitle('Common Activation Functions and Their Derivatives', fontsize=16, fontweight='bold', y=1.01)
    plt.tight_layout()
    plt.savefig(os.path.join(_IMAGES, 'activation_functions.png'), dpi=150, bbox_inches='tight')
    plt.close()
    print("[可视化] 激活函数对比图已保存至 " + os.path.join(_IMAGES, 'activation_functions.png'))


def print_tensor_shape_table(caches: List[Dict], parameters: Dict[str, np.ndarray]):
    """
    打印前向传播中所有张量的形状表格。

    参数:
        caches: 前向传播的缓存列表
        parameters: 参数字典
    """
    print("\n" + "=" * 70)
    print("【张量形状总览表】")
    print("=" * 70)
    print(f"{'步骤':<10} {'名称':<12} {'形状':<22} {'说明'}")
    print("-" * 70)

    # 输入数据
    print(f"{'输入':<10} {'X (a[0])':<12} {str(caches[0]['a_prev'].shape):<22} {'输入数据(特征数 × 样本数)'}")
    L = len(parameters) // 2  # 层数

    for l in range(1, L + 1):  # 遍历每一层
        cache = caches[l - 1]  # 获取第 l 层的缓存
        # 权重矩阵
        print(f"{'权重':<10} {f'W[{l}]':<12} {str(parameters[f'W{l}'].shape):<22} "
              f"{'第 ' + str(l) + ' 层权重矩阵'}")
        # 偏置向量
        print(f"{'偏置':<10} {f'b[{l}]':<12} {str(parameters[f'b{l}'].shape):<22} "
              f"{'第 ' + str(l) + ' 层偏置向量'}")
        # 线性输出
        print(f"{'第 ' + str(l) + ' 层':<10} {f'z[{l}]':<12} {str(cache['z'].shape):<22} "
              f"{'线性变换输出(W·a_prev + b)'}")
        # 激活输出
        print(f"{'第 ' + str(l) + ' 层':<10} {f'a[{l}]':<12} {str(cache['a'].shape):<22} "
              f"{'激活函数输出(下一层输入)'}")
        print(f"{'':<10} {'':<12} {'':<22}  min={cache['a'].min():.4f}, max={cache['a'].max():.4f}")

    print("-" * 70)
    total_params = sum(p.size for p in parameters.values())  # 计算总参数数量
    print(f"  总参数量: {total_params} 个")
    print(f"  缓存张量数: {len(caches) * 3} 个 (每层: z, a_prev, a)")
    print("=" * 70)


def plot_forward_data_flow(caches: List[Dict]):
    """
    可视化前向传播中激活值的流动变化。
    绘制每层激活值的分布直方图,观察数据在网络中的演变。

    参数:
        caches: 前向传播的缓存列表
    """
    L = len(caches)  # 层数
    fig, axes = plt.subplots(1, L + 1, figsize=(4 * (L + 1), 4))

    # ---- 绘制输入分布 ----
    a_prev_vals = caches[0]['a_prev'].flatten()  # 输入数据展开为一维
    axes[0].hist(a_prev_vals, bins=30, color='#4A90D9', alpha=0.7, edgecolor='white')
    axes[0].set_title(f'Input Layer a[0]\nshape={caches[0]["a_prev"].shape}', fontsize=10)
    axes[0].set_xlabel('Value')
    axes[0].set_ylabel('Frequency')
    axes[0].axvline(x=0, color='red', linestyle='--', alpha=0.5)  # 零参考线

    # ---- 绘制每层激活分布 ----
    for l in range(L):
        a_vals = caches[l]['a'].flatten()  # 第 l 层激活值展开
        axes[l + 1].hist(a_vals, bins=30, color='#F39C12', alpha=0.7, edgecolor='white')
        axes[l + 1].set_title(f'Layer {l+1} a[{l+1}]\nshape={caches[l]["a"].shape}', fontsize=10)
        axes[l + 1].set_xlabel('Value')
        axes[l + 1].set_ylabel('Frequency')
        axes[l + 1].axvline(x=0, color='red', linestyle='--', alpha=0.5)  # 零参考线

    plt.suptitle('Layer-wise Evolution of Activation Distribution During Forward Propagation', fontsize=14, fontweight='bold')
    plt.tight_layout()
    plt.savefig(os.path.join(_IMAGES, 'forward_data_flow.png'), dpi=150, bbox_inches='tight')
    plt.close()
    print("[可视化] 前向传播数据流图已保存至 " + os.path.join(_IMAGES, 'forward_data_flow.png'))


# ============================================================================
# 第五部分:主程序
# ============================================================================

def main():
    """
    主程序:演示完整的前向传播流程。

    1. 生成合成数据
    2. 初始化一个 3 层 MLP
    3. 执行前向传播,存储所有中间值
    4. 打印张量形状表格
    5. 可视化网络结构和激活函数
    """
    print("╔══════════════════════════════════════════════════════════════════╗")
    print("║        s05 计算图与前向传播 — NumPy 从头实现 MLP 前向传播       ║")
    print("╚══════════════════════════════════════════════════════════════════╝")

    # ---- 1. 生成合成数据集 ----
    np.random.seed(0)  # 固定随机种子,保证可复现
    n_samples = 32       # 样本数量(mini-batch size)
    n_features = 3       # 输入特征数
    X = np.random.randn(n_features, n_samples)  # 生成随机输入数据 (3, 32)
    print(f"\n[数据] 生成了 {n_samples} 个样本,每个 {n_features} 个特征")
    print(f"  输入 X shape: {X.shape}")
    print(f"  X 范围: [{X.min():.4f}, {X.max():.4f}], 均值: {X.mean():.4f}")

    # ---- 2. 定义网络结构 ----
    # 3 层 MLP: [输入3] → [隐藏层4] → [隐藏层4] → [输出1]
    layer_dims = [3, 4, 4, 1]
    print(f"\n[网络结构] 各层神经元数量: {layer_dims}")
    print(f"  输入层: {layer_dims[0]} 个神经元")
    for l in range(1, len(layer_dims) - 1):
        print(f"  隐藏层 {l}: {layer_dims[l]} 个神经元")
    print(f"  输出层: {layer_dims[-1]} 个神经元")

    # ---- 3. 初始化参数 ----
    print(f"\n[初始化] 使用 He 初始化方法...")
    parameters = initialize_parameters(layer_dims)

    # ---- 4. 选择激活函数 ----
    # 隐藏层使用 ReLU(现代神经网络的默认选择),输出层使用 sigmoid(二分类场景)
    activations = [relu, relu, sigmoid]
    print(f"\n[激活函数] 隐藏层: ReLU × 2, 输出层: Sigmoid")

    # ---- 5. 执行前向传播 ----
    y_pred, caches = forward_pass(X, parameters, activations, verbose=True)

    # ---- 6. 打印张量形状表格 ----
    print_tensor_shape_table(caches, parameters)

    # ---- 7. 可视化 ----
    print("\n[可视化] 生成图形...")

    # 绘制网络结构图
    X_single = X[:, 0:1]  # 取第一个样本 (3, 1)
    plot_network_structure(parameters, X_single)

    # 绘制激活函数对比图
    plot_activation_functions()

    # 绘制前向传播数据流
    plot_forward_data_flow(caches)

    # ---- 8. 最终总结 ----
    print("\n" + "=" * 70)
    print("【总结】")
    print("=" * 70)
    print(f"  ✓ 完成了 {len(caches)} 层 MLP 的前向传播")
    print(f"  ✓ 输入: {X.shape} → 输出: {y_pred.shape}")
    print(f"  ✓ 共存储了 {len(caches)} 个 cache(每个含 z, a_prev, a)")
    print(f"  ✓ 总参数量: {sum(p.size for p in parameters.values())}")
    print(f"\n  这些中间值将在反向传播中被使用——")
    print(f"  - z[l] 用于计算激活函数的导数 φ'(z[l])")
    print(f"  - a[l-1] 用于计算权重梯度 dW[l] = δ[l] · (a[l-1])^T")
    print(f"  - a[l] 作为下一层的输入继续前向传播")
    print(f"\n  下一节 s06 将讲解如何利用这些缓存进行反向传播。")
    print("=" * 70)


if __name__ == "__main__":
    main()