理解神经网络 | 前馈人工神经网络为例
当我们谈及人工智能时,我们大多数情况下指的是机器学习。机器学习是计算机科学的一个分支,它使计算机能够从数据中学习并改进自己的性能,而不需要显式编程。通俗地说,机器学习就像是教计算机通过观察大量示例来识别模式和规律,然后利用这些模式去做出预测或决策。
这种强大的能力通常是由神经网络技术驱动的。虽然神经网络早在20世纪中叶就被发明,但它们的真正复兴是由于深度学习的兴起。深度学习是一种新的范式,它通过构建和训练包含多层(即“深度”)的神经网络,使计算机能够处理更复杂的数据并执行更复杂的任务,如图像识别、语音识别和自然语言处理。
生物学
想要理解神经网络,可以从生物学角度展开。在大脑中,每个神经细胞称为神经元(neuron)。大脑中的神经元通过称为突触(synapse)的连接彼此连成网。当神经元接收到足够的电信号时,它会通过突触将信号传递给下一个神经元,这个过程称为神经传递。通过这种方式,神经元网络能够处理和传输信息,形成复杂的神经网络(neural network)。
人工神经网络
一种带有反向传播(backpropagation)的前馈(feed-forward)网络是最常见的一种人工神经网络类型。前馈意味着信号在网络中通常只向一个方向传递:从输入层到输出层。反向传播则表示在每次信号前向传播结束后,通过计算输出误差,误差会从输出层向输入层反向传递。这个过程调整网络中的权重,使得在未来的传播中误差减小,特别是针对对误差负最大责任的神经元进行权重调整。
神经元
人工神经网络中的最小单位是神经元。每个神经元拥有一个权重向量,即一组浮点数。输入向量(也是一些浮点数)将被传递给神经元。神经元通过点积操作将这些输入与其权重相乘并求和。然后,对该点积结果应用一个激活函数(activation function),并将其输出。
激活函数是对神经元输出的转换器。激活函数几乎总是非线性的,这使得神经网络能够处理和表示非线性问题。如果没有激活函数,则整个神经网络将只是一个线性变换,失去处理复杂模式的能力。
分层
在本文所描述的前馈人工神经网络中,神经元被分为多个层。每层由一定数量的神经元排成行或列构成。信号总是从一层单向传递到下一层。每层中的神经元发送其输出信号,作为下一层神经元的输入。每层的每个神经元都与下一层的每个神经元相连。
一个简单的人工神经网络可以将层分为三类。第一层叫做“输入层”,它接收外部的信息。最后一层叫做“输出层”,它输出结果,这些结果需要外部人来解释才能理解它们的意义。输入层和输出层之间的层叫做“隐藏层”。在简单的神经网络中,可能只有一个隐藏层,但在复杂的深度学习网络中,隐藏层的数量可以非常多。
反向传播
在前向传播过程中,网络会根据输入数据生成预测结果。然后,我们计算网络预测结果和实际结果之间的误差(损失函数),这个误差表示预测的准确程度。为了减小这种误差,我们需要修改神经元的权重(权重影响神经元的输出传递到下一层的重要性),为此,我们需要将误差从输出层向输入层逐层传播(反向),它通过计算每个神经元的梯度(即该神经元对总误差的贡献),来确定每个神经元的权重需要如何调整,以减少整体误差。
误差是在训练阶段计算的,通过比较神经网络的预测输出与已知的真实输出,来确定误差。训练阶段的目标是通过最小化这个误差来调整网络的权重。大多数神经网络在使用之前,都必须经过训练(监督机器学习)。我们必须知道通过某些输入能够获得的正确输出,以便用预期输出和实际输出的差异来查找误差并修正权重。神经网络在最开始时是一无所知的,直至它们知晓对于某组特定输入集的正确答案,在这之后才能为其他输入做好准备。反向传播仅发生在训练期间。
反向传播的过程细节有些许复杂,但其核心要义其实很好理解。首先,网络会根据输入数据产生一个预测结果。然后,我们将这个预测结果与实际的正确结果进行比较,计算出误差(即预测和实际之间的差距)。对于每个输出层的神经元,我们会计算它对整体误差的贡献。这是通过计算它的误差与它的激活函数的导数的乘积来完成的。这一步的结果叫做“Delta值”。然后,我们需要计算隐藏层神经元的Delta值。这个过程类似于计算输出层的Delta值,但需要考虑隐藏层神经元如何影响输出层的误差。对于每个隐藏层的神经元,我们计算它对输出层误差的贡献。这通过查看隐藏层神经元与下一层的连接权重来完成。然后,我们将这个贡献值乘以该隐藏层神经元的激活函数的导数,得到该神经元的Delta值。最后,使用这些Delta值来调整网络中所有神经元的权重。通过调整权重,我们希望减少网络的总体误差。
网络中每个神经元的权重都会进行更新,更新方式是把每个权重的最近一次输入、神经元的 delta 和一个名为学习率(learning rate)的数相乘,再将结果与现有权重相加。这种改变神经元权重的方式被称为梯度下降(gradient descent)。
构建神经网络
为了便于理解这一过程,我们可以尝试自己手动实现一个神经网络(当然,您在真正开发项目时并不必如此。同时,本文构建的神经网络也无法符合您的需求,仅能作为学习案例)
环境准备
您只需要准备一个Python 3.7 及以上的编程环境即可。本文默认您掌握Python的语法,并能够进行相关编程操作。
点积与激活函数
我们需要构建一份工具代码util.py以存放一些常用计算函数。正如在上文所了解到的,反向传播的过程需要使用到点积,可以尝试编写一个点积方法:
from typing import List
from math import exp
def dot_product(xs: List[float], ys: List[float]) -> float:
return sum(x * y for x, y in zip(xs, ys))
神经网络的主要任务是学习复杂的模式和关系,为此需要能够拟合非线性的数据和函数,以处理复杂的非线性问题。同时,激活函数能够将输出限制在一个合理范围,防止过大或过小。激活函数的作用发生在信号被传递到下一层之前,其对神经元的输出进行转换。常用的激活函数如下表所示:
激活函数 | 作用 |
---|---|
Sigmoid函数 | 将输出限制在0到1之间,适合用于二分类问题的输出层。 |
Tanh函数 | 将输出限制在-1到1之间,通常比Sigmoid函数表现更好,因为它的输出范围更广。 |
ReLU函数(修正线性单元) | 对于正输入,输出等于输入,对于负输入,输出为0。ReLU函数计算简单且能有效缓解梯度消失问题。 |
Leaky ReLU和ELU(指数线性单元) | 这些是ReLU的改进版本,旨在进一步改进网络的性能。 |
此处我们采用Sigmoid函数,在util.py中续写如下代码:
# 激活函数
def 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)
神经元的实现
我们在neuron.py中编写如下代码,以定义神经网络中的神经元。它包含了神经元的基本功能和属性:
from typing import List, Callable
from util import dot_product
class 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)
初始化方法中存在一系列参数,其意义如下:
weights: List[float]
:神经元的权重,这里是一个浮点数列表,每个浮点数代表一个输入的权重。
learning_rate: float
:学习率,用于在训练过程中更新权重。
activation_function: Callable[[float], float]
:激活函数,它是一个接受一个浮点数并返回一个浮点数的函数,用于计算神经元的输出。
derivative_activation_function: Callable[[float], float]
:激活函数的导数,用于计算激活函数的梯度。
output_cache: float
:缓存神经元的输出值,以避免在计算过程中重复计算。
delta: float
:用于存储神经元的Delta值,这是在反向传播过程中计算得到的,用于调整权重。
神经元还具有输出:
inputs: List[float]
:神经元的输入值,是一个浮点数列表。
self.output_cache
:计算输入值和权重的点积,存储在 output_cache
中。这是神经元在激活函数应用之前的加权总和。
dot_product(inputs, self.weights)
:调用 dot_product
函数计算输入向量和权重向量的点积。
return self.activation_function(self.output_cache)
:将计算得到的点积值传递给激活函数,得到神经元的最终输出。
层的实现
神经网络的每一层需要同时维护三种数据状态:所含的神经元、上一层的缓存和输出的缓存。为此可以编写layer.py,初始化层对象:
from __future__ import annotations
from typing import List, Callable, Optional
from random import random
from neuron import Neuron
from util import dot_product
class 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:
# 保存上一层的引用(如果是输入层,则为 None)
self.previous_layer: Optional[Layer] = previous_layer
# 初始化当前层的神经元列表
self.neurons: List[Neuron] = []
# 创建当前层的神经元
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类中中编写输出函数:
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
对于输出函数而言,如果当前层是输入层,outputs
方法直接将输入数据作为输出,并更新 output_cache
。如果当前层不是输入层,则遍历该层的所有神经元,计算每个神经元的输出,并将这些输出存储在 output_cache
中。 方法最后返回当前层的输出缓存 self.output_cache
,供网络的下一层使用。
在反向传播时需要计算两种不同类型的 delta,即输出层中神经元的 delta 和隐藏层中神经元的delta。因此Layer类中应该包含这两个方法:
def calculate_deltas_for_output_layer(self, expected: List[float]) -> None:
# 遍历当前层的每个神经元
for n in range(len(self.neurons)):
# 计算当前神经元的误差(delta)
# delta = 激活函数的导数 * (期望输出 - 实际输出)
self.neurons[n].delta = (self.neurons[n].derivative_activation_function(self.neurons[n].output_cache)
* (expected[n] - self.output_cache[n]))
def 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]
# 获取下一层中所有神经元的误差(delta)
next_deltas: List[float] = [n.delta for n in next_layer.neurons]
# 计算加权误差的总和
sum_weights_and_deltas: float = dot_product(next_weights, next_deltas)
# 计算当前神经元的误差(delta)
# delta = 激活函数的导数 * 下一层加权误差的总和
neuron.delta = neuron.derivative_activation_function(neuron.output_cache) * sum_weights_and_deltas
神经网络的实现
神经网络对象本身只包含一种状态数据,即神经网络管理的层对象。Network 类负责初始化其构成层,在network.py中编写初始化方法:
from __future__ import annotations
from typing import List, Callable, TypeVar, Tuple
from functools import reduce
from layer import Layer
from util import sigmoid, derivative_sigmoid
T = TypeVar('T')
class 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:
# 检查网络结构是否至少包含3层(输入层、隐藏层和输出层)
if len(layer_structure) < 3:
raise ValueError("Error: Should be at least 3 layers (1 input, 1 hidden, 1 output)")
# 初始化一个空列表,用于存储网络中的所有层
self.layers: List[Layer] = []
# 创建输入层,前一层为None(因为输入层没有前一层)
input_layer: Layer = Layer(None, layer_structure[0], learning_rate,
activation_function, derivative_activation_function)
# 将输入层添加到网络的层列表中
self.layers.append(input_layer)
# 遍历层结构中的其余层(隐藏层和输出层)
for previous, num_neurons in enumerate(layer_structure[1:]):
# 创建当前层,前一层为之前添加的层
next_layer = Layer(self.layers[previous], num_neurons, learning_rate,
activation_function, derivative_activation_function)
# 将当前层添加到网络的层列表中
self.layers.append(next_layer)
Network类具有一个输出方法:
def outputs(self, o_input: List[float]) -> List[float]:
return reduce((lambda inputs, layer: layer.outputs(inputs)), self.layers, o_input)
使用 reduce 函数将输入数据传递给每一层,计算并返回最终的输出。
Network类还需要一个方法计算网络中每个神经元的delta,这里就是依次调用层对象中那两个计算delta的方法:
def backpropagate(self, expected: List[float]) -> None:
# 计算网络输出层的误差(delta)
last_layer: int = len(self.layers) - 1
self.layers[last_layer].calculate_deltas_for_output_layer(expected)
# 从倒数第二层开始,反向计算每一层的误差(delta)
for i in range(last_layer - 1, 0, -1):
# 计算隐藏层的误差(delta),需要使用下一层的误差
self.layers[i].calculate_deltas_for_hidden_layer(self.layers[i + 1])
这里虽然计算了误差,但并不会去更改网络中的权重。权重的更改依赖于判断误差,因此必须在执行完backpropagate()
函数后,才能够被调用,此处Network类加入更新权重的方法:
def update_weights(self) -> None:
# 遍历网络中的每一层,从第二层开始(跳过输入层)
for layer in self.layers[1:]:
# 遍历当前层的每个神经元
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))
那么,一个完整的流程是,经过训练后,计算误差,并依靠误差,更改权重。Network类应该有一个训练方法:
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)
# 基于期望输出和实际输出进行反向传播,计算误差(delta)
self.backpropagate(ys)
# 更新所有神经元的权重
self.update_weights()
经过训练后,Network需要进行验证,该类理应拥有一个验证方法,以检验神经网络预测的准确性:
def 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
这样,就完成了一个简单且通用的人工神经网络的构建。
解决分类问题
由于分类问题的数据集获取较为简单,此处尝试使用上文构建的人工神经网络解决分类问题。解决分类问题的机器学习技术有很多。例如支持向量机(support vector machine)、决策树(decision tree)或朴素贝叶斯分类算法(naive Bayes classifier)。不过,当前使用神经网络技术对图像进行分类成为一种主流。神经网络拥有强大的潜能。
数据归一化
在被输入神经网络之前,待处理的数据集通常需要进行一些清理。清理可能会包括移除无关字符、删除重复项、修复错误等。对于即将被处理的两个数据集,需要执行的清理工作就是归一化。所谓归一化,就是读取以不同尺度(scale)记录的属性值,并将它们转换为相同的尺度。
我们上文实现的激活函数sigmoid会使神经网络中的每个神经元输出 0 到 1 之间的值,因此我们就将数据的尺度范围转为0到1之间。对于最大值为 max、最小值为 min 的某个属性范围内的任意值V,转换公式就是 newV = (oldV- min)/(max - min)这种转换被成为特征缩放。在util.py中可以实现此方法:
def 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)):
# 将当前值归一化为 [0, 1] 之间的值
dataset[row_num][col_num] = (dataset[row_num][col_num] - minimum) / (maximum - minimum)
鸢尾花分类问题
数据集可以在此处进行下载。鸢尾花数据集来自美国加利福尼亚大学的 UCI 机器学习库。该数据集包含 150 个鸢尾花植物样本,分为 3 个不同的品种,每个品种 50 个样本。每种植物以 4 种不同的属性进行考量:萼片长度、萼片宽度、花瓣长度和花瓣宽度。
在iris_test.py中,我们尝试读取鸢尾花数据集,并进行数据处理:
import csv
from typing import List
from util import normalize_by_feature_scaling
from network import Network
from random import shuffle
if __name__ == "__main__":
# 初始化存储数据的列表
iris_parameters: List[List[float]] = [] # 存储鸢尾花数据的特征值
iris_classifications: List[List[float]] = [] # 存储鸢尾花数据的分类标签
iris_species: List[str] = [] # 存储鸢尾花的物种标签
# 打开 'iris.csv' 文件进行读取
with open('iris.csv', mode='r') as iris_file:
# 使用 csv.reader 读取文件内容,并将其转换为列表
irises: List = list(csv.reader(iris_file))
# 随机打乱数据行的顺序
shuffle(irises)
# 遍历每一行数据
for iris in irises:
# 提取前四列(特征值)并将其转换为浮点数
parameters: List[float] = [float(n) for n in iris[0:4]]
iris_parameters.append(parameters) # 将特征值添加到 iris_parameters 列表中
# 提取第五列(物种标签)
species: str = iris[4]
# 将物种标签转换为分类标签的 one-hot 编码
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 列表中
iris_species.append(species)
# 对特征值进行归一化处理
normalize_by_feature_scaling(iris_parameters)
我们需要根据预测输出来确定预测物种(最大值),可以编写如下方法:
def iris_interpret_output(output: List[float]) -> str:
# 如果最大值是第一个元素,则说明预测的物种是 "Iris-setosa"
if max(output) == output[0]:
return "Iris-setosa"
# 如果最大值是第二个元素,则说明预测的物种是 "Iris-versicolor"
elif max(output) == output[1]:
return "Iris-versicolor"
# 否则,预测的物种是 "Iris-virginica"
else:
return "Iris-virginica"
然后,步入正题。在main方法下续写,创建一个神经网络对象:
iris_network: Network = Network([4, 6, 3], 0.3)
参数给定了包含 3 层(1 个输入层、1 个隐藏层和 1 个输出层)的网络[4, 6, 3]。输入层包含 4 个神经元,隐藏层包含 6 个神经元,输出层包含 3 个神经元。输入层中的 4 个神经元直接映射到用于对每个样本进行分类的 4 个参数。输出层中的 3 个神经元直接映射到 3 个不同的品种,对于每次的输入,我们都要分类为这 3 个品种。
然后,划分训练集并进行50次训练迭代:
# 从 iris_parameters 中提取前 140 个样本作为训练数据
iris_trainers: List[List[float]] = iris_parameters[0:140]
# 从 iris_classifications 中提取前 140 个样本的正确分类标签作为训练数据的标签
iris_trainers_corrects: List[List[float]] = iris_classifications[0:140]
# 进行 50 次训练迭代
for _ in range(50):
# 使用提取的训练数据和正确分类标签训练神经网络
iris_network.train(iris_trainers, iris_trainers_corrects)
划分测试集并进行验证:
# 从 iris_parameters 中提取第 141 到第 150 个样本作为测试数据
iris_testers: List[List[float]] = iris_parameters[140:150]
# 从 iris_species 中提取第 141 到第 150 个样本的实际分类标签作为测试数据的标签
iris_testers_corrects: List[str] = iris_species[140:150]
# 使用测试数据和实际分类标签对训练好的神经网络进行验证
# iris_interpret_output 用于将神经网络的输出概率转换为具体的物种名称
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}%")
一个可能的结果为:
9 correct of 10 = 90.0%
为什么?
神经网络虽然如此强大,但它却是一种黑箱。即便运行一切正常,用户也无法深入了解神经网络是如何解决问题的。例如,我们构建的鸢尾花数据集分类程序输入的 4 个参数并没有经过什么论证,而是凭感觉输入。神经网络可以解决问题,但不能解释问题是如何解决的。同时,因为它的这种特性,这就导致了神经网络如果需要达到较高的准确率,则依赖于庞大的数据集,对于个人而言,获取庞大的数据集并不是容易的事情。就算个人搞定了数据集,训练过程也必须执行大量的计算,这非常耗费时间和性能。
不过好在,运行训练完成的神经网络所需的计算成本远远低于训练时的成本。