神经网络的基本原理

深度学习基础

深度学习是指基于"深度"(⾄少具有两个隐藏层)神经⽹络的机器学习⽅法。在本模块将学习神经⽹络的基本原理,了解深度学习的计算环境,掌握深度学习框架PyTorch的编程技能,最后基于全连接结构的神经⽹络完成MNIST数字⼿写体识别任务。
深度学习发展史

神经网络的基本原理

神经⽹络是⼀种模仿⽣物神经系统的计算模型,它由⼤量互相连接的节点(⼜被称为"神经元")组成,它们之间存在复杂的连接关系,通过连接权重来传递信息。神经元节点在收到其它节点传递过来的信息之后,⾸先通过线性模型的⽅式集成信息,再通过节点上定义的激活函数有选择性地透出信息,继续传递给其它神经元。

神经⽹络模型在上世纪50年代就已经出现,但由于算⼒的限制,只能训练简单结构的⽹络,因⽽⼀直没有做出突破性的成果。直到2011年,在GPU算⼒的⽀持下,Geoffrey Hinton和邓⼒⾸次成功地将深度神经⽹络应⽤在语⾳识别上,将识别错误率相对降低了20〜30%,深度学习时代正式到来。

1.神经元

神经元是构成神经⽹络的基本单元。下图所示的是⼀个⽣物神经元的结构,由细胞体、树突和轴突等结构组成。其中,细胞体是神经元的代谢中⼼,负责维持神经元的⽣存和功能。树突是神经元的输⼊部分,接收来⾃其它神经元的信号。细胞体对接收到的信号进⾏整合,当整合之后的信号强度超过某个阈值,就会将其通过轴突输出,传递给其它的神经元。

上述⽣物神经元之间信息传递的机制,被早期的神经科学家抽象为线性感知机:

在上图所示的计算模型中:

  • x0 , x1 , x2为神经元所接收到的数据

  • w0 , w1 , w2 为对应连接上的权重

神经元在接收到数据之后,⾸先按照线性模型的⽅式进⾏集成,再通过阶跃函数进⾏输出。

上图所示线性感知机的计算公式为:

其中, z= w0x0 + w1x1 + w2x2

由于阶跃函数不连续,不利于优化,因此经常将阶跃函数替换为 Sigmoid函数,此时神经元的输出为:

可以看出,此时神经元的输出符合逻辑回归模型,因此,逻辑回归模型可以看作是最简单的神经⽹络(只有⼀个神经元构成)。它实质上是⼀个线性的分类模型,当 时,预测的类别为1;当时,预测的类别为0。两类数据之间的分类边界为,是⼀条直线或超平⾯。

sigmoid函数的性质:

2.逻辑回归

逻辑回归是⼀个⼆分类模型, 函数的输出是样本属于⽬标类(y=1)的概率,即:

如果收集了⼀个训练数据集,那么对于其中的每⼀个训练样本,通过模型预测其类别为的概率为:

其中

显然希望极⼤化这个概率。为了计算⽅便,取对数概率,得到:


再对取负数,就得到样本相关的损失函数:


训练集上总体的损失函数为:


模型参数 (即权重) 通过梯度下降进⾏优化。为了平衡训练速度和效果,⼀般采⽤⼩批量梯度下降。对于每个训练epoch,⾸先打乱训练集中的样本顺序,然后在乱序之后的数据列表中,依次取B个样本 (微批) 进⾏权重更新。如果在epoch开始的时候,模型权重为,那么在进⾏了第1次更新之后变为,之后是 以此类推。下图说明了⼩批量梯度下降的权重更新过程,注意:在每次更新权重时,⽤于计算梯度的数据是不同的


梯度下降算法依赖梯度 (偏导向量) 的计算。

基于链式法则,对于权重 的偏导函数为:

(1)

其中,第⼀个因式为损失函数对于逻辑回归输出的偏导
(2)

(2)式实际上与模型的函数表达式⽆关,它只与损失函数如何度量的差异有关。

第⼆个因式为逻辑回归模型中 Sigmoid 函数的输出对其输⼊的偏导

(3)

第三个因式为线性模型输出对权重的偏导:

(4)

(3)、(4)两式只与模型的函数表达式有关。

将(2)-(4)式带⼊(1)式,即可得到完整的偏导函数:

(5)

化简(5),可得:

(6)

