使用浅层神经网络识别图片中的英文字母

一、实验介绍

1.1 实验内容

本次实验我们正式开始我们的项目:使用神经网络识别图片中的英文字母。

激动人心的时刻到了,我们将运用神经网络的魔力,解决一个无法使用手工编程解决的问题。如果你(自认为)是一个程序员,本次实验结束后,你将变得与其他只会手工编写程序的程序员不同。

1.2 实验知识点

  • “浅层”与“深度”的区别
  • 泛化性能
  • 随机梯度下降算法
  • 如何对矩阵求导
  • 编写我们的损失层

1.3 实验环境

  • python 2.7
  • numpy 1.12.1
  • scipy 0.19.0

二、实验步骤

2.1 是“浅层”好还是“深度”好?

2.1.1 神经网络的潜能

这里先插入一个问题,我们一开始直接把神经网络的模型结构告诉了大家,但有一个问题似乎被忽视掉了:神经网络是万能的吗?或者说,对于神经网络来说,会不会存在其无法表示的问题?这个问题不是很好回答,但可以告诉大家的一点是,数学上可以证明,满足一定条件的神经网络,可以以任意精度逼近任何函数。这里给出了一个直观的解释为什么神经网络有这样的能力。所以,神经网络确实是非常强大。

2.1.2 为什么“深度”更好

界定多"深"才算深度学习的标准不一,一种较常见的界定方法是,我们将神经网络除输入和输出层之外的层叫做隐层(hidden layer),当隐层的数量大于1时,就可以称之为深度学习。我们第一次实验所放的第一张神经网络结构图,只有一个隐层,可以称之为“浅层神经网络”,本次实验将会实现的神经网络模型就会是类似的结构。
“深度”神经网络要比“浅层”神经网络更好,这里面的原因有很多,其中最重要的一点是,深度神经网络可以利用“层次化”的信息表达减少网络中的参数数量,而且能够提高模型的表达能力,即靠后的网络层可以利用靠前的网络层中提取的较低层次的信息组合成更高层次或者更加抽象的信息。

2.2 准备训练数据

2.2.1 获取训练数据

为了完成我们的项目,我们需要准备足够的训练数据data, 构建一个浅层神经网络模型model, 并且使用梯度下降算法learn去优化我们的模型。
我们先来解决训练数据的问题,我已经事先准备好了一些带有标签(label,代表图片上的字母是什么,0代表A,1代表B,依次类推)的训练图片,你可以直接运行以下命令下载并解压它们:

wget http://labfile.oss.aliyuncs.com/courses/814/data.tar.gz
tar zxvf data.tar.gz

解压之后,我们得到了一个文件夹pic和三个分别名为trainvalidatetesttxt格式文件,pic文件夹下一共有60000张图片,每张图片的尺寸为17*17,包含一个不等宽的大写英文字母。train.txt文件有40000行,每行的格式为"图片路径 标签",代表一张有标签训练图片,validate.txttest.txt文件格式与train.txt类似,且都包含10000行。

你可以使用cat命令查看这三个文件中的内容:

cat train.txt

2.2.2 训练、验证和测试 & 泛化性能

train.txtvalidate.txttest.txt将我们的数据划分成了三个部分。进行这样的划分是有原因的,在实际运用深度学习解决分类问题的过程中,我们总是将数据划分为训练集验证集测试集

我们的学习算法learn利用训练集来对模型中的参数进行优化,为了检验这些参数是否足够“好”,可以通过观察训练过程中的损失函数值来判断,但通过损失函数值来判断有一个问题,就是我们的模型可能只是“记住”了所有的训练数据,而不是真正的学会了训练数据中所包含的问题本身的性质。就像是如果我们考试时总是出原题,那笨学生只要把所有题目都记住也一样可以取得高分。

所以为了检验我们的模型是在“学习”而不是在“死记硬背”,我们再使用与训练集不同的验证集对模型进行测试,当模型对验证集的分类准确率也比较高时,就可以认为我们的模型是真正的在“学习”,此时我们称我们的模型拥有较好的泛化性能(generalization)--能够正确的对未曾见过的测试样例做出正确的预测。

然而这里还是有一个问题,别忘了除了模型里的参数,我们还手动设置了超参数,我们的超参数也有可能只能适应一部分数据,所以为了避免这种情况,需要再设置一个与训练集和验证集都不同的测试集,测试在当前超参数的设置下,我们的模型具有良好的泛化性能

