从头开始编写完整神经网络的方法

21世纪10年代末期一说起人工智能方面所取得的进步,我们通常关注的是名为机器学习(machine learning)的特定的子学科。所谓机器学习是指不用被明确告知,计算机就会学习一些新的知识。这些进步往往是由名为神经网络(neural network)的机器学习技术驱动的。虽然神经网络在几十年前就被发明了,但因为改良的硬件和新发现的研究导向的软件技术开启了一种名为深度学习(deep learning)的新范式,使神经网络已经经历了某种程度的复兴。

深度学习已被证明是一种具备广泛适应性的技术。从对冲基金算法到生物信息学,到处都有它的用武之地。消费者熟悉的两种深度学习应用是图像识别和语音识别。例如,向教字助理提问天气状况,或者用拍照程序进行人脸识别,这里面就可能有某些深度学习算法在运行。

深度学习技术使用的构建模块与较简单的神经网络一样。在本章中,我们将通过构建一个简单的神经网络来探讨这些模块。这里的实现不会是最先进的,但它将是理解深度学习的基础,深度学习基于的神经网络将比我们要构建的神经网络更为复杂。大多数机器学习的业界人士不会从头开始构建神经网络,他们会利用流行的、高度优化的、现成的框架来完成繁重的任务。虽然本章无助于学习某种特定框架的使用方式,即将构建的神经网络对实际应用也没什么意义,但仍将有助于我们了解那些框架底层的工作方式。

7.1 生物学基础

人类的大脑是现存最令人难以置信的计算设备。它无法像微处理器那样快速地处理数字,但它适应新情况、学习新技能和创新的能力是任何已知的机器都无法超越的。自计算机诞生之日起,科学家就一直对大脑机制的建模很感兴趣。大脑中的每个神经细胞称为神经元(neuron)。大脑中的神经元通过名为突触(synapse)的连接彼此连成网。电流经过突触来驱动这些神经元网络,也称为神经网络(neural network)。

注意 出于类比考虑,上述对生物神经元的描述是粗略的过于简化的说法。事实上,生物神经元包含轴突(axon)、树突(dendrite)和细胞核等部分,这会令人回想起高中的生物课。突触实际上是神经元之间的间隙,这里分泌出的神经递质(neurotransmitter)能够传递电信号。

尽管科学家已经识别出神经元的组成部分和功能,但我们对生物神经网络形成复杂思维模式的细节仍然未能很好地理解。它们是如何处理信息的?它们如何形成原创的想法?大部分对大脑工作方式的认识都来自宏观层面的观察。当人进行某项活动或思考某个想法时,对大脑进行功能性核磁共振(fMRI)扫描就会显示血液流动的方位(如图7-1所示)。通过这些宏观技术,我们能够推断出大脑各个部分的连接情况,但这些技术无法解释各个神经元如何帮助开发新想法的奥秘。

图片来自公共资源,美国国家卫生研究所图7-1 研究人员研究大脑的fMRI图像。fMRI图像不能说明各个神经元的工作方式及神经网络的组织方式

全球范围内的科学家团队都在竞相破解大脑的奥秘,但请考虑这一点:人类的大脑中大约有100 000 000 000(1000亿)个神经元,每个神经元可能连接的神经元多达数万个。即便计算机拥有数十亿个逻辑门和数万亿字节(TB)内存,用当前的技术也不可能对一颗人脑完成建模。在可预见的未来,人类仍可能是最先进的通用学习体。

注意 所谓的强人工智能(strong AI),也就是通用人工智能(artificial general intelligence),其目标就是获得与人类能力相当的通用学习机器。纵观历史,目前这仍然是存在于科幻小说中的事物。弱人工智能(weak AI)则是已司空见惯的AI类型:计算机智能地完成预先配置好的指定任务。

如果我们对生物神经网络并不完全了解,又该如何将其建模为高效的计算技术呢?虽然数字神经网络,称为人工神经网络(artificial neural network),受到了生物神经网络的启发,但也仅仅是受到了启发。现代的人工神经网络并不像对应的生物神经网络那样工作,事实上也不可能做到,因为生物神经网络如何开展工作尚不为人所完全了解。

7.2 人工神经网络

在本节中我们将介绍最常见的人工神经网络类型,一种带有反向传播(backpropagation)的前馈(feed-forward)网络,后续还将为其开发代码。前馈意味着信号在网络上通常往一个方向传递。反向传播则表示每次信号在网络中传播结束后都要查明误差,并尝试在网络上将这些误差的修正方案进行反向分发,特别是会影响对误差负最大责任的神经元。其他类型的人工神经网络还有许多,本章或许会激起大家进一步进行探索的兴趣。

7.2.1 神经元

人工神经网络中的最小单位是神经元。神经元拥有一个权重向量,即一串浮点数。输入的向量(也只是一些浮点数)将被传递给神经元。神经元用点积操作将这些输入与其权重合并在一起。然后对该点积执行激活函数(activation function),并将结果输出。上述操作可被视为与真正的神经元行为类似。

激活函数是神经元输出的转换器。激活函数几乎总是非线性的,这使得神经网络可以将结果表示为非线性问题。如果没有激活函数,则整个神经网络将只是一个线性转换。图7-2展示了一个神经元及其操作。

图7-2 每个神经元将其权重与输入信号结合,生成一个经过激活函数修正的输出信号

注意 本节中有一些数学术语可能不会出现在微积分先修课程或线性代数课程中。对向量或点积的解释已经超出了本章的范围,但即便你没有完全理解这些数学知识,也可能从本章后续的内容中对神经网络的行为获得一定的直觉上的了解。本章后面会出现一些微积分的知识,包括导数和偏导数的运用,但即使你没有完全理解这些数学知识,也应该能跟得上这些代码。事实上,本章不会解释如何用微积分进行公式推导,本章的重点将是求导的应用。

7.2.2 分层

在典型的前馈人工神经网络中,神经元被分为多个层。每层由一定数量的神经元排成行或列构成,是行还是列由示意图而定,两者是等价的。在下面将要构建的前馈网络中,信号总是从一层单向传递到下一层。每层中的神经元发送其输出信号,作为下一层神经元的输入。每层的每个神经元都与下一层的每个神经元相连。