公式需不需要记住?

  • 常⽤的公式应该记住,例如sigmoid 函数,softmax函数,回归问题和分类问题的损失函数等。
  • 梯度下降中的求导公式可以不⽤记忆,着重理解因变量、中间变量和⾃变量之间的依赖关系,以及通过链式法则偏导传递的过程。
  • 有些公式虽然可以不记忆,但掌握它们的性质很重要。例如sigmoid 函数的导函数,虽然不⼀定需要背下来具体的公式形式,但掌握"当⾃变量⾜够⼩或⾜够⼤时,sigmoid的导数趋近于0"这⼀性质⾮常有必要!
    梯度即为偏导向量:

⼩批量梯度下降中使⽤的梯度,为微批中所有样本对权重的平均梯度:

在计算出梯度之后,权重按照下列公式进⾏更新:

其中 t表示权重迭代次数。 η是学习率,是⼀个训练超参数。

由于⼩批量梯度下降的梯度估计也带有随机性,因此通常也被称为"随机梯度下降",即SGD (Stochastic Gradient Descent)。

⾯试点:神经⽹络为什么采⽤随机梯度下降(⽽不是批量梯度下降)进⾏优化?

对于绝⼤部分的神经⽹络模型,其损失函数没有全局极⼩值,但在参数空间(权重空间)中存在多个梯度为0的点,称为稳定点。稳定点不⼀定是⼀个可接受的优化结果,它可能是⼀个损失值较⼤的局部极⼩点、⼀个局部极⼤点或者是⼀个鞍点 (对于某个权重⽅向是局部极⼤点,⽽对于另外⼀个权重⽅向是局部极⼩点 )。

如果采⽤批量梯度下降进⾏权重的优化,那么如果优化过程中的权重刚好落⼊某个不理想的稳定点,由于此时梯度为0,权重的数值不会发⽣任何变化。⽽到了下⼀次权重更新时,由于梯度是基于全体训练样本估计的,偏导函数在每⼀轮参数更新中都是相同的,因此梯度仍然是0,权重仍然不会得到更新......这样,训练就被"卡"住了,权重⼀直得不到更新,训练loss⼀直不会下降。

再来看基于随机梯度下降的情况 (⼩批量梯度下降也可以被称为随机梯度下降,因为梯度计算同样具有随机性)。如果在优化过程中权重落⼊了稳定点,由于梯度为0,权重的数值不会发⽣任何变化,本次更新结束,程序进⼊下⼀次参数更新的流程。由于每次权重更新时基于的训练数据不同,下⼀次参数更新时的损失函数和前⼀次会有所差异,因此即使权重相同,梯度也可能会有所不同,因此参数会得到更新,可能就会跳出当前这个不理想的稳定点。

因此,除了参数更新的速度快,随机梯度下降还具有⼀定的逃逸不理想稳定点(包括局部极⼩点)的能⼒,这⼀点对于神经⽹络的训练⾄关重要。

接下来,从神经⽹络的⻆度,⽤代码实现逻辑回归。在这个代码实现中,可以着重关注:

  • 对神经⽹络"LogisticNetwork"类和数据集"Dataset"类的抽象。
  • 随机梯度下降的流程。
  • Numpy ndarray的运算。
python 复制代码
import numpy as np

