不依靠第三方库(除了numpy)实现一个神经网络

news/2024/6/30 23:18:56 标签: MLP, Softmax回归, BP算法, 梯度下降

现在各种机器学习、深度学习第三方库都有非常成熟高效的神经网络实现,借助这些第三方库,短短几行代码就能实现一个神经网络。但是对于一个机器学习/深度学习的入门者来说,这些代码封装得太过彻底,往往一行代码就能实现BP算法或者梯度下降算法,这导致很多初学者即使掌握了繁复的数学推导后,依旧对神经网络的工作流程没有一个直观的认知。在我看来,自己动手实现一个神经网络,包括BP算法梯度下降算法等,是将理论应用于实践的最佳尝试。

实验整体介绍

本次实验将在MNIST数据集上进行,MNIST是一个0-9手写数字数据集,每个样本是28*28的灰度图。官方数据集包含训练样本60000条,测试样本10000条,下面是一些手写数字样例:

本实验没有选择官网上的数据集,而是选择deeplearning.net上公布的数据集,其将数据集分成了训练集、验证集和测试集,其大小分别是50000,10000,10000,同时将样本的每个像素值归一化到[0,1]之间了。因此可以确定模型的输入是784维;输出是10维,对应每个数字的概率。显而易见,这是一个多分类问题,可以采用Softmax回归对输入特征进行分类。

Softmax回归

计算模型输出

首先从最简单的Softmax回归开始,我们不对输入向量进行任何特征提取,直接利用Softmax回归对其进行分类,如下图所示:

因此,可以写出Softmax回归的数学表达式:
y = s o f t m a x ( x w ) y = softmax(xw) y=softmax(xw)

其中, w w w是模型的权重,大小为784*10, x x x是输入,大小为m*784, y y y是输出,大小为m*10。

将Softmax函数展开,如下图所示:

其中,虚线框内就是Softmax函数的处理过程,可以看到,Softmax函数形式如下:
y i = e z i ∑ k e z k y_i=\frac{e^{z_i}}{\sum_ke^{z_k}} yi=kezkezi

因此,Softmax回归代码表示如下:

def SoftmaxRegression(x, w):
    z = np.dot(x, w)
    e = np.exp(z)
    y = (np.transpose(e) / np.sum(e, axis=1)).T

计算模型损失

损失函数选择负对数似然函数,也即最大似然估计,因此损失函数的数学表达式是:
C = − log ⁡ y r C=-\log y_r C=logyr

y r y_r yr是样本标签 r r r对应的预测概率值,以手写数字识别为例,假设某样本对应的真实标签是3,而神经网络预测样本是3的概率为 y r y_r yr,显然 y r y_r yr越接近于1,网络预测得越准,反之 y r y_r yr越接近于0,网络预测越不准,给予的惩罚也应该越大,负对数似然函数恰好满足该性质。

因此,负对数似然代价代码表示如下:

def negative_log_likehood(p_y_given_x, y):
    return -np.mean(np.log(p_y_given_x)[np.arange(y.shape[0]), y])

"p_y_given_x"是模型的输出,表示样本属于每个标签的概率,"y"是样本真实标签。

接着是最小化损失,得到最优模型参数。计算损失函数的最小值采用的是梯度下降算法,其需要计算损失函数在 w w w处的梯度值。

计算 ∇ C ( w ) \nabla C(w) C(w)

∂ C ∂ w = ∂ C ∂ z ∂ z ∂ w \frac{\partial C}{\partial w} = \frac{\partial C}{\partial z}\frac{\partial z}{\partial w} wC=zCwz

其中, ∂ z ∂ w = x \frac{\partial z}{\partial w}=x wz=x,所以要求的只有 ∂ C ∂ z \frac{\partial C}{\partial z} zC

根据
C = − log ⁡ y r y r = e z r ∑ k e z k C = -\log y_r\\ y_r=\frac{e^{z_r}}{\sum_ke^{z_k}} C=logyryr=kezkezr