第一层称为输入层,它从某个外部实体接收信号。最后一层称为输出层,其输出通常必须经由外部角色解释才能得出有意义的结果。输入层和输出层之间的层称为隐藏层。在本章中,我们即将构建的简单神经网络中只有一个隐藏层,但深度学习网络的隐藏层会有很多。图7-3呈现了一个简单神经网络中各层的协同工作过程。请注意某一层的输出是如何用作下一层每个神经元的输入的。

图7-3 一个简单的神经网络,这个神经网络有一个包含2个神经元的输入层、一个包含4个神经元的隐藏层和一个包含3个神经元的输出层。每层的神经元数量可以是任意多个

这些层只是对浮点数做一些操作。输入层的输入是浮点数,输出层的输出也是浮点数。

显然,这些数字必须代表一些有意义的东西。不妨将此神经网络想象为要对黑白的动物小图片进行分类。也许输入层有100个神经元,代表10像素×10像素的动物图片中每个像素的灰度值,而输出层则有5个神经元,代表此图片是哺乳动物、爬行动物、两栖动物、鱼类或鸟类的可能性。最终的分类可以由浮点数输出值最大的那个输出神经元来确定。假设输出数值分别为0.24、0.65、0.70、0.12和0.21,则此图片将被确定为两栖动物。

7.2.3 反向传播

最后一部分,也是最复杂的部分,就是反向传播。反向传播将在神经网络的输出中发现误差,并用它来修正神经元的权重。某个神经元对误差担负的责任越大,对其修正就会越多。但误差从何而来?我们如何才能知道存在误差呢?

误差由被称为训练(training)的神经网络应用阶段获得。

提示 本节会有几个步骤写成了数学公式。伪公式(符号不一定很恰当)写在了配图中。这种写法将让那些对数学符号不在行(或生疏)的人更容易读懂这些公式。如果你对更正规的符号(及公式的推导)感兴趣,请查看Russell和Norvig的《人工智能:一种现代的方法(第3版)》的第18章[1]。

大多数神经网络在使用之前,都必须经过训练。我们必须知道通过某些输入能够获得的正确输出,以便用预期输出和实际输出的差异来查找误差并修正权重。换句话说,神经网络在最开始时是一无所知的,直至它们知晓对于某组特定输入集的正确答案,在这之后才能为其他输入做好准备。反向传播仅发生在训练期间。

注意 因为大多数神经网络都必须经过训练,所以其被认为是一种监督机器学习。请回想一下第6章,k均值聚类算法和其他聚类算法被认为是一种无监督机器学习算法,因为它们一旦启动就无须进行外部干预。除本章介绍的这种神经网络之外,其他还有一些类型的神经网络是不需要预训练的,那些神经网络可被视为无监督机器学习。

反向传播的第一步,是计算神经网络针对某些输入的输出与预期输出之间的误差。输出层中的所有神经元都会具有这一误差(每个神经元都有一个预期输出及其实际输出)。然后,输出神经元的激活函数的导数将会应用于该神经元在其激活函数被应用之前输出的值(这里缓存了一份应用激活函数前的输出值)。将求导结果再乘以神经元的误差,求其delta。求delta公式用到了偏导数,其微积分推导过程超出了本书的范围,大致就是要计算出每个输出神经元承担的误差量。有关此计算的示意图,如图7-4所示。

图7-4 在训练的反向传播阶段计算输出神经元的delta的机制

然后必须为网络所有隐藏层中的每个神经元计算delta。每个神经元对输出层的不正确输出所承担的责任都必须明确。输出层中的delta将会用于计算上一个隐藏层中的delta。根据下层各神经元权重的点积和在下层中已算出的delta,可以算出上一层的delta。将这个值乘以调用神经元最终输出(在调用激活函数之前已缓存)的激活函数的导数,即可获得当前神经元的delta。同样,这个公式是用偏导数推导得出的,有关介绍可以在更专业的数学课本中找到。

图7-5呈现了隐藏层中各神经元的delta的实际计算过程。在包含多个隐藏层的网络中,神经元O1、O2和O3可能不属于输出层,而属于下一个隐藏层。

图7-5 隐藏层中神经元的delta的计算过程

最重要的一点是,网络中每个神经元的权重都必须进行更新,更新方式是把每个权重的最近一次输入、神经元的delta和一个名为学习率(learning rate)的数相乘,再将结果与现有权重相加。这种改变神经元权重的方式被称为梯度下降(gradient descent)。这就像爬一座小山,表示神经元的误差函数向最小误差的点不断靠近。delta代表了爬山的方向,学习率则会影响攀爬的速度。不经过反复的试错,很难为未知的问题确定良好的学习率。图7-6呈现了隐藏层和输出层中每个权重的更新方式。

图7-6 用前面步骤求得的delta、原权重、原输入和用户指定的学习率更新每个隐藏层和输出层中神经元的权重

一旦权重更新完毕,神经网络就可以用其他输入和预期输出再次进行训练。此过程将一直重复下去,直至该神经网络的用户认为其已经训练好了,这可以用正确输出已知的输入进行测试来确定。

反向传播确实比较复杂。如果你还未掌握所有细节,请不必担心。仅凭本节的讲解可能还不够充分。在理想情况下,编写反向传播算法的实现代码会提升你对它的理解程度。在实现神经网络和反向传播时,请牢记一个首要主题:反向传播是一种根据每个权重对造成不正确输出所承担的责任来调整该权重的方法。

7.2.4 全貌

本节已经介绍了很多基础知识。虽然细节还没有呈现出什么意义,但重要的是要牢记反向传播的前馈网络具备以下特点。

  • 信号(浮点数)在各个神经元间单向传递,这些神经元按层组织在一起。每层所有的神经元都与下一层的每个神经元相连。
  • 每个神经元(输入层除外)都将对接收到的信号进行处理,将信号与权重(也是浮点数)合并在一起并调用激活函数。
  • 在训练过程中,将网络的输出与预期输出进行比较,计算出误差。
  • 误差在网络中反向传播(返回出发地)以修改权重,使其更有可能创建正确的输出。

训练神经网络的方法远不止本书介绍的这一种。信号在神经网络中的移动方式还有很多种。这里介绍的及后续将要实现的方法,只是一种特别常见的形式,适合作为一种正规的介绍。附录B列出了进一步学习神经网络(包括其他类型)和数学知识所需的资源。

