Chap1-1 Numpy手搓神经网络—入门PyTorch

文章目录

背景

上一篇博客:Chap1:Neural Networks with NumPy(手搓神经网络理解原理)

不借助pytorch、tensorflow等现代框架,

纯靠numpy,我们就能够手搓一个简单的神经网络;

逻辑很简单,我们将神经网络(此处指MLP)当做是一个算法问题,

从数学角度我们能够理解流程逻辑,从代码角度我们能够规划好数据输入输出流(前向传播数据计算loss,反向传播梯度更新参数),

所以我们绝对能够纯手工搓1个简单的神经网络出来。

这里强调一下,手搓神经网络,并不是为了自找苦吃,而是为了让我们能够更加深入地理解神经网络的数学、代码逻辑,只有吃透了最简单、最原始的模块,我们之后遇到再复杂的网络、再复杂的结构,

都不会再吓到。

因为我们已经能够手搓1个最简单的系统(还原论的本质),再复杂也只是模块封装的表象。

本篇博客就是主要讲我们在Numpy中学会手搓神经网络之后,如何快速适应PyTorch开发,直接进入下一步正式的PyTorch入门。

模板

还是以上一篇博客为例,

原始代码模板

先写核心底层类库,主要是一些网络层的定义init+前向后向方法的实现

python 复制代码
# Dense layer
class Layer_Dense:
  """
  Dense layer of a neural network
  Facilitates:
  - Forward propogation of data throught layer
  - Backward propogation of gradients during training
  """

  # Layer initialization
  def __init__(self, n_inputs, n_neurons):
    # Initialize weights and biases
    self.weights = 0.01 * np.random.randn(n_inputs, n_neurons)
    self.biases = np.zeros((1, n_neurons))

  # Forward pass
  def forward(self, inputs):
    # Remember input values
    self.inputs = inputs
    # Calculate output values from inputs, weights and biases
    self.output = np.dot(inputs, self.weights) + self.biases

  # Backward pass
  def backward(self, dvalues):
    # Gradients on parameters
    self.dweights = np.dot(self.inputs.T, dvalues)
    self.dbiases = np.sum(dvalues, axis=0, keepdims=True)
    # Gradient on values
    self.dinputs = np.dot(dvalues, self.weights.T)


# ReLU activation
class Activation_ReLU:
  """
  Rectified linear unit activation function
  Applied to input of neural network layer
  Introduces non-linearity into the network
  """
  # Forward pass
  def forward(self, inputs):
    # Remember input values
    self.inputs = inputs
    # Calculate output values from inputs
    self.output = np.maximum(0, inputs)

  # Backward pass
  def backward(self, dvalues):
    # Since we need to modify original variable,
    # let's make a copy of values first
    self.dinputs = dvalues.copy()
    # Zero gradient where input values were negative
    self.dinputs[self.inputs <= 0] = 0


# Softmax classifier - combined Softmax activation
# and cross-entropy loss for faster backward step
class Activation_Softmax_Loss_CategoricalCrossentropy():
  """
  Combination of softmax activation function and categorical cross entropy loss function
  Commonly used in classification tasks
  We minimize loss by adjustng model parameters to improve performance
  """

  # create activation and loss function objectives
  def __init__(self):
    self.activation = Activation_Softmax()
    self.loss = Loss_CategoricalCrossentropy()

  # forward pass
  def forward(self, inputs, y_true):
    # output layer's activation function
    self.activation.forward(inputs)
    # set the output
    self.output = self.activation.output
    # calculate and return loss value
    return self.loss.calculate(self.output, y_true)

  # backward pass
  def backward(self, dvalues, y_true):

    # number of samples
    samples = len(dvalues)

    # if labels one-hot encoded, turn into discrete values
    if len(y_true.shape) == 2:
      y_true = np.argmax(y_true, axis=1)

    # copy so we can safely modify
    self.dinputs = dvalues.copy()
    # Calculate gradient
    self.dinputs[range(samples), y_true] -= 1
    # Normalize gradient
    self.dinputs = self.dinputs / samples


# Adam optimizer
class Optimizer_Adam:

  """
  Adam optimization algorithm to optimize parameters of neural network
  Initalize with learning rate, decay, epsilon, momentum
  Pre-update params: Adjust learning rate based on decay
  Update params: Update params using momentum and cache corrections
  Post-update params: Track number of optimization steps performed
  """

  # Initialize optimizer - set settings
  def __init__(self, learning_rate=0.001, decay=0., epsilon=1e-7, beta_1=0.9, beta_2=0.999):
    self.learning_rate = learning_rate
    self.current_learning_rate = learning_rate
    self.decay = decay
    self.iterations = 0
    self.epsilon = epsilon
    self.beta_1 = beta_1
    self.beta_2 = beta_2

  # Call once before any parameter updates
  def pre_update_params(self):
    if self.decay:
      self.current_learning_rate = self.learning_rate * (1. / (1. + self.decay * self.iterations))

  # Update parameters
  def update_params(self, layer):

    # If layer does not contain cache arrays, create them filled with zeros
    if not hasattr(layer, 'weight_cache'):
      layer.weight_momentums = np.zeros_like(layer.weights)
      layer.weight_cache = np.zeros_like(layer.weights)
      layer.bias_momentums = np.zeros_like(layer.biases)
      layer.bias_cache = np.zeros_like(layer.biases)

    # Update momentum with current gradients
    layer.weight_momentums = self.beta_1 * layer.weight_momentums + (1 - self.beta_1) * layer.dweights
    layer.bias_momentums = self.beta_1 * layer.bias_momentums + (1 - self.beta_1) * layer.dbiases

    # Get corrected momentum
    # self.iteration is 0 at first pass
    # and we need to start with 1 here
    weight_momentums_corrected = layer.weight_momentums / (1 - self.beta_1 ** (self.iterations + 1))
    bias_momentums_corrected = layer.bias_momentums / (1 - self.beta_1 ** (self.iterations + 1))

    # update cache with squared current gradients
    layer.weight_cache = self.beta_2 * layer.weight_cache + (1 - self.beta_2) * layer.dweights**2
    layer.bias_cache = self.beta_2 * layer.bias_cache + (1 - self.beta_2) * layer.dbiases**2

    # get corrected cache
    weight_cache_corrected = layer.weight_cache / (1 - self.beta_2 ** (self.iterations + 1))
    bias_cache_corrected = layer.bias_cache / (1 - self.beta_2 ** (self.iterations + 1))

    # Vanilla SGD parameter update + normalization with square root cache
    layer.weights += -self.current_learning_rate * weight_momentums_corrected / (np.sqrt(weight_cache_corrected) + self.epsilon)
    layer.biases += -self.current_learning_rate * bias_momentums_corrected / (np.sqrt(bias_cache_corrected) + self.epsilon)

  # call once after any parameter updates
  def post_update_params(self):
    self.iterations += 1


# Softmax activation
class Activation_Softmax:

  """
  Softmax activation function for multi-class classification
  Compute probabilities for each class
  """

  # Forward pass
  def forward(self, inputs):
    # Remember input values
    self.inputs = inputs
    # Get unnormalized probabilities
    exp_values = np.exp(inputs - np.max(inputs, axis=1, keepdims=True))

    # Normalize them for each sample
    probabilities = exp_values / np.sum(exp_values, axis=1, keepdims=True)

    self.output = probabilities

  # Backward pass
  def backward(self, dvalues):
  # 注意,这里我们是实现了softmax的反向传播backward方法
    # Create uninitialized array
    self.dinputs = np.empty_like(dvalues)

    # Enumerate outputs and gradients
    for index, (single_output, single_dvalues) in enumerate(zip(self.output, dvalues)):
      # Flatten output array
      single_output = single_output.reshape(-1, 1)

      # Calculate Jacobian matrix of the output
      jacobian_matrix = np.diagflat(single_output) - np.dot(single_output, single_output.T)

      # Calculate sample-wise gradient and add it to the array of sample gradients
      self.dinputs[index] = np.dot(jacobian_matrix, single_dvalues)


# Common loss class
class Loss:

  # calculates data and regularization losses, given model output and ground truth values
  def calculate(self, output, y):

    # calculate sample losses
    sample_losses = self.forward(output, y)

    # calculate mean losses
    data_loss = np.mean(sample_losses)

    # return loss
    return data_loss


# cross entropy loss
class Loss_CategoricalCrossentropy(Loss):
  """
  Computes categorical cross entropy
  Quantifies discrepency between predicted and true class probabilities
  """

  # forward pass
  def forward(self, y_pred, y_true):

    # number samples in batch
    samples = len(y_pred)

    # clip data to prevent division by 0
    # clip both sides to not drag mean towards any value
    y_pred_clipped = np.clip(y_pred, 1e-7, 1 - 1e-7)

    # probabilities for target values (only if categorical labels)
    if len(y_true.shape) == 1:
      correct_confidences = y_pred_clipped[ range(samples), y_true ]

    # mask values (only for one-hot encoded labels)
    elif len(y_true.shape) == 2:
      correct_confidences = np.sum( y_pred_clipped * y_true, axis=1 )

    # losses
    negative_log_likelihoods = -np.log(correct_confidences)
    return negative_log_likelihoods

  # backward pass
  def backward(self, dvalues, y_true):

    # number of samples
    samples = len(dvalues)
    # Number of labels in every sample
    # We'll use the first sample to count them
    labels = len(dvalues[0])

    if len(y_true.shape) == 1:
      y_true = np.eye(labels)[y_true]

    # calculate gradient
    self.dinputs = -y_true / dvalues
    # Normalize gradient
    self.dinputs = self.dinputs / samples

然后调用各层实例,构建一个静态的网络结构,

这一步其实更像是我们现实pytorch工程中init直接调用API的部分,因为我们不用手写前面的底层类库

python 复制代码
# Create Dense layer with 2 input features and 64 output values
dense1 = Layer_Dense(2, 64)

# Create ReLU activation (to be used with Dense layer):
activation1 = Activation_ReLU()

# Create second Dense layer with 64 input features (as we take output
# of previous layer here) and 3 output values (output values)
dense2 = Layer_Dense(64, 3)

# Create Softmax classifier's combined loss and activation
loss_activation = Activation_Softmax_Loss_CategoricalCrossentropy()

# Create optimizer
optimizer = Optimizer_Adam(learning_rate=0.05, decay=5e-7)

开始训练,在训练中手动串联起来每一层的forward以及backward方法,

并且计算loss,优化器实现等

python 复制代码
# Train in loop
for epoch in range(10001):
  # Perform a forward pass of our training data through this layer
  dense1.forward(X)

  # Perform a forward pass through activation function
  # takes the output of first dense layer here
  activation1.forward(dense1.output)

  # Perform a forward pass through second Dense layer
  # takes outputs of activation function of first layer as inputs
  dense2.forward(activation1.output)

  # Perform a forward pass through the activation/loss function
  # takes the output of second dense layer here and returns loss
  loss = loss_activation.forward(dense2.output, y)

  # Calculate accuracy from output of activation2 and targets
  # calculate values along first axis
  predictions = np.argmax(loss_activation.output, axis=1)

  if len(y.shape) == 2:
    y = np.argmax(y, axis=1)

  accuracy = np.mean(predictions==y)

  if not epoch % 100:
    print(f'epoch: {epoch}, ' + f'acc: {accuracy:.3f}, ' + f'loss: {loss:.3f}, ' + f'lr: {optimizer.current_learning_rate}')

  # backward pass
  loss_activation.backward(loss_activation.output, y)
  dense2.backward(loss_activation.dinputs)
  activation1.backward(dense2.dinputs)
  dense1.backward(activation1.dinputs)

  # update weights and biases
  optimizer.pre_update_params()
  optimizer.update_params(dense1)
  optimizer.update_params(dense2)
  optimizer.post_update_params()

训练日志如下:

然后就是predict的运用,一般pytorch中,我们是写在一个单独的py文件中,以及可视化功能分离

python 复制代码
import matplotlib.pyplot as plt

# Assuming you have access to the weights and biases of your trained model
# For the weights and biases of dense1 and dense2 layers
W1, b1 = dense1.weights, dense1.biases
W2, b2 = dense2.weights, dense2.biases

# Create a meshgrid of points covering the feature space
h = 0.02
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                     np.arange(y_min, y_max, h))

# Flatten the meshgrid points and apply the first dense layer and ReLU activation
points = np.c_[xx.ravel(), yy.ravel()]
z1 = np.dot(points, W1) + b1
a1 = np.maximum(0, z1)

# Apply the second dense layer
z2 = np.dot(a1, W2) + b2

# Apply softmax activation to get probabilities
exp_scores = np.exp(z2 - np.max(z2, axis=1, keepdims=True))
probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True)

# Predictions
predictions = np.argmax(probs, axis=1)
Z = predictions.reshape(xx.shape)

# Plot decision boundary
plt.contourf(xx, yy, Z, cmap='brg', alpha=0.8)

# Plot data points
plt.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap='brg')
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.show()

注意,我们这里原始的模板代码其实实现了softmax的反向传播梯度的方法,

但是正常情况下我们是不需要的。

这是数学推导上的完美配合!我在上一篇博客中也提到了。

正因为上述的数学简化,为了计算效率和代码简洁性,在几乎所有的深度学习框架(包括 PyTorch, TensorFlow 以及我们这里给出的原始手搓框架)中,训练阶段通常不单独使用 Softmax 层进行反向传播,而是使用一个组合层。

  • 单独的 Softmax 层: 只用于推理(Inference/Predict)阶段,只需要输出概率,不需要算 Loss,也不需要反向传播。所以它只需要 forward
  • SoftmaxWithLoss 层(假设混合层): 用于训练(Training)阶段。它内部包含了 Softmax 和 Loss 的计算,并且它的 backward 方法就是直接计算 y−t。

总而言之,一般softmax层的实现,我们只会实现forward方法,而backward方法一般会和loss整合在一起。

所以之所以还要单独地写一个softmax层,主要是设计用于输出层的推理。

当我们训练好网络后,拿去预测一张图片是猫还是狗时,我们只需要前向传播得到概率即可,此时不需要推导,也就根本不需要backward方法。

整合之后的代码模板

前面的原始代码过于零碎,现在整合一下,

整体的静态框架定义不变,就是抽象出来底层、动态构建的网络结构,然后抽象出来独立的model、train、predict等方法

python 复制代码
import numpy as np

# =============================================================================
# 第一部分:底层核心类库 
# =============================================================================

# 1. 全连接层
class Layer_Dense:
    def __init__(self, n_inputs, n_neurons):
        """
        Description
        -----------
        初始化全连接层的权重和偏置, 注意这是一个抽象的全连接层类, 不是输入层!

        Args
        ----
        n_inputs : int
            当前层输入特征的数量(例, 输入28x28图像, 则n_inputs=784, 就是feature维度)
        n_neurons : int
            当前层输入神经元的数量

        Notes
        -----
        - 1, 解释: 输入层数据格式是「样本 x 特征(n_samples, n_features), 隐藏层核心是「神经元数量(n_neurons), 权重矩阵用「特征 x 神经元(n_features, n_neurons)」的维度设计,正是为了通过矩阵乘法让两者高效衔接;
        输入数据形状是 (n_samples, n_features)(比如 100 个样本, 每个样本 784 个特征 → (100, 784)),权重矩阵是 (n_features, n_neurons)(784 个特征 x 10 个神经元 → (784, 10));
        (n_samples, n_features) @ (n_features, n_neurons) = (n_samples, n_neurons)
        - 2, 当前全连接层单层的构建未涉及激活函数, 只是单纯的线性变换(矩阵乘法+偏置), 激活函数会在后续单独实现, 所以所有的output都是线性变换的结果, 我们直接考虑loss计算和反向传播即可, 不需要考虑激活函数的非线性影响, 就是将output作为当前层的最终输出(类比激活函数之后的输出)
        """

        # 初始化(n_inputs, n_neurons)形状的权重矩阵, 采用随机的标准正态分布
        # 缩放0.01以防止权重过大, 避免前向传播时输出过大导致梯度消失/爆炸
        self.weights = 0.01 * np.random.randn(n_inputs, n_neurons)

        # 初始为(1, n_neurons)形状的全0偏置向量, 每个神经元对应一个偏置值
        # 每个神经元只有 1 个偏置(管 "神经元的偏移");所有样本共享这组偏置(管 "规则通用")
        # 偏置的本质是 "与样本无关的神经元偏移",所有样本共享同一组偏置(1 个神经元 1 个偏置)
        # 偏置的第 2 维(神经元数)必须和「输入 × 权重」结果的第 2 维(神经元数)完全一致(比如都是 10)------ 因为要给每个神经元加专属偏移,维度不匹配就加错了;
        # 偏置的第 1 维(样本数)用 1,是因为广播机制会自动把 1 扩展成实际样本数(比如 100)------ 既满足 "所有样本共享偏置",又避免存储冗余(不用存 100 份重复的偏置)。
        # 所以这里偏置形状是 (1, n_neurons), 而不是反过来(n_neurons, 1)
        self.biases = np.zeros((1, n_neurons))
    
    def forward(self, inputs):
        """
        Description
        -----------
        前向传播, 计算当前层的输出(输入的线性变换)

        Args
        ----
        inputs : np.ndarray
            输入数据, 上一层的输出, 形状为(上一层输出样本数, 上一层输出特征数), 也就是(n_samples, n_inputs/features)

            
        Notes
        -----
        - 1, forward方法依然是在前面整体抽象的全连接层中定义的,不特指输入层到第一层隐藏层, 而是可以作为任意两层之间的全连接层;
        只能说是当前层, 无论是哪一层, 全连接层的输出形状都是 (n_samples, 上一层神经元数);
        不管是输入层后的第一层,还是隐藏层之间,只要传入符合维度的 inputs(上一层输出/上一层神经元数/当前层输入feature数), 并提前定义好对应维度的 weights(n_in_features x 当前层神经元数)和 biases(1 x 当前层神经元数),就能自动完成前向传播计算。
        - 2, 理解抽象全连接层中inputs的维度:
            - 通用维度: inputs.shape = (样本数, 当前层输入特征数),与层位置无关;
            - 样本数不变:所有层的 inputs 第一维度都是同一批样本数,贯穿网络;------》从矩阵乘法角度来看, 任意中间层的行数=第1个矩阵的行数
            - 输入特征数来源:当前层的 "输入特征数" = 上一层的输出特征数 = 上一层的神经元数量;
            - 抽象复用性:正因为维度规则通用,这个 forward 方法才能作为任意全连接层使用,只需匹配上一层输出和自身权重维度即可
        - 3, 此处的全连接层不包括激活函数, 只是单纯的线性变换(矩阵乘法+偏置), 激活函数会在后续单独实现, 所以所有的output都是线性变换的结果, 我们直接考虑loss计算和反向传播即可, 不需要考虑激活函数的非线性影响, 就是将output作为当前层的最终输出(类比激活函数之后的输出)
        """
        # 保存当前层的输入, 用于后续反向传播计算梯度
        self.inputs = inputs

        # 计算当前层的输出: output = inputs @ weights + biases (矩阵乘法+广播机制)
        # 输入数据 X:100 个样本,每个样本 784 个特征 → 形状 (100, 784);
        # 权重 weights:784 个特征 × 10 个神经元 → 形状 (784, 10);------》X @ weights → 形状 (100, 784) @ (784, 10) = (100, 10)
        # 偏置 biases:1 行 × 10 个神经元 → 形状 (1, 10)(全 0 初始化,即 [[0,0,0,...,0]])。------》NumPy 的广播机制会自动把 (1,10) 的偏置 "复制扩展" 成 (100,10)
        # 刚好实现了 "给每个神经元的所有样本输出,都加同一个偏移量"------ 这正是 "与样本无关、每个神经元 1 个偏置" 
        self.output = np.dot(inputs, self.weights) + self.biases
    
    def backward(self, dvalues):
        """
        Description
        -----------
        反向传播, 计算当前层的梯度 (更新梯度值用于优化器更新参数)

        Args
        ----
        dvalues : np.ndarray
            下一层传递过来的梯度, 损失对当前层输出的偏导, 作为当前层需要计算梯度的起点;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入)

        
        Notes
        -----
        - 1, 我们这里的表述是下一层(靠近output)传到上一层(靠近input), 从loss传到输入的反向顺序说法, 所谓的上下是按照正常正向数据传递的说法表述
        - 2, 对于矩阵微积分求导部分的数学符号以及规则说明, 可以参考: https://blog.csdn.net/weixin_62528784/article/details/156519242?spm=1001.2014.3001.5501
        """
        # 计算权重的梯度:损失对权重的偏导 = 输入的转置 @ 下一层梯度
        # 原理: 依据链式法则, ∂L/∂W = ∂L/∂out * ∂out/∂W
        # 其中 ∂out/∂W = inputs.T, 因为 out = inputs @ weights + biases------》这一点可以从矩阵求导的分母布局法理解(输入的转置的形状正好和权重形状匹配)
        # 而 ∂L/∂out 就是 dvalues, 因为 dvalues = ∂L/∂out, dvalues就是定义为loss对这一层输出的梯度, 所以dvalues是我们计算的起点
        self.dweights = np.dot(self.inputs.T, dvalues)

        # 计算偏置的梯度:损失对偏置的偏导 = 下一层梯度的求和(下一层沿样本轴求和)
        # 原理: 依据链式法则, ∂L/∂b = ∂L/∂out * ∂out/∂b
        # 其中 ∂out/∂b = 1 (因为偏置是加法项, 对每个样本都一样), 因为 out = inputs @ weights + biases
        # 所以 ∂L/∂b = sum(∂L/∂out) = sum(dvalues)
        # keepdims=True保持维度为(1, n_neurons),与偏置形状一致
        self.dbiases = np.sum(dvalues, axis=0, keepdims=True)

        # 计算输入的梯度:损失对输入的偏导 = 下一层梯度 @ 权重的转置
        # 原理: 依据链式法则, ∂L/∂inputs = ∂L/∂out * ∂out/∂inputs
        # 其中 ∂out/∂inputs = weights.T, 因为 out = inputs @ weights + biases ------》和前面一样可以从矩阵求导的分母布局法理解(权重的转置的形状正好和输入形状匹配)
        # 而 ∂L/∂out 就是 dvalues, 因为 dvalues = ∂L/∂out
        # 所以 ∂L/∂inputs = dvalues @ weights.T
        # 原理: 将梯度反向传给上一层, 用于前一层的参数更新
        self.dinputs = np.dot(dvalues, self.weights.T)