class LogisticNetwork:
    '''
    将逻辑回归模型从神经网络的角度重新实现 
    对于神经网络,我们只关注模型的前向计算和反向计算过程
    前向计算:从输入的特征计算出模型的输出,即预测值
    反向计算:根据损失函数对于模型输出的导数,计算模型权重和偏置的梯度
    '''

    def __init__(self, input_dim, weight=None, bias=None):
        self.input_dim = input_dim
        self.weight = np.random.randn(input_dim, 1)    # 对象内部保存的模型权重,基于正态分布进行了初始化
        self.bias = np.random.randn(1, 1)              # 对象内部保存的模型权重偏置,基于正态分布进行了初始化

        if weight is not None:
            self.weight[:] = weight
        if bias is not None:
            self.bias[:] = bias

        # 其他更新参数所需要的变量
        self.weight_grad = np.zeros([input_dim, 1])  # 对象内部保存的权重梯度,是经过样本之间平均处理的,因此一个权重对应一个梯度值
        self.bias_grad = 0.0                    # 对象内部保存的偏置梯度,是经过样本之间平均处理的,因此一个偏置对应一个梯度值

        self.inputs_buff = None
        self.outputs_buff = None

    def forward(self, inputs):
        '''
        forward method: compute the output of the network
        inputs: 输入的特征,shape:(sample_num, input_dim)
        return: 经过逻辑回归计算出的预测值,shape: (sample_num, 1)
        '''

        
        z = np.matmul(inputs, self.weight) + self.bias
        outputs = 1 / (1 + np.exp(-z))
        
        self.inputs_buff = np.copy(inputs)
        self.outputs_buff = np.copy(outputs)

        return outputs
    
    def backward(self, out_grads):
        '''
        backward method: compute the gradient of the loss with respect to the weight and bias
        out_grads: 损失函数对于逻辑回归输出的导数,每条训练样本对应一个导数值,shape: (sample_num, 1)
        inputs: 与out_grads相对应的输入特征,shape: (sample_num, input_dim)
        preds: 将inputs通过forward()方法计算得到的预测值,shape: (sample_num, 1)
        return: None (计算出的梯度保存在对象内部)
        '''

        sample_num = self.inputs_buff.shape[0]

        # 首先计算损失函数对于线性模型输出的梯度z_grads
        # z_grads = out_grads * preds * (1 - preds)
        z_grads = out_grads * self.outputs_buff * (1 - self.outputs_buff)

        # 然后计算损失函数对于权重梯度
        # z_grads shape: (sample_num, 1)
        # inputs shape: (sample_num, input_dim)
        # 输出的weight_grads shape: (sample_num, input_dim)
        # 需要将z_grads进行广播,扩展到(sample_num, input_dim), 再与inputs进行对应元素相乘的运算
        weight_grads = z_grads * self.inputs_buff

        # 同样的方式计算损失函数对于偏置的梯度,可视为输入特征为1.0的情况
        bias_grads = z_grads

        # 对梯度进行平均
        self.weight_grad = weight_grads.mean(axis=0).reshape(-1, 1)   # 在样本之间取平均
        self.bias_grad = bias_grads.mean().reshape(-1, 1)           # 在样本之间取平均
    
    def update(self, lr, weight_decay):
        self.weight = (1 - lr * weight_decay) * self.weight - lr * self.weight_grad
        self.bias = (1 - lr * weight_decay) * self.bias - lr * self.bias_grad

        self.weight_grad[:] = 0.0
        self.bias_grad[:] = 0.0

class Dataset:
    '''
    数据集
    '''

    def __init__(self, sample_num, seed):
        '''
        sample_num: 生成随机样本的个数
        seed: 随机种子
        '''
        self.sample_num = sample_num

        # 生成特征
        np.random.seed(seed=seed)
        self.x = np.random.rand(sample_num, 2)  # 服从[0, 1]之间均匀分布的随机数
        
        # 生成标签:如果 x2 >= x1,则y=1, 否则y=0
        self.y = np.ones((sample_num, 1)) * (self.x[:, 1] >= self.x[:, 0]).reshape(-1, 1)

    def __len__(self):
        return self.sample_num
    
    def __getitem__(self, index):
        return self.x[index], self.y[index]
    
    def shuffle(self):
        # 打乱数据集
        index = np.arange(self.sample_num)
        np.random.shuffle(index)

        self.x = self.x[index]
        self.y = self.y[index]

    def plot(self):
        import matplotlib.pyplot as plt

        plt.scatter(self.x[self.y[:, 0] == 1][:, 0], self.x[self.y[:, 0] == 1][:, 1], marker='+', color='blue')
        plt.scatter(self.x[self.y[:, 0] == 0][:, 0], self.x[self.y[:, 0] == 0][:, 1], marker='o', color='green')
        plt.show()

def calc_loss(preds, labels):
    # 计算损失函数
    return -labels * np.log(preds+1e-10) - (1 - labels) * np.log(1-preds+1e-10)

def calc_loss_grad(preds, labels):
    return -labels / (preds+1e-10) + (1 - labels) / (1 - preds+1e-10)

def test_model(model, dataset):
    # 测试模型
    preds = model.forward(dataset.x)
    pred_labels = (preds >= 0.5).astype(np.int32)
    accuracy = (pred_labels == dataset.y)

    loss = calc_loss(preds, dataset.y)

    return loss.mean(), accuracy.mean()