2.2.3 预处理训练数据

对于图片数据,我们首先需要将它们转换成输入向量的形式,并且由于我们是有监督学习,每张图片的标签也必须与对应的图片向量一一对应。
编写数据预处理脚本preprocess.py如下:

# Created by wz on 17-3-23.
# encoding=utf-8
import sys
from scipy import misc
import numpy as np


def main():
    l = len(sys.argv)
    if l < 2:  # 检查参数的数量是否足够
        print'eg: python img2pkl.py list.txt dst.npy\n' \
             'convert image to npy\n'
        return

    src = sys.argv[1]
    dst = sys.argv[2] if l > 2 else 'data.pkl'
    with open(src, 'r') as f:  # 读取图片列表
        list = f.readlines()

    data = []
    labels = []
    for i in list:
        name, label = i.strip('\n').split(' ')  # 将图片列表中的每一行拆分成图片名和图片标签
        print name + ' processed'
        img = misc.imread(name)  # 将图片读取出来,存入一个矩阵
        img /= 255  # 将图片转换为只有0、1值的矩阵
        img.resize((img.size, 1))  # 为了之后的运算方便,我们将图片存储到一个img.size*1的列向量里面
        data.append(img)
        labels.append(int(label))

    print 'write to npy'
    np.save(dst, [data, labels])  # 将训练数据以npy的形式保存到成本地文件
    print 'completed'


if __name__ == '__main__':
    main()

读入图片数据需要scipy模块,使用以下命令安装:

sudo pip install scipy

我们的预处理脚本接收两个参数,第一个参数src对应之前我们提到的train.txtvalidate.txttest.txt,我们从src中读取图片的路径和它的标签。第二个参数dst代表我们将预处理好的图片数据保存到哪里,我们直接使用np.save()函数将数组保存到npy文件。

注意原始图片中只有0和255两种灰度值,我们的代码对图片灰度值除以了255,将图片矩阵转换成了只包含0-1值的矩阵。同时我们将图片矩阵转换成了列向量,注意这里的列向量的尺寸是img.sizex1而不是img.size,即我们其实是使用矩阵的形式表示向量,这样可以方便我们之后的运算。

我们可以使用以下命令将图片转换成npy文件:

python preprocess.py train.txt train.npy
python preprocess.py validate.txt validate.npy
python preprocess.py test.txt test.npy

然后你会发现生成了3个文件

2.3 编写数据层 & 随机梯度下降算法

预处理好了训练数据之后,我们还需要将数据读入我们的神经网络,为了一致性,我们将读入数据的操作放到一个数据层里面。创建layers.py文件,数据层代码如下:

import numpy as np

class Data:
    def __init__(self, name, batch_size):  # 数据所在的文件名name和batch中图片的数量batch_size
        with open(name, 'rb') as f:
            data = np.load(f)
        self.x = data[0]  # 输入x
        self.y = data[1]  # 预期正确输出y
        self.l = len(self.x)
        self.batch_size = batch_size
        self.pos = 0  # pos用来记录数据读取的位置

    def forward(self):
        pos = self.pos  
        bat = self.batch_size
        l = self.l
        if pos + bat >= l:  # 已经是最后一个batch时,返回剩余的数据,并设置pos为开始位置0
            ret = (self.x[pos:l], self.y[pos:l])
            self.pos = 0
            index = range(l)
            np.random.shuffle(index)  # 将训练数据打乱
            self.x = self.x[index]
            self.y = self.y[index]
        else:  # 不是最后一个batch, pos直接加上batch_size
            ret = (self.x[pos:pos + bat], self.y[pos:pos + bat])
            self.pos += self.batch_size

        return ret, self.pos  # 返回的pos为0时代表一个epoch已经结束

    def backward(self, d):  # 数据层无backward操作
        pass

这里先要介绍梯度下降算法的实际运用版本:随机梯度下降算法(stochastic gradient descent)。在实际的深度学习训练过程当中,我们每次计算梯度并更新参数值时,总是一次性计算多个输入数据的梯度,并将这些梯度求平均值,再使用这个平均值对参数进行更新。这样做可以利用并行计算来提高训练速度。我们将一次性一起计算的一组数据称为一个batch。同时,我们称所有训练图片都已参与一遍训练的一个周期称为一个epoch。每个epoch结束时,我们会将训练数据重新打乱,这样可以获得更好的训练效果。我们通常会训练多个epoch