7.3 预备知识

神经网络用到的数学机制需要进行大量的浮点操作。在开发简单神经网络的实际结构之前,我们需要用到一些数学原语(primitive)。这些简单的原语将被广泛运用于后面的代码中,因此如果我们能找到使其加速的方法,将能真正改善神经网络的性能。

警告 本章的代码无疑比本书的其他代码都要复杂。需要构建的代码有很多,而实际执行结果只有在最后才能看到。有很多相关资源会帮你用几行代码就构建一个神经网络,但是本示例的目标是要探究其运作机制,以及各组件如何以高可读性和高扩展性的方式协同工作。这就是本书的目标,尽管代码越长表现力越强。

7.3.1 点积

大家都还记得,前馈阶段和反向传播阶段都需要用到点积。幸运的是,用Python内置函数zip()和sum()很容易就能实现点积。先把函数保存在util.py文件中。具体代码如代码清单7-1所示。

代码清单7-1 util.py

from typing import Listfrom math import exp# dot product of two vectorsdef dot_product(xs: List[float], ys: List[float]) -> float: return sum(x * y for x, y in zip(xs, ys))

7.3.2 激活函数

回想一下,在信号被传递到下一层之前,激活函数对神经元的输出进行转换(如图7-2所示)。激活函数有两个目的:一是让神经网络不只是能表示线性变换的解(只要激活函数本身不只是线性变换);二是能将每个神经元的输出保持在一定范围内。激活函数应该具有可计算的导数,这样它就能用于反向传播。

sigmoid函数就是一组流行的激活函数。图7-7中展示了一种特别流行的sigmoid函数(通常“sigmoid函数”就是指它),在图中被称为S(x),还给出了它的表达式及其导数(S′(x))。sigmoid函数的结果一定是介于0和1之间的值。大家即将看到,让数值始终保持在0和1之间对神经网络来说是很有用的。图7-7中的公式很快就会出现在代码中了。

图7-7 sigmoid激活函数(S(x))会始终返回0到1之间的值。注意它的导数(S′(x))同样也很容易计算

其他的激活函数还有很多,但这里将采用sigmoid函数。下面把图7-7中的公式直接转换为代码,如代码清单7-2所示。

代码清单7-2 util.py(续)

# the classic sigmoid activation functiondef sigmoid(x: float) -> float:    return 1.0 / (1.0 + exp(-x))def derivative_sigmoid(x: float) -> float:    sig: float = sigmoid(x)    return sig * (1 - sig)

7.4 构建神经网络

为了对神经网络中的3种组织单位(神经元、层和神经网络本身)进行建模,我们将会创建多个类。为简单起见,将从最小的神经元开始,再到核心组件(层),直至构建最大组件(整个神经网络)。随着组件从小到大,我们会对前一级进行封装。神经元对象只能看到自己。层对象会看到其包含的神经元和其他层。神经网络对象则能看到全部的层。

注意 本章有很多代码行会比较长,无法完全适应印刷书籍的行宽限制。我们强烈建议读者下载本章的源代码,并在计算机屏幕上浏览代码。

7.4.1 神经元的实现

先从神经元开始吧。一个神经元对象将会保存很多状态,包括其权重、delta、学习率、最近一次输出的缓存、激活函数及其导数等。其中某些内容如果保存在高一个级别的对象中(后续的Layer类中),性能可能会更好,但为了演示,它们还是被包含在代码清单7-3的Neuron类中。

代码清单7-3 neuron.py(续)

from typing import List, Callablefrom util import dot_productclass Neuron: def __init__(self, weights: List[float], learning_rate: float, activation_function: Callable[[float], float], derivative_activation_function: Callable[[float], float]) -> None: self.weights: List[float] = weights self.activation_function: Callable[[float], float] = activation_function self.derivative_activation_function: Callable[[float], float] = derivative_ activation_function self.learning_rate: float = learning_rate self.output_cache: float = 0.0 self.delta: float = 0.0 def output(self, inputs: List[float]) -> float: self.output_cache = dot_product(inputs, self.weights) return self.activation_function(self.output_cache)

大多数参数都在__init__()方法中完成初始化。因为在首次创建Neuron时,delta和output_cache是未知的,所以只是将它们初始化为0。所有神经元的变量都是可变的。在神经元的生命周期中,它们的值可能永远都不会发生变化(即将如此运用),但为了保持灵活性还是设为可变为好。如果这个Neuron类将与其他类型的神经网络一起合作,则其中某些值可能会在运行中发生变化。有一些神经网络可以在求解过程中改变学习率,并自动尝试各种不同的激活函数。这里我们将努力让Neuron类保持最大的灵活性,以便适应其他的神经网络应用。

除__init__()之外,Neuron类只有一个output()方法。output()的参数为进入神经元的输入信号(inputs),它调用本章的前面讨论过的公式(如图7-2所示)。输入信号通过点积操作与权重合并在一起,并在output_cache中留了一份缓存数据。回想一下介绍反向传播的章节,在应用激活函数之前获得的这个值将用于计算delta。最后,信号被继续发送给下一层(从output()返回)之前,将对其应用激活函数。

就这些了!这个神经网络中的神经元个体非常简单,除了读取输入信号、对其进行转换并发送结果以供进一步处理,它不做别的事情。它维护着供其他类使用的几种状态数据。

7.4.2 层的实现

本章的神经网络中的层对象需要维护3种状态数据:所含神经元、其上一层和输出缓存。输出缓存类似于神经元的缓存,但高一个级别。它缓存了层中每一个神经元在调用激活函数之后的输出。

在创建时,层对象的主要职责是初始化其内部的神经元。因此,Layer类的__init__()方法需要知道应该初始化多少个神经元,它们的激活函数是什么,以及它们的学习率为多少。在本章这个简单的神经网络中,层中的每个神经元都有相同的激活函数和学习率。具体代码如代码清单7-4所示。

代码清单7-4 layer.py(续)