if __name__ == '__main__':
    # 生成数据集
    train_dataset = Dataset(500, 0)
    valid_dataset = Dataset(100, 1)
    test_dataset = Dataset(100, 2)
    #train_dataset.plot()
    #test_dataset.plot()

    # 训练超参数设置
    config = {
        "lr": 0.5, 
        "epoch": 1000000,
        "weight_decay": 0.001, 
        "batch_size": 256,
        "max_no_improve_epoch": 100
    }

    # 创建模型对象
    model = LogisticNetwork(2)

    # 开始模型训练
    valid_loss = []
    best_valid_loss = 1e10
    best_model = {
        "weight": None,
        "bias": None
    }
    no_improve_epoch = 0

    for epoch in range(config['epoch']):
        # 打乱训练集中的样本顺序
        train_dataset.shuffle()

        iter = 0
        while (iter + config['batch_size']) < len(train_dataset):
            # 获取一个batch的数据
            batch_x = train_dataset.x[iter: iter + config['batch_size']]
            batch_y = train_dataset.y[iter: iter + config['batch_size']]

            # 前向计算
            preds = model.forward(batch_x)

            # 计算loss
            loss = calc_loss(preds, batch_y)

            # 计算损失函数对于模型输出的导数, shape: (batch_size, 1)
            out_grads = calc_loss_grad(preds, batch_y)

            # 反向计算
            model.backward(out_grads)

            # 更新权重和偏置
            model.update(config['lr'], config['weight_decay'])

            iter += config['batch_size']

        # 在验证集上测试模型
        valid_loss, valid_accuracy = test_model(model, valid_dataset)
        print(f"epoch: {epoch}, valid_loss: {valid_loss}, valid_accuracy: {valid_accuracy}, no_improve_epoch: {no_improve_epoch}")  

        if valid_loss < best_valid_loss:
            best_valid_loss = valid_loss
            best_model['weight'] = model.weight.copy()
            best_model['bias'] = model.bias.copy()
            no_improve_epoch = 0
        else:
            no_improve_epoch += 1

        if no_improve_epoch >= config['max_no_improve_epoch']:
            break
        
    # 测试模型
    print(f"best model:\n{best_model}")
    best_model = LogisticNetwork(2, best_model['weight'], best_model['bias'])
    
    test_loss, test_accuracy = test_model(best_model, test_dataset)
    print(f"test_loss: {test_loss}, test_accuracy: {test_accuracy}")

3.异或问题

异或 (XOR) 是⼀种⼆值逻辑运算,对于两个输⼊ x1 与 x2,异或运算的输出值如下表所示。

如果将异或运算的结果作为类别标签,样本的分布如图所⽰。显然,不存在任何⼀条直线能够将样本正确分类,因此简单神经⽹络是⽆法解决异或问题的,需要更多的神经元。

图示为⼀个由三个神经元构成的神经⽹络,紫⾊的神经元负责将原始的特征(x1,x2) 变换为(h1,h2),⻩⾊的神经元则以(h1,h2)作为特征,作出最后的预测。每个神经元同样包含了线性模型和sigmoid函数变换。

在通过紫⾊神经元之后,原始特征空间中的点(1,1)被映射到了原点附近,⽽其他三个点还在原有位置附近,因此很容易能够被线性分类器给区分开。紫⾊神经元构成了"隐藏层",它们的作⽤是提取更有效的特征。⻩⾊神经元构成了"输出层",作⽤是利⽤隐藏层提取的特征进⾏预测。表示原始特征输⼊的⿊⾊节点,在逻辑上也被看作神经⽹络的⼀层,叫作"输⼊层"。

4.多层感知机

我们在异或问题上证明了多层神经⽹络可以解决线性不可分的问题。这种相邻两层节点之间是全连接结构的神经⽹络叫做多层感知机 (Multi-Layer Perceptron),缩写为MLP。多层感知机由输⼊层、隐藏层和输出层组成,其中隐藏层的作⽤是提取⾮线性特征,输出层的作⽤是进⾏预测。

4.1.隐藏层

隐藏层的作⽤是提取特征,可以有多层,每层的神经元数量可以不相同。越靠近输出层,所提取特征的语义层级 (或抽象层级)越⾼。具有两个及两个以上隐藏层的神经⽹络被称为"深度神经⽹络"。

隐藏层神经元的激活函数有多种选择,除了Sigmoid 之外,还可以采⽤Tanh (双曲正切函数)、ReLu 和LeakyReLu等。但需要注意的是,神经⽹络依靠激活函数带来⾮线性输出,因此,隐藏层的激活函数⼀定是⾮线性的。

主要的激活函数:

4.2.输出层