2.3 编写一次处理一个batch的全连接层 & 对矩阵求导的窍门

在上次实验中,我们实现了一个全连接FullyConnect层,但是那段代码只能处理输出是一个标量的情况,对于输出是多个节点的情况无法处理。而且当一个batch中包含多个训练图片数据时,那段代码更是无法正常工作。

所以我们需要重新编写我们的全连接层,由于batch的引入,这时的全连接层要难了很多:

class FullyConnect:
    def __init__(self, l_x, l_y):  # 两个参数分别为输入层的长度和输出层的长度
        self.weights = np.random.randn(l_y, l_x) / np.sqrt(l_x)  # 使用随机数初始化参数,请暂时忽略这里为什么多了np.sqrt(l_x)
        self.bias = np.random.randn(l_y, 1)  # 使用随机数初始化参数
        self.lr = 0  # 先将学习速率初始化为0,最后统一设置学习速率

    def forward(self, x):
        self.x = x  # 把中间结果保存下来,以备反向传播时使用
        self.y = np.array([np.dot(self.weights, xx) + self.bias for xx in x])  # 计算全连接层的输出
        return self.y  # 将这一层计算的结果向前传递

    def backward(self, d):
        ddw = [np.dot(dd, xx.T) for dd, xx in zip(d, self.x)]  # 根据链式法则,将反向传递回来的导数值乘以x,得到对参数的梯度
        self.dw = np.sum(ddw, axis=0) / self.x.shape[0]
        self.db = np.sum(d, axis=0) / self.x.shape[0]
        self.dx = np.array([np.dot(self.weights.T, dd) for dd in d])

        # 更新参数
        self.weights -= self.lr * self.dw
        self.bias -= self.lr * self.db
        return self.dx  # 反向传播梯度

为了理解上面的代码,我们以一个包含100个训练输入数据的batch为例,分析一下具体执行流程:
我们的l_x为输入单个数据向量的长度,在这里是17*17=289,l_y代表全连接层输出的节点数量,由于大写英文字母有26个,所以这里的l_y=26。
所以,我们的self.weights的尺寸为26*289, self.bias的尺寸为26*1(self.bias也是通过矩阵形式表示的向量)。forward()函数的输入x在这里的尺寸就是100*289*1(batch_size*向量长度*1)。backward()函数的输入d代表从前面的网络层反向传递回来的“部分梯度值”,其尺寸为100*26*1(batch_size*输出层节点数l_y*1)。

forward()函数里的代码比较好理解,由于这里的x包含了多组数据,所以要对每组数据分别进行计算。

backward()函数里的代码就不太好理解了,ddw保存的是对于每组输入数据,损失函数对于参数的梯度。由于这里的参数是一个26*289的矩阵,所以,我们需要求损失函数对矩阵的导数。(对矩阵求导可能大部分本科生都不会。但其实也不难,如果你线性代数功底可以,可以尝试推导矩阵求导公式。)不过这里有一个简便的方法去推断对矩阵求导时应该如何计算:由于这里的参数矩阵本身是26*289的,那损失函数对于它的梯度(即损失函数对参数矩阵求导的结果)的尺寸也一定是26*289的。而这里每组输入数据的尺寸是289*1,每组数据对应的部分梯度尺寸为26*1,要得到一个26*289尺寸的梯度矩阵,就只能是一个26*1尺寸的矩阵乘以一个1*289尺寸的矩阵,需要对输入数据进行转置。所以这里计算的是np.dot(dd,xx.T)
对一个batch里的数据分别求得梯度之后,按照随机梯度下降算法的要求,我们需要对所有梯度求平均值,得到self.dw, 其尺寸为26*289,刚好与我们的self.weights匹配。

由于全连接层对bias的部分导数为1,所以这里对于bias的梯度self.bias就直接等于从之前的层反向传回来的梯度的平均值。
损失函数对于输入x的梯度值self.dx的求解与self.dw类似。由于输入数据self.x中的一个数据的尺寸为289*1,self.weights的尺寸为26*289, dd的尺寸为26*1, 所以需要对self.weights进行转置。即“289*1=(289*26)*(26*1)”。

最后是使用梯度更新参数,注意这里的self.lr即为前面我们提到过的学习速率alpha,它是一个需要我们手工设定的超参数。