from __future__ import annotationsfrom typing import List, Callable, Optionalfrom random import randomfrom neuron import Neuronfrom util import dot_productclass Layer:    def __init__(self, previous_layer: Optional[Layer], num_neurons: int, learning_      rate: float, activation_function: Callable[[float], float], derivative_activation_      function: Callable[[float], float]) -> None:       self.previous_layer: Optional[Layer] = previous_layer       self.neurons: List[Neuron] = []       # the following could all be one large list comprehension        for i in range(num_neurons):           if previous_layer is None:               random_weights: List[float] = []           else:               random_weights = [random() for _ in range(len(previous_layer.neurons))]           neuron: Neuron = Neuron(random_weights, learning_rate, activation_function,              derivative_activation_function)           self.neurons.append(neuron)       self.output_cache: List[float] = [0.0 for _ in range(num_neurons)]

当信号在神经网络中前馈时,Layer必须让每个神经元都对其进行处理。请记住,层中的每个神经元都会接收到上一层中每个神经元传入的信号。outputs()正是如此处理的。outputs()还会返回处理后的结果(以便经由网络传递到下一层)并将输出缓存一份。如果不存在上一层,则表示本层为输入层,只要将信号向前传递给下一层即可。具体代码如代码清单7-5所示。

代码清单7-5 layer.py(续)

def outputs(self, inputs: List[float]) -> List[float]: if self.previous_layer is None: self.output_cache = inputs else: self.output_cache = [n.output(inputs) for n in self.neurons] return self.output_cache

在反向传播时需要计算两种不同类型的delta:输出层中神经元的delta和隐藏层中神经元的delta。图7-4和图7-5中分别给出了公式的描述,代码清单7-6中的两个方法只是机械地将公式转换成了代码。稍后在反向传播过程中神经网络对象将会调用这两个方法。

代码清单7-6 layer.py(续)

# should only be called on output layerdef calculate_deltas_for_output_layer(self, expected: List[float]) -> None:    for n in range(len(self.neurons)):        self.neurons[n].delta = self.neurons[n].derivative_activation_function        (self.neurons[n].output_cache) * (expected[n] - self.output_cache[n])# should not be called on output layerdef calculate_deltas_for_hidden_layer(self, next_layer: Layer) -> None:    for index, neuron in enumerate(self.neurons):        next_weights: List[float] = [n.weights[index] for n in next_layer.neurons]        next_deltas: List[float] = [n.delta for n in next_layer.neurons]        sum_weights_and_deltas: float = dot_product(next_weights, next_deltas)        neuron.delta = neuron.derivative_activation_function(neuron.output_cache) *        sum_weights_and_deltas

7.4.3 神经网络的实现

神经网络对象本身只包含一种状态数据,即神经网络管理的层对象。Network类负责初始化其构成层。

__init__()方法的参数为描述网络结构的int列表。例如,列表[2, 4, 3]描述的网络为:输入层有2个神经元,隐藏层有4个神经元,输出层有3个神经元。在这个简单的网络中,假设网络中的所有层都将采用相同的神经元激活函数和学习率。具体代码如代码清单7-7所示。

代码清单7-7 network.py

from __future__ import annotationsfrom typing import List, Callable, TypeVar, Tuplefrom functools import reducefrom layer import Layerfrom util import sigmoid, derivative_sigmoidT = TypeVar('T') # output type of interpretation of neural networkclass Network: def __init__(self, layer_structure: List[int], learning_rate: float, activation_function: Callable[[float], float] = sigmoid, derivative_activation_function: Callable[[float], float] = derivative_sigmoid) -> None: if len(layer_structure) < 3: raise ValueError('Error: Should be at least 3 layers (1 input, 1 hidden, 1 output)') self.layers: List[Layer] = [] # input layer input_layer: Layer = Layer(None, layer_structure[0], learning_rate, activation_function, derivative_activation_function) self.layers.append(input_layer) # hidden layers and output layer for previous, num_neurons in enumerate(layer_structure[1::]): next_layer = Layer(self.layers[previous], num_neurons, learning_rate, active ation_function, derivative_activation_function) self.layers.append(next_layer)

神经网络的输出是信号经由其所有层传递之后的结果。注意简洁的reduce()函数是如何用在outputs()中,将信号反复从一层传递到下一层,进而传遍整个网络的。具体代码如代码清单7-8所示。

代码清单7-8 network.py(续)

# Pushes input data to the first layer, then output from the first# as input to the second, second to the third, etc.def outputs(self, input: List[float]) -> List[float]:    return reduce((lambda inputs, layer: layer.outputs(inputs)), self.layers, input)

backpropagate()方法负责计算网络中每个神经元的delta。它会依次调用Layer类的calculate_deltas_for_output_layer()方法和calculate_deltas_for_hidden_layer()方法。还记得吧,在反向传播时要反向计算delta。它会把给定输入集的预期输出值传递给calculate_ deltas_for_output_layer()。该方法将用预期输出值求出误差,以供计算delta时使用。具体代码如代码清单7-9所示。

代码清单7-9 network.py(续)

# Figure out each neuron's changes based on the errors of the output# versus the expected outcomedef backpropagate(self, expected: List[float]) -> None: # calculate delta for output layer neurons last_layer: int = len(self.layers) - 1 self.layers[last_layer].calculate_deltas_for_output_layer(expected) # calculate delta for hidden layers in reverse order for l in range(last_layer - 1, 0, -1): self.layers[l].calculate_deltas_for_hidden_layer(self.layers[l + 1])

backpropagate()的确负责计算所有的delta,但它不会真的去修改网络中的权重。update_weights()必须在backpropagate()之后才能被调用,因为权重的修改依赖delta。update_weights()方法直接来自图7-6中的公式。具体代码如代码清单7-10所示。

代码清单7-10 network.py(续)

# backpropagate() doesn't actually change any weights# this function uses the deltas calculated in backpropagate() to# actually make changes to the weightsdef update_weights(self) -> None:    for layer in self.layers[1:]: # skip input layer        for neuron in layer.neurons:            for w in range(len(neuron.weights)):                neuron.weights[w] = neuron.weights[w] + (neuron.learning_rate *                     (layer.previous_layer.output_cache[w]) * neuron.delta)

在每轮训练结束时,会对神经元的权重进行修改。必须向神经网络提供训练数据集(同时给出输入与预期的输出)。train()方法的参数即为输入列表的列表和预期输出列表的列表。train()方法在神经网络上运行每一组输入,然后以预期输出为参数调用backpropagate(),然后再调用update_weights(),以更新网络的权重。不妨试着在train()方法中添加代码,使得在神经网络中传递训练数据集时能把误差率打印出来,以便于查看梯度下降过程中误差率是如何逐渐降低的。具体代码如代码清单7-11所示。