# 2. ReLU 激活函数
class Activation_ReLU:
    """
    Description
    -----------
    ReLU(Rectified Linear Unit) 激活函数
    用于引入非线性, 解决线性模型无法拟合复杂数据的问题

    Notes
    -----
    - 1, 此处单独实现ReLU激活函数类, 作为独立的激活层使用, 不考虑与全连接层耦合
    """
    def forward(self, inputs):
        """
        Description
        -----------
        前向传播, 计算ReLU激活函数的输出(out_relu)

        Args
        ----
        inputs : np.ndarray
            in_relu, 输入数据, 上一层的输出, 全连接层的线性输出, 形状与全连接层输出一致, 也就是不考虑与激活函数耦合时的全连接层输出;
            
        Notes
        -----
        - 1, 理论上全连接层输出inputs+本层激活函数之后的输出才是当前层的最终输出, 但由于此处不考虑耦合, 此处只是独立的1个激活层, 所以直接将ReLU的输出作为当前层的最终输出
        """

        # 保存当前层的输入(in_relu), 用于后续反向传播判断梯度是否为0
        self.inputs = inputs
        # 计算ReLU激活函数的输出(out_relu): output = max(0, inputs), ReLU函数将负值置0, 保持正值不变
        self.output = np.maximum(0, inputs)

    def backward(self, dvalues):
        """ 
        Description
        -----------
        反向传播, 计算ReLU激活函数的梯度, 用于更新前一层的梯度(ReLU层链式法则传递), 
        计算公式: ∂L/∂in_relu = ∂L/∂out_relu * ∂out_relu/∂in_relu
        
        Args
        ----
        dvalues : np.ndarray
            out_relu, 下一层传递过来的梯度, 损失对当前层输出的偏导, 也就是损失对ReLU层输出的偏导, 作为当前层需要计算梯度的起点;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入)

        Notes
        -----
        - 1, ReLU层在反向传播时的核心任务: 
            - 接收下一层传递过来的梯度 dvalues = ∂L/∂out_relu (损失对ReLU层输出的偏导);
            - 计算当前层的梯度 dinputs = ∂L/∂in_relu (损失对ReLU层输入的偏导), 传递给前一层用于更新梯度;
            - 将调整后的梯度传递给上一层(通常是全连接层), 供上一层计算参数(权重/偏置)的梯度;
            - 依据ReLU的梯度规则调整梯度值(输入<=0位置的梯度置0, 输入>0位置的梯度保持不变);
        - 2, 激活函数层都是剥离开来, 单独实现的, 上一个全连接层的输出作为当前ReLU层的输入, 当前ReLU层的输出作为下一个全连接层的输入;
        """

        # 复制下一层传递过来的梯度, 作为当前层的梯度初始值
        # 若ReLU层后接全连接层, 则dvalues就是全连接层backward方法计算出的self.dinputs(全连接层的输入梯度, 对应ReLU层的输出梯度)
        # 为什么复制? 因为我们需要修改梯度值, 不能直接修改传入的dvalues, 因为下一层的梯度结果dvalues可能还会被其他层使用
        # 这样可以避免影响到其他层的梯度计算
        # ReLU的梯度计算需要根据输入值是否大于0来决定(也就是需要基于自身输入调整)
        self.dinputs = dvalues.copy()

        # ReLU的梯度规则: 当输入值<=0时, 梯度为0; 当输入值>0时, 梯度保持不变, 为1
        # 此处self.inputs就是ReLU层的输入值(in_relu), 要计算其梯度, ∂L/∂in_relu = ∂L/∂out_relu * ∂out_relu/∂in_relu
        # - 当in_relu <= 0, 也就是 self.inputs <= 0, 则 ∂out_relu/∂in_relu = 0, 因为ReLU函数在该区间的梯度为0 ------> 整体梯度 ∂L/∂in_relu = ∂L/∂out_relu * 0 = 0, 即把self.dinputs对应位置置0
        # - 当in_relu > 0, 也就是 self.inputs > 0, 则 ∂out_relu/∂in_relu = 1, 因为ReLU函数在该区间的梯度为1 ------> 整体梯度 ∂L/∂in_relu = ∂L/∂out_relu * 1 = ∂L/∂out_relu, 即self.dinputs保持不变
        # 因此我们需要将输入值<=0的位置的梯度置0, 不传递该位置的梯度
        self.dinputs[self.inputs <= 0] = 0

# 3. Softmax 激活函数 (用于预测)
class Activation_Softmax:
    """
    Description
    -----------
    softmax激活函数层, 用于多分类任务的输出层, 将线性输出转化为概率分布
    

    Notes
    -----
    - 1, 本类中未实现反向传播, 通常与交叉熵损失结合使用, 以简化反向传播计算    

    """

    def forward(self, inputs):
        """ 
        Description
        -----------
        前向传播, 计算Softmax激活函数的输出概率分布, 计算公式: softmax(xi) = exp(xi) / sum(exp(xj))

        Args
        ----
        inputs : np.ndarray
            输入数据, 上一层的输出, 全连接层的线性输出, 形状与全连接层输出一致, 简单理解为in_softmax;
            输出层全连接层的线性输出, 称为Logits, 形状为(样本数, 类别数)

        output : np.ndarray (在状态中保存)
            softmax激活函数的输出概率分布, 形状与输入一致, (样本数, 类别数);
            inputs是softmax层输入, output是softmax层输出
        """

        # 保存当前层的输入, 用于后续计算(反向传播时需要用到, 本类中未实现反向传播, 通常与交叉熵损失结合)
        self.inputs = inputs
        # step1: 为了数值稳定性, 减去每行(每个样本)的最大值, 防止指数函数溢出(np.exp过大可能返回inf)
        exp_values = np.exp(inputs - np.max(inputs, axis=1, keepdims=True))
        # step2: 计算每行(每个样本)的指数和, softmax概率=每个样本的指数值/该样本所有指数值的和
        self.output = exp_values / np.sum(exp_values, axis=1, keepdims=True)

# 4. 通用 Loss 父类
class Loss:
    """
    Description
    -----------
    定义损失函数的统一接口, 所有具体损失函数类均继承自该父类

    """
    def calculate(self, output, y):
        """
        Description
        -----------
        计算损失的公共方法: 返回平均损失(数据损失)

        Args
        ----
        output : np.ndarray
            模型的预测输出, 形状为(样本数, 类别数)
        y : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)

        """

        # 调用子类实现的forward方法, 计算每个样本的损失(样本损失)
        # 调用具体损失函数的前向传播方法, 计算每个样本的损失, forward方法在子类中具体实现, 父类中只是定义抽象接口
        sample_losses = self.forward(output, y)

        # 计算所有样本的平均损失(数据损失), 作为模型优化的目标
        data_loss = np.mean(sample_losses)
        return data_loss

# 5. 交叉熵损失 (含 Softmax)
class Loss_CategoricalCrossentropy(Loss):
    """       
    Description
    -----------
    交叉熵损失函数, 用于多分类任务, 与softmax配合使用

    Args
    ----
    继承自 Loss 父类, 实现具体的前向和反向传播方法
    """
    def forward(self, y_pred, y_true):
        """  
        Description
        -----------
        前向传播, 计算每个样本的交叉熵损失
        
        Args
        ----
        y_pred : np.ndarray
            模型的预测输出概率, 形状为(样本数, 类别数), softmax层的输出概率
        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)
        """
        
        # 获取样本数量, 用于后续平均损失计算
        samples = len(y_pred)
        # 防止log(0)导致数值不稳定, 对预测概率进行裁剪
        # 裁剪范围在[1e-7, 1-1e-7]之间
        y_pred_clipped = np.clip(y_pred, 1e-7, 1 - 1e-7)

        # 从模型输出的概率矩阵 y_pred_clipped 中,提取每个样本「真实类别对应的预测概率」
        # 用于后续计算交叉熵损失: -log(正确类别的预测概率)
        # 分两种情况处理真实标签: 类别索引或独热编码
        # case1: 真实标签为类别索引 (一维数组), 如 y_true = [0, 2, 1] 其shape为(3,)
        if len(y_true.shape) == 1:
            # 列表索引取出每个样本对应的正确类别的预测概率
            # 前面提到过, 列表索引index是按位置配对取元素
            correct_confidences = y_pred_clipped[range(samples), y_true]

        # case2: 真实标签为独热编码 (二维数组), 如 y_true = [[1,0,0], [0,0,1], [0,1,0]]
        elif len(y_true.shape) == 2:
            # 通过逐元素相乘并沿类别轴求和, 获取每个样本对应的正确类别的预测概率
            # 注意 * 就是逐元素乘法, 就是线性代数中的Hadamard积, 哈达玛乘积(维度相同)
            # np.dot 或 @ 是矩阵乘法
            # 预测概率与独热编码逐元素乘法, 等价于取真实类别的概率(独热编码只有真实类别为1)
            correct_confidences = np.sum(y_pred_clipped * y_true, axis=1)

        # correct_confidences中保存了每个样本「真实类别对应的预测概率」, 形状为(samples,)
        # 计算交叉熵损失: -log(正确类别的预测概率)
        negative_log_likelihoods = -np.log(correct_confidences)
        return negative_log_likelihoods
    
    def backward(self, dvalues, y_true):
        """   
        Description
        -----------
        反向传播, 计算损失对模型输出y_pred的梯度, 记为∂L/∂y_pred, 并将结果存入self.dinputs, 最终传递给前一层(通常是输出层的全连接层, 当然最后一层一般是softmax层), 
        用于后续参数(权重, 偏置)的梯度计算和更新
        
        Args
        ----
        dvalues : np.ndarray
            下一层传递过来的梯度, 损失对当前层输出的偏导;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入);
            当然, 在此处独立的交叉熵损失层中, dvalues其实就是y_pred, 因为损失层是网络的最后一层, 没有更下游的层了, 所以dvalues就是损失对y_pred的偏导的起点;
            即损失计算的输入, 也就是模型的预测输出y_pred
        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)

        Notes
        -----
        - 1, 公式推导的是「单个样本的梯度」,但代码中计算的是所有样本的平均梯度(因损失 L 通常取平均值),因此需要除以样本数
        - 2, 该层中的dvalues其实就是y_pred, dinputs就是损失对y_pred的梯度, 也就是∂L/∂y_pred
        """

        # 获取样本数和类别数
        # 注意此处dvalues就是y_pred(模型的预测输出), 因为交叉熵损失层是最后一层, 没有更下游的层了
        samples = len(dvalues)
        labels = len(dvalues[0])
        # 如果输入是类别索引, 则转换为独热编码形式
        if len(y_true.shape) == 1:
            # np.eys(label)生成单位矩阵,y_true作为索引取出对应行, 即独热编码
            # np.eye(labels):生成 (类别数, 类别数) 的单位矩阵(对角线上为 1,其余为 0), 每行对应1个类别的独热编码
            # [y_true]:用真实类别索引取单位矩阵的对应行,得到独热编码形式
            y_true = np.eye(labels)[y_true]

        # 交叉熵损失对y_pred的梯度公式: -真实标签/预测概率
        # 计算公式为 ∂L/∂y_pred = ∂(-sum(y_true * log(y_pred)))/∂y_pred 
        # 一般为了计算方便, log的底数取e, 其实就是ln, 求导就是1/x
        # 然后我们以样本的某一个类别为例子来推导:
        # L = -y_true  * log(y_pred), 对y_pred,c求导:
        # ∂L/∂y_pred,c = ∂(-sumk(y_true,k * log(y_pred,k)))/∂y_pred,c
        # 只有当k=c时, 导数不为0, 其他k≠c时导数为0, 所以:
        # ∂L/∂y_pred,c = -y_true,c/y_pred,c 
        # 推广到所有类别, 就是 ∂L/∂y_pred = -y_true / y_pred
        self.dinputs = -y_true / dvalues
        # 归一化梯度, 除以样本数, 保持梯度规模稳定(确保梯度规模与样本数量无关)
        self.dinputs = self.dinputs / samples

# 6. Softmax + Loss 组合类 (为了反向传播更稳定)
class Activation_Softmax_Loss_CategoricalCrossentropy():
    """  
    Description
    -----------
    将softmax激活函数与交叉熵损失结合, 优化反向传播稳定性;
    因为单独计算softmax和交叉熵的梯度会有数值不稳定问题, 组合后可简化梯度计算

    Notes
    -----
    - 1, 该类封装了softmax激活和交叉熵损失, 提供前向和反向传播方法, 具体调用类的实现细节见前面softmax和交叉熵损失类
    
    """
    def __init__(self):
        """
        Description
        -----------
        初始化组合类, 创建softmax激活和交叉熵损失实例
        """
        
        # 实现见前面
        # softmax激活层
        self.activation = Activation_Softmax()
        # 交叉熵损失层
        self.loss = Loss_CategoricalCrossentropy()
    def forward(self, inputs, y_true):
        """ 
        Description
        -----------
        前向传播, 计算softmax输出和交叉熵损失(先激活再计算损失)

        Args
        ----
        inputs : np.ndarray
            输入数据, 上一层的输出, 全连接层的线性输出, 形状与全连接层输出一致;
            输出层全连接层的线性输出, 称为Logits, 形状为(样本数, 类别数), 
            也就是in_softmax, softmax层的输入

        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码), 同交叉熵损失
        """

        # 对Logits做softmax激活, 得到概率分布
        # 也就是Activation_Softmax()类的forward方法
        self.activation.forward(inputs)

        # 保存激活后的输出(概率分布), 用于后续反向传播
        # 也就是softmax激活函数的输出概率分布, 形状与输入一致, (样本数, 类别数);
        # inputs是softmax层输入, output是softmax层输出
        self.output = self.activation.output

        # 计算平均交叉熵损失,调用交叉熵损失的calculate方法
        # 实际实现细节上调用的是Loss_CategoricalCrossentropy类的forward方法, 也就是将softmax层的输出作为输入计算损失
        return self.loss.calculate(self.output, y_true)
    def backward(self, dvalues, y_true):
        """
        Description
        -----------
        反向传播, 计算组合层的梯度, 直接计算简化后的梯度公式(也就是直接计算损失对Logits的梯度, 避免数值不稳定)
        Args
        ----
        dvalues : np.ndarray
            下一层传递过来的梯度, 损失对当前层输出的偏导;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入);
            当然, 在此处softmax+交叉熵组合层中, dvalues其实就是y_pred, 因为组合层是网络的最后一层, 没有更下游的层了, 所以dvalues就是损失对y_pred的偏导的起点;
            即损失计算的输入, 也就是模型的预测输出y_pred
        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)
        
        
        Notes
        -----
        - 1, 注意该组合层backward的目标是计算损失对Logits的梯度(也就是损失对全连接层输出的梯度), 以便传递给前一层(通常是输出层的全连接层), 用于更新参数;
        也就是计算 ∂L/∂Logits, 而不是单独计算softmax层或交叉熵损失层的梯度;
        """

        # 获取样本数量
        # 注意这里的dvalues就是y_pred(模型的预测输出), 因为softmax+交叉熵组合层是最后一层, 没有更下游的层了
        samples = len(dvalues)  
        if len(y_true.shape) == 2:
            # 若真实标签为独热编码, 则转换为类别索引(方便后续索引操作), 独热变索引
            y_true = np.argmax(y_true, axis=1)
        # 复制dvalues(此处dvalues是softmax的输出, 即概率分布), 因为y_pred可能有其他作用, 比如说计算准确率等, 不能直接修改
        self.dinputs = dvalues.copy()

        # 关键步骤:简化之后的梯度也就是softmax+交叉熵的联合梯度 = 预测概率 - 真实标签(独热编码形式)
        # 对每个样本的 "真实类别对应的概率" 减 1, 等价于将独热编码的 y_true 中 "1" 的位置减 1, "0" 的位置不变
        # 用的还是列表索引
        self.dinputs[range(samples), y_true] -= 1
        # 梯度归一化: 除以样本数, 保持梯度规模稳定(确保梯度规模与样本数量无关)
        self.dinputs = self.dinputs / samples

# 7. Adam 优化器
class Optimizer_Adam:
    """
    Description
    -----------
    Adam优化器类, 用于更新神经网络的权重和偏置参数, 结合动量(Momentum)和自适应学习率(RMSProp)的优化算法, 通过积累历史梯度信息动态调整参数更新策略,实现更快收敛和更稳定的训练;
    收敛快, 稳定, 适合大多数神经网络训练

    Adam的核心是维护两个关键变量:
    - 动量(momentum): 积累历史梯度的"方向", 缓解SGD在局部最优附近的震荡, 加速沿稳定方向的收敛;量纲是"梯度的累积", 梯度是损失对参数的偏导, 量纲是损失值/参数值
    - 自适应缓存(cache): 积累历史梯度的"幅度", 为每个参数动态调整学习率(梯度大的参数用小步长, 梯度小的参数用大步长);量纲是"梯度平方的累积", 量纲是(损失值/参数值)^2
    其中偏差修正: 初始阶段动量和缓存接近0, 通过修正项使其更接近真实值, 避免初期更新过慢

    注意, 优化器维护的动量和缓存只是辅助变量, 与此处model的权重/偏置核心参数不同;
    辅助变量的初始化≠核心参数的初始化

    """
    def __init__(self, learning_rate=0.001, decay=0., epsilon=1e-7, beta_1=0.9, beta_2=0.999):
        """  
        Description
        -----------
        初始化Adam优化器的超参数

        Args
        ----
        learning_rate : float
            初始学习率, 控制参数更新的步长大小, 默认0.001
        decay : float
            学习率衰减率, 控制学习率随迭代次数逐渐减小, 默认0.0(不衰减)
        epsilon : float
            防止除0错误的小常数, 用于数值稳定性, 默认1e-7
        beta_1 : float
            动量项的指数衰减率(控制动量的历史贡献, 默认0.9)
        beta_2 : float
            自适应学习率项的指数衰减率(控制平方梯度的历史贡献, 默认0.999)
        """
        
        # 初始学习率, 即初始步长, 控制参数更新的幅度
        self.learning_rate = learning_rate
        # 当前学习率(可能会随迭代次数逐渐衰减), 实际用于更新的学习率
        self.current_learning_rate = learning_rate
        # 学习率衰减率(控制实际用于更新的学习率随迭代次数减小, 避免后期震荡), 默认0不衰减
        self.decay = decay
        # 迭代次数(用于学习率衰减计算和偏差修正)
        self.iterations = 0
        # 防止除0错误的小常数, 数值稳定项
        self.epsilon = epsilon
        # 动量项系数, 控制动量的历史贡献; 即动量项的指数衰减率, 控制历史动量的"记忆比例"(β1越大, 记忆越久)
        self.beta_1 = beta_1
        # 自适应学习率项系数, 控制平方梯度的历史贡献; 即自适应学习率项/缓存项的指数衰减率, 控制历史平方梯度的"记忆比例"(β2越大, 记忆越久)
        self.beta_2 = beta_2
    
    def pre_update_params(self):
        """
        Description
        -----------
        在更新参数前调用, 用于调整当前学习率(如果设置了衰减);
        在每次参数更新之前, 根绝迭代次数调整当前学习率(current_learning_rate), 仅当decay>0时生效
        """
        if self.decay:
            # 若启动学习率衰减, 则根据迭代次数调整当前学习率/按公式更新当前学习率
            # 衰减公式: lr = initial_lr / (1 + decay * iterations)
            # 训练前期, 迭代次数小, 当前学习率接近初始值, 大步长快速收敛; 训练后期, 迭代次数大, 当前学习率减小, 小步长精细调整参数, 避免在最优解附近震荡
            # 目的: 训练前期使用较大学习率快速收敛, 后期使用较小学习率精细调整
            self.current_learning_rate = self.learning_rate * (1. / (1. + self.decay * self.iterations))
    
    def update_params(self, layer):
        """ 
        Description
        ---------
        接收1个全连接层(Layer_Dense示例), 利用该层的梯度(dweights/dbiases)更新其相应参数(权重和偏置)
        
        Args
        ----
        layer : Layer_Dense
            需要更新参数的层实例, 该层必须包含dweights和dbiases属性(梯度)
        """
        
        # 如果该层没有weight_cache属性(没有历史信息, 说明是首次更新), 则初始化动量和缓存数组(与参数形状一致, 因为动量公式中每一轮迭代中的动量本质是历史梯度的一个加权平均, 所以形状要和梯度一致, 而参数的梯度形状和参数本身一致)
        # 首次更新某层时,没有历史梯度数据, 所以创建与权重 / 偏置形状完全一致的全 0 数组,用于存储历史动量(weight_momentums)和历史平方梯度(weight_cache)
        if not hasattr(layer, 'weight_cache'):

            # layer.weights 是该层的权重参数
            # layer.weight_momentums: 是该层的权重动量, 量纲是"梯度的累积"
            # layer.weight_cache: 是该层的权重缓存, 量纲是"梯度平方的累积"

            # 动量数组(momentums): 存储历史梯度的指数移动平均(用于动量更新)
            layer.weight_momentums = np.zeros_like(layer.weights)
            # np.zeros_like: 创建与layer.weights形状相同的全0数组
            layer.weight_cache = np.zeros_like(layer.weights)

            # 缓存数组(cache): 存储历史平方梯度的指数移动平均(用于自适应学习率)
            layer.bias_momentums = np.zeros_like(layer.biases)
            layer.bias_cache = np.zeros_like(layer.biases)
        
        # 1, 更新动量数组(权重与偏置) ------> ⚠️ 动量法体现
        # 公式: momentum = beta_1 * previous_momentum + (1 - beta_1) * current_gradient
        # mt=β1⋅mt-1+(1-β1)⋅∇Wt  当前迭代的动量=β1乘以前一迭代的动量+(1-β1)乘以当前迭代的权重梯度
        # 作用: 积累历史梯度方向, 加速收敛(如沿同一方向则步长变大)
        layer.weight_momentums = self.beta_1 * layer.weight_momentums + (1 - self.beta_1) * layer.dweights
        layer.bias_momentums = self.beta_1 * layer.bias_momentums + (1 - self.beta_1) * layer.dbiases
        
        # 2, 动量偏差修正: 初始迭代时momentum接近0, 需修正为更精确的估计 ------> 矫正加权和为1
        # 公式: corrected_momentum = momentum / (1 - beta_1^(iterations + 1))
        # 原因: beta1接近1, 迭代初期1 = beta1^t 较小, 修正后momentum更接近真实值
        weight_momentums_corrected = layer.weight_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        bias_momentums_corrected = layer.bias_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        
        # 3, 更新缓存数组(权重与偏置) ------> ⚠️ 自适应学习率体现(也就是RMSProp)
        # 公式: cache = beta_2 * previous_cache + (1 - beta_2) * current_gradient^2
        # 作用: 记录梯度平方的历史, 用于自适应调整学习率(梯度大则步长小, 反之则步长大)
        layer.weight_cache = self.beta_2 * layer.weight_cache + (1 - self.beta_2) * layer.dweights**2
        layer.bias_cache = self.beta_2 * layer.bias_cache + (1 - self.beta_2) * layer.dbiases**2
        
        # 4, 缓存偏差修正: 同动量修正, 解决初始迭代时cache接近0的问题 ------> 矫正加权和为1 不能
        # 公式: corrected_cache = cache / (1 - beta_2^(iterations + 1))
        weight_cache_corrected = layer.weight_cache / (1 - self.beta_2 ** (self.iterations + 1))
        bias_cache_corrected = layer.bias_cache / (1 - self.beta_2 ** (self.iterations + 1))
        
        # 5, 最终参数更新: 结合动量修正和自适应学习率
        # 公式: 参数 = 参数 - 学习率*修正动量/(sqrt(修正缓存) + 小常数)
        # 修正动量: 提供梯度的方向和累计效应(解决SGD震荡问题)
        # 修正缓存: 调整学习率以适应不同参数的梯度规模(自适应调整每个参数的学习率, 梯度大则步长小, 避免震荡)
        # 小常数: 防止除0错误, 保持数值稳定
        layer.weights += -self.current_learning_rate * weight_momentums_corrected / (np.sqrt(weight_cache_corrected) + self.epsilon)
        layer.biases += -self.current_learning_rate * bias_momentums_corrected / (np.sqrt(bias_cache_corrected) + self.epsilon)
    
    def post_update_params(self):
        # 迭代次数更新: 每次参数更新后迭代次数+1, 用于下一次衰减和偏差修正
        self.iterations += 1