这里的矩阵求导确实不太好处理,容易出错,请你仔细分析每一个变量代表的含义,如果对一个地方不清楚,请回到前面看看相关的概念是如何定义的。

2.4 激活函数层

由于numpy能够同时处理标量和矩阵的情况,所以我们之前写的激活函数sigmoid层可以不用修改直接使用:

class Sigmoid:
    def __init__(self):  # 无参数,不需初始化
        pass

    def sigmoid(self, x):
        return 1 / (1 + np.exp(-x))

    def forward(self, x):
        self.x = x
        self.y = self.sigmoid(x)
        return self.y

    def backward(self, d):
        sig = self.sigmoid(self.x)
        self.dx = d * sig * (1 - sig)
        return self.dx  # 反向传递梯度

sigmoid函数将输出限制在0到1之间,刚好可以作为概率看待。这里我们有26个输入节点,经过sigmoid层计算之后,哪个输出节点的数值最大,就认为图片上最有可能是该节点代表的字母。比如如果输出层第0个节点值最大,就认为图片上的字母是“A”, 如果第25个节点的值最大,就认为图片上的字母是“Z”。

注意一般在计算神经网络的深度时我们一般不把激活层算进去,但这里为了编程方便,也将激活函数视为单独的一层。  

2.5 损失函数层

之前我们讲解过二次损失函数quadratic loss的定义,这里我们来实现它:

class QuadraticLoss:
    def __init__(self):
        pass

    def forward(self, x, label):
        self.x = x
        self.label = np.zeros_like(x)  # 由于我们的label本身只包含一个数字,我们需要将其转换成和模型输出值尺寸相匹配的向量形式
        for a, b in zip(self.label, label):
            a[b] = 1.0  # 只有正确标签所代表的位置概率为1,其他为0
        self.loss = np.sum(np.square(x - self.label)) / self.x.shape[0] / 2  # 求平均后再除以2是为了表示方便
        return self.loss

    def backward(self):
        self.dx = (self.x - self.label) / self.x.shape[0]  # 2被抵消掉了
        return self.dx

随机梯度下降算法里,每次前向计算和反向传播都会计算包含多个输入数据的一个batch。所以损失函数值在随后也要除以batch中包含的数据数量, 即self.x.shape[0],同时这里除以了2, 这个地方的2可以和对二次损失函数求导后多出来的系数2抵消掉。所以,我们的损失函数变成了:

2.6 准确率层

前面我们提到过,为了判断经过训练的模型是否具有良好的泛化性能,需要使用验证集和测试集对模型的效果进行检验。所以我们还需要一个计算准确率的层:

class Accuracy:
    def __init__(self):
        pass

    def forward(self, x, label):  # 只需forward
        self.accuracy = np.sum([np.argmax(xx) == ll for xx, ll in zip(x, label)])  # 对预测正确的实例数求和
        self.accuracy = 1.0 * self.accuracy / x.shape[0]
        return self.accuracy

如果我们的神经网络的输出层中,概率最大的节点的下标与实际的标签label相等,则预测正确。预测正确的数量除以总的数量,就得到了正确率。

2.7 构建神经网络

我们已经写好了所有必须的网络层,并所有网络层都放到一个layers.py文件里。  

接下来我们要使用这些层构建出一个完整的神经网络,方法很简单,按顺序把它们“堆叠”起来就可以了,就像搭积木一样,创建shallow.py文件:

# encoding=utf-8
from layers import *


def main():
    datalayer1 = Data('train.npy', 1024)  # 用于训练,batch_size设置为1024
    datalayer2 = Data('validate.npy', 10000)  # 用于验证,所以设置batch_size为10000,一次性计算所有的样例
    inner_layers = []
    inner_layers.append(FullyConnect(17 * 17, 26))
    inner_layers.append(Sigmoid())
    losslayer = QuadraticLoss()
    accuracy = Accuracy()

    for layer in inner_layers:
        layer.lr = 1000.0  # 为所有中间层设置学习速率

    epochs = 20
    for i in range(epochs):
        print 'epochs:', i
        losssum = 0
        iters = 0
        while True:
            data, pos = datalayer1.forward()  # 从数据层取出数据
            x, label = data
            for layer in inner_layers:  # 前向计算
                x = layer.forward(x)

            loss = losslayer.forward(x, label)  # 调用损失层forward函数计算损失函数值
            losssum += loss
            iters += 1
            d = losslayer.backward()  # 调用损失层backward函数层计算将要反向传播的梯度

            for layer in inner_layers[::-1]:  # 反向传播
                d = layer.backward(d)

            if pos == 0:  # 一个epoch完成后进行准确率测试
                data, _ = datalayer2.forward()
                x, label = data
                for layer in inner_layers:
                    x = layer.forward(x)
                accu = accuracy.forward(x, label)  # 调用准确率层forward()函数求出准确率
                print 'loss:', losssum / iters
                print 'accuracy:', accu
                break