代码清单7-11 network.py(续)

# train() uses the results of outputs() run over many inputs and compared# against expecteds to feed backpropagate() and update_weights()def train(self, inputs: List[List[float]], expecteds: List[List[float]]) -> None: for location, xs in enumerate(inputs): ys: List[float] = expecteds[location] outs: List[float] = self.outputs(xs) self.backpropagate(ys) self.update_weights()

在神经网络经过训练后,我们需要对其进行测试。validate()的参数为输入和预期输出(与train()的参数没什么区别),但它们不会用于训练,而会用来计算准确度的百分比。这里假定网络已经过训练。validate()还有一个参数是函数interpret_output(),该函数用于解释神经网络的输出,以便将其与预期输出进行比较。或许预期输出不是一组浮点数,而是像'Amphibian'这样的字符串。interpret_output()必须读取作为网络输出的浮点数,并将其转换为可以与预期输出相比较的数据。interpret_output()是特定于某数据集的自定义函数。validate()将返回分类成功的类别数量、通过测试的样本总数和成功分类的百分比。具体代码如代码清单7-12所示。

代码清单7-12 network.py(续)

# for generalized results that require classification # this function will return the correct number of trials # and the percentage correct out of the totaldef validate(self, inputs: List[List[float]], expecteds: List[T], interpret_output:      Callable[[List[float]], T]) -> Tuple[int, int, float]:    correct: int = 0    for input, expected in zip(inputs, expecteds):        result: T = interpret_output(self.outputs(input))        if result == expected:            correct += 1    percentage: float = correct / len(inputs)    return correct, len(inputs), percentage

至此神经网络就完工了!已经可以用来进行一些实际问题的测试了。虽然此处构建的架构是通用的,足以应对各种不同的问题,但这里将重点解决一种流行的问题,即分类问题。

7.5 分类问题

在第6章中,用k均值聚类进行了数据集的分类,那时对每个单独数据的归属没有预先的设定。在聚类过程中,我们知道需要找到数据的一些类别,但事先不知道这些类别是什么。在分类问题中,我们仍然要尝试对数据集进行分类,但是会有预设的类别。例如,假设要对一组动物图片进行分类,我们可能会提前确定哺乳动物、爬行动物、两栖动物、鱼类和鸟类等类别。

可用于解决分类问题的机器学习技术有很多。或许你听说过支持向量机(support vector machine)、决策树(decision tree)或朴素贝叶斯分类算法(naive Bayes classifier)。其他还有很多。近来,神经网络已经在分类领域中得到广泛应用。与其他的一些分类算法相比,神经网络的计算更为密集,但它能够对表面看不出是什么类型的数据进行分类,这使其成为一种强大的技术。很多有趣的图像分类程序在为现代的图片软件赋能,这些程序背后都用到了神经网络分类算法。

为什么对分类问题应用神经网络出现了复兴现象呢?因为硬件的运行速度已经变得足够快了,与其他算法相比,神经网络需要的额外计算量相对于获得的收益而言变得划算起来了。

7.5.1 数据的归一化

在被输入神经网络之前,待处理的数据集通常需要进行一些清理。清理可能会包括移除无关字符、删除重复项、修复错误和其他琐事。对于即将被处理的两个数据集,需要执行的清理工作就是归一化。在第6章中,我们用KMeans类中的zscore_normalize()方法完成了归一化。归一化就是读取以不同尺度(scale)记录的属性值,并将它们转换为相同的尺度。

因为有了sigmoid激活函数,神经网络中的每个神经元都会输出0到1之间的值。看来0到1之间的尺度对于输入数据集中的属性也有意义是合乎逻辑的。将尺度从某一范围转换为0到1之间的范围并没有什么挑战性。对于最大值为max、最小值为min的某个属性范围内的任意值V,转换公式就是newV =(oldV - min)/(max - min)。此操作被称为特征缩放(feature scaling)。代码清单7-13给出的是加入util.py中的一种Python实现。

代码清单7-13 util.py(续)

# assume all rows are of equal length# and feature scale each column to be in the range 0 - 1def normalize_by_feature_scaling(dataset: List[List[float]]) -> None: for col_num in range(len(dataset[0])): column: List[float] = [row[col_num] for row in dataset] maximum = max(column) minimum = min(column) for row_num in range(len(dataset)): dataset[row_num][col_num] = (dataset[row_num][col_num] - minimum) / (maximum - minimum)

看一下参数dataset。它是一个引用,指向即将在原地被修改的列表的列表。换句话说,normalize_by_feature_scaling()收到的不是数据集的副本,而是对原始数据集的引用。这里是要对值进行修改,而不是接收转换过的副本。

另外请注意,本程序假定数据集是由float类型数据构成的二维列表。

7.5.2 经典的鸢尾花数据集

就像经典计算机科学问题一样,机器学习中也有经典的数据集。这些数据集可用于验证新技术,并与现有技术进行比较。它们还能用作首次学习机器学习算法的良好起点。最著名的机器学习数据集或许就是鸢尾花数据集了。该数据集最初收集于20世纪30年代,包含150个鸢尾花(花很漂亮)植物样本,分为3个不同的品种,每个品种50个样本。每种植物以4种不同的属性进行考量:萼片长度、萼片宽度、花瓣长度和花瓣宽度。

值得注意的是,神经网络并不关心各个属性所代表的含义。它的训练模型并不会区分萼片长度和花瓣长度的重要程度。如果需要进行这种区分,则由该神经网络的用户进行适当的调整。

本书附带的源码库包含了一个以鸢尾花数据集为特征值的逗号分隔值(CSV)文件[2]。鸢尾花数据集来自美国加利福尼亚大学的UCI机器学习库[3]。CSV文件只是一个文本文件,其值以逗号分隔。CSV文件是表格式数据(包括电子表格)的通用交换格式。

以下是iris.csv中的一些数据行:

5.1,3.5,1.4,0.2,Iris-setosa4.9,3.0,1.4,0.2,Iris-setosa4.7,3.2,1.3,0.2,Iris-setosa4.6,3.1,1.5,0.2,Iris-setosa5.0,3.6,1.4,0.2,Iris-setosa