# =============================================================================
# 第二部分:通用深度网络封装 (UniversalDeepModel)
# =============================================================================

class UniversalDeepModel:
    """
    Description
    -----------
    这是一个通用的、支持任意深度的全连接神经网络封装类; 它自动管理层的创建、前向传播、反向传播和参数更新.
    本质是组装工具类, 根据后续用户配置动态搭建网络(隐藏层数量/神经元数, 输入输出维度), 并自动协调"前向传播-损失计算-反向传播-参数更新"的流程.

    Notes
    -----
    - 1, 封装目的是为了降低使用复杂度, 用户只需指定网络结构和训练参数(数据和网络结构), 无需手动管理每一层和训练细节(也就是无需关注梯度计算/层间维度匹配等细节)
    """
    def __init__(self, input_dim, hidden_layer_sizes, output_dim, learning_rate=0.001, decay=0.):
        """
        Description
        -----------
            初始化模型架构, 动态创建隐藏层和输出层, 并设置优化器和损失函数.
            只是设计网络结构, 并未进行训练, 也就是层的初始化和连接.
        
        Args
        ----
            input_dim (int): 输入数据的特征数量 (例如 2, 784), 主要是每一层的输入维度, 可以视为上一层的输出维度/神经元数量, 如 28x28 图像展平后为 784
            hidden_layer_sizes (list of int): 一个列表,定义隐藏层的结构
                例如 [64] 表示 1 个隐藏层,有 64 个神经元
                例如 [128, 64] 表示 2 个隐藏层,第一层 128, 第二层 64
            output_dim (int): 输出类别的数量, 例如 10 表示有 10 个类别 (0-9)
            learning_rate (float): 初始学习率, 控制参数更新步长, 默认0.001
            decay (float): 学习率衰减率, 防止训练后期震荡, 默认0.0(不衰减)
        """
        self.layers = [] # 用于存储所有的网络层 (Dense 和 ReLU), 仅存隐藏层, 输出层单独处理, 因输出层激活函数与隐藏层不同
        self.optimizer = Optimizer_Adam(learning_rate=learning_rate, decay=decay)
        
        # --- 1. 动态构建隐藏层 ---
        # current_input_dim 记录当前层的输入维度,初始为数据的输入维度
        current_input_dim = input_dim 
        
        for i, n_neurons in enumerate(hidden_layer_sizes):
            # 创建全连接层:输入维度current_input_dim -> 当前隐藏层神经元数n_neurons
            # 隐藏层的固定结构:全连接层(线性变换)+ ReLU 层(非线性激活)
            # 示例, 若hidden_layer_sizes=[64,32], 则self.layers会依次添加: Dense(input_dim->64)->ReLU->Dense(64->32)->ReLU
            dense_layer = Layer_Dense(current_input_dim, n_neurons)
            self.layers.append(dense_layer)
            
            # 创建激活函数层:每个全连接层后通常接一个 ReLU, 引入非线性
            activation_layer = Activation_ReLU()
            self.layers.append(activation_layer)
            
            # 更新下一层的输入维度为当前层的神经元数
            current_input_dim = n_neurons
            
            print(f"构建层 {i+1}: Dense({dense_layer.weights.shape[0]}->{n_neurons}) + ReLU")

        # --- 2. 构建输出层 ---
        # 最后一层全连接:最后一个隐藏层神经元数 -> 输出类别数
        # 注意:最后一层通常不接 ReLU,而是接 Softmax (包含在 loss_activation 中)
        # 单独存储输出层全连接层(因后续反向传播需优先处理输出层梯度)
        self.final_dense = Layer_Dense(current_input_dim, output_dim)
        print(f"构建输出层: Dense({current_input_dim}->{output_dim}) + Softmax")
        
        # --- 3. 定义损失函数和输出激活 ---
        # 使用 Softmax + CrossEntropy 的组合类
        # 输出层激活+损失:Softmax(概率化)+ 交叉熵(损失计算)组合类
        self.loss_activation = Activation_Softmax_Loss_CategoricalCrossentropy()

    def forward(self, X):
        """
        Description
        -----------
            前向传播:数据从输入流向输出, 只是计算输出, 不进行训练.
            前向传播是 "数据流转阶段":输入数据从隐藏层逐层传递,最终经过输出层全连接层,得到线性输出(Logits)(Softmax 在损失计算时才执行);
            层间传递:每个层的 forward 方法会更新自身的 self.output, 并作为下一层的输入, 无需用户手动处理维度匹配(底层 Layer_Dense 已保证维度正确)
        
        Args
        ----
            X (np.ndarray): 输入数据, 形状为(样本数, 特征数)
        """
        # 1. 数据先流经所有隐藏层
        current_output = X # 初始输入为原始数据
        for layer in self.layers:
            layer.forward(current_output) # 调用层的前向传播(Dense算线性输出,ReLU做激活)
            current_output = layer.output # 将当前层的输出作为下一层的输入
            
        # 2. 流经最后一个全连接层
        self.final_dense.forward(current_output)
        
        # 3. 返回最终层的输出 (Logits),注意此时还没过 Softmax
        # Softmax 激活被封装在 loss_activation 中,会在计算损失时自动执行(避免重复计算)
        return self.final_dense.output

    def train(self, X, y, epochs=1000, print_every=100):
        """
        Description
        -----------
            训练循环, 是模型的 "迭代优化阶段",整合了 "前向传播→损失计算→梯度反向传播→参数更新" 的全流程,是训练模型的入口.

        Args
        ----
            X (np.ndarray): 输入数据, 形状为(样本数, 特征数)
            y (np.ndarray): 真实标签, 可为类别索引或独热编码, 形状为(样本数,)或(样本数, 类别数) (独热编码)
            epochs (int): 训练轮数, 控制训练的迭代次数, 默认1000
            print_every (int): 每多少轮打印一次训练状态, 默认100

        """
        for epoch in range(epochs):
            # ====================
            # A. 前向传播 (Forward)
            # ====================
            
            # 1. 计算网络主体输出, 线性输出(Logits)
            final_outputs = self.forward(X)
            
            # 2. 计算损失 (同时做 Softmax)
            # self.loss_activation.forward 接收Logits,先做Softmax得到概率,再算交叉熵损失
            # forward 返回的是 loss 值
            loss = self.loss_activation.forward(final_outputs, y)

            # ====================
            # B. 打印状态 (Logging)
            # ====================
            if not epoch % print_every:
                # 1. 计算预测类别(从概率分布取最大值索引)
                predictions = np.argmax(self.loss_activation.output, axis=1)
                # 2. 处理标签(若为独热编码,转成类别索引)
                if len(y.shape) == 2:
                    y_labels = np.argmax(y, axis=1)
                else:
                    y_labels = y
                # 3. 计算准确率(预测正确的样本数 / 总样本数)
                accuracy = np.mean(predictions == y_labels)
                
                # 4. 打印当前轮数, 准确率, 损失, 学习率, 梯度范数⚠️
                # 如果可以还想打印一下梯度的范数
                print(f'Epoch: {epoch}, ' +
                      f'Acc: {accuracy:.3f}, ' +
                      f'Loss: {loss:.3f}, ' +
                      f'LR: {self.optimizer.current_learning_rate:.6f}, '+
                      f'Grad Norm: {np.mean([np.linalg.norm(layer.dweights) for layer in self.layers if hasattr(layer, "dweights")]):.3f}')

            # ====================
            # C. 反向传播 (Backward)
            # 反向传播是 "梯度回流阶段",核心是根据损失计算所有参数(权重、偏置)的梯度,遵循 "从输出层→隐藏层→输入层" 的顺序(梯度链式法则)
            # ====================
            
            # 1. 从 Loss 开始反向传播
            # 1. 从损失层开始反向传播(计算对Logits的梯度)
            # self.loss_activation.backward 直接返回损失对输出层全连接层输出(Logits)的梯度
            self.loss_activation.backward(self.loss_activation.output, y)
            
            # 2. 反向传播经过输出层
            # 输入是 loss 层的梯度 (dinputs)
            # 2. 输出层全连接层反向传播(计算对输出层权重/偏置的梯度,及对隐藏层输出的梯度)
            self.final_dense.backward(self.loss_activation.dinputs)
            
            # 3. 反向传播经过所有隐藏层 (需要倒序遍历!因为梯度从后往前传)
            # 这里的梯度链是:上一层的 dinputs -> 当前层的 backward
            # 初始梯度:输出层对隐藏层输出的梯度
            back_gradient = self.final_dense.dinputs
            
            for layer in reversed(self.layers):
                layer.backward(back_gradient) # 调用层的反向传播(计算当前层梯度)
                back_gradient = layer.dinputs # 更新梯度:当前层对前一层输入的梯度 → 传给前一层

            # ====================
            # D. 参数更新 (Optimize)
            # ====================
            
            # 1. 预更新:处理学习率衰减(若开启)
            self.optimizer.pre_update_params()
            
            # 2. 更新隐藏层的参数(仅全连接层有参数,ReLU无参数)
            for layer in self.layers:
                # 只有 Layer_Dense 有参数(weights/biases),ReLU 没有
                if hasattr(layer, 'weights'):
                    self.optimizer.update_params(layer)
            
            # 3. 更新输出层的参数
            self.optimizer.update_params(self.final_dense)
            
            # 4. 后更新:迭代次数+1(用于下一轮衰减计算和Adam的偏差修正)
            self.optimizer.post_update_params()

    def predict(self, X):
        """
        Description
        -----------
        使用训练好的模型进行预测, 返回类别概率分布.
        训练完成后,通过 predict 方法对新数据进行预测,输出类别概率分布(方便用户判断预测置信度)
        预测函数:输入数据,输出概率分布
        推理流程:新数据→前向传播(Logits)→Softmax(概率)
        

        Args
        ----
        X : np.ndarray
            输入数据, 形状为(样本数, 特征数)

        Notes
        -----
        - 1, 输出解读:例如输出 [[0.05, 0.9, 0.05]] 表示样本属于第 2 类的概率为 90%,可通过 np.argmax(probs, axis=1) 得到最终预测类别
        """
        # 1. 前向传播得到Logits(线性输出)
        logits = self.forward(X)
        # 2. 对Logits执行Softmax,转为概率分布
        self.loss_activation.activation.forward(logits)
        # 3. 返回概率分布(形状:(样本数, 类别数))
        return self.loss_activation.activation.output

# =============================================================================
# 第三部分:【用户配置区】 (万金油模板接口)
# =============================================================================

# --- 1. 数据准备 (Data Preparation) ---
# 这里是唯一需要你根据实际任务修改数据加载逻辑的地方
# 示例:生成 300 个样本,2 个特征,3 分类
print("正在生成数据...")
N_SAMPLES = 300
INPUT_FEATURES = 2
NUM_CLASSES = 3
X_data = np.random.randn(N_SAMPLES, INPUT_FEATURES) # 你的输入数据, 随机生成标准正态分布数据, 形状 (300, 2)
y_data = np.random.randint(0, NUM_CLASSES, size=(N_SAMPLES,)) # 你的标签, 随机生成 0,1,2 三类标签, 形状 (300,)

# --- 2. 模型配置 (Model Configuration) ---
# 只要修改这里,就能改变网络的深度和宽度
# 场景 A: 简单网络 -> hidden_layers = [64]
# 场景 B: 深层网络 -> hidden_layers = [128, 128, 64]
MY_HIDDEN_LAYERS = [64, 64]  # 2个隐藏层,每层64个神经元

model = UniversalDeepModel(
    input_dim=INPUT_FEATURES,       # 自动适配输入数据
    hidden_layer_sizes=MY_HIDDEN_LAYERS, # 在这里定义你有多少层,每层多大
    output_dim=NUM_CLASSES,         # 自动适配输出类别
    learning_rate=0.05,             # 学习率
    decay=1e-4                      # 学习率衰减 (防止后期震荡)
)

# --- 3. 训练 (Training) ---
print("\n开始训练...")
model.train(
    X_data, 
    y_data, 
    epochs=2000,    # 训练轮数
    print_every=10 # 每多少轮打印一次
)

# --- 4. 验证/使用 (Inference) ---
print("\n模型使用示例:")
# 假设来了一条新数据
new_sample = np.array([[0.5, -1.2]]) # 新样本, 形状 (1, 2)
probs = model.predict(new_sample) # 预测类别概率分布, 形状 (1, 3)
pred_class = np.argmax(probs, axis=1) # 预测类别索引

print(f"输入: {new_sample}")
print(f"各类别概率: {probs}")
print(f"预测类别: {pred_class}")

将层级划分出来:

  • 底层核心类库
  • 网络结构+forward实现,train和predict实现

简单实用模拟数据进行运行:

这个,就是我们用Numpy纯手搓的一个神经网络,仅作为原型而言,确实有一些不足:

1,Epoch, Batch, Iteration 与 SGD 的关系

首先需要明确一下Epoch、Batch、Iteration之间的关系:

简单来说,我们训练model的时候,一般不是将所有的数据一次性喂给model来计算loss、分析梯度、更新参数;

而是将所有的data分成多个batch,一个batch大小就是batch_size,

然后我们实际训练的时候,每一次都只用1个batch的数据去训练神经网络,也就是这1个batch的输入数据,先forward前向流动,计算loss,然后反向传播梯度,来更新参数,只有更新完了参数,我们才叫完成1次训练,也就是1次iteration,我们可以发现1个batch的数据用于1次iteration。

然后一次iteration当然是不够的,我们的数据有那么多,一次iteration相当于网络只看到了我们的1个batch数据,那么其他batch数据中的知识就没有学到,那肯定要物尽其用。

所以我们会尝试在不同的iteration中使用不同的batch数据,然后(所有数据/batch_size)=iteration次数,

只要迭代完了这些次数,那就意味着我们的网络已经看完了所有的batch的数据,那就是一次epoch。

因为要满足我们每一个数据都能够在1个epoch中被看完,所以这个抽样过程非常明确,就是无放回抽样。

只有无放回抽样,遍历完所有batch,才能算作1个Epoch。

Epoch 的核心定义是「所有训练数据被遍历一次」------ 若用「有放回抽样」,会出现两个问题:

  1. 数据重复:部分样本可能被多次抽取(同一 Epoch 内重复参与更新);
  2. 数据遗漏 :部分样本可能一次都没被抽取(未参与该 Epoch 的训练);
    这两种情况都违背了「遍历所有数据」的前提,因此有放回抽样不可能构成一个完整的 Epoch

而且少抽漏抽,会导致数据分布变化,比如说我有一个偏态数据,结果我每一个batch中就是总是抽不到尾部分布的数据,那么我们让神经网络学习到的数据分布的规律,实际上就和真实数据分布的规律会有差异;

如果多抽/重复抽了,那么相当于模型多次反复看了这个样本数据,就会有bias偏差存在。

总而言之,一个 Epoch(回合)通常被视为一种"无放回抽样"的过程。

在一个任务里是无放回抽样,但在任务之间是有放回抽样的。

实际训练中的操作逻辑是:

  1. 每个 Epoch 开始前,先将训练集随机打乱顺序(保证随机性,避免模型记住数据顺序);
  2. 按 Batch Size 把打乱后的数据集「切割成连续的 Batch 块」(本质是无放回的 "均分抽样");
  3. 依次训练所有 Batch(每个 Batch 对应 1 次 Iteration),直到所有 Batch 都训练完 ------ 此时所有样本都被遍历过,才算 1 个 Epoch 结束。

例:假设训练集共 1000 个样本,Batch Size=200,则 1 个 Epoch 需要训练 5 个 Batch(1000/200=5),即 5 次 Iteration。

然后问题是什么呢?问题在于batch_size,我这个手搓代码中使用的是全量梯度下降 (Full Batch Gradient Descent),而不是 SGD(随机梯度下降),也就是说我在一次iteration中用的是full batch(全部数据一次训练都看),而不是mini batch(严格按照一次训练只看部分数据)。

关于full batch vs mini batch的区别以及优劣,详情可以参考李宏毅老师的深度学习网课:https://www.youtube.com/watch?v=zzbr1h9sF54

如果要改成mini-batch的话,只需要在train方法里加入batch_size参数即可,然后每1个epoch开始前都需要shuffle,再进行无放回抽样,直到所有样本都被迭代完

python 复制代码
    for epoch in range(epochs):
            
            # ==========================
            # A. 训练阶段 (Training Loop)
            # ==========================
            if batch_size is None:
                # 场景 1: 全量梯度下降 (我们原来的逻辑)
                self._train_step(X_train, y_train)
            else:
                # 场景 2: Mini-batch SGD (工业界标准)
                # step 1: 打乱数据
                indices = np.arange(n_samples)
                np.random.shuffle(indices)
                X_shuffled = X_train[indices]
                y_shuffled = y_train[indices] if len(y_train.shape)==1 else y_train[indices] # 兼容 one-hot 或 label
                
                # step 2: 按批次迭代
                for start_idx in range(0, n_samples, batch_size):
                    end_idx = min(start_idx + batch_size, n_samples)
                    X_batch = X_shuffled[start_idx:end_idx]
                    y_batch = y_shuffled[start_idx:end_idx]
                    
                    self._train_step(X_batch, y_batch) # 对每个 batch 更新一次参数

# 这里修改了一下单步训练逻辑
def _train_step(self, X_batch, y_batch):
        """单步训练逻辑:前向 -> Loss -> 反向 -> 更新"""
        # 1. Forward
        logits = self.forward(X_batch)
        # 2. Loss + Softmax Output
        self.loss_activation.forward(logits, y_batch)
        # 3. Backward
        self.loss_activation.backward(self.loss_activation.output, y_batch)
        self.final_dense.backward(self.loss_activation.dinputs)
        back_gradient = self.final_dense.dinputs
        for layer in reversed(self.layers):
            layer.backward(back_gradient)
            back_gradient = layer.dinputs
        # 4. Update
        self.optimizer.pre_update_params()
        for layer in self.layers:
            if hasattr(layer, 'weights'): self.optimizer.update_params(layer)
        self.optimizer.update_params(self.final_dense)
        self.optimizer.post_update_params()

2,早停 (Early Stopping) 与阈值机制

真实的训练其实是不知道要跑多少次Epoch的,所以我们这里的代码设置为2000次epoch,其实是纯粹随机设置的1个值,目的只是为了演示。

真实的训练不知道要跑多少 Epoch。早停的逻辑是:

  • 准备一个 验证集 (Validation Set)(网络从未见过的数据)。在每个 Epoch 结束时,计算验证集的 Loss。如果 验证集 Loss 连续 N 个 Epoch (Patience) 没有下降,就强制停止训练。
  • 或者是自定义1个阈值

我们可以添加patience早停机制,比如说用验证集,多少个epoch验证集的loss还是降不下来就不训练了

python 复制代码
def evaluate(self, X, y):
        """辅助函数:计算给定数据集的 loss 和 accuracy"""
        probabilities = self.predict(X) # 使用 predict 得到概率 (N, n_classes)
        
        # 计算 Loss (需要调用 loss_activation.forward 但不需要梯度)
        # 注意:这里有点 hack,因为 forward 会覆盖 self.output, 但对于只是评估没关系
        # 为了不影响 self.output,我们可以临时计算,或者确保在 epoch 结束时评估不影响下一轮
        # 你的架构中 loss_activation.forward 接收 Logits,所以我们需要先获得 Logits
        logits = self.forward(X) 
        loss = self.loss_activation.forward(logits, y)
        
        predictions = np.argmax(probabilities, axis=1)
        if len(y.shape) == 2:
            y_labels = np.argmax(y, axis=1)
        else:
            y_labels = y
        accuracy = np.mean(predictions == y_labels)
        return loss, accuracy