可以发现每一个 z i z_i zi的变化都会导致 y r y_r yr的变化,最终导致损失的变化。从公式中我们也注意到,当 i = r \bold{i=r} i=r时,影响的传播如下:

Δ z r ⟶ y r ⟶ C ↘ ∑ ↗ \begin{aligned} \Delta z_r&\quad\longrightarrow\quad y_r \longrightarrow C\\ &\searrow \textstyle\sum\nearrow \end{aligned} ΔzryrC

所以
(1) ∂ C ∂ z r = ∂ C ∂ y r ∂ y r ∂ z r = − 1 y r ( e z r ∑ k e z k − e z r e z r ( ∑ k e z k ) 2 ) = − 1 y r ( y r − y r 2 ) = y r − 1 \frac{\partial C}{\partial z_r}=\frac{\partial C}{\partial y_r}\frac{\partial y_r}{\partial z_r}=-\frac{1}{y_r}(\frac{e^{z_r}}{\sum_ke^{z_k}}-e^{z_r}\frac{e^{z_r}}{(\sum_ke^{z_k})^2})=-\frac{1}{y_r}(y_r-y_r^2)=y_r-1 \tag 1 zrC=yrCzryr=yr1(kezkezrezr(kezk)2ezr)=yr1(yryr2)=yr1(1)

同理,当 i = /   r \bold{i {=}\mathllap{/\,} r} i=/r时,影响传播图如下:
Δ z i ⟶ ∑ ⟶ y r ⟶ C \Delta z_i \longrightarrow \textstyle\sum \longrightarrow y_r \longrightarrow C ΔziyrC

所以
(2) ∂ C ∂ z i = ∂ C ∂ y r ∂ y r ∂ z i = − 1 y r ( − e z r e z i ( ∑ k e z k ) 2 ) = − 1 y r ( − y r y r ) = y i − 0 \frac{\partial C}{\partial z_i}=\frac{\partial C}{\partial y_r}\frac{\partial y_r}{\partial z_i}=-\frac{1}{y_r}(-e^{z_r}\frac{e^{z_i}}{(\sum_ke^{z_k})^2})=-\frac{1}{y_r}(-y_ry_r)=y_i-0 \tag 2 ziC=yrCziyr=yr1(ezr(kezk)2ezi)=yr1(yryr)=yi0(2)

将(1)和(2)统一起来,写成向量形式有
∂ C ∂ z = y − y ^ \frac{\partial C}{\partial z}=y - \hat{y} zC=yy^

其中, y ^ \hat{y} y^是样本真实标签对应的one-hot向量表示,可以由下面代码转换得到:

def getTruthmatrix(y):
    matrix = np.zeros((len(y), 10))
    for i in np.arange(len(y)):
        matrix[i, y[i]] = 1
    return matrix

最终, ∇ C ( w ) \nabla C(w) C(w)的表达式如下:
∂ C ∂ w = ∂ C ∂ z ∂ z ∂ w = x T ( y − y ^ ) \frac{\partial C}{\partial w} = \frac{\partial C}{\partial z}\frac{\partial z}{\partial w}=x^T(y-\hat{y}) wC=zCwz=xT(yy^)

表示成代码如下:

def grad(x, y, y_truth_matrix):
    grad_w = np.dot(np.transpose(x), y - y_truth_matrix) / y_truth_matrix.shape[0]
    return grad_w 

更新梯度

有了在每个 w w w处的梯度值,就可以调用梯度下降算法迭代更新 w w w了,根据梯度下降算法,
w n + 1 = w n − α ∇ C ( w n ) w^{n+1} = w^n-\alpha \nabla C(w^n) wn+1=wnαC(wn)

梯度下降的代码如下:

def gradient_descent(x, y, w, alpha):
    y_truth_matrix = getTruthmatrix(y)
    
    # 正向传播得到模型预测值
   probabilities  = SoftmaxRegression(x, w)
  
    # 损失
    cost = negative_log_likehood(probabilities, y)  # 权重w更新前的误差
    
    # 计算梯度 
    grad_w = grad(x, y, y_truth_matrix)

    # 更新参数
    new_w = w - alpha * grad_w
    
    return (new_w, cost)