每行代表一个数据点,其中的4个数字分别代表4种属性(萼片长度、萼片宽度、花瓣长度和花瓣宽度),再次声明,它们实际代表的意义是无所谓的。每行末尾的名称代表鸢尾花的特定品种。这5行都属于同一品种,因为此样本是从iris.csv文件开头读取的,3类品种数据是各自放在一起保存的,每个品种都有50行数据。

为了从磁盘读取CSV文件,我们将会用到Python标准库中的一些函数。csv模块将有助于我们以结构化的方式读取数据。内置的open()函数将会创建一个用于传给csv.reader()的文件对象。在代码清单7-14中,除这几行读取文件的代码之外,其余都只是对CSV文件中的数据进行重新排列,以备神经网络训练和验证之用。

代码清单7-14 iris_test.py

import csvfrom typing import Listfrom util import normalize_by_feature_scalingfrom network import Networkfrom random import shuffleif __name__ == '__main__': iris_parameters: List[List[float]] = [] iris_classifications: List[List[float]] = [] iris_species: List[str] = [] with open('iris.csv', mode='r') as iris_file: irises: List = list(csv.reader(iris_file)) shuffle(irises) # get our lines of data in random order for iris in irises: parameters: List[float] = [float(n) for n in iris[0:4]] iris_parameters.append(parameters) species: str = iris[4] if species == 'Iris-setosa': iris_classifications.append([1.0, 0.0, 0.0]) elif species == 'Iris-versicolor': iris_classifications.append([0.0, 1.0, 0.0]) else: iris_classifications.append([0.0, 0.0, 1.0]) iris_species.append(species) normalize_by_feature_scaling(iris_parameters)

iris_parameters代表每个样本的4种属性集,这些样本将用于对鸢尾花进行分类。iris_classifications是每个样本的实际分类。此处的神经网络将包含3个输出神经元,每个神经元代表一种可能的品种。例如,最终输出的[0.9, 0.3, 0.1]将代表山鸢尾(iris-setosa),因为第一个神经元代表该品种,这里它的数值最大。

为了训练,正确答案是已知的,因此每条鸢尾花数据都带有预先标记的答案。对于应为山鸢尾的花朵数据,iris_classifications中的数据项将会是[1.0, 0.0, 0.0]。这些值将用于计算每步训练后的误差。iris_species直接对应每条花朵数据应该归属的英文类别名称。山鸢尾在数据集中将被标记为'Iris-setosa'。

警告 上述代码中缺少了错误检查代码,这会让代码变得相当危险,因此这些代码不适用于生产环境,但用来测试是没有问题的。

代码清单7-15中的代码定义了神经网络对象。

代码清单7-15 iris_test.py(续)

iris_network: Network = Network([4, 6, 3], 0.3)

layer_structure参数给定了包含3层(1个输入层、1个隐藏层和1个输出层)的网络[4, 6, 3]。输入层包含4个神经元,隐藏层包含6个神经元,输出层包含3个神经元。输入层中的4个神经元直接映射到用于对每个样本进行分类的4个参数。输出层中的3个神经元直接映射到3个不同的品种,对于每次的输入,我们都要分类为这3个品种。与其他一些公式相比,隐藏层的6个神经元存放的更多是一些尝试和误差的结果。learning_rate也是如此。如果神经网络算法的准确度不够理想,不妨多次尝试这两个值(隐藏层中的神经元数量和学习率)。具体代码如代码清单7-16所示。

代码清单7-16 iris_test.py(续)

def iris_interpret_output(output: List[float]) -> str: if max(output) == output[0]: return 'Iris-setosa' elif max(output) == output[1]: return 'Iris-versicolor' else: return 'Iris-virginica'

iris_interpret_output()是一个实用函数,将会被传给神经网络对象的validate()方法,用于识别正确的分类。

至此,终于可以对神经网络对象进行训练了。具体代码如代码清单7-17所示。

代码清单7-17 iris_test.py(续)

# train over the first 140 irises in the data set 50 timesiris_trainers: List[List[float]] = iris_parameters[0:140]iris_trainers_corrects: List[List[float]] = iris_classifications[0:140]for _ in range(50):    iris_network.train(iris_trainers, iris_trainers_corrects)

这里将对150条鸢尾花数据集的前140条进行训练。还记得吧,从CSV文件中读取的数据行是经过重新排列的。这确保了每次运行程序时,训练的都是数据集的不同子集。注意,这140条鸢尾花数据会被训练50次。训练的次数将对神经网络的训练时间产生很大影响。一般来说,训练次数越多,神经网络算法就越准确。最后的测试代码将会用数据集中的最后10条鸢尾花数据来验证分类的正确性。具体代码如代码清单7-18所示。

代码清单7-18 iris_test.py(续)

# test over the last 10 of the irises in the data setiris_testers: List[List[float]] = iris_parameters[140:150]iris_testers_corrects: List[str] = iris_species[140:150]iris_results = iris_network.validate(iris_testers, iris_testers_corrects, iris_ interpret_output)print(f'{iris_results[0]} correct of {iris_results[1]} = {iris_results[2] * 100}%')

上述所有工作引出了最终求解的问题:在数据集中随机选取10条鸢尾花数据,这里的神经网络对象可以对其中多少条数据进行正确分类?每个神经元的起始权重都是随机的,因此每次不同的运行都可能会得出不同的结果。不妨试着对学习率、隐藏神经元的数量和训练迭代次数进行调整,以便让神经网络对象变得更加准确。

最终应该会得出类似如下的结果:

9 correct of 10 = 90.0%

7.5.3 葡萄酒的分类

下面将用另一个数据集对本章的神经网络模型进行测试,该数据集是基于对多个意大利葡萄酒品种的化学分析得来的[4]。数据集中有178个样本。使用方式与鸢尾花数据集大致相同,只是CSV文件的布局稍有差别。下面给出一个示例:

1,14.23,1.71,2.43,15.6,127,2.8,3.06,.28,2.29,5.64,1.04,3.92,10651,13.2,1.78,2.14,11.2,100,2.65,2.76,.26,1.28,4.38,1.05,3.4,10501,13.16,2.36,2.67,18.6,101,2.8,3.24,.3,2.81,5.68,1.03,3.17,11851,14.37,1.95,2.5,16.8,113,3.85,3.49,.24,2.18,7.8,.86,3.45,14801,13.24,2.59,2.87,21,118,2.8,2.69,.39,1.82,4.32,1.04,2.93,735