def train(self, X_train, y_train, validation_data=None, epochs=1000, batch_size=None, patience=10, print_every=100):
        """
        Args
        ----
        batch_size : int
            如果为 None,则进行全量梯度下降。如果指定数字 (e.g. 32),则进行 Mini-batch SGD。
        validation_data : tuple (X_val, y_val)
            验证集数据,用于早停监控。
        patience : int
            早停的耐心值,多少个 epoch 验证集 loss 不降就停止。
        """
        
        # 1. 初始化历史记录字典 (用于可视化)
        self.history = {
            'loss': [], 'acc': [], 
            'val_loss': [], 'val_acc': [], 
            'lr': [], 'grad_norm': []
        }
        
        # 2. 早停初始化
        best_val_loss = float('inf')
        patience_counter = 0
        
        n_samples = len(X_train)
        
        for epoch in range(epochs):
            
            # ==========================
            # A. 训练阶段 (Training Loop)
            # ==========================
            if batch_size is None:
                # 场景 1: 全量梯度下降 (你原来的逻辑)
                self._train_step(X_train, y_train)
            else:
                # 场景 2: Mini-batch SGD (工业界标准)
                # step 1: 打乱数据
                indices = np.arange(n_samples)
                np.random.shuffle(indices)
                X_shuffled = X_train[indices]
                y_shuffled = y_train[indices] if len(y_train.shape)==1 else y_train[indices] # 兼容 one-hot 或 label
                
                # step 2: 按批次迭代
                for start_idx in range(0, n_samples, batch_size):
                    end_idx = min(start_idx + batch_size, n_samples)
                    X_batch = X_shuffled[start_idx:end_idx]
                    y_batch = y_shuffled[start_idx:end_idx]
                    
                    self._train_step(X_batch, y_batch) # 对每个 batch 更新一次参数

            # ==========================
            # B. 评估与监控 (Evaluation)
            # ==========================
            # 每个 Epoch 结束,计算一次全量数据的指标
            train_loss, train_acc = self.evaluate(X_train, y_train)
            self.history['loss'].append(train_loss)
            self.history['acc'].append(train_acc)
            self.history['lr'].append(self.optimizer.current_learning_rate)
            
            # 记录梯度范数 (取最后一批的梯度)
            grad_norms = [np.linalg.norm(layer.dweights) for layer in self.layers if hasattr(layer, "dweights")]
            mean_grad_norm = np.mean(grad_norms) if grad_norms else 0.0
            self.history['grad_norm'].append(mean_grad_norm)

            # 验证集评估 & 早停逻辑
            if validation_data is not None:
                X_val, y_val = validation_data
                val_loss, val_acc = self.evaluate(X_val, y_val)
                self.history['val_loss'].append(val_loss)
                self.history['val_acc'].append(val_acc)
                
                # Check Early Stopping
                if val_loss < best_val_loss:
                    best_val_loss = val_loss
                    patience_counter = 0 # Loss 创新低,重置计数器
                else:
                    patience_counter += 1 # Loss 没降,计数器+1
                    
                if patience_counter >= patience:
                    print(f"\n[Early Stopping] 触发!在 Epoch {epoch} 停止训练。Best Val Loss: {best_val_loss:.4f}")
                    break
            
            # 打印日志
            if not epoch % print_every:
                log_msg = f'Epoch: {epoch}, Loss: {train_loss:.3f}, Acc: {train_acc:.3f}, Grad: {mean_grad_norm:.3f}, LR: {self.optimizer.current_learning_rate:.5f}'
                if validation_data:
                    log_msg += f', Val Loss: {val_loss:.3f}, Val Acc: {val_acc:.3f}'
                print(log_msg)

    def _train_step(self, X_batch, y_batch):
        """单步训练逻辑:前向 -> Loss -> 反向 -> 更新"""
        # 1. Forward
        logits = self.forward(X_batch)
        # 2. Loss + Softmax Output
        self.loss_activation.forward(logits, y_batch)
        # 3. Backward
        self.loss_activation.backward(self.loss_activation.output, y_batch)
        self.final_dense.backward(self.loss_activation.dinputs)
        back_gradient = self.final_dense.dinputs
        for layer in reversed(self.layers):
            layer.backward(back_gradient)
            back_gradient = layer.dinputs
        # 4. Update
        self.optimizer.pre_update_params()
        for layer in self.layers:
            if hasattr(layer, 'weights'): self.optimizer.update_params(layer)
        self.optimizer.update_params(self.final_dense)
        self.optimizer.post_update_params()

然后实际训练的话,也很简单,改几个参数即可

python 复制代码
# 1. 生成数据
X, y = np.random.randn(1000, 2), np.random.randint(0, 3, 1000)
# 简单的切分验证集 (这里手动切分,实际用 sklearn.train_test_split)
X_train, X_val = X[:800], X[800:]
y_train, y_val = y[:800], y[800:]

# 2. 定义模型
model = UniversalDeepModel_Enhanced(input_dim=2, hidden_layer_sizes=[64, 64], output_dim=3)

# 3. 训练 (使用 Mini-batch 和 早停)
model.train(
    X_train, y_train, 
    validation_data=(X_val, y_val), 
    epochs=1000, 
    batch_size=32,   # 开启 Mini-batch SGD
patience=20, # 20轮 loss 不降就停
print_every=50
)

3,打印的日志信息log

这一块,我们常规的在tensor board中看到的,一般是model的loss、model的指标(比如说准确率accuracy)等;

然后这里的话我还另外设置了打印梯度信息,因为朴素的SGD想法就是如果model卡主了,loss一直降不下去,会不会是因为参数更新到了error surface梯度为0的地方,

但是实际情况会比较复杂,loss降不下去的时候model不一定收敛了,也有可能是在1个局部最低点的周围不停来回震荡,总之我们也需要实时监控一下梯度信息。

首先第1个问题,我们需要看哪些参数的梯度?

我们知道,梯度计算出来的需求,其实就是为了更新参数,梯度决定了参数更新的方向。

神经网络中参数很多,需要计算梯度的地方也很多(我们前面代码中常规意义的参数θ是权重weights与偏置bias,除了计算每一层这些参数的梯度之外,我们其实还计算了每一次输出的梯度,包括中间层、激活层,以及最后的输出)。虽然梯度很多、参数也很多,但是我们梯度监控无需逐个查看,只需要聚焦于"有参数可更新的层"(比如说ReLU层这种无参数层其实无需监控),而至于不同参数层,对损失影响程度也有区别。

我们这里必须要清楚,梯度本真是一个与参数形状相同的矩阵(或张量tensor)。

比如说一个Dense全连接层,权重W的形状shape是(784,64),那么它的梯度∂L/∂W 也是一个 (784, 64) 的矩阵,详情可以参考我之前的矩阵微积分速通博客------关于标量对矩阵求导的分析;同理bias偏置等等等等。

然后这个矩阵,784x64=50176个数字,我们不可能肉眼看清这么多数字,不可能全都打印出来,何况这只是其中一个中间层的权重,我们还没说其他的层。

所以我们一般需要一个标量来代表这个矩阵的整体规模或者说是强度,那么我们就需要范数,比如说L2范数(Frobenius 范数)。

作用就是将一个矩阵压缩成一个非负实数,表示梯度的长度。

(范数的核心作用就是把高维的张量(矩阵 / 向量)压缩成一个非负标量,用这个标量代表梯度的 "整体规模" 或 "能量")

然后范数这里稍微提一嘴,

就是向量的话,L2范数我们都能够理解。

矩阵的范数,如何定义?

此处参考:https://zh.wikipedia.org/wiki/矩陣範數

矩阵的范数定义比较复杂也有很多,但是我们在深度学习中简单的应用的话,我们只需要看Frobenius 范数即可。

矩阵A的Frobenius范数定义为矩阵A各项元素的绝对值平方的总和开根。

是不是很熟悉,其实就是沿着向量范数的直觉定义的,所以F范数我们其实就可以看作是向量L2范数的直接推广,形式上我们很快就能够接受。

从操作上来看,其实就是将一个矩阵拉长为1个向量,比如说flatten,然后用向量的L2范数方法去算F范数。

总而言之,我们能够用1个矩阵的F范数来表征这个矩阵的长度/能量/整体幅度关系,

所以我们如果要监控梯度的话,可以直接打印梯度矩阵的F范数。

梯度的输出形式确定了,那么回到我们的问题,我们要打印哪些参数(哪些内容)的梯度F范数?我们应该监控谁的梯度?

答案是:我们要监控**每一层(Layer-wise)**的权重梯度,

具体来说,对于每一个深层网络:

  • 输入端附近的层(第一层/浅层):这是最容易出问题的地方。根据链式法则,梯度是从输出层一层层乘回去的。
    • 如果出现梯度消失(Vanishing Gradient),每一层乘一个小于 1 的数,乘到第一层时,梯度范数会变成极小(接近 0,如 1e-7)。这时浅层参数根本不更新,网络学不到底层特征。
  • 输出端附近的层(最后一层/深层):这里的梯度最直接。如果这里梯度都很大(梯度爆炸),那整个网络都会飞掉。
  • 中间层:观察它们的梯度范数是否处于一个合理的数量级(比如 1e-3 到 1e-1 之间)。

然后有了各层梯度范数的曲线之后,我们可以来判断epoch训练到最后,model到底是收敛了还是卡主了:

  • 正常收敛:梯度范数开始比较大,随着loss下降,范数逐渐减小并趋近于稳定振荡(不会变为0)
  • 卡主(梯度消失):第1层的梯度范数若是急剧下降到0或一直趴在0附近不动,而loss也不降,说明梯度没有传回来
  • 崩了(梯度爆炸):范数突然变成NaN或超级大(1e+10),说明学习率太大或需要梯度裁剪

然后我前面提供的代码图方便,其实把所有层的梯度都混在一起算平均了,其实理论上按照前面讲的我们应该按层记录。

而且可以看到,我这里是把日志信息打印放在了backward方法前面,这也就意味着我打印的梯度信息只是此次更新之前的,也就是我每一次前向传递数据计算loss之后打印出来上一次迭代的梯度,

所以第1次迭代的时候因为没有第0轮的梯度信息,会显示Nan

我们可以放到backward方法后面,然后打印实际每一层的梯度信息(当然差一层其实可以忽略不计,也不一定非得全打印)

学习率和准确率同理print,整体大概如下

4,训练过程可视化

因为前面3的数据我们已经获得了,也print了出来,其实我们完全可以绘制成监控曲线。

比较常规的做法,就是初始化一个history字典,然后记录各个监控指标键值对信息,最后对收集的list绘图。

顺便我们可以定义1个可视化函数,将model的history属性拿出来查看

python 复制代码
# 定义一个可视化函数
# --- 可视化函数 ---
def plot_training_history(history):
    epochs = range(len(history['loss']))
    
    plt.figure(figsize=(15, 5))
    
    # 图1: Loss 曲线
    plt.subplot(2, 3, 1)
    plt.plot(epochs, history['loss'])
    plt.title('Loss Curve')
    plt.xlabel('Epoch')
    plt.legend()
    
    # 图2: Accuracy 曲线
    plt.subplot(2, 3, 2)
    plt.plot(epochs, history['acc'])
    plt.title('Accuracy Curve')
    plt.xlabel('Epoch')
    plt.legend()
    
    # 图3: Learning Rate 曲线
    plt.subplot(2, 3, 3)
    plt.plot(epochs, history['lr'])
    plt.title('Learning Rate Decay')
    plt.xlabel('Epoch')
    plt.ylabel('Learning Rate')

    # 图4: First Layer Gradient Norm 曲线 (诊断用)
    plt.subplot(2, 3, 4)
    plt.plot(epochs, history['grad_norm_first'], color='green')
    plt.title('First Layer Gradient Norm (Stability Check)')
    plt.xlabel('Epoch')
    plt.ylabel('Norm')

    # 图5: Last Layer Gradient Norm 曲线 (诊断用)
    plt.subplot(2, 3, 5)
    plt.plot(epochs, history['grad_norm_last'], color='red')
    plt.title('Last Layer Gradient Norm (Stability Check)')
    plt.xlabel('Epoch')
    plt.ylabel('Norm')

    # 图6: Mean Gradient Norm 曲线 (诊断用)
    plt.subplot(2, 3, 6)
    plt.plot(epochs, history['grad_norm_mean'])
    plt.title('Mean Gradient Norm (Stability Check)')
    plt.xlabel('Epoch')
    plt.ylabel('Mean Norm')
    
    plt.tight_layout()
    plt.show()

因为我们是按照一定间隔来记录epoch的,所以日志打印的时候x轴的索引不是实际的epoch数,只是有记录loss的epoch数。

我们可以改成每1轮打印1次

修改之后的代码模板

对于前面整合处提到的一些可以改进的地方进行了修改,其实前面说了那么多,都是在修改train里的代码逻辑,

我们其实可以看到model、predict这些函数都没有变,

所以我们其实分离开来model py文件(init+forward方法)、train py文件(loss+优化+backward方法)、predict py文件,

我们每次只需要单独修改完善train py文件即可。

总的来说,我们前面的原始手搓Numpy神经网络,可以改进的地方如下:

我们这里为了演示方便,仅修改2+4,对于1+3,前面每部分如何修改已经提过了,示例code也给出了。

python 复制代码
import numpy as np
import matplotlib.pyplot as plt

# =============================================================================
# 第一部分:底层核心类库 
# =============================================================================

# 1. 全连接层
class Layer_Dense:
    def __init__(self, n_inputs, n_neurons):
        """
        Description
        -----------
        初始化全连接层的权重和偏置, 注意这是一个抽象的全连接层类, 不是输入层!

        Args
        ----
        n_inputs : int
            当前层输入特征的数量(例, 输入28x28图像, 则n_inputs=784, 就是feature维度)
        n_neurons : int
            当前层输入神经元的数量

        Notes
        -----
        - 1, 解释: 输入层数据格式是「样本 x 特征(n_samples, n_features), 隐藏层核心是「神经元数量(n_neurons), 权重矩阵用「特征 x 神经元(n_features, n_neurons)」的维度设计,正是为了通过矩阵乘法让两者高效衔接;
        输入数据形状是 (n_samples, n_features)(比如 100 个样本, 每个样本 784 个特征 → (100, 784)),权重矩阵是 (n_features, n_neurons)(784 个特征 x 10 个神经元 → (784, 10));
        (n_samples, n_features) @ (n_features, n_neurons) = (n_samples, n_neurons)
        - 2, 当前全连接层单层的构建未涉及激活函数, 只是单纯的线性变换(矩阵乘法+偏置), 激活函数会在后续单独实现, 所以所有的output都是线性变换的结果, 我们直接考虑loss计算和反向传播即可, 不需要考虑激活函数的非线性影响, 就是将output作为当前层的最终输出(类比激活函数之后的输出)
        """

        # 初始化(n_inputs, n_neurons)形状的权重矩阵, 采用随机的标准正态分布
        # 缩放0.01以防止权重过大, 避免前向传播时输出过大导致梯度消失/爆炸
        self.weights = 0.01 * np.random.randn(n_inputs, n_neurons)

        # 初始为(1, n_neurons)形状的全0偏置向量, 每个神经元对应一个偏置值
        # 每个神经元只有 1 个偏置(管 "神经元的偏移");所有样本共享这组偏置(管 "规则通用")
        # 偏置的本质是 "与样本无关的神经元偏移",所有样本共享同一组偏置(1 个神经元 1 个偏置)
        # 偏置的第 2 维(神经元数)必须和「输入 × 权重」结果的第 2 维(神经元数)完全一致(比如都是 10)------ 因为要给每个神经元加专属偏移,维度不匹配就加错了;
        # 偏置的第 1 维(样本数)用 1,是因为广播机制会自动把 1 扩展成实际样本数(比如 100)------ 既满足 "所有样本共享偏置",又避免存储冗余(不用存 100 份重复的偏置)。
        # 所以这里偏置形状是 (1, n_neurons), 而不是反过来(n_neurons, 1)
        self.biases = np.zeros((1, n_neurons))
    
    def forward(self, inputs):
        """
        Description
        -----------
        前向传播, 计算当前层的输出(输入的线性变换)

        Args
        ----
        inputs : np.ndarray
            输入数据, 上一层的输出, 形状为(上一层输出样本数, 上一层输出特征数), 也就是(n_samples, n_inputs/features)

            
        Notes
        -----
        - 1, forward方法依然是在前面整体抽象的全连接层中定义的,不特指输入层到第一层隐藏层, 而是可以作为任意两层之间的全连接层;
        只能说是当前层, 无论是哪一层, 全连接层的输出形状都是 (n_samples, 上一层神经元数);
        不管是输入层后的第一层,还是隐藏层之间,只要传入符合维度的 inputs(上一层输出/上一层神经元数/当前层输入feature数), 并提前定义好对应维度的 weights(n_in_features x 当前层神经元数)和 biases(1 x 当前层神经元数),就能自动完成前向传播计算。
        - 2, 理解抽象全连接层中inputs的维度:
            - 通用维度: inputs.shape = (样本数, 当前层输入特征数),与层位置无关;
            - 样本数不变:所有层的 inputs 第一维度都是同一批样本数,贯穿网络;------》从矩阵乘法角度来看, 任意中间层的行数=第1个矩阵的行数
            - 输入特征数来源:当前层的 "输入特征数" = 上一层的输出特征数 = 上一层的神经元数量;
            - 抽象复用性:正因为维度规则通用,这个 forward 方法才能作为任意全连接层使用,只需匹配上一层输出和自身权重维度即可
        - 3, 此处的全连接层不包括激活函数, 只是单纯的线性变换(矩阵乘法+偏置), 激活函数会在后续单独实现, 所以所有的output都是线性变换的结果, 我们直接考虑loss计算和反向传播即可, 不需要考虑激活函数的非线性影响, 就是将output作为当前层的最终输出(类比激活函数之后的输出)
        """
        # 保存当前层的输入, 用于后续反向传播计算梯度
        self.inputs = inputs

        # 计算当前层的输出: output = inputs @ weights + biases (矩阵乘法+广播机制)
        # 输入数据 X:100 个样本,每个样本 784 个特征 → 形状 (100, 784);
        # 权重 weights:784 个特征 × 10 个神经元 → 形状 (784, 10);------》X @ weights → 形状 (100, 784) @ (784, 10) = (100, 10)
        # 偏置 biases:1 行 × 10 个神经元 → 形状 (1, 10)(全 0 初始化,即 [[0,0,0,...,0]])。------》NumPy 的广播机制会自动把 (1,10) 的偏置 "复制扩展" 成 (100,10)
        # 刚好实现了 "给每个神经元的所有样本输出,都加同一个偏移量"------ 这正是 "与样本无关、每个神经元 1 个偏置" 
        self.output = np.dot(inputs, self.weights) + self.biases
    
    def backward(self, dvalues):
        """
        Description
        -----------
        反向传播, 计算当前层的梯度 (更新梯度值用于优化器更新参数)

        Args
        ----
        dvalues : np.ndarray
            下一层传递过来的梯度, 损失对当前层输出的偏导, 作为当前层需要计算梯度的起点;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入)

        
        Notes
        -----
        - 1, 我们这里的表述是下一层(靠近output)传到上一层(靠近input), 从loss传到输入的反向顺序说法, 所谓的上下是按照正常正向数据传递的说法表述
        - 2, 对于矩阵微积分求导部分的数学符号以及规则说明, 可以参考: https://blog.csdn.net/weixin_62528784/article/details/156519242?spm=1001.2014.3001.5501
        """
        # 计算权重的梯度:损失对权重的偏导 = 输入的转置 @ 下一层梯度
        # 原理: 依据链式法则, ∂L/∂W = ∂L/∂out * ∂out/∂W
        # 其中 ∂out/∂W = inputs.T, 因为 out = inputs @ weights + biases------》这一点可以从矩阵求导的分母布局法理解(输入的转置的形状正好和权重形状匹配)
        # 而 ∂L/∂out 就是 dvalues, 因为 dvalues = ∂L/∂out, dvalues就是定义为loss对这一层输出的梯度, 所以dvalues是我们计算的起点
        self.dweights = np.dot(self.inputs.T, dvalues)

        # 计算偏置的梯度:损失对偏置的偏导 = 下一层梯度的求和(下一层沿样本轴求和)
        # 原理: 依据链式法则, ∂L/∂b = ∂L/∂out * ∂out/∂b
        # 其中 ∂out/∂b = 1 (因为偏置是加法项, 对每个样本都一样), 因为 out = inputs @ weights + biases
        # 所以 ∂L/∂b = sum(∂L/∂out) = sum(dvalues)
        # keepdims=True保持维度为(1, n_neurons),与偏置形状一致
        self.dbiases = np.sum(dvalues, axis=0, keepdims=True)

        # 计算输入的梯度:损失对输入的偏导 = 下一层梯度 @ 权重的转置
        # 原理: 依据链式法则, ∂L/∂inputs = ∂L/∂out * ∂out/∂inputs
        # 其中 ∂out/∂inputs = weights.T, 因为 out = inputs @ weights + biases ------》和前面一样可以从矩阵求导的分母布局法理解(权重的转置的形状正好和输入形状匹配)
        # 而 ∂L/∂out 就是 dvalues, 因为 dvalues = ∂L/∂out
        # 所以 ∂L/∂inputs = dvalues @ weights.T
        # 原理: 将梯度反向传给上一层, 用于前一层的参数更新
        self.dinputs = np.dot(dvalues, self.weights.T)


# 2. ReLU 激活函数
class Activation_ReLU:
    """
    Description
    -----------
    ReLU(Rectified Linear Unit) 激活函数
    用于引入非线性, 解决线性模型无法拟合复杂数据的问题

    Notes
    -----
    - 1, 此处单独实现ReLU激活函数类, 作为独立的激活层使用, 不考虑与全连接层耦合
    """
    def forward(self, inputs):
        """
        Description
        -----------
        前向传播, 计算ReLU激活函数的输出(out_relu)

        Args
        ----
        inputs : np.ndarray
            in_relu, 输入数据, 上一层的输出, 全连接层的线性输出, 形状与全连接层输出一致, 也就是不考虑与激活函数耦合时的全连接层输出;
            
        Notes
        -----
        - 1, 理论上全连接层输出inputs+本层激活函数之后的输出才是当前层的最终输出, 但由于此处不考虑耦合, 此处只是独立的1个激活层, 所以直接将ReLU的输出作为当前层的最终输出
        """

        # 保存当前层的输入(in_relu), 用于后续反向传播判断梯度是否为0
        self.inputs = inputs
        # 计算ReLU激活函数的输出(out_relu): output = max(0, inputs), ReLU函数将负值置0, 保持正值不变
        self.output = np.maximum(0, inputs)

    def backward(self, dvalues):
        """ 
        Description
        -----------
        反向传播, 计算ReLU激活函数的梯度, 用于更新前一层的梯度(ReLU层链式法则传递), 
        计算公式: ∂L/∂in_relu = ∂L/∂out_relu * ∂out_relu/∂in_relu
        
        Args
        ----
        dvalues : np.ndarray
            out_relu, 下一层传递过来的梯度, 损失对当前层输出的偏导, 也就是损失对ReLU层输出的偏导, 作为当前层需要计算梯度的起点;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入)

        Notes
        -----
        - 1, ReLU层在反向传播时的核心任务: 
            - 接收下一层传递过来的梯度 dvalues = ∂L/∂out_relu (损失对ReLU层输出的偏导);
            - 计算当前层的梯度 dinputs = ∂L/∂in_relu (损失对ReLU层输入的偏导), 传递给前一层用于更新梯度;
            - 将调整后的梯度传递给上一层(通常是全连接层), 供上一层计算参数(权重/偏置)的梯度;
            - 依据ReLU的梯度规则调整梯度值(输入<=0位置的梯度置0, 输入>0位置的梯度保持不变);
        - 2, 激活函数层都是剥离开来, 单独实现的, 上一个全连接层的输出作为当前ReLU层的输入, 当前ReLU层的输出作为下一个全连接层的输入;
        """

        # 复制下一层传递过来的梯度, 作为当前层的梯度初始值
        # 若ReLU层后接全连接层, 则dvalues就是全连接层backward方法计算出的self.dinputs(全连接层的输入梯度, 对应ReLU层的输出梯度)
        # 为什么复制? 因为我们需要修改梯度值, 不能直接修改传入的dvalues, 因为下一层的梯度结果dvalues可能还会被其他层使用
        # 这样可以避免影响到其他层的梯度计算
        # ReLU的梯度计算需要根据输入值是否大于0来决定(也就是需要基于自身输入调整)
        self.dinputs = dvalues.copy()

        # ReLU的梯度规则: 当输入值<=0时, 梯度为0; 当输入值>0时, 梯度保持不变, 为1
        # 此处self.inputs就是ReLU层的输入值(in_relu), 要计算其梯度, ∂L/∂in_relu = ∂L/∂out_relu * ∂out_relu/∂in_relu
        # - 当in_relu <= 0, 也就是 self.inputs <= 0, 则 ∂out_relu/∂in_relu = 0, 因为ReLU函数在该区间的梯度为0 ------> 整体梯度 ∂L/∂in_relu = ∂L/∂out_relu * 0 = 0, 即把self.dinputs对应位置置0
        # - 当in_relu > 0, 也就是 self.inputs > 0, 则 ∂out_relu/∂in_relu = 1, 因为ReLU函数在该区间的梯度为1 ------> 整体梯度 ∂L/∂in_relu = ∂L/∂out_relu * 1 = ∂L/∂out_relu, 即self.dinputs保持不变
        # 因此我们需要将输入值<=0的位置的梯度置0, 不传递该位置的梯度
        self.dinputs[self.inputs <= 0] = 0