以上就是Softmax回归的主要流程,重复上述梯度更新步骤,直至前后两次更新的损失不再下降(小于指定阈值)或者达到迭代次数。关于梯度下降的更多原理可以参考这篇博客。

神经网络

我们都知道,神经网络的隐含层可以看作是特征提取的过程,然后根据指定任务接一个输出层,所以针对我们的手写数字识别问题,输出层将是Softmax层,形式如下图所示:

首先,对神经网络中的用到的符号进行指定:
z l : z^l: zl l l l层神经元的输入;
a l : a^l: al l l l层神经元的输出;
w i j l : w_{ij}^l: wijl连接 l − 1 l-1 l1层第 i i i个神经元与 l l l层第 j j j个神经元的权重;
L L L:输出层

前向传播

输入 x x x经过每一层连接权重和神经元激活函数,逐层传播,其代码形式如下:

def forward_porpagation(x, W):
    activations = [x]    # 用于存储每一层的输出
    zs = [x]    # 用于存储每一层的输入
    a = x
    # 隐藏层
    for i in range(len(W) - 1):
        z = np.dot(a, W[i])
        zs.append(z)
        a = sigmoid(z)
        activations.append(a)
    # 输出层
    z = np.dot(a, W[-1])
    e = np.exp(z)
    zs.append(z)
    p = (np.transpose(e) / np.sum(e, axis=1)).T
    activations.append(p)
    
    return (activations, zs)

注意,此时 W W W是一个列表,每个元素表示每一层连接权重,"zs"和"activations"分别用来保存每一层的输入和输出。

损失函数同样是负对数似然函数,同样需要计算 ∂ C ∂ w \frac{\partial C}{\partial w} wC,但神经网络包含参数数目巨大,求导过程复杂,分开求每一层 w l w^l wl将非常耗时,同时,我们发现在求解前面层权重的导数的过程中会包含求解其后面层权重的导数,此时就需要采用BP算法

BP算法推导

对于神经网络中任意一个权重 w i j l w_{ij}^l wijl,其对于损失的影响传播过程如下:
Δ w i j l ⟶ z i l ⟶ ⋯ ⟶ C \Delta w_{ij}^l\longrightarrow z_i^l\longrightarrow \cdots\longrightarrow C ΔwijlzilC

所以
∂ C ∂ w i j l = ∂ C ∂ z j l ∂ z j l ∂ w i j l \frac{\partial C}{\partial w_{ij}^l}=\frac{\partial C}{\partial z_j^l}\frac{\partial z_j^l}{\partial w_{ij}^l} wijlC=zjlCwijlzjl

其中 ∂ z j l ∂ w i j l = a i l − 1 \frac{\partial z_j^l}{\partial w_{ij}^l}=a_i^{l-1} wijlzjl=ail1,剩下关键要求的就是 ∂ C ∂ z j l \frac{\partial C}{\partial z_j^l} zjlC,令其等于 δ j l \delta_{j}^l δjl,我们知道 z j l z_j^l zjl是第 l l l层第 j j j个神经元的输入,经过一个激活函数得到该神经元的输出 a j l a_j^l ajl,通常除去输出层会根据任务的不同而选择不同的激活函数外(如二分类问题选择logistics函数,多分类问题选择softmax函数),其他层神经元的激活函数往往相同。因此,我们在计算 ∂ C ∂ z j l \frac{\partial C}{\partial z_j^l} zjlC时需要分为输出层与非输出层。
1、当 l l l是输出层时,为了区别开来,我们将 l l l写成 L L L,其影响传播如下:
Δ z j L ⟶ a j L = y j L ⟶ C \Delta z_j^L\longrightarrow a_j^L=y_j^L\longrightarrow C ΔzjLajL=yjLC