输出层的作⽤是完成预测,因此输出层的结构取决于预测任务的类型。

对于回归问题,输出层的节点数量为1,并且没有⾮线性函数:

其中,为输出层的权重向量,为最后⼀个隐藏层的输出, b为偏置项。

对于**⼆分类问题**,输出层的节点数量为1,节点上的激活函数为Sigmoid,所预测的是样本属于正类的概率:

对于多分类问题,输出层节点的数量等于类别数,输出层的激活函数为Softmax,每个节点所预测的是样本属于相应类别的概率。具体公式在MNIST数字⼿写体实验的部分进⾏讲解。

⽹络结构的设计问题

隐藏层和输出层具有不同的作⽤,因此隐藏层和输出层在结构设计⽅⾯是解耦的。

  • 隐藏层的作⽤是提取特征,因此隐藏层的结构设计和数据特点有关。例如:结构化数据⼀般采⽤全连接的⽹络结构;图像数据⼀般先经过卷积神经⽹络提取局部特征,再通过全连接⽹络进⾏整体特征的抽象和变换;⽂本数据⼀般通过Transformer提取语义特征。
  • 输出层的作⽤是进⾏预测,因此输出层的结构只与预测问题的类型有关,⽽与数据特点⽆关。

5.正向传播和反向传播

神经⽹络有两个计算问题:

  • 正(前)向传播 (Forward Propagation):从特征输⼊开始,从下到上逐层计算神经⽹络的输出,直到计算出最终的预测结果。每⼀层的输出都是下⼀层的输⼊。
  • 反(后)向传播 (Backward Propagation):从损失函数对于神经⽹络预测结果的偏导开始,从上到下逐层计算损失函数对于每⼀层⽹络输出的偏导,⽤于计算每⼀层权重的梯度。

下边,结合⼀个例⼦,说明正向传播和反向传播的计算过程。

图⽰的神经⽹络包含两个隐藏层,隐藏层节点的激活函数为ReLU。输出层节点只包含线性模型,⽤于解决数值预测的问题,即回归问题。假设我们输⼊的样本x1=0.5,x2=1.0,y=0.8。

正向传播的过程如下:

1、以 x1 、 x2 为输⼊,计算 h1 、 h2 :


2、以 h1 、 h2 为输⼊,计算 h3 、 h4 :


3、以 h3 、 h4 为输⼊,计算


4、将y相⽐对,计算损失函数值:

正向传播的过程结束,之后开始反向传播:

1、计算对于 的导数:

2、已知损失函数对于的导数,计算损失函数对于 [w9 , w10] 的梯度,同时将梯度传播到 [h3 , h4] :


(1)计算损失函数对于 w9 、 w10 的偏导:


其中第⼀个因式在上⼀步已算出,因此只需要计算第⼆个因式即可:

将两个因式相乘:


同理,损失函数对于 w10的偏导:


(2) 计算损失函数对于 h3 、 h4 的偏导:


其中第⼀个因式已知,第⼆个因式:


将两个因式相乘:


同理可得:

3、已知损失函数对于[h3,h4] 的梯度,计算损失函数对于[w5 , w6w7 , w8] 的梯度,同时将梯度传播到 [*h1,*h2] 。

(1)计算损失函数对于 w5、 w6、 w7、 w8 的偏导:

(2)计算损失函数对于 h1、 h2 的偏导:

由于:

因此:

同理可得:


4、已知损失函数对于 [h1 , h2 ] 的梯度,计算损失函数对于 [w1 , w2 , w3 , w4 ] 的梯度:
已知 ,显然可得:


在求出损失函数对所有权重的梯度之后,就可以进⾏权重更新,设学习率 η= 0.1 :

可以验证⼀下,在进⾏了⼀次参数更新之后,预测值是否更加接近真实值。重新进⾏⼀次正向传播
1、以 x 1 、 x 2 为输⼊,计算 h 1 、 h2 :

2、以 h 1 、 h 2 为输⼊,计算 h 3 、 h4 :

3、以 h3 、 h4 为输⼊,计算

4、将 y相⽐对,计算损失函数值:

可⻅权重更新之后,预测值的确更加接近真实值了。

6.全连接层的向量化计算

现在来梳理⼀下全连接⽹络的计算。

对于图示的全连接⽹络,输⼊节点数为3,输出节点数为5,设神经元上的激活函数为Sigmoid函数,即。这样每个输出节点实际上分别对应一个逻辑回归模型,每个逻辑回归的权重(w)和偏置(b)是独立的。