# 3. Softmax 激活函数 (用于预测)
class Activation_Softmax:
    """
    Description
    -----------
    softmax激活函数层, 用于多分类任务的输出层, 将线性输出转化为概率分布
    

    Notes
    -----
    - 1, 本类中未实现反向传播, 通常与交叉熵损失结合使用, 以简化反向传播计算    

    """

    def forward(self, inputs):
        """ 
        Description
        -----------
        前向传播, 计算Softmax激活函数的输出概率分布, 计算公式: softmax(xi) = exp(xi) / sum(exp(xj))

        Args
        ----
        inputs : np.ndarray
            输入数据, 上一层的输出, 全连接层的线性输出, 形状与全连接层输出一致, 简单理解为in_softmax;
            输出层全连接层的线性输出, 称为Logits, 形状为(样本数, 类别数)

        output : np.ndarray (在状态中保存)
            softmax激活函数的输出概率分布, 形状与输入一致, (样本数, 类别数);
            inputs是softmax层输入, output是softmax层输出
        """

        # 保存当前层的输入, 用于后续计算(反向传播时需要用到, 本类中未实现反向传播, 通常与交叉熵损失结合)
        self.inputs = inputs
        # step1: 为了数值稳定性, 减去每行(每个样本)的最大值, 防止指数函数溢出(np.exp过大可能返回inf)
        exp_values = np.exp(inputs - np.max(inputs, axis=1, keepdims=True))
        # step2: 计算每行(每个样本)的指数和, softmax概率=每个样本的指数值/该样本所有指数值的和
        self.output = exp_values / np.sum(exp_values, axis=1, keepdims=True)

# 4. 通用 Loss 父类
class Loss:
    """
    Description
    -----------
    定义损失函数的统一接口, 所有具体损失函数类均继承自该父类

    """
    def calculate(self, output, y):
        """
        Description
        -----------
        计算损失的公共方法: 返回平均损失(数据损失)

        Args
        ----
        output : np.ndarray
            模型的预测输出, 形状为(样本数, 类别数)
        y : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)

        """

        # 调用子类实现的forward方法, 计算每个样本的损失(样本损失)
        # 调用具体损失函数的前向传播方法, 计算每个样本的损失, forward方法在子类中具体实现, 父类中只是定义抽象接口
        sample_losses = self.forward(output, y)

        # 计算所有样本的平均损失(数据损失), 作为模型优化的目标
        data_loss = np.mean(sample_losses)
        return data_loss

# 5. 交叉熵损失 (含 Softmax)
class Loss_CategoricalCrossentropy(Loss):
    """       
    Description
    -----------
    交叉熵损失函数, 用于多分类任务, 与softmax配合使用

    Args
    ----
    继承自 Loss 父类, 实现具体的前向和反向传播方法
    """
    def forward(self, y_pred, y_true):
        """  
        Description
        -----------
        前向传播, 计算每个样本的交叉熵损失
        
        Args
        ----
        y_pred : np.ndarray
            模型的预测输出概率, 形状为(样本数, 类别数), softmax层的输出概率
        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)
        """
        
        # 获取样本数量, 用于后续平均损失计算
        samples = len(y_pred)
        # 防止log(0)导致数值不稳定, 对预测概率进行裁剪
        # 裁剪范围在[1e-7, 1-1e-7]之间
        y_pred_clipped = np.clip(y_pred, 1e-7, 1 - 1e-7)

        # 从模型输出的概率矩阵 y_pred_clipped 中,提取每个样本「真实类别对应的预测概率」
        # 用于后续计算交叉熵损失: -log(正确类别的预测概率)
        # 分两种情况处理真实标签: 类别索引或独热编码
        # case1: 真实标签为类别索引 (一维数组), 如 y_true = [0, 2, 1] 其shape为(3,)
        if len(y_true.shape) == 1:
            # 列表索引取出每个样本对应的正确类别的预测概率
            # 前面提到过, 列表索引index是按位置配对取元素
            correct_confidences = y_pred_clipped[range(samples), y_true]

        # case2: 真实标签为独热编码 (二维数组), 如 y_true = [[1,0,0], [0,0,1], [0,1,0]]
        elif len(y_true.shape) == 2:
            # 通过逐元素相乘并沿类别轴求和, 获取每个样本对应的正确类别的预测概率
            # 注意 * 就是逐元素乘法, 就是线性代数中的Hadamard积, 哈达玛乘积(维度相同)
            # np.dot 或 @ 是矩阵乘法
            # 预测概率与独热编码逐元素乘法, 等价于取真实类别的概率(独热编码只有真实类别为1)
            correct_confidences = np.sum(y_pred_clipped * y_true, axis=1)

        # correct_confidences中保存了每个样本「真实类别对应的预测概率」, 形状为(samples,)
        # 计算交叉熵损失: -log(正确类别的预测概率)
        negative_log_likelihoods = -np.log(correct_confidences)
        return negative_log_likelihoods
    
    def backward(self, dvalues, y_true):
        """   
        Description
        -----------
        反向传播, 计算损失对模型输出y_pred的梯度, 记为∂L/∂y_pred, 并将结果存入self.dinputs, 最终传递给前一层(通常是输出层的全连接层, 当然最后一层一般是softmax层), 
        用于后续参数(权重, 偏置)的梯度计算和更新
        
        Args
        ----
        dvalues : np.ndarray
            下一层传递过来的梯度, 损失对当前层输出的偏导;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入);
            当然, 在此处独立的交叉熵损失层中, dvalues其实就是y_pred, 因为损失层是网络的最后一层, 没有更下游的层了, 所以dvalues就是损失对y_pred的偏导的起点;
            即损失计算的输入, 也就是模型的预测输出y_pred
        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)

        Notes
        -----
        - 1, 公式推导的是「单个样本的梯度」,但代码中计算的是所有样本的平均梯度(因损失 L 通常取平均值),因此需要除以样本数
        - 2, 该层中的dvalues其实就是y_pred, dinputs就是损失对y_pred的梯度, 也就是∂L/∂y_pred
        """

        # 获取样本数和类别数
        # 注意此处dvalues就是y_pred(模型的预测输出), 因为交叉熵损失层是最后一层, 没有更下游的层了
        samples = len(dvalues)
        labels = len(dvalues[0])
        # 如果输入是类别索引, 则转换为独热编码形式
        if len(y_true.shape) == 1:
            # np.eys(label)生成单位矩阵,y_true作为索引取出对应行, 即独热编码
            # np.eye(labels):生成 (类别数, 类别数) 的单位矩阵(对角线上为 1,其余为 0), 每行对应1个类别的独热编码
            # [y_true]:用真实类别索引取单位矩阵的对应行,得到独热编码形式
            y_true = np.eye(labels)[y_true]

        # 交叉熵损失对y_pred的梯度公式: -真实标签/预测概率
        # 计算公式为 ∂L/∂y_pred = ∂(-sum(y_true * log(y_pred)))/∂y_pred 
        # 一般为了计算方便, log的底数取e, 其实就是ln, 求导就是1/x
        # 然后我们以样本的某一个类别为例子来推导:
        # L = -y_true  * log(y_pred), 对y_pred,c求导:
        # ∂L/∂y_pred,c = ∂(-sumk(y_true,k * log(y_pred,k)))/∂y_pred,c
        # 只有当k=c时, 导数不为0, 其他k≠c时导数为0, 所以:
        # ∂L/∂y_pred,c = -y_true,c/y_pred,c 
        # 推广到所有类别, 就是 ∂L/∂y_pred = -y_true / y_pred
        self.dinputs = -y_true / dvalues
        # 归一化梯度, 除以样本数, 保持梯度规模稳定(确保梯度规模与样本数量无关)
        self.dinputs = self.dinputs / samples

# 6. Softmax + Loss 组合类 (为了反向传播更稳定)
class Activation_Softmax_Loss_CategoricalCrossentropy():
    """  
    Description
    -----------
    将softmax激活函数与交叉熵损失结合, 优化反向传播稳定性;
    因为单独计算softmax和交叉熵的梯度会有数值不稳定问题, 组合后可简化梯度计算

    Notes
    -----
    - 1, 该类封装了softmax激活和交叉熵损失, 提供前向和反向传播方法, 具体调用类的实现细节见前面softmax和交叉熵损失类
    
    """
    def __init__(self):
        """
        Description
        -----------
        初始化组合类, 创建softmax激活和交叉熵损失实例
        """
        
        # 实现见前面
        # softmax激活层
        self.activation = Activation_Softmax()
        # 交叉熵损失层
        self.loss = Loss_CategoricalCrossentropy()
    def forward(self, inputs, y_true):
        """ 
        Description
        -----------
        前向传播, 计算softmax输出和交叉熵损失(先激活再计算损失)

        Args
        ----
        inputs : np.ndarray
            输入数据, 上一层的输出, 全连接层的线性输出, 形状与全连接层输出一致;
            输出层全连接层的线性输出, 称为Logits, 形状为(样本数, 类别数), 
            也就是in_softmax, softmax层的输入

        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码), 同交叉熵损失
        """

        # 对Logits做softmax激活, 得到概率分布
        # 也就是Activation_Softmax()类的forward方法
        self.activation.forward(inputs)

        # 保存激活后的输出(概率分布), 用于后续反向传播
        # 也就是softmax激活函数的输出概率分布, 形状与输入一致, (样本数, 类别数);
        # inputs是softmax层输入, output是softmax层输出
        self.output = self.activation.output

        # 计算平均交叉熵损失,调用交叉熵损失的calculate方法
        # 实际实现细节上调用的是Loss_CategoricalCrossentropy类的forward方法, 也就是将softmax层的输出作为输入计算损失
        return self.loss.calculate(self.output, y_true)
    def backward(self, dvalues, y_true):
        """
        Description
        -----------
        反向传播, 计算组合层的梯度, 直接计算简化后的梯度公式(也就是直接计算损失对Logits的梯度, 避免数值不稳定)
        Args
        ----
        dvalues : np.ndarray
            下一层传递过来的梯度, 损失对当前层输出的偏导;
            dvalues(当前层梯度起点) = ∂L(整体loss)/∂out(当前层输出, 也就是下一层输入);
            当然, 在此处softmax+交叉熵组合层中, dvalues其实就是y_pred, 因为组合层是网络的最后一层, 没有更下游的层了, 所以dvalues就是损失对y_pred的偏导的起点;
            即损失计算的输入, 也就是模型的预测输出y_pred
        y_true : np.ndarray
            真实标签, 可为类别索引或独热编码, 对应形状为(样本数,)或(样本数, 类别数) (独热编码)
        
        
        Notes
        -----
        - 1, 注意该组合层backward的目标是计算损失对Logits的梯度(也就是损失对全连接层输出的梯度), 以便传递给前一层(通常是输出层的全连接层), 用于更新参数;
        也就是计算 ∂L/∂Logits, 而不是单独计算softmax层或交叉熵损失层的梯度;
        """

        # 获取样本数量
        # 注意这里的dvalues就是y_pred(模型的预测输出), 因为softmax+交叉熵组合层是最后一层, 没有更下游的层了
        samples = len(dvalues)  
        if len(y_true.shape) == 2:
            # 若真实标签为独热编码, 则转换为类别索引(方便后续索引操作), 独热变索引
            y_true = np.argmax(y_true, axis=1)
        # 复制dvalues(此处dvalues是softmax的输出, 即概率分布), 因为y_pred可能有其他作用, 比如说计算准确率等, 不能直接修改
        self.dinputs = dvalues.copy()

        # 关键步骤:简化之后的梯度也就是softmax+交叉熵的联合梯度 = 预测概率 - 真实标签(独热编码形式)
        # 对每个样本的 "真实类别对应的概率" 减 1, 等价于将独热编码的 y_true 中 "1" 的位置减 1, "0" 的位置不变
        # 用的还是列表索引
        self.dinputs[range(samples), y_true] -= 1
        # 梯度归一化: 除以样本数, 保持梯度规模稳定(确保梯度规模与样本数量无关)
        self.dinputs = self.dinputs / samples

# 7. Adam 优化器
class Optimizer_Adam:
    """
    Description
    -----------
    Adam优化器类, 用于更新神经网络的权重和偏置参数, 结合动量(Momentum)和自适应学习率(RMSProp)的优化算法, 通过积累历史梯度信息动态调整参数更新策略,实现更快收敛和更稳定的训练;
    收敛快, 稳定, 适合大多数神经网络训练

    Adam的核心是维护两个关键变量:
    - 动量(momentum): 积累历史梯度的"方向", 缓解SGD在局部最优附近的震荡, 加速沿稳定方向的收敛;量纲是"梯度的累积", 梯度是损失对参数的偏导, 量纲是损失值/参数值
    - 自适应缓存(cache): 积累历史梯度的"幅度", 为每个参数动态调整学习率(梯度大的参数用小步长, 梯度小的参数用大步长);量纲是"梯度平方的累积", 量纲是(损失值/参数值)^2
    其中偏差修正: 初始阶段动量和缓存接近0, 通过修正项使其更接近真实值, 避免初期更新过慢

    注意, 优化器维护的动量和缓存只是辅助变量, 与此处model的权重/偏置核心参数不同;
    辅助变量的初始化≠核心参数的初始化

    """
    def __init__(self, learning_rate=0.001, decay=0., epsilon=1e-7, beta_1=0.9, beta_2=0.999):
        """  
        Description
        -----------
        初始化Adam优化器的超参数

        Args
        ----
        learning_rate : float
            初始学习率, 控制参数更新的步长大小, 默认0.001
        decay : float
            学习率衰减率, 控制学习率随迭代次数逐渐减小, 默认0.0(不衰减)
        epsilon : float
            防止除0错误的小常数, 用于数值稳定性, 默认1e-7
        beta_1 : float
            动量项的指数衰减率(控制动量的历史贡献, 默认0.9)
        beta_2 : float
            自适应学习率项的指数衰减率(控制平方梯度的历史贡献, 默认0.999)
        """
        
        # 初始学习率, 即初始步长, 控制参数更新的幅度
        self.learning_rate = learning_rate
        # 当前学习率(可能会随迭代次数逐渐衰减), 实际用于更新的学习率
        self.current_learning_rate = learning_rate
        # 学习率衰减率(控制实际用于更新的学习率随迭代次数减小, 避免后期震荡), 默认0不衰减
        self.decay = decay
        # 迭代次数(用于学习率衰减计算和偏差修正)
        self.iterations = 0
        # 防止除0错误的小常数, 数值稳定项
        self.epsilon = epsilon
        # 动量项系数, 控制动量的历史贡献; 即动量项的指数衰减率, 控制历史动量的"记忆比例"(β1越大, 记忆越久)
        self.beta_1 = beta_1
        # 自适应学习率项系数, 控制平方梯度的历史贡献; 即自适应学习率项/缓存项的指数衰减率, 控制历史平方梯度的"记忆比例"(β2越大, 记忆越久)
        self.beta_2 = beta_2
    
    def pre_update_params(self):
        """
        Description
        -----------
        在更新参数前调用, 用于调整当前学习率(如果设置了衰减);
        在每次参数更新之前, 根绝迭代次数调整当前学习率(current_learning_rate), 仅当decay>0时生效
        """
        if self.decay:
            # 若启动学习率衰减, 则根据迭代次数调整当前学习率/按公式更新当前学习率
            # 衰减公式: lr = initial_lr / (1 + decay * iterations)
            # 训练前期, 迭代次数小, 当前学习率接近初始值, 大步长快速收敛; 训练后期, 迭代次数大, 当前学习率减小, 小步长精细调整参数, 避免在最优解附近震荡
            # 目的: 训练前期使用较大学习率快速收敛, 后期使用较小学习率精细调整
            self.current_learning_rate = self.learning_rate * (1. / (1. + self.decay * self.iterations))
    
    def update_params(self, layer):
        """ 
        Description
        ---------
        接收1个全连接层(Layer_Dense示例), 利用该层的梯度(dweights/dbiases)更新其相应参数(权重和偏置)
        
        Args
        ----
        layer : Layer_Dense
            需要更新参数的层实例, 该层必须包含dweights和dbiases属性(梯度)
        """
        
        # 如果该层没有weight_cache属性(没有历史信息, 说明是首次更新), 则初始化动量和缓存数组(与参数形状一致, 因为动量公式中每一轮迭代中的动量本质是历史梯度的一个加权平均, 所以形状要和梯度一致, 而参数的梯度形状和参数本身一致)
        # 首次更新某层时,没有历史梯度数据, 所以创建与权重 / 偏置形状完全一致的全 0 数组,用于存储历史动量(weight_momentums)和历史平方梯度(weight_cache)
        if not hasattr(layer, 'weight_cache'):

            # layer.weights 是该层的权重参数
            # layer.weight_momentums: 是该层的权重动量, 量纲是"梯度的累积"
            # layer.weight_cache: 是该层的权重缓存, 量纲是"梯度平方的累积"

            # 动量数组(momentums): 存储历史梯度的指数移动平均(用于动量更新)
            layer.weight_momentums = np.zeros_like(layer.weights)
            # np.zeros_like: 创建与layer.weights形状相同的全0数组
            layer.weight_cache = np.zeros_like(layer.weights)

            # 缓存数组(cache): 存储历史平方梯度的指数移动平均(用于自适应学习率)
            layer.bias_momentums = np.zeros_like(layer.biases)
            layer.bias_cache = np.zeros_like(layer.biases)
        
        # 1, 更新动量数组(权重与偏置) ------> ⚠️ 动量法体现
        # 公式: momentum = beta_1 * previous_momentum + (1 - beta_1) * current_gradient
        # mt=β1⋅mt-1+(1-β1)⋅∇Wt  当前迭代的动量=β1乘以前一迭代的动量+(1-β1)乘以当前迭代的权重梯度
        # 作用: 积累历史梯度方向, 加速收敛(如沿同一方向则步长变大)
        layer.weight_momentums = self.beta_1 * layer.weight_momentums + (1 - self.beta_1) * layer.dweights
        layer.bias_momentums = self.beta_1 * layer.bias_momentums + (1 - self.beta_1) * layer.dbiases
        
        # 2, 动量偏差修正: 初始迭代时momentum接近0, 需修正为更精确的估计 ------> 矫正加权和为1
        # 公式: corrected_momentum = momentum / (1 - beta_1^(iterations + 1))
        # 原因: beta1接近1, 迭代初期1 = beta1^t 较小, 修正后momentum更接近真实值
        weight_momentums_corrected = layer.weight_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        bias_momentums_corrected = layer.bias_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        
        # 3, 更新缓存数组(权重与偏置) ------> ⚠️ 自适应学习率体现(也就是RMSProp)
        # 公式: cache = beta_2 * previous_cache + (1 - beta_2) * current_gradient^2
        # 作用: 记录梯度平方的历史, 用于自适应调整学习率(梯度大则步长小, 反之则步长大)
        layer.weight_cache = self.beta_2 * layer.weight_cache + (1 - self.beta_2) * layer.dweights**2
        layer.bias_cache = self.beta_2 * layer.bias_cache + (1 - self.beta_2) * layer.dbiases**2
        
        # 4, 缓存偏差修正: 同动量修正, 解决初始迭代时cache接近0的问题 ------> 矫正加权和为1 不能
        # 公式: corrected_cache = cache / (1 - beta_2^(iterations + 1))
        weight_cache_corrected = layer.weight_cache / (1 - self.beta_2 ** (self.iterations + 1))
        bias_cache_corrected = layer.bias_cache / (1 - self.beta_2 ** (self.iterations + 1))
        
        # 5, 最终参数更新: 结合动量修正和自适应学习率
        # 公式: 参数 = 参数 - 学习率*修正动量/(sqrt(修正缓存) + 小常数)
        # 修正动量: 提供梯度的方向和累计效应(解决SGD震荡问题)
        # 修正缓存: 调整学习率以适应不同参数的梯度规模(自适应调整每个参数的学习率, 梯度大则步长小, 避免震荡)
        # 小常数: 防止除0错误, 保持数值稳定
        layer.weights += -self.current_learning_rate * weight_momentums_corrected / (np.sqrt(weight_cache_corrected) + self.epsilon)
        layer.biases += -self.current_learning_rate * bias_momentums_corrected / (np.sqrt(bias_cache_corrected) + self.epsilon)
    
    def post_update_params(self):
        # 迭代次数更新: 每次参数更新后迭代次数+1, 用于下一次衰减和偏差修正
        self.iterations += 1

# =============================================================================
# 第二部分:通用深度网络封装 (UniversalDeepModel)
# =============================================================================