所以
∂ C ∂ z j L = ∂ C ∂ y j L ∂ y j L ∂ z j L = ∇ C ( y L ) ∗ σ ′ ( z j L ) \frac{\partial C}{\partial z_j^L}=\frac{\partial C}{\partial y_j^L}\frac{\partial y_j^L}{\partial z_j^L}=\nabla C(y^L)*\sigma'(z_j^L) zjLC=yjLCzjLyjL=C(yL)σ(zjL)

2、当 l l l非输出层时,其影响传播如下:

所以
∂ C ∂ z j l = ∑ k ∂ C ∂ z k l + 1 ∂ z k l + 1 ∂ a j l ∂ a j l ∂ z j l = ∑ k δ k l + 1 w j k l + 1 σ ′ ( z j l ) \frac{\partial C}{\partial z_j^l}=\sum_k\frac{\partial C}{\partial z_k^{l+1}}\frac{\partial z_k^{l+1}}{\partial a_j^l}\frac{\partial a_j^l}{\partial z_j^l}=\sum_k \delta_k^{l+1}w_{jk}^{l+1}\sigma'(z_j^l) zjlC=kzkl+1Cajlzkl+1zjlajl=kδkl+1wjkl+1σ(zjl)

关于BP算法的更多推导可以参考我之前的博客《计算图(Computational Graph)的角度理解反向传播算法(Backpropagation)》和《反向传播(BP)算法》,里面有更加详细的推导。

针对我们手写数字识别问题,将其写成矩阵形式如下:
δ L = y − y ^ δ l = δ l + 1 ( w l + 1 ) T ∗ σ ′ ( z l ) \begin{aligned} \delta^L&=y-\hat{y}\\ \delta^l&=\delta^{l+1}(w^{l+1})^T*\sigma'(z^l) \end{aligned} δLδl=yy^=δl+1(wl+1)Tσ(zl)

反向传播算法的代码形式如下:

def backprop(activations, zs, w, y):
    grad_w = [np.zeros(weight.shape) for weight in w]
    # 反向传播
    delta = activations[-1] - y    # 最后一层, m*10
    grad_w[-1] = np.dot(np.transpose(activations[-2]), delta)
    for l in range(2, len(w) + 1):
        delta = np.dot(delta, w[-l+1].transpose()) * sigmoid_derivative(zs[-l])     # 其它层
        grad_w[-l] = np.dot(np.transpose(activations[-l-1]), delta) / y.shape[0]
        
    return grad_w

然后采用梯度下降算法对权重进行更新。

模型构建与优选

通常,我们在训练集上训练模型,更新模型参数,在验证集上对模型性能进行验证,当模型在验证集上的性能达到要求时,得到的模型就是最优模型,然后在测试集上测试模型。

def build_model(datasets, w_initial, alpha, n_epochs, epsilon, batch_size):
    W = w_initial
    
    # 训练集,验证集,测试集
    train_set_x, train_set_y = datasets[0]      
    valid_set_x, valid_set_y = datasets[1]
    test_set_x, test_set_y = datasets[2]
    
    # 将数据集分成较小的batch
    n_train_batches = train_set_x.shape[0] // batch_size     # 训练集划分成batch数目
    
    # 模型训练
    best_valid_cost = 0
    validation_frequency = 100   # 梯度下降迭代validation_frequency次,在验证集上验证一次模型性能
    epoch = 0
    done_looping = False
    while (epoch < n_epochs) and (not done_looping):
        epoch += 1
        for batch_index in range(n_train_batches):
            x = train_set_x[batch_index * batch_size: (batch_index + 1) * batch_size]  # 每次训练的x
            y = train_set_y[batch_index * batch_size: (batch_index + 1) * batch_size]  # 对应的y
            # 训练模型
            W, cost_train = gradient_descent(x, y, W, alpha, L1_reg, L2_reg)
            # 验证模型
            cost_valid, error_num_valid = valid_model(valid_set_x, valid_set_y, W)
            this_validation_loss = error_num_valid / valid_set_x.shape[0]       # 错误率
            
            num_iter = (epoch - 1) * n_train_batches + batch_index
            if num_iter % validation_frequency == 0:
                # 跟踪验证集上性能变化
                print("梯度下降迭代%d次后,验证集上误差为:%f,准确率为:%f%%" % (num_iter, cost_valid, (1 - this_validation_loss) * 100))
            
            # 判断是否early stopping
            if abs(cost_valid - best_valid_cost) < epsilon:
                done_looping = True
                print("验证集上误差不再下降,模型训练结束")
                break
            else:
                best_valid_cost = cost_valid
              
    if not done_looping:
        print("达到最大epoch次数,模型训练结束")
    
    # 模型测试
    test_precision = test_model(test_set_x, test_set_y, W)
    print("测试集上模型准确率为:%f%%" % (test_precision * 100))

