理解神经网络 | 前馈人工神经网络为例

理解神经网络 | 前馈人工神经网络为例

当我们谈及人工智能时,我们大多数情况下指的是机器学习。机器学习是计算机科学的一个分支,它使计算机能够从数据中学习并改进自己的性能,而不需要显式编程。通俗地说,机器学习就像是教计算机通过观察大量示例来识别模式和规律,然后利用这些模式去做出预测或决策。

这种强大的能力通常是由神经网络技术驱动的。虽然神经网络早在20世纪中叶就被发明,但它们的真正复兴是由于深度学习的兴起。深度学习是一种新的范式,它通过构建和训练包含多层(即“深度”)的神经网络,使计算机能够处理更复杂的数据并执行更复杂的任务,如图像识别、语音识别和自然语言处理。

生物学

想要理解神经网络,可以从生物学角度展开。在大脑中,每个神经细胞称为神经元(neuron)。大脑中的神经元通过称为突触(synapse)的连接彼此连成网。当神经元接收到足够的电信号时,它会通过突触将信号传递给下一个神经元,这个过程称为神经传递。通过这种方式,神经元网络能够处理和传输信息,形成复杂的神经网络(neural network)。

人工神经网络

一种带有反向传播(backpropagation)的前馈(feed-forward)网络是最常见的一种人工神经网络类型。前馈意味着信号在网络中通常只向一个方向传递:从输入层到输出层。反向传播则表示在每次信号前向传播结束后,通过计算输出误差,误差会从输出层向输入层反向传递。这个过程调整网络中的权重,使得在未来的传播中误差减小,特别是针对对误差负最大责任的神经元进行权重调整。

神经元

人工神经网络中的最小单位是神经元。每个神经元拥有一个权重向量,即一组浮点数。输入向量(也是一些浮点数)将被传递给神经元。神经元通过点积操作将这些输入与其权重相乘并求和。然后,对该点积结果应用一个激活函数(activation function),并将其输出。

激活函数是对神经元输出的转换器。激活函数几乎总是非线性的,这使得神经网络能够处理和表示非线性问题。如果没有激活函数,则整个神经网络将只是一个线性变换,失去处理复杂模式的能力。

image-20240730153151489

分层

在本文所描述的前馈人工神经网络中,神经元被分为多个层。每层由一定数量的神经元排成行或列构成。信号总是从一层单向传递到下一层。每层中的神经元发送其输出信号,作为下一层神经元的输入。每层的每个神经元都与下一层的每个神经元相连。

一个简单的人工神经网络可以将层分为三类。第一层叫做“输入层”,它接收外部的信息。最后一层叫做“输出层”,它输出结果,这些结果需要外部人来解释才能理解它们的意义。输入层和输出层之间的层叫做“隐藏层”。在简单的神经网络中,可能只有一个隐藏层,但在复杂的深度学习网络中,隐藏层的数量可以非常多。

image-20240730153718130

反向传播

在前向传播过程中,网络会根据输入数据生成预测结果。然后,我们计算网络预测结果和实际结果之间的误差(损失函数),这个误差表示预测的准确程度。为了减小这种误差,我们需要修改神经元的权重(权重影响神经元的输出传递到下一层的重要性),为此,我们需要将误差从输出层向输入层逐层传播(反向),它通过计算每个神经元的梯度(即该神经元对总误差的贡献),来确定每个神经元的权重需要如何调整,以减少整体误差。

误差是在训练阶段计算的,通过比较神经网络的预测输出与已知的真实输出,来确定误差。训练阶段的目标是通过最小化这个误差来调整网络的权重。大多数神经网络在使用之前,都必须经过训练(监督机器学习)。我们必须知道通过某些输入能够获得的正确输出,以便用预期输出和实际输出的差异来查找误差并修正权重。神经网络在最开始时是一无所知的,直至它们知晓对于某组特定输入集的正确答案,在这之后才能为其他输入做好准备。反向传播仅发生在训练期间。

反向传播的过程细节有些许复杂,但其核心要义其实很好理解。首先,网络会根据输入数据产生一个预测结果。然后,我们将这个预测结果与实际的正确结果进行比较,计算出误差(即预测和实际之间的差距)。对于每个输出层的神经元,我们会计算它对整体误差的贡献。这是通过计算它的误差与它的激活函数的导数的乘积来完成的。这一步的结果叫做“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 个参数并没有经过什么论证,而是凭感觉输入。神经网络可以解决问题,但不能解释问题是如何解决的。同时,因为它的这种特性,这就导致了神经网络如果需要达到较高的准确率,则依赖于庞大的数据集,对于个人而言,获取庞大的数据集并不是容易的事情。就算个人搞定了数据集,训练过程也必须执行大量的计算,这非常耗费时间和性能。

不过好在,运行训练完成的神经网络所需的计算成本远远低于训练时的成本。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值