其中 分别为每个逻辑回归模型的参数,为输入向量:

如果将(1)~(5)式合并为一个矩阵运算:

其中W是一个3x5的矩阵,每一列对应于一个输出节点的权重:

分别是一个向量:

上述计算也可以拆分为两个步骤:

均为长度为5的向量。

6.1.正向传播

在正向传播的过程中,设输⼊数据包含B个样本,分别为 ,同样排列为⼀个矩阵,其中的每⾏表⽰⼀个样本:

H的尺寸为(B, 3),每行表示一个样本。线性模型的输出为:

即:(需要利用广播机制)

其中

之后再经过激活函数(按元素计算),即可得到输出:

6.2.反向传播

在反向传播过程中,我们已知损失函数对于 H′ 的梯度矩阵:

1、⾸先来求损失函数对于线性模型输出Z的梯度,这一步需要乘以Sigmoid函数的导数:

只需要在矩阵的每个元素乘以对应的

其中 o 表示对应元素相乘的运算。也是一个尺寸为(B, 5)的矩阵,其中每一行对应于一个样本。

2、接下来求权重矩阵的梯度,先看通过第 k个样本的损失函数计算的梯度 是一个3x5的矩阵,与W的元素一一对应。

其中,是第k个样本的输入向量,尺寸为(3,1);的第k行,尺寸为(1,5)。

微批中所有样本对 W的总梯度为:

3、最后求输入H 的梯度:

7.神经网络的代码实现(基于numpy)

  • 构建通用的全连接层类
  • 基于全连接层类构建MLP类
  • 解决非线性回归问题

model.py

python 复制代码
import numpy as np

class NeuralNetworkBase():
    '''
    神经网络的基类
    '''
    def __init__(self):
        self.params = {}
        pass
    
    def forward(self, inputs):
        pass
    
    def backward(self, out_grads):
        pass
    
    def update(self):
        pass
    
    def print_params(self):
        for key, value in self.params.items():
            print(key, value)
    
    @staticmethod
    def sigmoid(inputs):
        return 1 / (1 + np.exp(-inputs))
    
    @staticmethod
    def sigmoid_grad(outputs):
        return outputs * (1 - outputs)
    
    @staticmethod
    def linear(inputs):
        return inputs
    
    @staticmethod
    def linear_grad(outputs):
        return np.ones_like(outputs).astype(np.float32)
    
    @staticmethod
    def relu(inputs):
        return (inputs > 0).astype(np.float32) * inputs
    
    @staticmethod
    def relu_grad(outputs):
        return (outputs > 0).astype(np.float32)
    
class FCLayer(NeuralNetworkBase):
    '''
    全连接层网络 (一层)
    '''
    
    def __init__(self, input_dim, output_dim, activation="linear", weight=None, bias=None):
        super().__init__()
        
        # 输入和输出节点数
        self.input_dim = input_dim 
        self.output_dim = output_dim 
        
        # 配置激活函数
        self.activation_fn = self.linear
        self.activation_grad_fn = self.linear_grad
        
        if activation == "sigmoid":
            self.activation_fn = self.sigmoid
            self.activation_grad_fn = self.sigmoid_grad
            
        if activation == "relu":
            self.activation_fn = self.relu
            self.activation_grad_fn = self.relu_grad
            
        # 初始化参数
        self.weight = np.random.randn(input_dim, output_dim)    # (input_dim, output_dim)
        self.bias = np.random.randn(1, output_dim)    # (1, output_dim)
        
        self.params['weight'] = self.weight
        self.params['bias'] = self.bias
        
        if weight is not None:
            self.weight[:] = weight
        if bias is not None:
            self.bias[:] = bias
   
        # data buffer
        self.inputs_buff = None
        self.outputs_buff = None
        
        # gradients buff
        self.weight_grad = np.zeros(self.weight.shape)
        self.bias_grad = np.zeros(self.bias.shape)
        
    def forward(self, inputs):
        # 计算z
        z = np.matmul(inputs, self.weight) + self.bias
        
        # 计算经过激活函数的outputs
        outputs = self.activation_fn(z)
        
        # buffering data
        self.inputs_buff = np.copy(inputs)
        self.outputs_buff = np.copy(outputs)
        
        return outputs
    
    def backward(self, out_grads):
        # 计算z的梯度
        zgrads = out_grads * self.activation_grad_fn(self.outputs_buff)
        
        # 计算权重和偏置的梯度
        self.weight_grad[:] = np.matmul(self.inputs_buff.T, zgrads) / zgrads.shape[0]
        self.bias_grad[:] = np.matmul(np.ones((1, zgrads.shape[0])).astype(np.float32), zgrads) / zgrads.shape[0]
        
        # 返回input_grads
        return np.matmul(zgrads, self.weight.T)
    
    def update(self, lr, weight_decay=0.0):
        self.weight[:] = self.weight * (1- lr * weight_decay) - lr * self.weight_grad
        self.bias[:] = self.bias * (1- lr * weight_decay) - lr * self.bias_grad
        
        # 清空中间变量的缓存
        self.inputs_buff = None
        self.outputs_buff = None
        self.weight_grad[:] = 0
        self.bias_grad[:] = 0
        