每行的第一个值一定是1到3之间的整数,代表该条样本为3个品种之一。但请注意这里用于分类的参数更多一些。在鸢尾花数据集中,只有4个参数。而在这个葡萄酒数据集中,则有13个参数。

本章的神经网络模型的扩展性非常好,这里只需增加输入神经元的数量即可。wine_test.py类似于iris_test.py,但为了适应数据文件的布局差异而进行了一些微小的改动。具体代码如代码清单7-19所示。

代码清单7-19 wine _test.py

import csvfrom typing import Listfrom util import normalize_by_feature_scalingfrom network import Networkfrom random import shuffleif __name__ == '__main__':    wine_parameters: List[List[float]] = []    wine_classifications: List[List[float]] = []    wine_species: List[int] = []    with open('wine.csv', mode='r') as wine_file:        wines: List = list(csv.reader(wine_file, quoting=csv.QUOTE_NONNUMERIC))        shuffle(wines) # get our lines of data in random order        for wine in wines:            parameters: List[float] = [float(n) for n in wine[1:14]]            wine_parameters.append(parameters)            species: int = int(wine[0])            if species == 1:                wine_classifications.append([1.0, 0.0, 0.0])           elif species == 2:                wine_classifications.append([0.0, 1.0, 0.0])           else:                wine_classifications.append([0.0, 0.0, 1.0])           wine_species.append(species)    normalize_by_feature_scaling(wine_parameters)

如前所述,在这个葡萄酒分类的神经网络模型中,层的参数需要用到13个输入神经元(每个参数一个神经元)。此外还需要3个输出神经元。(葡萄酒品种有3种,就像有3种鸢尾花一样。)有意思的是,虽然隐藏层中神经元的数量少于输入层中神经元的数量,但该神经网络对象的运行效果还算不错。一种直观的解释可能是,某些输入参数其实对分类没有帮助,在处理过程中将它们剔除会很有意义。当然,事实上这并不是隐藏层中神经元的数量减少却仍能正常工作的原因,但这种直观的想法还是挺有趣的。具体代码如代码清单7-20所示。

代码清单7-20 wine _test.py(续)

wine_network: Network = Network([13, 7, 3], 0.9)

与之前一样,不妨试验一下不同数量的隐藏层神经元或不同的学习率,这会很有趣的。具体代码如代码清单7-21所示。

代码清单7-21 wine _test.py(续)

def wine_interpret_output(output: List[float]) -> int:    if max(output) == output[0]:        return 1    elif max(output) == output[1]:        return 2    else:        return 3

wine_interpret_output()类似于iris_interpret_output()。因为没有葡萄酒品种的名称,所以这里只能采用原数据集给出的整数值。具体代码如代码清单7-22所示。

代码清单7-22 wine _test.py(续)

# train over the first 150 wines 10 timeswine_trainers: List[List[float]] = wine_parameters[0:150]wine_trainers_corrects: List[List[float]] = wine_classifications[0:150]for _ in range(10): wine_network.train(wine_trainers, wine_trainers_corrects)

数据集中的前150个样本将用于训练,剩下最后28个样本将用于验证。样本的训练次数为10次,明显少于训练鸢尾花数据集的50次。不知出于何种原因(可能是数据集的固有特性,也可能是学习率和隐藏神经元数量这些参数有调整),该数据集只需要少于鸢尾花数据集的训练次数就能达到高于鸢尾花数据集的准确度。具体代码如代码清单7-23所示。

代码清单7-23 wine _test.py(续)

# test over the last 28 of the wines in the data setwine_testers: List[List[float]] = wine_parameters[150:178]wine_testers_corrects: List[int] = wine_species[150:178]wine_results = wine_network.validate(wine_testers, wine_testers_corrects, wine_  interpret_output)print(f'{wine_results[0]} correct of {wine_results[1]} = {wine_results[2] * 100}%')

运气不错,这个神经网络模型应该能很准确地对28个样本进行分类。

27 correct of 28 = 96.42857142857143%

7.6 为神经网络提速

神经网络需要用到很多向量/矩阵方面的数学知识。从本质上说,这意味着要读取数据列表并立即对所有数据项进行某种操作。随着机器学习在社会生活中不断推广应用,经过优化的高性能向量/矩阵数学库变得越来越重要了。其中有很多库充分利用了GPU,因为GPU对上述用途进行过优化。向量/矩阵是计算机图形学的核心内容。大家可能对一个较早的库规范已有所耳闻,这个库规范就是基础线性代数子程序(Basic Linear Algebra Subprogram,BLAS)。NumPy是一种流行的Python数值库,它就是以BLAS为基础的。

除GPU之外,CPU还具有能够加速向量/矩阵处理的扩展指令。NumPy中就包括一些函数,这些函数采用了单指令多数据(Single Instruction, Multiple Data,SIMD)指令集。SIMD指令是一种特殊的微处理器指令,允许一次处理多条数据。有时SIMD会被称为向量指令(vector instruction)。

不同的微处理器包含的SIMD指令也不一样。例如,G4的SIMD扩展指令(21世纪00年代早期Mac中的PowerPC架构处理器)被称为AltiVec。与iPhone中的微处理器一样,ARM微处理器具有名为NEON的扩展指令。现代Intel微处理器则包含名为MMX、SSE、SSE2和SSE3的SIMD扩展指令。幸运的是,大家不需要知道这些指令有什么差异。NumPy之类的库会自动选择正确的指令,以便基于程序当前所处的底层架构实现高效计算。

因此,现实世界中的神经网络库(与本章的玩具库不同)会采用NumPy数组作为基本的数据结构,而不用Python标准库中的列表,这并不令人意外,但它们做的远不止这些。像TensorFlow和PyTorch这类流行的Python神经网络库,不仅采用SIMD指令,而且大量运用GPU进行计算。由于GPU明确就是为快速向量计算而设计的,因此与只在CPU上运行相比,GPU能将神经网络的运行速度提升一个数量级。