总结

以上实现了一个最简单的Softmax回归和神经网络,上面只贴出了主要步骤的代码实现,完整的代码可以在下面这个链接下载,代码在linux环境下实现,如有问题,欢迎多多交流!

参考文献

DeepLearningTutorials
Softmax-Regression
神经网络中 BP 算法的原理与 Python 实现源码解析
Python Image.fromarray() doesn’t accept my ndarray input which is built from a list


http://www.niftyadmin.cn/n/1357281.html

相关文章

主题模型(4)——LDA模型及其Gibbs Sample求解

之前关于主题模型整理了《文本建模之Unigram Model&#xff0c;PLSA与LDA》与《再看LDA主题模型》两篇博客&#xff0c;以及针对PLSA的求解整理了博客《主题模型&#xff08;3&#xff09;——PLSA模型及其EM算法求解》&#xff0c;这一篇博客将继续整理LDA&#xff08;Latent …

层次主题模型——Hierarchical LDA

在LDA主题模型提出后&#xff0c;其在很多领域都取得了很成功的应用&#xff0c;如生物信息、信息检索和计算机视觉等。但是诸如LDA之类的主题模型&#xff0c;将文档主题视为一组“flat”概率分布&#xff0c;一个主题与另一个主题之间没有直接关系&#xff0c;因此它们能够用…

Linux查看监听、启动监听、启动数据库实例

lsnrctl status --查看监听状态(在数据库外执行) lsnrctl start --启动监听(在数据库外执行) 成功启动。 startup --启动数据库实例&#xff08;进入数据库里面执行&#xff09; 成功启动。 LINUX 登录Oracle数据库命令&#xff1a;sqlplus / as sysdba

Oracle存储过程恢复emp表

CREATE OR REPLACE PROCEDURE SP_EMP_REBUILED IS /* 程序说明 程序名称&#xff1a;S_EMP_REBUILED 创建人 &#xff1a;zyl 创建时间&#xff1a;2021-12-22 功能介绍&#xff1a;用于恢复EMP表 修改记录&#xff1a; 修改时间 修改人 …

Oracle存储函数--计算平年闰年

CREATE OR REPLACE FUNCTION F_YEAR( I_YEAR NUMBER ) RETURN VARCHAR2 IS BEGIN IF MOD(I_YEAR,2)0 AND MOD(I_YEAR,100)<>0 OR MOD(I_YEAR,400)0 THEN RETURN 闰年; ELSE RETURN 平年; END IF; END F_YEAR;

Oracle简单查询练习题

1.查询EMP表中的员工姓名、岗位、薪资&#xff0c;查询时列名前附带表原名 SELECT EMP.ENAME,EMP.JOB,EMP.SAL FROM EMP; 2.查询EMP表中的员工姓名、岗位、薪资&#xff0c;并在结果中以中文展示字段名 SELECT ENAME 员工姓名,JOB 岗位,SAL 薪资 FROM EMP; 3.查询DEPT表中…

条件查询练习题

--基础题 1.查询20号部门的员工姓名、岗位、薪资 SELECT ENAME,JOB,SAL/*,DEPTNO*/ FROM EMP WHERE DEPTNO 20; 2.查询工资超过3000的员工的姓名、薪资 SELECT ENAME,SAL FROM EMP WHERE SAL >3000; 3.查询10号部门以外的员工的所有信息 SELECT * FROM EMP WHERE DE…