class MLP(NeuralNetworkBase):
    '''
    多层感知机
    '''
    def __init__(self, dims, hidden_activation="linear", output_activation="linear", params=None):
        super().__init__()
        
        # 创建隐藏层
        self.hidden_layers = []
        self.hidden_layer_num = len(dims)-2
        
        for i in range(self.hidden_layer_num):
            layer_name = "hidden_layer_" + str(i)
            weight = None
            bias = None
            
            if params is not None:
                weight_name = layer_name + "_weight"
                bias_name = layer_name + "_bias"
                weight = params.get(weight_name, None)
                bias = params.get(bias_name, None)
            self.hidden_layers.append(FCLayer(dims[i], dims[i+1], hidden_activation, weight, bias))
            
            # 把当前隐藏层的参数放到self.params中
            self.params[layer_name + "_weight"] = self.hidden_layers[-1].weight
            self.params[layer_name + "_bias"] = self.hidden_layers[-1].bias
            
        # 创建输出层
        layer_name = "output_layer"
        weight = None
        bias = None
        if params is not None:
            weight_name = layer_name + "_weight"
            bias_name = layer_name + "_bias"
            weight = params.get(weight_name, None)
            bias = params.get(bias_name, None)
        self.output_layer = FCLayer(dims[-2], dims[-1], output_activation, weight, bias)
        self.params[layer_name + "_weight"] = self.output_layer.weight
        self.params[layer_name + "_bias"] = self.output_layer.bias

    def forward(self, inputs):
        for layer in self.hidden_layers:
            inputs = layer.forward(inputs)
        return self.output_layer.forward(inputs)
    
    def backward(self, out_grads):
        out_grads = self.output_layer.backward(out_grads)
        for layer in reversed(self.hidden_layers):
            out_grads = layer.backward(out_grads)
            
    def update(self, lr, weight_decay=0.0):
        for layer in self.hidden_layers:
            layer.update(lr, weight_decay)
        self.output_layer.update(lr, weight_decay)

mlp.py

python 复制代码
import numpy as np
from model import *
import copy

class Dataset:
    def __init__(self, sample_num, seed):
        self.sample_num = sample_num
        
        # 生成x
        np.random.seed(seed)
        
        # 基于正态分布生成x
        self.x = np.random.randn(sample_num, 2)
        
        # 基于x计算y
        self.y = (self.x[:, 0] ** 2 + 2 * self.x[:, 0] * self.x[:, 1] + self.x[:, 0] - self.x[:, 1] +0.5).reshape(-1, 1)
    
    def __len__(self):
        return self.sample_num
        
    def __getitem__(self, index):
        return self.x[index], self.y[index]
    
    def shuffle(self):
        # 打乱数据集
        index = np.arange(self.sample_num)
        np.random.shuffle(index)
        
        self.x = self.x[index]
        self.y = self.y[index]
        
def calc_loss(preds, labels):
    return (preds-labels) ** 2

def calc_loss_grad(preds, labels):
    return 2 * (preds - labels)