class UniversalDeepModel:
    """
    Description
    -----------
    这是一个通用的、支持任意深度的全连接神经网络封装类; 它自动管理层的创建、前向传播、反向传播和参数更新.
    本质是组装工具类, 根据后续用户配置动态搭建网络(隐藏层数量/神经元数, 输入输出维度), 并自动协调"前向传播-损失计算-反向传播-参数更新"的流程.

    Notes
    -----
    - 1, 封装目的是为了降低使用复杂度, 用户只需指定网络结构和训练参数(数据和网络结构), 无需手动管理每一层和训练细节(也就是无需关注梯度计算/层间维度匹配等细节)
    """
    def __init__(self, input_dim, hidden_layer_sizes, output_dim, learning_rate=0.001, decay=0.):
        """
        Description
        -----------
            初始化模型架构, 动态创建隐藏层和输出层, 并设置优化器和损失函数.
            只是设计网络结构, 并未进行训练, 也就是层的初始化和连接.
        
        Args
        ----
            input_dim (int): 输入数据的特征数量 (例如 2, 784), 主要是每一层的输入维度, 可以视为上一层的输出维度/神经元数量, 如 28x28 图像展平后为 784
            hidden_layer_sizes (list of int): 一个列表,定义隐藏层的结构
                例如 [64] 表示 1 个隐藏层,有 64 个神经元
                例如 [128, 64] 表示 2 个隐藏层,第一层 128, 第二层 64
            output_dim (int): 输出类别的数量, 例如 10 表示有 10 个类别 (0-9)
            learning_rate (float): 初始学习率, 控制参数更新步长, 默认0.001
            decay (float): 学习率衰减率, 防止训练后期震荡, 默认0.0(不衰减)
        """
        self.layers = [] # 用于存储所有的网络层 (Dense 和 ReLU), 仅存隐藏层, 输出层单独处理, 因输出层激活函数与隐藏层不同
        self.optimizer = Optimizer_Adam(learning_rate=learning_rate, decay=decay)
        
        # --- 1. 动态构建隐藏层 ---
        # current_input_dim 记录当前层的输入维度,初始为数据的输入维度
        current_input_dim = input_dim 
        
        for i, n_neurons in enumerate(hidden_layer_sizes):
            # 创建全连接层:输入维度current_input_dim -> 当前隐藏层神经元数n_neurons
            # 隐藏层的固定结构:全连接层(线性变换)+ ReLU 层(非线性激活)
            # 示例, 若hidden_layer_sizes=[64,32], 则self.layers会依次添加: Dense(input_dim->64)->ReLU->Dense(64->32)->ReLU
            dense_layer = Layer_Dense(current_input_dim, n_neurons)
            self.layers.append(dense_layer)
            
            # 创建激活函数层:每个全连接层后通常接一个 ReLU, 引入非线性
            activation_layer = Activation_ReLU()
            self.layers.append(activation_layer)
            
            # 更新下一层的输入维度为当前层的神经元数
            current_input_dim = n_neurons
            
            print(f"构建层 {i+1}: Dense({dense_layer.weights.shape[0]}->{n_neurons}) + ReLU")

        # --- 2. 构建输出层 ---
        # 最后一层全连接:最后一个隐藏层神经元数 -> 输出类别数
        # 注意:最后一层通常不接 ReLU,而是接 Softmax (包含在 loss_activation 中)
        # 单独存储输出层全连接层(因后续反向传播需优先处理输出层梯度)
        self.final_dense = Layer_Dense(current_input_dim, output_dim)
        print(f"构建输出层: Dense({current_input_dim}->{output_dim}) + Softmax")
        
        # --- 3. 定义损失函数和输出激活 ---
        # 使用 Softmax + CrossEntropy 的组合类
        # 输出层激活+损失:Softmax(概率化)+ 交叉熵(损失计算)组合类
        self.loss_activation = Activation_Softmax_Loss_CategoricalCrossentropy()

    def forward(self, X):
        """
        Description
        -----------
            前向传播:数据从输入流向输出, 只是计算输出, 不进行训练.
            前向传播是 "数据流转阶段":输入数据从隐藏层逐层传递,最终经过输出层全连接层,得到线性输出(Logits)(Softmax 在损失计算时才执行);
            层间传递:每个层的 forward 方法会更新自身的 self.output, 并作为下一层的输入, 无需用户手动处理维度匹配(底层 Layer_Dense 已保证维度正确)
        
        Args
        ----
            X (np.ndarray): 输入数据, 形状为(样本数, 特征数)
        """
        # 1. 数据先流经所有隐藏层
        current_output = X # 初始输入为原始数据
        for layer in self.layers:
            layer.forward(current_output) # 调用层的前向传播(Dense算线性输出,ReLU做激活)
            current_output = layer.output # 将当前层的输出作为下一层的输入
            
        # 2. 流经最后一个全连接层
        self.final_dense.forward(current_output)
        
        # 3. 返回最终层的输出 (Logits),注意此时还没过 Softmax
        # Softmax 激活被封装在 loss_activation 中,会在计算损失时自动执行(避免重复计算)
        return self.final_dense.output

    def train(self, X, y, epochs=1000, print_every=100):
        """
        Description
        -----------
            训练循环, 是模型的 "迭代优化阶段",整合了 "前向传播→损失计算→梯度反向传播→参数更新" 的全流程,是训练模型的入口.

        Args
        ----
            X (np.ndarray): 输入数据, 形状为(样本数, 特征数)
            y (np.ndarray): 真实标签, 可为类别索引或独热编码, 形状为(样本数,)或(样本数, 类别数) (独热编码)
            epochs (int): 训练轮数, 控制训练的迭代次数, 默认1000
            print_every (int): 每多少轮打印一次训练状态, 默认100

        """

        # 初始化历史记录字典 (用于可视化)
        self.history = {
            'loss': [], 'acc': [], 
            'lr': [], 'grad_norm_first': [],
            'grad_norm_last': [], 'grad_norm_mean': []
        }


        for epoch in range(epochs):
            # ====================
            # A. 前向传播 (Forward)
            # ====================
            
            # 1. 计算网络主体输出, 线性输出(Logits)
            final_outputs = self.forward(X)
            
            # 2. 计算损失 (同时做 Softmax)
            # self.loss_activation.forward 接收Logits,先做Softmax得到概率,再算交叉熵损失
            # forward 返回的是 loss 值
            loss = self.loss_activation.forward(final_outputs, y)

            # ====================
            # B. 打印状态 (Logging)
            # ====================
            if not epoch % print_every:
                # 1. 计算预测类别(从概率分布取最大值索引)
                predictions = np.argmax(self.loss_activation.output, axis=1)
                # 2. 处理标签(若为独热编码,转成类别索引)
                if len(y.shape) == 2:
                    y_labels = np.argmax(y, axis=1)
                else:
                    y_labels = y
                # 3. 计算准确率(预测正确的样本数 / 总样本数)
                accuracy = np.mean(predictions == y_labels)
                
                # 4. 打印当前轮数, 准确率, 损失, 学习率, 梯度范数⚠️
                # 对于梯度范数, 此处收集所有含有权重的层(隐藏层, 输出层)
                # 注意: self.layers里面混杂了Dense和ReLU层, 只有Dense层有dweights属性, 我们可以由此过滤出含权重的全连接层
                all_dense_layers = [layer for layer in self.layers if hasattr(layer, "dweights")]
                all_dense_layers.append(self.final_dense)  # 加上输出层, 都是layer_dense实例

                # 收集梯度范数
                grad_norms = []
                for layer in all_dense_layers:
                    # 注意:在第0轮训练还没做backward时,dweights可能不存在,做个保护
                    if hasattr(layer, 'dweights'):
                        norm = np.linalg.norm(layer.dweights)
                        grad_norms.append(norm)
                    else:
                        grad_norms.append(0.0)

                # 保存历史记录
                self.history['loss'].append(loss)
                self.history['acc'].append(accuracy)
                self.history['lr'].append(self.optimizer.current_learning_rate)
                if grad_norms:
                    self.history['grad_norm_first'].append(grad_norms[0])
                    self.history['grad_norm_last'].append(grad_norms[-1])
                    self.history['grad_norm_mean'].append(np.mean(grad_norms))

                # 打印信息
                # grad_norms[0] -> 第一层 (最靠近输入,最容易梯度消失)
                # grad_norms[-1] -> 最后一层 (最靠近输出,最容易梯度爆炸)
                if grad_norms:
                    print(f"Epoch {epoch}, " +
                          f"Acc {accuracy:.4f}, " +
                          f"Loss {loss:.4f}, " +
                          f"LR {self.optimizer.current_learning_rate:.6f} | " +
                        f"Grad Norm First: {grad_norms[0]:.4e}, " + # 监控底层是否学得动
                        f"Last: {grad_norms[-1]:.4e}, " +           # 监控源头信号强不强
                        f"Mean: {np.mean(grad_norms):.4e}")          # 监控整体

            # ====================
            # C. 反向传播 (Backward)
            # 反向传播是 "梯度回流阶段",核心是根据损失计算所有参数(权重、偏置)的梯度,遵循 "从输出层→隐藏层→输入层" 的顺序(梯度链式法则)
            # ====================
            
            # 1. 从 Loss 开始反向传播
            # 1. 从损失层开始反向传播(计算对Logits的梯度)
            # self.loss_activation.backward 直接返回损失对输出层全连接层输出(Logits)的梯度
            self.loss_activation.backward(self.loss_activation.output, y)
            
            # 2. 反向传播经过输出层
            # 输入是 loss 层的梯度 (dinputs)
            # 2. 输出层全连接层反向传播(计算对输出层权重/偏置的梯度,及对隐藏层输出的梯度)
            self.final_dense.backward(self.loss_activation.dinputs)
            
            # 3. 反向传播经过所有隐藏层 (需要倒序遍历!因为梯度从后往前传)
            # 这里的梯度链是:上一层的 dinputs -> 当前层的 backward
            # 初始梯度:输出层对隐藏层输出的梯度
            back_gradient = self.final_dense.dinputs
            
            for layer in reversed(self.layers):
                layer.backward(back_gradient) # 调用层的反向传播(计算当前层梯度)
                back_gradient = layer.dinputs # 更新梯度:当前层对前一层输入的梯度 → 传给前一层

            # ====================
            # D. 参数更新 (Optimize)
            # ====================
            
            # 1. 预更新:处理学习率衰减(若开启)
            self.optimizer.pre_update_params()
            
            # 2. 更新隐藏层的参数(仅全连接层有参数,ReLU无参数)
            for layer in self.layers:
                # 只有 Layer_Dense 有参数(weights/biases),ReLU 没有
                if hasattr(layer, 'weights'):
                    self.optimizer.update_params(layer)
            
            # 3. 更新输出层的参数
            self.optimizer.update_params(self.final_dense)
            
            # 4. 后更新:迭代次数+1(用于下一轮衰减计算和Adam的偏差修正)
            self.optimizer.post_update_params()

    def predict(self, X):
        """
        Description
        -----------
        使用训练好的模型进行预测, 返回类别概率分布.
        训练完成后,通过 predict 方法对新数据进行预测,输出类别概率分布(方便用户判断预测置信度)
        预测函数:输入数据,输出概率分布
        推理流程:新数据→前向传播(Logits)→Softmax(概率)
        

        Args
        ----
        X : np.ndarray
            输入数据, 形状为(样本数, 特征数)

        Notes
        -----
        - 1, 输出解读:例如输出 [[0.05, 0.9, 0.05]] 表示样本属于第 2 类的概率为 90%,可通过 np.argmax(probs, axis=1) 得到最终预测类别
        """
        # 1. 前向传播得到Logits(线性输出)
        logits = self.forward(X)
        # 2. 对Logits执行Softmax,转为概率分布
        self.loss_activation.activation.forward(logits)
        # 3. 返回概率分布(形状:(样本数, 类别数))
        return self.loss_activation.activation.output


# 定义一个可视化函数
# --- 可视化函数 ---
def plot_training_history(history):
    epochs = range(len(history['loss']))
    
    plt.figure(figsize=(15, 5))
    
    # 图1: Loss 曲线
    plt.subplot(2, 3, 1)
    plt.plot(epochs, history['loss'])
    plt.title('Loss Curve')
    plt.xlabel('Epoch')
    plt.legend()
    
    # 图2: Accuracy 曲线
    plt.subplot(2, 3, 2)
    plt.plot(epochs, history['acc'])
    plt.title('Accuracy Curve')
    plt.xlabel('Epoch')
    plt.legend()
    
    # 图3: Learning Rate 曲线
    plt.subplot(2, 3, 3)
    plt.plot(epochs, history['lr'])
    plt.title('Learning Rate Decay')
    plt.xlabel('Epoch')
    plt.ylabel('Learning Rate')

    # 图4: First Layer Gradient Norm 曲线 (诊断用)
    plt.subplot(2, 3, 4)
    plt.plot(epochs, history['grad_norm_first'], color='green')
    plt.title('First Layer Gradient Norm (Stability Check)')
    plt.xlabel('Epoch')
    plt.ylabel('Norm')

    # 图5: Last Layer Gradient Norm 曲线 (诊断用)
    plt.subplot(2, 3, 5)
    plt.plot(epochs, history['grad_norm_last'], color='red')
    plt.title('Last Layer Gradient Norm (Stability Check)')
    plt.xlabel('Epoch')
    plt.ylabel('Norm')

    # 图6: Mean Gradient Norm 曲线 (诊断用)
    plt.subplot(2, 3, 6)
    plt.plot(epochs, history['grad_norm_mean'])
    plt.title('Mean Gradient Norm (Stability Check)')
    plt.xlabel('Epoch')
    plt.ylabel('Mean Norm')
    
    plt.tight_layout()
    plt.show()

# =============================================================================
# 第三部分:【用户配置区】 (万金油模板接口)
# =============================================================================

# --- 1. 数据准备 (Data Preparation) ---
# 这里是唯一需要你根据实际任务修改数据加载逻辑的地方
# 示例:生成 300 个样本,2 个特征,3 分类
print("正在生成数据...")
N_SAMPLES = 300
INPUT_FEATURES = 2
NUM_CLASSES = 3
X_data = np.random.randn(N_SAMPLES, INPUT_FEATURES) # 你的输入数据, 随机生成标准正态分布数据, 形状 (300, 2)
y_data = np.random.randint(0, NUM_CLASSES, size=(N_SAMPLES,)) # 你的标签, 随机生成 0,1,2 三类标签, 形状 (300,)

# --- 2. 模型配置 (Model Configuration) ---
# 只要修改这里,就能改变网络的深度和宽度
# 场景 A: 简单网络 -> hidden_layers = [64]
# 场景 B: 深层网络 -> hidden_layers = [128, 128, 64]
MY_HIDDEN_LAYERS = [64, 64]  # 2个隐藏层,每层64个神经元

model = UniversalDeepModel(
    input_dim=INPUT_FEATURES,       # 自动适配输入数据
    hidden_layer_sizes=MY_HIDDEN_LAYERS, # 在这里定义你有多少层,每层多大
    output_dim=NUM_CLASSES,         # 自动适配输出类别
    learning_rate=0.05,             # 学习率
    decay=1e-4                      # 学习率衰减 (防止后期震荡)
)

# --- 3. 训练 (Training) ---
print("\n开始训练...")
model.train(
    X_data, 
    y_data, 
    epochs=2000,    # 训练轮数
    print_every=1 # 每多少轮打印一次
)

# 可视化训练过程
plot_training_history(model.history)

# --- 4. 验证/使用 (Inference) ---
print("\n模型使用示例:")
# 假设来了一条新数据
new_sample = np.array([[0.5, -1.2]]) # 新样本, 形状 (1, 2)
probs = model.predict(new_sample) # 预测类别概率分布, 形状 (1, 3)
pred_class = np.argmax(probs, axis=1) # 预测类别索引

print(f"输入: {new_sample}")
print(f"各类别概率: {probs}")
print(f"预测类别: {pred_class}")

我们直接看日志可视化图:

可以看到loss会周期性震荡,梯度也是,

因为我们的数据是随机生成的。

这里我们如果还是用前面Chap1中提到的演示数据,详情参考我的上一篇博客Chap1:Neural Networks with NumPy(手搓神经网络理解原理)

python 复制代码
import nnfs
from nnfs.datasets import spiral_data

# Create dataset
X, y = spiral_data(samples=5000, classes=3)

plt.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap='brg')

我们生成了一个有规律的5000个样本/每分类的3分类螺旋散点数据,也就是一共15k个样本,三类均匀分别为5k个


现在我们就用前面简单的手搓网络来进行分类任务(如果是经典机器学习的话,我们这里可能会用二次判别分析、支持向量机之类的,但是现在,我们不搞复杂的数据,我们要大力出奇迹!)

这是一个简单的三层网络,三层的MLP感知机(三层的全连接前馈网络)

前面所有的代码都不需要改,我们只要改输入数据部分即可

python 复制代码
# =============================================================================
# 第三部分:【用户配置区】 (万金油模板接口)
# =============================================================================

# --- 1. 数据准备 (Data Preparation) ---
# 使用前面的螺旋数据集
# 每个样本2 个特征,lable 3 分类
INPUT_FEATURES = 2
NUM_CLASSES = 3

# --- 2. 模型配置 (Model Configuration) ---
MY_HIDDEN_LAYERS = [64, 64]  # 2个隐藏层,每层64个神经元

model = UniversalDeepModel(
    input_dim=INPUT_FEATURES,       # 自动适配输入数据
    hidden_layer_sizes=MY_HIDDEN_LAYERS, # 在这里定义你有多少层,每层多大
    output_dim=NUM_CLASSES,         # 自动适配输出类别
    learning_rate=0.05,             # 学习率
    decay=1e-4                      # 学习率衰减 (防止后期震荡)
)

# --- 3. 训练 (Training) ---
print("\n开始训练...")
model.train(
    X, 
    y, 
    epochs=2000,    # 训练轮数
    print_every=1 # 每多少轮打印一次
)

# 可视化训练过程
plot_training_history(model.history)

# --- 4. 验证/使用 (Inference) ---
print("\n模型使用示例:")
# 假设来了一条新数据
new_sample = np.array([[0.5, -1.2]]) # 新样本, 形状 (1, 2)
probs = model.predict(new_sample) # 预测类别概率分布, 形状 (1, 3)
pred_class = np.argmax(probs, axis=1) # 预测类别索引

print(f"输入: {new_sample}")
print(f"各类别概率: {probs}")
print(f"预测类别: {pred_class}")

花费时间30s左右,下面是日志。

我们可以看到loss和准确率效果都不错,后面基本上都稳定不变了;

如果没有打印梯度信息的话,我们可能会认为是收敛了,

这也就是我们这里的明智之处,把梯度信息也打印了出来,我们其实可以看到梯度(第一层权重、最后一层权重,以及整体均值)并没有为0,梯度也在震荡,所以loss一直降不下去(我们看到loss是降在0.2左右没下去)

下面是我full batch训练5000个epoch的

修改了网络结构(4层简单的全连接前馈神经网络),full batch 5000个的

看监控曲线,很典型的一个梯度不稳定性(Gradient Instability)问题,

如果我们的日志显示梯度范数经常在 10.0 以上,甚至出现 100.0 或 NaN,那绝对是梯度爆炸了。

  • Last Layer 爆炸:通常意味着我们的 Loss 计算或者 Softmax 有问题,或者简单的学习率太大。
  • First Layer 爆炸:通常意味着输入数据没有做标准化(Normalization)。

我这里稍微提一下能够做的:

  1. 先检查 输入数据归一化(这通常是第一层梯度的罪魁祸首)。比如说我们的输入数据X是什么范围?是不是0-255的像素值?或者是几千几万的生物序列特征值?
    1. 建议缩放到[0,1]或[-1,1],或标准正态分布(mean=0,std=1)
    2. 比如说
python 复制代码
X = (X - np.mean(X)) / np.std(X)
  1. 如果不奏效,降低学习率(除以 10)。现在学习率是不是太大了,可能是0.1或0.01?那就改成0.001或0.0001初始化,也许是解决梯度爆炸最快的方法
  2. 如果还不行,加上 梯度裁剪。所谓梯度裁剪,其实就是不管梯度算出来多大,我强行把它按住,不让它破坏参数,比如说:
python 复制代码
# === 在 Optimizer 更新之前插入 ===

# 1. 这种是"全局范数裁剪"(Global Norm Clipping),是目前最标准的做法
# 设定一个阈值,比如 1.0 或 5.0
max_grad_norm = 1.0 

# 计算所有层梯度的总范数
total_norm = np.sqrt(sum(np.sum(layer.dweights**2) for layer in model.layers if hasattr(layer, 'dweights')))

clip_coef = max_grad_norm / (total_norm + 1e-6)
if clip_coef < 1:
    for layer in model.layers:
        if hasattr(layer, 'dweights'):
            layer.dweights *= clip_coef
            layer.dbiases *= clip_coef
            
# === 插入结束,然后再调用 optimizer.update(layer) ===
  1. 检查一下权重初始化有没有问题

我们这里简单修改一下初始化的权重

比如说改成He初始化

python 复制代码
self.weights = np.random.randn(n_inputs, n_neurons) * np.sqrt(2. / n_inputs)

初始学习率也改一下

衍生:更一般的手搓神经网络

深度学习框架的核心骨架

前面我们手搓的Numpy神经网络虽然很简陋(逻辑有漏洞、模块很少,代码才不到500行),但它实际上已经包含了现代深度学习框架(如PyTorch、TensorFlow)的核心骨架。

我们先分析一下这个简单的代码,透视神经网络构建的底层逻辑,我会掺杂一点现代深度学习的工程规范进行对比。

手写框架(Numpy造轮子) vs 使用现代框架(如PyTorch)

我们先看"第一部分:底层核心类库 ":

这一部分其实是定义一些网络层、损失、激活函数层的forward以及backward方法,以及优化器(可以忽略)

但是在现代Pytorch开发中,第一部分底层核心类库通常不需要我们自己写。

我们再来看第二部分,这一部分主要是定义网络结构,并串联起来整体的forward、backward(整合在train里了)方法,这就是一个静态的、数据能够流通的网络结构;

并实现了train以及predict方法,这是实践。

而恰恰是这一部分,定义网络结构,是我们开发工作的重心。

前面的手搓网络
  • Part 1: 底层核心 (Wheel-making)
    • 必须自己手写 Linear 层、Sigmoid 激活函数、MSE 损失函数。
    • 最痛苦的地方:每个层不仅要写 forward(怎么算输出),还必须精通微积分,手推并手写 backward(怎么算梯度/导数)。比如写一个全连接层,我们得自己算 dL/dW和 dL/db。
  • Part 2: 模型封装
    • 我们需要手动串联这些层。
    • 训练时,需要显式地一步步调用 backward() 把梯度传回去。
现代 PyTorch 实现(我们现在普遍的工作流)

在 PyTorch 中,角色发生了巨大的变化,

  • 对于 Part 1(底层核心):完全不需要手写,PyTorch 已经内置了无数的"积木"。
    • 我们想用全连接层?直接调用 nn.Linear,不需要自己手写它的权重、偏置初始化,前向后向方法。
    • 我们想用激活函数?直接调用 F.relunn.ReLU
    • 我们想用损失函数?直接调用 nn.CrossEntropyLoss

最关键的是: 我们不需要写 backward 方法!PyTorch 拥有一个强大的引擎叫做 Autograd(自动微分系统)。只要我们用它的积木搭建了前向传播(Forward),它会构建一个计算图(Computational Graph),并在后台自动为我们推导并计算所有的梯度。这是现代深度学习框架的"魔法"所在。

  • 对于 Part 2(定义网络结构):这是我们的主要工作,

我们只需要定义网络类,通常继承自 nn.Module。我们需要做两件事:

复制代码
1. `__init__`:列出我们要用哪些"积木"(层)。
2. `forward`:定义数据怎么流过这些积木。
一个简单两层网络的比对

代码比对上,假设我们要实现一个简单的两层网络(Linear -> Relu -> Linear):

如果是我们手搓:

python 复制代码
# 我们必须自己实现 LinearLayer 类及其 backward 方法
class MyLinear:
    def forward(self, x): ...
    def backward(self, grad): ... # 还要自己手算矩阵求导!

# 使用时
layer1 = MyLinear(...)
layer2 = MyLinear(...)
def train_step(x, y):
    h = layer1.forward(x)    # 正向
    out = layer2.forward(h)
    loss = compute_loss(out, y)
    
    grad = loss_backward(loss) 
    grad = layer2.backward(grad) # 反向传播必须手动把梯度一层层传回去
    grad = layer1.backward(grad)

如果是现代PyTorch版:

python 复制代码
import torch.nn as nn

class ModernNet(nn.Module):
    def __init__(self):
        super().__init__()
        # 1. 拿出积木
        self.layer1 = nn.Linear(10, 20)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(20, 1)

    def forward(self, x):
        # 2. 这里的代码看似只有前向,其实 Pytorch 已经在偷偷记录计算图
        x = self.layer1(x)
        x = self.relu(x)
        x = self.layer2(x)
        return x

# 使用时
model = ModernNet()
loss = criterion(model(input), target)
loss.backward()  # 🔥这句话一出,Autograd 瞬间自动帮我们把所有参数的梯度算好了,不用手传

总的来说:

  • 底层手写? 不需要。除非我们要发明一种数学上全新的层(比如某种特殊的注意力机制),否则一般不需要碰底层导数。
  • Forward 方法? 这是我们唯一需要逻辑清晰地编写的部分,决定了数据怎么走。
  • Backward 方法? 完全不用写,PyTorch 的 loss.backward() 一键搞定。

那么我们可以看到,抛开训练、使用不谈,只要我们学会了简单的PyTorch语法,一点点基础的线性代数基础知识,然后就可像调用API一样直接搭积木,分分钟定义1个简单的神经网络出来,当然只是静态定义。

所以在框架的调用api底层,里面其实就是矩阵乘法和链式法则求导,就像是在实际做项目时,我们就像是在开法拉利(PyTorch),不需要关心引擎(底层求导)是怎么造出来的。

构建一个Model到底需要什么?

构建一个 Model 的核心就是:

  1. __init__: 定义网络结构(层、参数初始化)。
  2. forward: 定义数据流向(输入怎么变成输出)。

这就够了! train(训练循环)和 predict(推理)通常不写在 Model 类里面,而是作为外部的脚本或单独的类来调用 Model。这样做是为了解耦(Decoupling)------模型定义归模型,训练逻辑归训练。

所以我们一般的神经网络构建,其实一般Model一个类(静态网络结构怎么定义,init;数据流向forward),train一个类(如何训练,重点在loss),predict(推理)一个类,然后其他的一些数据处理函数,

每一个类通常写成一个py文件比较工程化。

现代深度学习工程规范

在实际的工程项目(如计算生物学中的蛋白质结构预测、基因序列分析)中,代码不会全部塞在一个文件里。我们需要模块化、分层化的设计。

文件组织结构 (Project Structure)

1个规范的项目,通常长下面这样:

python 复制代码
project_root/
│
├── data/                   # 存放原始数据和处理后的数据
│   ├── raw/
│   └── processed/
│
├── configs/                # 配置文件 (超参数、路径)
│   └── config.yaml
│
├── src/                    # 核心源代码
│   ├── __init__.py
│   ├── dataset.py          # 1. 数据处理 (Data Loader)
│   ├── model.py            # 2. 模型定义 (Network Architecture)
│   ├── trainer.py          # 3. 训练逻辑 (Training Loop)
│   ├── utils.py            # 4. 辅助函数 (Metrics, Logging)
│   └── predict.py          # 5. 推理/预测逻辑
│
├── main.py                 # 项目入口 (整合所有模块)
├── requirements.txt        # 依赖库
└── README.md

红色框框的部分是我们前面提到的:

我之前也出了一篇博客,讲机器学习一般项目的文件组织、工程规范,参考结构化组织我们的python工程项目

核心模块
数据处理 (src/dataset.py)

原则:数据处理与模型定义完全分离。

  • 内容: 这里定义一个类(通常继承自 torch.utils.data.Dataset)。
  • 职责:
    1. 读取原始文件(如 FASTA 格式的蛋白质序列)。
    2. 进行预处理(One-hot 编码、归一化、截断/填充)。
    3. 返回一个样本 (input, label)
  • 为什么分离? 这样我们的模型可以保持纯净。无论数据是蛋白质、图片还是文本,只要处理成张量(Tensor),模型都能吃进去。
模型定义 (src/model.py)

原则:只定义结构和前向传播,不包含训练逻辑。就是前面的init+forward方法

python 复制代码
import torch.nn as nn