请明确一点:绝不能像本章这样只用Python的标准库来简单地实现神经网络产品,而应采用经过高度优化的、启用了SIMD和GPU的库,如TensorFlow。只有以下情况是例外,即为教学而设计或是只能在没有SIMD指令或GPU的嵌入式设备上运行的神经网络库。

7.7 神经网络问题及其扩展

得益于在深度学习方面所取得的进步,神经网络现在正在风靡,但它有一些显著的缺点。最大的问题是神经网络解决方案是一种类似于黑盒的模型。即便运行一切正常,用户也无法深入了解神经网络是如何解决问题的。例如,在本章中我们构建的鸢尾花数据集分类程序并没有明确展示输入的4个参数分别对输出的影响程度。在对每个样本进行分类时,萼片长度比萼片宽度更为重要吗?

如果对已训练网络的最终权重进行仔细分析,是有可能得出一些见解的,但这种分析并不容易,并且无法做到像线性回归算法那么精深,线性回归可以对被建模的函数中每个变量的作用做出解释。换句话说,神经网络可以解决问题,但不能解释问题是如何解决的。

神经网络的另一个问题是,为了达到一定的准确度,通常需要数据量庞大的数据集。想象一下户外风景图的分类程序。它可能需要对数千种不同类型的图像(森林、山谷、山脉、溪流、草原等)进行分类。训练用图可能就需要数百万张。如此大型的数据集不但难以获取,而且对某些应用程序而言可能根本就不存在。为了收集和存储如此庞大的数据集而拥有数据仓库和技术设施的,往往都是大公司和政府机构。

最后,神经网络的计算代价很高。可能大家已经注意到了,只是鸢尾花数据集的训练过程就能让Python解释器不堪重负。纯Python环境下(不带NumPy之类的C支持库)的计算性能是不太理想,但最要紧的是,在任何采用神经网络的计算平台上,训练过程都必须执行大量的计算,这会耗费很多时间。提升神经网络性能的技巧有很多(如使用SIMD指令或GPU),但训练神经网络终究还是需要执行大量的浮点运算。

有一条告诫非常好,就是训练神经网络比实际运用神经网络的计算成本高。某些应用程序不需要持续不断的训练。在这些情况下,只要把训练完毕的神经网络放入应用程序,就能开始求解问题了。例如,Apple的Core ML框架的第一个版本甚至不支持训练。它只能帮助应用程序开发人员在自己的应用程序中运行已训练过的神经网络模型。照片应用程序的开发人员可以下载免费的图像分类模型,将其放入Core ML,马上就能开始在应用程序中使用高性能的机器学习算法了。

本章只构建了一类神经网络,即带反向传播的前馈网络。如上所述,还有很多其他类型的神经网络。卷积神经网络也是前馈的,但它具有多个不同类型的隐藏层、各种权重分配机制和其他一些有意思的属性,使其特别适用于图像分类。而在反馈神经网络中,信号不只是往一个方向传播。它们允许存在反馈回路,并已经证明能有效应用于手写识别和语音识别等连续输入类应用。

我们可以对本章的神经网络进行一种简单的扩展,即引入偏置神经元(bias neuron),这会提升网络的性能。偏置神经元就像某个层中的一个虚拟神经元(dummy neuron),它允许下一层的输出能够表达更多的函数,这可以通过给定一个常量输入(仍通过权重进行修改)来实现。在求解现实世界的问题时,即便是简单的神经网络通常也会包含偏置神经元。如果在本章的现有神经网络中添加了偏置神经元,我们可能只需较少的训练就能达到相近级别的准确度。

7.8 现实世界的应用

尽管人工神经网络在20世纪中叶就已被首次设想出来,但直到近十年才变得司空见惯。由于缺乏性能足够强大的硬件,人工神经网络的广泛应用曾经饱受阻碍。现在机器学习领域中增长最火爆的就是人工神经网络了,因为它们确实有效!

近几十年以来,人工神经网络已经实现了一些最激动人心的用户交互类计算应用,包括实用语音识别(准确度足够实用)、图像识别和手写识别。语音识别应用存在于Dragon Naturally Speaking之类的录入辅助程序和Siri、Alexa、Cortana等数字助理中。Facebook运用人脸识别技术自动为照片中的人物打上标记,这是图像识别应用的一个实例。在最新版的iOS中,可以用手写识别功能搜索记事本中的内容,哪怕内容是手写的也没问题。

光学字符识别(Optical Character Recognition,OCR)是一种早期的识别技术,神经网络可以为其提供引擎。扫描文档时会用到OCR技术,它返回的不是图像,而是可供选择的文本。OCR技术能让收费站读取车牌信息,还能让邮政服务对信件进行快速分拣。

本章已演示了神经网络可成功应用于分类问题。神经网络能够获得良好表现的类似应用还有推荐系统。不妨考虑一下,Netflix推荐了你可能喜欢的电影,Amazon推荐了你可能想读的书。还有其他一些机器学习技术也适用于推荐系统(Amazon和Netflix不一定将神经网络用于推荐系统,它们的系统似乎是专用的),因此只有对所有可用技术都做过研究之后,才应该考虑采用神经网络。

任何需要近似计算某个未知函数的场合,都可以使用神经网络,这使它们很擅长预测。可以用神经网络来预测体育赛事、选举或股票市场的结果,事实上也确实如此。当然,预测的准确程度就要看训练有多好,与未知结果事件相关的可用数据集有多大,神经网络的参数调优程度如何,以及训练要迭代多少次了。像大多数神经网络应用一样,用于预测时最大的难点之一就是确定神经网络本身的结构,最终往往还是得靠反复试错来确定。

本文摘自《算法精粹 经典计算机科学问题的Python实现》

本书是一本面向中高级程序员的算法教程,借助Python语言,用经典的算法、编码技术和原理来求解计算机科学的一些经典问题。全书共9章,不仅介绍了递归、结果缓存和位操作等基本编程组件,还讲述了常见的搜索算法、常见的图算法、神经网络、遗传算法、k均值聚类算法、对抗搜索算法等,运用了类型提示等Python高级特性,并通过各级方案、示例和习题展开具体实践。
本书将计算机科学与应用程序、数据、性能等现实问题深度关联,定位独特,示例经典,适合有一定编程经验的中高级Python程序员提升用Python解决实际问题的技术、编程和应用能力。

(0)

相关推荐