# main
if __name__ == '__main__':
    train_dataset = Dataset(sample_num=1000, seed=0)
    valid_dataset = Dataset(sample_num=1000, seed=1)
    test_dataset = Dataset(sample_num=1000, seed=2)
    
    # config
    config = {
        "mlp_structure": [2, 20, 10, 1], 
        "hidden_activation": "relu",
        "lr": 0.001,
        "epochs": 100000,
        "weight_decay": 0.0, 
        "batch_size": 64,
        "max_no_prove_epochs": 1000
    }
    
    # 创建模型对象
    model = MLP(config["mlp_structure"], config["hidden_activation"])
    
    best_valid_loss = 1e10
    no_improve_epochs = 0
    best_model = None
    
    for epoch in range(config['epochs']):
        # shuffle traing dataset
        train_dataset.shuffle()
        
        iter = 0
        while (iter + config['batch_size'] < len(train_dataset)):
            # 获取一个微批的数据
            batch_x, batch_y = train_dataset[iter: iter+config['batch_size']]
            
            # 正向传播
            preds = model.forward(batch_x)
            
            # 计算loss
            loss = calc_loss(preds, batch_y)
            
            # 反向传播
            out_grads = calc_loss_grad(preds, batch_y)
            model.backward(out_grads)
        
            # 更新参数
            model.update(config['lr'], config['weight_decay'])
            
            iter += config['batch_size']
            
        # 验证集上验证
        valid_preds = model.forward(valid_dataset.x)
        valid_mean_loss = calc_loss(valid_preds, valid_dataset.y).mean()
        
        # 比较当前的best_valid_loss
        if valid_mean_loss < best_valid_loss:
            best_valid_loss = valid_mean_loss
            no_improve_epochs = 0
            #best_model = model.params.copy()
            #best_model = {}
            #for key in model.params:
            #    best_model[key] = model.params[key].copy()
            best_model = copy.deepcopy(model.params)
        else:
            no_improve_epochs += 1
            
        score = 1 - valid_mean_loss / valid_dataset.y.var()
        print(f'epoch: {epoch}, valid_loss: {valid_mean_loss:.5f}, valid_loss_score: {score:.5f}, no_improve_epochs: {no_improve_epochs}')
        
        if no_improve_epochs > config['max_no_prove_epochs']:
            break
    
    best_valid_score = 1 - best_valid_loss / valid_dataset.y.var()
    print(f'------- Now training has been finished. -------')
    print(f'best_valid_loss: {best_valid_loss}, best_valid_score: {best_valid_score:.5f}')
    
    # 测试集上测试
    best_model = MLP(config['mlp_structure'], config['hidden_activation'], params=best_model)
    
    test_mean_loss = calc_loss(best_model.forward(test_dataset.x), test_dataset.y).mean()
    print(f'test_loss: {test_mean_loss}, test_loss_score: {1 - test_mean_loss / test_dataset.y.var()}')
    

⾯试点:早停机制 (Early Stopping)

深度神经⽹络的参数量⼤,因此很容易发⽣过拟合。早停机制是防⽌神经⽹络训练发⽣过拟合的重要⼿段。

在⼀般情况下,训练过程中会观察训练Loss (即在训练数据上估计的损失函数值) 的变化情况,直到训练Loss下降到最低点时,才会终⽌训练。⽽在神经⽹络的训练中,如果这样操作,就很容易导致过拟合。

为了防⽌过拟合,⼀般会从神经⽹络的训练集中随机采样⼀部分数据 (占训练集总量的10~20%) 作为验证集,余下的数据才作为真正的训练集。在训练过程中,需要观察验证集上的Loss,只要验证集上的Loss停⽌下降了,就要终⽌训练。由于此时训练Loss往往还没有降到最低,因此称为"早停"。
⾯试点:梯度消失和梯度爆炸

相关推荐
HIT_Weston1 小时前
11、【AI】【Agent】联网使用大模型(DashScope&OpenAI)
人工智能
Zhansiqi1 小时前
day33
人工智能·深度学习·机器学习
GlobalInfo1 小时前
汽车域控制模块市场增长率(CAGR)为10.4%:发展方向的启示
大数据·人工智能·汽车
远离UE42 小时前
GPU学习笔记
人工智能
CNNACN电商经济2 小时前
脑洞科技2025年报透露的“超维计算“或将引爆下一轮增长
人工智能
yuhaiqiang2 小时前
最强的 AI也许不是无所不知,但一定是最懂你的
人工智能
爱写代码的小朋友5 小时前
人工智能驱动下个性化学习路径的构建与实践研究——以K12数学学科为例
人工智能·学习
宝贝儿好7 小时前
【强化学习实战】第十一章:Gymnasium库的介绍和使用(1)、出租车游戏代码详解(Sarsa & Q learning)
人工智能·python·深度学习·算法·游戏·机器学习
绝世这天下9 小时前
【在 DGX Spark 上运行 vLLM-Omni 用于 Qwen3-TTS(语音设计,语音克隆)】
人工智能