class ProteinNet(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super().__init__()
        # 定义层,相当于你的 self.weights 初始化
        self.layer1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(hidden_size, num_classes)

    def forward(self, x):
        # 定义流向
        out = self.layer1(x)
        out = self.relu(out)
        out = self.layer2(out)
        return out

注意: 这里没有 backward,也没有 loss 计算。它只负责"输入 -> 输出"的映射。

训练器 (src/trainer.py)

原则:管理训练过程、验证、保存模型。

  • 内容: 这是一个管理类,它接收 ModelDataLoader
  • 职责:
    1. 定义损失函数(Loss Function)。
    2. 定义优化器(Optimizer,如 Adam, SGD)。
    3. 写循环:for epoch in range(epochs): ...
    4. 执行核心三步走:
      • optimizer.zero_grad() (清空梯度)
      • loss.backward() (反向传播,框架自动完成)
      • optimizer.step() (更新权重)
    5. 记录日志(TensorBoard)和保存模型权重(.pth 文件,关于model的权重文件,可以参考我的上一篇博客)。
辅助工具 (src/utils.py)
  • 内容: 存放通用的独立小函数。
  • 例子: 计算准确率(Accuracy)、F1-score、绘制 Loss 曲线、设置随机种子(保证结果可复现)。
流程总结:如何搭建一个深度学习网络(以计算生物学应用为例)?

假设我们要做一个预测蛋白质二级结构的任务:

  1. 数据准备 (dataset.py):
    • 写一个函数读取 .fasta 文件。
    • 将氨基酸序列(A, C, G...)转换为数字向量(One-hot)。
    • 划分训练集和测试集。
  2. 模型搭建 (model.py):
    • __init__ 中定义:卷积层(CNN,用于提取局部特征) -> LSTM层(用于处理序列依赖) -> 全连接层。
    • forward 中连接这些层。
  3. 配置训练 (main.py / trainer.py):
    • 实例化 Model。
    • 实例化 DataLoader。
    • 选择 Loss 函数(如 CrossEntropyLoss)以及优化器算法。
    • 开始训练循环。
  4. 评估与预测(predict.py):
    • 训练结束后,加载保存的权重。
    • 输入新的蛋白质序列,通过 model.forward() 得到预测结果。
一些问题
  • 训练数据和测试数据的处理和这个网络的 class 是写在一起吗?
    • 绝对不在一起。 数据处理写在 dataset.py,网络结构写在 model.py。两者通过 main.pytrain.py中的数据加载器(DataLoader)进行交互。这样做是为了让模型具有通用性(同一个模型结构可以跑不同的数据集)。
  • 如何平衡其他导入的库以及数据处理小函数?
    • 通用的小函数(如计算两个向量的距离)放在 utils.py
    • 特定于数据的小函数(如氨基酸编码表)放在 dataset.py 内部或作为私有方法。
    • 库的导入:遵循"按需导入"原则,通常在文件头部导入。

一句话总结:

模型类(Model)只管"怎么算"(架构),数据类(Dataset)只管"算什么"(数据),训练器(Trainer)只管"怎么学"(优化流程),预测器(Predict)只管"怎么用"(最终实践输出)。四者各司其职,互不干扰。

一些问题

关于 init:它是"空想"的吗?需要在训练中修改吗?
  • __init__ 的本质: 它是网络的静态架构定义。在这里,我们定义了网络有多少层、每层有多少个神经元、激活函数是什么。更重要的是,我们在这里初始化了权重(Weights)和偏置(Biases)------见底层核心类库。
    • 虽然网络还没有见过数据,但它不是"空想"的,它是一个随机初始化的实体。就像一个刚出生的婴儿,大脑结构已经有了(架构),但神经连接是随机的(权重),还没有学会知识。
  • 是否需要在训练中修改 __init__? 绝对不需要,也不能修改。
    • __init__ 定义的是"骨架"。训练过程修改的是骨架里的"填充物"(即权重矩阵 WW 和 bb 的数值),而不是骨架本身。
    • 训练迭代是在 train 函数中进行的,它通过梯度下降算法不断更新 self.weightsself.biases 的数值,但变量的形状(Shape)和层数永远不变。
关于 forwardbackward:为什么不单独写 backward
  • 标准做法: 在现代框架(PyTorch/TensorFlow)中,必须写 forward,通常不需要写 backward
    • Forward (前向传播): 定义数据如何从输入流向输出(例如:输入 -> 线性层 -> Sigmoid -> 输出)。这是我们必须告诉计算机的逻辑。
    • Backward (反向传播): 现代框架都有自动微分(Autograd)机制。只要我们定义了 forward,框架会自动构建计算图,并自动推导出 backward 的逻辑。
  • 我们的代码为何合并? 在我们前面提供的 NumPy 手搓代码中,因为没有自动微分引擎,所以必须手动推导梯度的数学公式(链式法则)。将 backward 逻辑写在 train 里是因为反向传播的目的就是为了更新权重(Training,训练的本质就是为了更新参数),这是一种简化的写法。
  • 工程规范: 如果我们自己写框架(手搓),backward 应该是一个独立的模块;但在使用 PyTorch 等框架时,我们只需要关注 forward,这些在前面比对中也都提到了。

项目文件组织

python 复制代码
project_root/
│
├── data/                   # 存放原始数据和处理后的数据
│   ├── raw/
│   └── processed/
│
├── configs/                # 配置文件 (超参数、路径)
│   └── config.yaml
│
├── src/                    # 核心源代码
│   ├── __init__.py
│   ├── dataset.py          # 1. 数据处理 (Data Loader)
│   ├── model.py            # 2. 模型定义 (Network Architecture)
│   ├── trainer.py          # 3. 训练逻辑 (Training Loop)
│   ├── utils.py            # 4. 辅助函数 (Metrics, Logging)
│   └── predict.py          # 5. 推理/预测逻辑
│
├── main.py                 # 项目入口 (整合所有模块)
├── requirements.txt        # 依赖库
└── README.md

参考:https://github.com/MaybeBio/bioinfor_script_modules/blob/main/70_Create_Deep_Learning_Project.py

封装的1个文件组织生成脚本

现在,对我们前面手搓的Numpy神经网络(此处演示用原始未修改前的模板,因为仅演示,code内容不重要,重点在于形式),我们按照工程化的层级文件组织结构

python 复制代码
import os
import sys

def create_project_structure(project_name):
    root_dir = project_name
    
    # 定义目录结构
    dirs = [
        os.path.join(root_dir, "data", "raw"),
        os.path.join(root_dir, "data", "processed"),
        os.path.join(root_dir, "configs"),
        os.path.join(root_dir, "src"),
    ]
    
    # 定义文件内容
    files = {}

    # 1. src/model.py - 存放网络组件和模型结构 (注意:这里不再包含 train 方法)
    files[os.path.join(root_dir, "src", "model.py")] = '''import numpy as np

# =============================================================================
# 一、底层核心类库 (Layers, Activations, Losses, Optimizer)
# =============================================================================

class Layer_Dense:
    def __init__(self, n_inputs, n_neurons):
        """初始化全连接层的权重和偏置"""
        self.weights = 0.01 * np.random.randn(n_inputs, n_neurons)
        self.biases = np.zeros((1, n_neurons))
    
    def forward(self, inputs):
        """前向传播"""
        self.inputs = inputs
        self.output = np.dot(inputs, self.weights) + self.biases
    
    def backward(self, dvalues):
        """反向传播"""
        self.dweights = np.dot(self.inputs.T, dvalues)
        self.dbiases = np.sum(dvalues, axis=0, keepdims=True)
        self.dinputs = np.dot(dvalues, self.weights.T)

class Activation_ReLU:
    def forward(self, inputs):
        self.inputs = inputs
        self.output = np.maximum(0, inputs)

    def backward(self, dvalues):
        self.dinputs = dvalues.copy()
        self.dinputs[self.inputs <= 0] = 0

class Activation_Softmax:
    def forward(self, inputs):
        self.inputs = inputs
        exp_values = np.exp(inputs - np.max(inputs, axis=1, keepdims=True))
        self.output = exp_values / np.sum(exp_values, axis=1, keepdims=True)

class Loss:
    def calculate(self, output, y):
        sample_losses = self.forward(output, y)
        data_loss = np.mean(sample_losses)
        return data_loss

class Loss_CategoricalCrossentropy(Loss):
    def forward(self, y_pred, y_true):
        samples = len(y_pred)
        y_pred_clipped = np.clip(y_pred, 1e-7, 1 - 1e-7)
        if len(y_true.shape) == 1:
            correct_confidences = y_pred_clipped[range(samples), y_true]
        elif len(y_true.shape) == 2:
            correct_confidences = np.sum(y_pred_clipped * y_true, axis=1)
        negative_log_likelihoods = -np.log(correct_confidences)
        return negative_log_likelihoods
    
    def backward(self, dvalues, y_true):
        samples = len(dvalues)
        labels = len(dvalues[0])
        if len(y_true.shape) == 1:
            y_true = np.eye(labels)[y_true]
        self.dinputs = -y_true / dvalues
        self.dinputs = self.dinputs / samples

class Activation_Softmax_Loss_CategoricalCrossentropy():
    """Softmax + CrossEntropy 组合层, 用于优化反向传播"""
    def __init__(self):
        self.activation = Activation_Softmax()
        self.loss = Loss_CategoricalCrossentropy()
        
    def forward(self, inputs, y_true):
        self.activation.forward(inputs)
        self.output = self.activation.output
        return self.loss.calculate(self.output, y_true)
        
    def backward(self, dvalues, y_true):
        samples = len(dvalues)  
        if len(y_true.shape) == 2:
            y_true = np.argmax(y_true, axis=1)
        self.dinputs = dvalues.copy()
        self.dinputs[range(samples), y_true] -= 1
        self.dinputs = self.dinputs / samples

class Optimizer_Adam:
    """Adam 优化器"""
    def __init__(self, learning_rate=0.001, decay=0., epsilon=1e-7, beta_1=0.9, beta_2=0.999):
        self.learning_rate = learning_rate
        self.current_learning_rate = learning_rate
        self.decay = decay
        self.iterations = 0
        self.epsilon = epsilon
        self.beta_1 = beta_1
        self.beta_2 = beta_2
    
    def pre_update_params(self):
        if self.decay:
            self.current_learning_rate = self.learning_rate * (1. / (1. + self.decay * self.iterations))
    
    def update_params(self, layer):
        if not hasattr(layer, 'weight_cache'):
            layer.weight_momentums = np.zeros_like(layer.weights)
            layer.weight_cache = np.zeros_like(layer.weights)
            layer.bias_momentums = np.zeros_like(layer.biases)
            layer.bias_cache = np.zeros_like(layer.biases)
        
        layer.weight_momentums = self.beta_1 * layer.weight_momentums + (1 - self.beta_1) * layer.dweights
        layer.bias_momentums = self.beta_1 * layer.bias_momentums + (1 - self.beta_1) * layer.dbiases
        
        weight_momentums_corrected = layer.weight_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        bias_momentums_corrected = layer.bias_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        
        layer.weight_cache = self.beta_2 * layer.weight_cache + (1 - self.beta_2) * layer.dweights**2
        layer.bias_cache = self.beta_2 * layer.bias_cache + (1 - self.beta_2) * layer.dbiases**2
        
        weight_cache_corrected = layer.weight_cache / (1 - self.beta_2 ** (self.iterations + 1))
        bias_cache_corrected = layer.bias_cache / (1 - self.beta_2 ** (self.iterations + 1))
        
        layer.weights += -self.current_learning_rate * weight_momentums_corrected / (np.sqrt(weight_cache_corrected) + self.epsilon)
        layer.biases += -self.current_learning_rate * bias_momentums_corrected / (np.sqrt(bias_cache_corrected) + self.epsilon)
    
    def post_update_params(self):
        self.iterations += 1

# =============================================================================
# 二、网络结构定义 (Model Architecture)
# =============================================================================

class UniversalDeepModel:
    """
    通用深度网络结构类
    负责: 定义网络层级结构, 前向传播
    解耦: 不再负责具体的 Training Loop, 专注于架构
    """
    def __init__(self, input_dim, hidden_layer_sizes, output_dim):
        self.layers = [] 
        current_input_dim = input_dim 
        
        # 1. 动态构建隐藏层
        for i, n_neurons in enumerate(hidden_layer_sizes):
            self.layers.append(Layer_Dense(current_input_dim, n_neurons))
            self.layers.append(Activation_ReLU())
            current_input_dim = n_neurons

        # 2. 构建输出层
        self.final_dense = Layer_Dense(current_input_dim, output_dim)
        
        # 3. 定义损失函数和输出激活
        self.loss_activation = Activation_Softmax_Loss_CategoricalCrossentropy()

    def forward(self, X):
        """前向传播: 计算 Logits (未经过 Softmax)"""
        current_output = X 
        for layer in self.layers:
            layer.forward(current_output)
            current_output = layer.output
        self.final_dense.forward(current_output)
        return self.final_dense.output

    def predict(self, X):
        """推理: 返回概率分布"""
        logits = self.forward(X)
        self.loss_activation.activation.forward(logits)
        return self.loss_activation.activation.output
'''

    # 2. src/trainer.py - 存放训练逻辑
    files[os.path.join(root_dir, "src", "trainer.py")] = '''import numpy as np

class Trainer:
    """
    训练器类
    负责: 管理训练循环, 梯度更新, 日志记录
    """
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.history = {'loss': [], 'acc': [], 'lr': [], 'grad_norm': []}

    def train(self, X_train, y_train, epochs=1000, batch_size=None, print_every=100):
        n_samples = len(X_train)
        
        for epoch in range(epochs):
            # --- 1. 数据准备 (支持 Mini-batch 扩展预留) ---
            # 简单起见,这里演示全量梯度下降 (Full Batch)
            # 如果需要 SGD,可以在这里加 Batch 循环
            
            # --- 2. 前向传播 ---
            # 计算网络输出 (Logits)
            logits = self.model.forward(X_train)
            # 计算损失
            loss = self.model.loss_activation.forward(logits, y_train)
            
            # --- 3. 统计指标 ---
            predictions = np.argmax(self.model.loss_activation.output, axis=1)
            y_labels = np.argmax(y_train, axis=1) if len(y_train.shape) == 2 else y_train
            accuracy = np.mean(predictions == y_labels)
            
            # 记录历史
            self.history['loss'].append(loss)
            self.history['acc'].append(accuracy)
            self.history['lr'].append(self.optimizer.current_learning_rate)

            # --- 4. 反向传播 ---
            # 从 Loss 开始反向
            self.model.loss_activation.backward(self.model.loss_activation.output, y_train)
            self.model.final_dense.backward(self.model.loss_activation.dinputs)
            
            # 反向流经隐藏层
            back_gradient = self.model.final_dense.dinputs
            for layer in reversed(self.model.layers):
                layer.backward(back_gradient)
                back_gradient = layer.dinputs
            
            # 记录梯度范数 (用于监控)
            grad_norms = [np.linalg.norm(layer.dweights) for layer in self.model.layers if hasattr(layer, "dweights")]
            self.history['grad_norm'].append(np.mean(grad_norms) if grad_norms else 0)

            # --- 5. 参数更新 ---
            self.optimizer.pre_update_params()
            for layer in self.model.layers:
                if hasattr(layer, 'weights'):
                    self.optimizer.update_params(layer)
            self.optimizer.update_params(self.model.final_dense)
            self.optimizer.post_update_params()

            # --- 6. 日志打印 ---
            if not epoch % print_every:
                print(f'Epoch: {epoch}, Acc: {accuracy:.3f}, Loss: {loss:.3f}, ' +
                      f'LR: {self.optimizer.current_learning_rate:.5f}, ' +
                      f'Grad Norm: {self.history["grad_norm"][-1]:.3f}')
'''

    # 3. src/dataset.py - 数据生成或加载
    files[os.path.join(root_dir, "src", "dataset.py")] = '''import numpy as np

def create_data(n_samples=300, n_features=2, n_classes=3):
    """
    生成演示用数据
    这里使用简单的随机正态分布数据
    """
    print(f"正在生成数据: {n_samples} 样本, {n_features} 特征, {n_classes} 类别...")
    X = np.random.randn(n_samples, n_features)
    y = np.random.randint(0, n_classes, size=(n_samples,))
    return X, y
'''

    # 4. src/utils.py - 辅助函数
    files[os.path.join(root_dir, "src", "utils.py")] = '''import matplotlib.pyplot as plt

def plot_history(history):
    """可视化训练历史"""
    print("绘制训练曲线...")
    epochs = range(len(history['loss']))
    
    plt.figure(figsize=(12, 4))
    
    plt.subplot(1, 3, 1)
    plt.plot(epochs, history['loss'], label='Loss')
    plt.title('Loss')
    plt.xlabel('Epoch')
    
    plt.subplot(1, 3, 2)
    plt.plot(epochs, history['acc'], label='Accuracy', color='green')
    plt.title('Accuracy')
    plt.xlabel('Epoch')
    
    plt.subplot(1, 3, 3)
    plt.plot(epochs, history['grad_norm'], label='Grad Norm', color='orange')
    plt.title('Gradient Norm')
    plt.xlabel('Epoch')
    
    plt.tight_layout()
    plt.show() # 在 Notebook 中会显示,在脚本中可能需要保存
    # plt.savefig('training_result.png')
'''

    # 5. src/__init__.py
    files[os.path.join(root_dir, "src", "__init__.py")] = ""
    
    # 6. src/predict.py - 推理脚本
    files[os.path.join(root_dir, "src", "predict.py")] = '''import numpy as np

def predict_single(model, sample):
    """对单个样本进行预测"""
    # 确保样本是 2D 数组 (1, n_features)
    if sample.ndim == 1:
        sample = sample.reshape(1, -1)
    
    probs = model.predict(sample)
    pred_class = np.argmax(probs, axis=1)[0]
    return pred_class, probs
'''

    # 7. main.py - 项目入口
    files[os.path.join(root_dir, "main.py")] = '''import numpy as np
from src.dataset import create_data
from src.model import UniversalDeepModel, Optimizer_Adam
from src.trainer import Trainer
from src.utils import plot_history
from src.predict import predict_single

# 1. 准备数据
X_data, y_data = create_data(n_samples=500, n_features=2, n_classes=3)

# 2. 配置模型与优化器
model = UniversalDeepModel(
    input_dim=2, 
    hidden_layer_sizes=[64, 64], 
    output_dim=3
)
optimizer = Optimizer_Adam(learning_rate=0.05, decay=1e-4)

# 3. 初始化训练器
trainer = Trainer(model, optimizer)

# 4. 开始训练
print("开始训练...")
trainer.train(X_data, y_data, epochs=2000, print_every=100)

# 5. 可视化结果 (如果运行环境支持)
# plot_history(trainer.history)

# 6. 推理示例
print("\\n模型推理示例:")
new_sample = np.array([0.5, -1.2])
pred_class, probs = predict_single(model, new_sample)
print(f"输入: {new_sample}")
print(f"预测类别: {pred_class}")
print(f"置信度: {probs}")
'''

    # 8. configs/config.yaml
    files[os.path.join(root_dir, "configs", "config.yaml")] = '''
model:
  hidden_layers: [64, 64]
  input_dim: 2
  output_dim: 3

training:
  epochs: 2000
  learning_rate: 0.05
  batch_size: 32
'''

    # 9. README.md
    files[os.path.join(root_dir, "README.md")] = f'''# {project_name}

这是一个由 `scaffold_project.py` 自动生成的深度学习项目结构,基于 NumPy 手搓神经网络实现。

## 目录结构
- `src/model.py`: 包含 Layer, Activation, Loss, Optimizer 以及 Model 架构定义。
- `src/trainer.py`: 包含训练循环逻辑 (Trainer 类)。
- `src/dataset.py`: 数据处理。
- `main.py`: 项目运行入口。

## 运行方法
```bash
python main.py'''


    # 执行创建
    print(f"正在创建项目: {project_name} ...")

    # 1. 创建目录
    for d in dirs:
        os.makedirs(d, exist_ok=True)
        print(f"  + 目录: {d}")
        
    # 2. 创建文件
    for filepath, content in files.items():
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(content)
        print(f"  + 文件: {filepath}")
        
    print(f"\\n项目 {project_name} 创建完成!")
    print(f"请运行: cd {project_name} && python main.py")
python 复制代码
create_project_structure("/data2/DL4Proteins/Chap1/Numpy_NN_Proj")

结果如下:

我们来比对一下这里的初级文件组织结构,这其实是一个很适合初学者的过渡性问题。

从"手搓 NumPy 神经网络"到"使用 PyTorch/TensorFlow 开发工程化项目",文件结构(Project Structure)其实非常相似,我们完全可以从这里直接一步入门。

区别不在于文件夹叫什么,而在于每个文件内部代码实现的抽象层级发生了巨大的变化,而这种变化我们手搓的Numpy神经网络能够用,只要体验一次我们就能够立马接受Pytorch中真实工程化的项目组织。


1. configs/config.yaml ------ 项目的大脑

python 复制代码
model:
  hidden_layers: [64, 64]
  input_dim: 2
  output_dim: 3

training:
  epochs: 2000
  learning_rate: 0.05
  batch_size: 32

作用:将"魔法数字"(Magic Numbers)从代码中剥离,其实就是一些超参数。

  • Numpy 版内容
    • 可能包含:learning_rate: 0.1, hidden_neurons: 64, epochs: 1000
    • 处理方式:通常在 Python 里简单读取字典。
  • PyTorch 工业级版
    • 内容 :除了上面的,还会增加 device: "cuda:0", num_workers: 4 (数据加载线程数), batch_size, pretrained_path 等。
    • 进阶 :现在业内不再只用简单的 yaml,而是流行使用 HydraMLCollections 。这允许我们通过命令行动态覆盖参数(例如 python main.py training.lr=0.001)。

2. data/ & src/dataset.py ------ 原料供给

python 复制代码
import numpy as np

def create_data(n_samples=300, n_features=2, n_classes=3):
    """
    生成演示用数据
    这里使用简单的随机正态分布数据
    """
    print(f"正在生成数据: {n_samples} 样本, {n_features} 特征, {n_classes} 类别...")
    X = np.random.randn(n_samples, n_features)
    y = np.random.randint(0, n_classes, size=(n_samples,))
    return X, y

作用:负责把硬盘上的图片/文本/表格变成模型能吃的数字矩阵, 一般数据预处理、数据清洗等就在这里进行,数据预处理之后再进入我们的网络结构。

  • Numpy 版 (我们手搓的)
    • 代码逻辑 :通常是一个函数 load_data()
    • 操作 :手动读取 CSV,手动 .reshape(),手动归一化 (x - mean) / std,最后返回两个大矩阵 X (N, D) 和 y (N,)。
    • 痛点:如果数据量太大(比如 100GB),内存会爆,NumPy 很难处理"边读边训"。
  • PyTorch 工业级版
    • 核心类torch.utils.data.Datasettorch.utils.data.DataLoader
    • 写法
python 复制代码
# src/dataset.py
import torch
from torch.utils.data import Dataset

class MyProteinDataset(Dataset):
    def __init__(self, file_path):
        # 只存文件路径,不一次性读入内存
        self.files = ... 
    
    def __getitem__(self, idx):
        # 只有用到这条数据时,才从硬盘读取
        # 这里做数据增强 (Augmentation)
        data = load_file(self.files[idx])
        return torch.tensor(data)

    def __len__(self):
        return len(self.files)
  • 串联 :在 main.pytrainer.py 中,我们把这个 dataset 塞给 DataLoader,它会自动帮我们做 Batch 切分Shuffle (打乱)多进程加速

3. src/model.py ------ 核心架构

我们这里只关注网络架构,也就是网络长什么样,至于其他的通通不管。

我这里是因为自己写底层核心类库,所以在网络架构前面要讲每一层定义的细节也要自己手写加进去,真正工程化操作时,一般只需要定义网络架构就行了

python 复制代码
import numpy as np

# =============================================================================
# 一、底层核心类库 (Layers, Activations, Losses, Optimizer)
# =============================================================================

class Layer_Dense:
    def __init__(self, n_inputs, n_neurons):
        """初始化全连接层的权重和偏置"""
        self.weights = 0.01 * np.random.randn(n_inputs, n_neurons)
        self.biases = np.zeros((1, n_neurons))

    def forward(self, inputs):
        """前向传播"""
        self.inputs = inputs
        self.output = np.dot(inputs, self.weights) + self.biases

    def backward(self, dvalues):
        """反向传播"""
        self.dweights = np.dot(self.inputs.T, dvalues)
        self.dbiases = np.sum(dvalues, axis=0, keepdims=True)
        self.dinputs = np.dot(dvalues, self.weights.T)

class Activation_ReLU:
    def forward(self, inputs):
        self.inputs = inputs
        self.output = np.maximum(0, inputs)

    def backward(self, dvalues):
        self.dinputs = dvalues.copy()
        self.dinputs[self.inputs <= 0] = 0

class Activation_Softmax:
    def forward(self, inputs):
        self.inputs = inputs
        exp_values = np.exp(inputs - np.max(inputs, axis=1, keepdims=True))
        self.output = exp_values / np.sum(exp_values, axis=1, keepdims=True)

class Loss:
    def calculate(self, output, y):
        sample_losses = self.forward(output, y)
        data_loss = np.mean(sample_losses)
        return data_loss

class Loss_CategoricalCrossentropy(Loss):
    def forward(self, y_pred, y_true):
        samples = len(y_pred)
        y_pred_clipped = np.clip(y_pred, 1e-7, 1 - 1e-7)
        if len(y_true.shape) == 1:
            correct_confidences = y_pred_clipped[range(samples), y_true]
        elif len(y_true.shape) == 2:
            correct_confidences = np.sum(y_pred_clipped * y_true, axis=1)
        negative_log_likelihoods = -np.log(correct_confidences)
        return negative_log_likelihoods

    def backward(self, dvalues, y_true):
        samples = len(dvalues)
        labels = len(dvalues[0])
        if len(y_true.shape) == 1:
            y_true = np.eye(labels)[y_true]
        self.dinputs = -y_true / dvalues
        self.dinputs = self.dinputs / samples

class Activation_Softmax_Loss_CategoricalCrossentropy():
    """Softmax + CrossEntropy 组合层, 用于优化反向传播"""
    def __init__(self):
        self.activation = Activation_Softmax()
        self.loss = Loss_CategoricalCrossentropy()

    def forward(self, inputs, y_true):
        self.activation.forward(inputs)
        self.output = self.activation.output
        return self.loss.calculate(self.output, y_true)

    def backward(self, dvalues, y_true):
        samples = len(dvalues)  
        if len(y_true.shape) == 2:
            y_true = np.argmax(y_true, axis=1)
        self.dinputs = dvalues.copy()
        self.dinputs[range(samples), y_true] -= 1
        self.dinputs = self.dinputs / samples

class Optimizer_Adam:
    """Adam 优化器"""
    def __init__(self, learning_rate=0.001, decay=0., epsilon=1e-7, beta_1=0.9, beta_2=0.999):
        self.learning_rate = learning_rate
        self.current_learning_rate = learning_rate
        self.decay = decay
        self.iterations = 0
        self.epsilon = epsilon
        self.beta_1 = beta_1
        self.beta_2 = beta_2

    def pre_update_params(self):
        if self.decay:
            self.current_learning_rate = self.learning_rate * (1. / (1. + self.decay * self.iterations))

    def update_params(self, layer):
        if not hasattr(layer, 'weight_cache'):
            layer.weight_momentums = np.zeros_like(layer.weights)
            layer.weight_cache = np.zeros_like(layer.weights)
            layer.bias_momentums = np.zeros_like(layer.biases)
            layer.bias_cache = np.zeros_like(layer.biases)

        layer.weight_momentums = self.beta_1 * layer.weight_momentums + (1 - self.beta_1) * layer.dweights
        layer.bias_momentums = self.beta_1 * layer.bias_momentums + (1 - self.beta_1) * layer.dbiases

        weight_momentums_corrected = layer.weight_momentums / (1 - self.beta_1 ** (self.iterations + 1))
        bias_momentums_corrected = layer.bias_momentums / (1 - self.beta_1 ** (self.iterations + 1))

        layer.weight_cache = self.beta_2 * layer.weight_cache + (1 - self.beta_2) * layer.dweights**2
        layer.bias_cache = self.beta_2 * layer.bias_cache + (1 - self.beta_2) * layer.dbiases**2

        weight_cache_corrected = layer.weight_cache / (1 - self.beta_2 ** (self.iterations + 1))
        bias_cache_corrected = layer.bias_cache / (1 - self.beta_2 ** (self.iterations + 1))

        layer.weights += -self.current_learning_rate * weight_momentums_corrected / (np.sqrt(weight_cache_corrected) + self.epsilon)
        layer.biases += -self.current_learning_rate * bias_momentums_corrected / (np.sqrt(bias_cache_corrected) + self.epsilon)

    def post_update_params(self):
        self.iterations += 1

# =============================================================================
# 二、网络结构定义 (Model Architecture)
# =============================================================================

class UniversalDeepModel:
    """
    通用深度网络结构类
    负责: 定义网络层级结构, 前向传播
    解耦: 不再负责具体的 Training Loop, 专注于架构
    """
    def __init__(self, input_dim, hidden_layer_sizes, output_dim):
        self.layers = [] 
        current_input_dim = input_dim 

        # 1. 动态构建隐藏层
        for i, n_neurons in enumerate(hidden_layer_sizes):
            self.layers.append(Layer_Dense(current_input_dim, n_neurons))
            self.layers.append(Activation_ReLU())
            current_input_dim = n_neurons

        # 2. 构建输出层
        self.final_dense = Layer_Dense(current_input_dim, output_dim)

        # 3. 定义损失函数和输出激活
        self.loss_activation = Activation_Softmax_Loss_CategoricalCrossentropy()

    def forward(self, X):
        """前向传播: 计算 Logits (未经过 Softmax)"""
        current_output = X 
        for layer in self.layers:
            layer.forward(current_output)
            current_output = layer.output
        self.final_dense.forward(current_output)
        return self.final_dense.output

    def predict(self, X):
        """推理: 返回概率分布"""
        logits = self.forward(X)
        self.loss_activation.activation.forward(logits)
        return self.loss_activation.activation.output

作用:定义神经网络长什么样,这是变化最大的地方。

  • Numpy 版 (我们手搓的)
    • 内容 :我们需要定义 Layer 类(存 weights, biases),还需要手写 forward() (包含点积公式) 和 backward() (包含链式法则导数公式)。
    • 核心难点 :必须自己管理梯度流 (self.dweights, self.dbiases)。
  • PyTorch 工业级版
    • 核心类 :继承 torch.nn.Module
    • 内容只写forward()绝不写 backward()
    • 原理:PyTorch 有 Autograd(自动微分引擎)。我们只要告诉它在前向传播中怎么算,它会构建动态计算图,反向传播是自动完成的。
    • 写法对比
python 复制代码
# src/model.py (PyTorch)
import torch.nn as nn

class DeepModel(nn.Module):
    def __init__(self):
        super().__init__()
        # 预定义层,不需要自己初始化 numpy 数组
        self.layer1 = nn.Linear(10, 64)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(64, 2)
    
    def forward(self, x):
        # 只要写数据怎么流
        x = self.layer1(x)
        x = self.relu(x)
        return self.layer2(x)
    # 根本没有 backward 方法!

4. src/trainer.py ------ 训练循环

python 复制代码
import numpy as np

class Trainer:
    """
    训练器类
    负责: 管理训练循环, 梯度更新, 日志记录
    """
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.history = {'loss': [], 'acc': [], 'lr': [], 'grad_norm': []}

    def train(self, X_train, y_train, epochs=1000, batch_size=None, print_every=100):
        n_samples = len(X_train)

        for epoch in range(epochs):
            # --- 1. 数据准备 (支持 Mini-batch 扩展预留) ---
            # 简单起见,这里演示全量梯度下降 (Full Batch)
            # 如果需要 SGD,可以在这里加 Batch 循环

            # --- 2. 前向传播 ---
            # 计算网络输出 (Logits)
            logits = self.model.forward(X_train)
            # 计算损失
            loss = self.model.loss_activation.forward(logits, y_train)

            # --- 3. 统计指标 ---
            predictions = np.argmax(self.model.loss_activation.output, axis=1)
            y_labels = np.argmax(y_train, axis=1) if len(y_train.shape) == 2 else y_train
            accuracy = np.mean(predictions == y_labels)

            # 记录历史
            self.history['loss'].append(loss)
            self.history['acc'].append(accuracy)
            self.history['lr'].append(self.optimizer.current_learning_rate)

            # --- 4. 反向传播 ---
            # 从 Loss 开始反向
            self.model.loss_activation.backward(self.model.loss_activation.output, y_train)
            self.model.final_dense.backward(self.model.loss_activation.dinputs)

            # 反向流经隐藏层
            back_gradient = self.model.final_dense.dinputs
            for layer in reversed(self.model.layers):
                layer.backward(back_gradient)
                back_gradient = layer.dinputs

            # 记录梯度范数 (用于监控)
            grad_norms = [np.linalg.norm(layer.dweights) for layer in self.model.layers if hasattr(layer, "dweights")]
            self.history['grad_norm'].append(np.mean(grad_norms) if grad_norms else 0)

            # --- 5. 参数更新 ---
            self.optimizer.pre_update_params()
            for layer in self.model.layers:
                if hasattr(layer, 'weights'):
                    self.optimizer.update_params(layer)
            self.optimizer.update_params(self.model.final_dense)
            self.optimizer.post_update_params()

            # --- 6. 日志打印 ---
            if not epoch % print_every:
                print(f'Epoch: {epoch}, Acc: {accuracy:.3f}, Loss: {loss:.3f}, ' +
                      f'LR: {self.optimizer.current_learning_rate:.5f}, ' +
                      f'Grad Norm: {self.history["grad_norm"][-1]:.3f}')

作用:让模型看着数据进行学习,基本上除了数据清洗,花样最多的地方就是这里了。

  • Numpy 版 (我们手搓的)
    • 逻辑 :双层循环。外层 Epoch,内层(如果有 Batch)。
    • 步棸 :Compute Output -> Calculate Loss -> model.backward() -> optimizer.update()
  • PyTorch 工业级版
    • 逻辑:结构完全一样,但 API 变了。
    • 标准五步法
python 复制代码
# src/trainer.py (简化版)
for X_batch, y_batch in dataloader:
    X_batch = X_batch.to(device) # 1. 搬到 GPU
    
    optimizer.zero_grad()        # 2. 梯度清零
    pred = model(X_batch)        # 3. 前向传播
    loss = loss_fn(pred, y_batch)# 4. 算 Loss
    loss.backward()              # 5. 反向传播 (AutoGrad)
    optimizer.step()             # 6. 更新权重
复制代码
- **进阶**:为了不重复造轮子,现在很多人这里会使用 **PyTorch Lightning** 或 **HuggingFace Trainer**。这样 `trainer.py` 甚至可以被简化成几行配置代码。------------ PyTorch Lightning 是 PyTorch 的轻量级封装框架,核心目标是「分离科研逻辑与工程细节」,让开发者聚焦模型本身(前向传播、损失计算),无需编写重复的训练循环、日志记录、GPU 调度等样板代码。Trainer 是 HuggingFace transformers 库内置的 端到端训练工具,专为 Transformer 类模型(如 BERT、GPT)设计,封装了更细分的 NLP/CV 训练场景,开箱即用性极强。
- **功能增加**:工业级 Trainer 还需要处理 **Checkpoints (模型保存/断点续训)**、**Early Stopping (早停)**、**Learning Rate Scheduler (学习率衰减)**。

5. src/utils.py ------ 仪表盘

python 复制代码
import matplotlib.pyplot as plt

def plot_history(history):
    """可视化训练历史"""
    print("绘制训练曲线...")
    epochs = range(len(history['loss']))

    plt.figure(figsize=(12, 4))

    plt.subplot(1, 3, 1)
    plt.plot(epochs, history['loss'], label='Loss')
    plt.title('Loss')
    plt.xlabel('Epoch')

    plt.subplot(1, 3, 2)
    plt.plot(epochs, history['acc'], label='Accuracy', color='green')
    plt.title('Accuracy')
    plt.xlabel('Epoch')

    plt.subplot(1, 3, 3)
    plt.plot(epochs, history['grad_norm'], label='Grad Norm', color='orange')
    plt.title('Gradient Norm')
    plt.xlabel('Epoch')

    plt.tight_layout()
    plt.show() # 在 Notebook 中会显示,在脚本中可能需要保存
    # plt.savefig('training_result.png')

作用:监控训练过程,辅助训练过程的一些工具、脚本,这一块规划其实就见仁见智了,有人认就是前面文件用不到的函数都扔到utils里。

  • Numpy 版
    • matplotlib 画个 Loss 曲线图存成本地图片。
    • 简单的 accuracy 计算函数。
  • PyTorch 工业级版
    • Metrics :使用 torchmetrics 库计算 Accuracy, F1, AUC,直接支持 GPU 计算。
    • Logging :不再只是 print。会使用 TensorBoardWandB (Weights & Biases)
    • 功能:我们可以实时在网页上看到 Loss 曲线、梯度的分布直方图、甚至预测错误的图片样本。

如何串联?(以 main.py 为例)

这是整个项目的指挥中心 。无论是 Numpy 手搓的原始项目还是 PyTorch 工程化项目,main.py 的逻辑流几乎是一致的:

python 复制代码
# main.py 逻辑伪代码对比

def main():
    # 1. 加载配置
    cfg = load_config("configs/config.yaml")

    # 2. 准备数据 (这是主要区别点)
    # Numpy: 
       X, y = create_data()
    # PyTorch: 
       dataset = MyDataset(cfg.data_path)
       dataloader = DataLoader(dataset, batch_size=cfg.batch_size, shuffle=True)

    # 3. 实例化模型
    # Numpy: 
       model = UniversalDeepModel(...)
    # PyTorch: 
       model = DeepModel(...).to(device)

    # 4. 实例化组件 (优化器/Loss)
    # Numpy: 手写的 Optimizer_Adam
    # PyTorch: torch.optim.Adam(model.parameters(), lr=cfg.lr)

    # 5. 启动训练
    # Numpy: 
       trainer = Trainer(model, optimizer)
       trainer.train(X, y)
    # PyTorch:
       trainer = Trainer(model, optimizer, dataloader, ...)
       trainer.train()

if __name__ == "__main__":
    main()

总结:从手搓到框架的"心态迁移"

维度 Numpy 手搓项目 PyTorch 工业级项目 我们的进阶任务
数学推导 核心。必须手推矩阵求导。 隐形。Autograd 帮你做了。 忘掉如何算导数,专注于设计层与层的数据流向
数据处理 一次性加载进内存 (Full Batch)。 流式加载 (Mini-batch) 学习使用 Dataset 接口,解决内存不足问题。
计算硬件 只能用 CPU。 GPU 加速 学会 .to("cuda"),理解数据要在 CPU 和 GPU 间搬运。并理解torch、CUDA等基础硬件常识
调试重点 "我的梯度公式推对了吗?" "我的 Tensor 维度匹配吗?(Shape Mismatch)" 学会看报错中的 Base shape: [32, 64], Target: [32, 1]。就是学会debug,时时刻刻debug看shape搭积木
代码量 80%在写底层计算,20%写逻辑。 20%写模型定义,80%写数据处理和实验记录。 数据工程变得比模型实现更重要,我们的主要工作其实在数据处理上,model反倒是次要。
相关推荐
山土成旧客1 天前
【Python学习打卡-Day38】PyTorch数据处理的黄金搭档:Dataset与DataLoader
pytorch·python·学习
算法狗21 天前
面试题:大模型训练需要设置温度系数吗?
人工智能·深度学习·机器学习·面试题
csdn_aspnet1 天前
Anaconda 加速 AI 模型训练:优化机器学习工作流效率的利器
人工智能·深度学习·机器学习·anaconda
EW Frontier1 天前
【DOA估计】波束成形 + 深度学习赋能!可解释高效单快拍 DOA 估计新方案 deep-MPDR【附python代码】
深度学习·doa估计
Blossom.1181 天前
强化学习推荐系统实战:从DQN到PPO的演进与落地
人工智能·python·深度学习·算法·机器学习·chatgpt·自动化
肥猪猪爸1 天前
Langchain实现ReAct Agent多变量工具调用
人工智能·神经网络·机器学习·自然语言处理·langchain·大模型·transformer
汤姆yu1 天前
基于深度学习的车牌识别系统
人工智能·深度学习
虫小宝1 天前
电商AI导购系统设计:基于深度学习的商品推荐算法与架构实践
人工智能·深度学习·推荐算法
爱思德学术1 天前
中国计算机学会(CCF)推荐学术会议-B(交叉/综合/新兴):CogSci 2026
人工智能·神经网络·认知科学