if __name__ == '__main__':
    main()

由于FullyConnect层和Sigmoid层在网络中的调用方式一模一样,所以把它们存到一个列表里,使用循环的方式调用。同时由于Sigmoid层一般不计入神经网络的深度,所以我们将这个列表命名为inner_layers而不是hidden_layers以免混淆。  

datalayer1数据层用来输出训练集数据,datalayer2数据层用来输出验证集数据。accuracy层用来在每个epoch结束时计算验证集上的准确率。  

上面的代码里只有一个隐层,构建的神经网络属于浅层神经网络,所以我们把这段代码存储在shallow.py文件里。

preprocess.py layers.py shallow.py三个文件可以使用以下命令获取:

wget http://labfile.oss.aliyuncs.com/courses/814/code.tar.gz
tar zxvf code.tar.gz

2.8 训练神经网络

终于,我们排除万难,准备好了训练数据,构建好了我们的浅层神经网络,也写好了训练算法,终于可以开始训练了!在terminal里输入:

python shallow.py

这里设置学习速率为1000(实际当中很少看到大于1的学习速率,下次实验我们会解释为什么这里的学习速率需要这么大),你可以尝试将学习速率改变成其他的值,观察损失函数值和准确率的变化情况。

我们看到每个epoch结束时,会先输出在训练集上的损失函数值,再输出在验证集上的准确率。  

20个epoch结束时,准确率大概会在0.9左右(为了节省时间这里只训练了20个epoch,你可以加大epochs的数值,看看最高能到多少,我这里测试大概是在0.93),这非常令人振奋不是吗!一个原本通过手工编程不可解的图片分类问题,(几乎)被我们解决了,0.9的准确率已经可以应用在一些实际的项目中了(比如这里),而且我们模型中的参数都是自动设定的,我们只是编写了模型和训练算法部分的代码。
而且,我们的代码具有很好的可扩展性,一方面我们可以很方便的向神经网络中添加更多的网络层使之成为真真的“深度神经网络”,另一方面我们也可以很方便的将我们的模型运用到其他图片分类问题当中,我们只编写了一次代码,就有可能能够解决多种问题!

不过,我要告诉你的是,我们的神经网络的性能还没有被完全发掘出来,我们的准确率还可以更高!这次实验的最开始我们提到过,深度神经网络会比浅层神经网络拥有更好的性能,下次实验,我们会尝试使用深度神经网络来提高我们的模型性能,进行真正的深度学习

三、实验总结

这次实验我们编写了数据预处理脚本、数据输入网络层、能够处理批量数据的FullyConnect层、损失函数层和准确率层,使用这些层构建出了只有一个隐层的浅层神经网络,并使用这个神经网络训练得到了一个效果已经很不错的模型。

在此课程的一开始,我就强调本课程不要求很高的数学水平,但是我相信你在实验的过程中还是逐渐的体会到了(尤其是编写FullyConnect层对矩阵求导数的时候),要想理解深度学习的原理,必须要具备一定的数学基础,数学就像是一把强大的战斧,帮你扫清一个个障碍,使原本不可解的问题变得可解。所以如果你想从事深度学习相关的工作,甚至进行深度学习领域的研究的话,请务必要重视学习相关数学知识。

本次实验,我们学习了:

  1. 深度神经网络比浅层神经网络更好
  2. 泛化性能是指一个模型能够正确预测未曾见过的样例的能力
  3. 随机梯度下降算法在每轮(epoch)训练开始时将所有数据打乱,每次训练一次性计算多个样例的平均值并使用平均值对参数进行更新

四、课后作业

  1. 修改上面的代码,在训练过程中打印出FullyConnect层的梯度值,观察这些值的大小。
  2. 增大epoch的值,测试我们的神经网络最高能达到多少准确率。

你可能感兴趣的:(使用浅层神经网络识别图片中的英文字母)