← 返回蜂巢洞察

神经网络解析:它们究竟是什么,以及如何使用Python来构建一个神经网络

你是否曾经好奇过,计算机是如何识别手写数字的,如何判断一封邮件是否属于垃圾邮件,如何推荐视频,又如何理解句子的含义呢? 许多现代人工智能系统都依赖于一种被称为 神经网络 的技术。 不过,这个名称可能会让人们觉得这种技术比实际情况要复杂得多。你可能会认为,构建一个神经网络需要高级的数学知识、一台性能强大的计算机以及成千上万的代码行。 其实并非如此。 从最基础的角度来看,神经网络是一种数学模型。它以一些数字作为输入,对这些数字进行计算,然后得出预测结果,再判断这个预测结果与正确答案之间的差距,并据此调整自身的参数,以便在下一次计算时能够取得更好的效果。 在本次教程中,我们将使用Python和Num

你是否曾经好奇过,计算机是如何识别手写数字的,如何判断一封邮件是否属于垃圾邮件,如何推荐视频,又如何理解句子的含义呢?

许多现代人工智能系统都依赖于一种被称为神经网络的技术。

不过,这个名称可能会让人们觉得这种技术比实际情况要复杂得多。你可能会认为,构建一个神经网络需要高级的数学知识、一台性能强大的计算机以及成千上万的代码行。

其实并非如此。

从最基础的角度来看,神经网络是一种数学模型。它以一些数字作为输入,对这些数字进行计算,然后得出预测结果,再判断这个预测结果与正确答案之间的差距,并据此调整自身的参数,以便在下一次计算时能够取得更好的效果。

在本次教程中,我们将使用Python和NumPy亲手构建一个神经网络。

我们将会涵盖以下内容:

我们将从单个人工神经元开始,逐步构建出一个完整的神经网络。通过这个过程,你将会理解权重和偏置的含义,了解激活函数的作用,明白网络是如何从错误中学习的,也弄清楚反向传播和梯度下降的具体原理,以及这些组成部分是如何协同工作的。

你不需要掌握高级的机器学习知识就能跟上教学进度。掌握一些基本的Python编程知识和代数知识会有帮助,不过在讲解过程中我会把相关的数学公式解释得通俗易懂。

1. 什么是神经网络?

让我们从一个简单的例子开始吧。

假设我们希望让计算机预测一个学生是否能通过考试。

我们可以向计算机提供以下信息:

  • 这个学生学习了多少个小时

  • 他们完成了多少道练习题

  • 他们之前的考试成绩是多少

例如:

学习时间 = 5小时 练习题数量 = 80道 之前成绩 = 82分

我们还知道这个学生最终是否通过了考试:

是否通过 = 1

在看到很多这样的例子之后,我们希望计算机能够从中学习到某种规律。

也许学习时间越长的学生表现会越好,也许之前的考试成绩也有参考价值,又或许完成一定数量的练习题也会有帮助。

我们不需要自己编写所有这些规则,而是可以将这些例子输入到神经网络中,让它自己去发现其中的关联关系。

其基本原理如下图所示:

神经网络的工作原理示意图

预测结果可能是像0.92这样的数值。

如果我们要预测通过考试的概率,就可以将这个数值理解为有大约92%的可能性能够通过考试。

重要的是,我们并没有直接告诉神经网络:“学习时间很重要,而之前的考试成绩更为重要”。

相反,神经网络会自行学习一些被称为“权重”的数值,这些数值决定了不同输入因素对预测结果的影响程度。

因此,这类系统才被称作“神经网络”。

2. 为什么叫神经网络?

这个名称来源于生物大脑的结构。

你的大脑中含有神经元,这些神经元能够接收信号、处理信息,并将结果传递给其他神经元。

人工神经网络并不是真正的人造大脑,它们的工作原理与生物神经元并不完全相同。但通过将许多简单的处理单元连接起来这一基本思路,人们才给这类系统起了“神经网络”这个名字。

一个非常简化的人工神经元结构如下图所示:

人工神经元的输入与输出过程

神经元接收数值,进行一些数学运算,然后产生另一个数值。

通过将许多这样的人工神经元连接在一起,就可以构成一个神经网络。

3. 神经网络的三个主要组成部分

一个简单的神经网络可以分为三种类型的层:

  1. 输入层

  2. 隐藏层

  3. 输出层

让我们来逐一了解这些层。

输入层

输入层包含了我们提供给网络的信息。

以我们的学生示例来说,可以有三种输入:

输入1 = 学习时间
输入2 = 练习题数量
输入3 = 之前的考试成绩

因此,某个学生的输入数据可能如下所示:

[5, 80, 82]

对于网络而言,这些数字并不一定代表“学习时间”或“考试分数”;对网络的数学处理部分来说,它们只不过是普通的数值而已。

这一点非常重要,需要牢记:

神经网络处理的对象是数值。

图像、文本、音频等其他形式的信息,在被神经网络处理之前,都必须先转化为数值形式。

隐藏层

在输入层之后就是隐藏层。

一个神经网络的结构可能如下所示:

示意图展示数据如何从输入层传递到隐藏层,再传到输出层

隐藏层包含那些会对输入数据进行计算处理的神经元。

一个神经网络可以只有一个隐藏层,也可以有多个隐藏层。

当一个神经网络拥有多个隐藏层时,我们通常会称它为深度神经网络

输出层

输出层会生成最终的结果。

对于简单的“是/否”类型的问题,我们可以用以下方式来表示答案:

0 = 否
1 = 是

例如:

0.12 → 很可能是否
0.91 → 很可能是是

对于涉及多个类别的问题,输出结果可能会包含多个数值:

猫 = 0.05
狗 = 0.90
鸟 = 0.05

其中最大的数值对应“狗”,因此模型会预测结果是“狗”。

4. 什么是神经元?

现在让我们来详细了解一下一个神经元的工作原理。

假设我们的神经元接收三个输入值:

x₁
x₂
x₃

每个输入值都对应着一个权重

w₁
w₂
w₃

神经元会将每个输入值与其对应的权重相乘,然后将所有结果相加,从而得到最终的输出值。

它还会添加一个被称为偏置的数值。

相应的方程式为:

z = x₁w₁ + x₂w₂ + x₃w₃ + b

如果这个方程式看起来令你感到困惑,也别担心。

其实它的含义就是:

输入值 × 权重
+
输入值 × 权重
+
输入值 × 权重
+
偏置

让我们用具体的数字来举例说明吧。

假设:

x₁ = 2
x₂ = 3
x₃ = 4

w₁ = 0.5
w₂ = 0.2
w₃ = 0.8

b = 1

那么:

z = (2 × 0.5) + (3 × 0.2) + (4 × 0.8) + 1

我们先计算每一部分的值:

2 × 0.5 = 1.0
3 × 0.2 = 0.6
4 × 0.8 = 3.2

然后将它们相加:

z = 1.0 + 0.6 + 3.2 + 1
z = 5.8

因此,这个神经元的计算结果为5.8

但我们的讨论还没有结束。

5. 什么是权重?

权重的作用是决定输入值对神经元的影响程度。

假设我们有:

x = 5

如果权重为:

w = 2

那么:

x × w = 5 × 2
      = 10

但如果是:

w = 0.1

那么:

x × w = 5 × 0.1
      = 0.5

同样的输入值,由于权重的不同,计算结果会相差很大。

你可以把权重想象成音量调节旋钮:较大的正权重会让输入值产生更强的正向影响,接近零的权重则会使输入值的影响减弱,而负权重则会使结果朝相反的方向变化。

神经网络会在训练过程中学习这些权重的数值。

6. 什么是偏置?

偏置也是加在神经元计算结果中的一个数值。

如果没有偏置,我们的公式就会变成:

z = x₁w₁ + x₂w₂ + x₃w₃

而加上偏置之后,公式就变成了:

z = x₁w₁ + x₂w₂ + x₃w₃ + b

为什么要添加这个额外的数值呢?因为这能为神经元带来更多的灵活性。

可以把偏置看作是调整神经元计算起点的机制。

神经网络会在训练过程中像学习权重一样来学习偏置的值。

所以当你看到:

权重 + 偏置

时,你就知道这些参数是神经网络在训练过程中可以调整的。

7. 为什么需要激活函数?

目前,我们的神经元已经能够计算出各种输入值的加权和:

z = x₁w₁ + x₂w₂ + ... + b

但神经网络需要学习的不仅仅是这种简单的加权和关系。

这时激活函数就派上了用场。激活函数会接收神经元计算出的结果,并对其进行处理或转换。

一种常见的激活函数是ReLU。ReLU代表修正线性单元

它的计算公式为:

ReLU(x) = max(0, x)

简单来说:

  • 如果数值是正数,就保持原值不变。

  • 如果数值是负数,就将其转换为0。

例如:

ReLU(-5) = 0
ReLU(-2) = 0
ReLU(0) = 0
ReLU(3) = 3
ReLU(10) = 10

在Python中,可以这样编写:

def relu(x):
    return max(0, x)

对于NumPy数组,也可以使用以下方式:

def relu(x):
    return np.maximum(0, x)

激活函数之所以重要,是因为它们能够让具有多层的神经网络学习更复杂的模式。

8. 用Python构建我们的第一个神经元

现在让我们把这些数学公式转化为Python代码吧。

首先,导入NumPy:

import numpy as np

NumPy为我们提供了处理数字、数组、向量和矩阵的工具。

接下来创建输入值:

x = np.array([2, 3, 4])

这样我们就创建了一个包含三个数值的数组:

[2, 3, 4]

然后创建权重值:

weights = np.array([0.5, 0.2, 0.8])

每个输入都有一个对应的权重:

x₁ = 2    w₁ = 0.5
x₂ = 3    w₂ = 0.2
x₃ = 4    w₃ = 0.8

接下来创建偏置值:

bias = 1

现在计算加权和:

z = np.dot(x, weights) + bias

np.dot()函数执行了我们之前介绍的乘法运算后再进行加法运算的操作。

在这个例子中:

np.dot(x, weights)

相当于:

4.8

最后加上偏置值:

4.8 + 1 = 5.8

接下来应用ReLU激活函数:

output = np.maximum(0, z)

因为z的值为5.8,所以ReLU函数会保持这个数值不变:

output = 5.8

最后输出结果:

print(output)

运行后将会输出:

5.8

因此,我们的整个神经元模型就是这样的:

import numpy as np

x = np.array([2, 3, 4])
weights = np.array([0.5, 0.2, 0.8])
bias = 1

z = np.dot(x, weights) + bias
output = np.maximum(0, z)

print(output)

我们刚刚创建了一个简单的人工神经元模型。

9. 从一个神经元到一个神经层

对于大多数有趣的问题来说,单个神经元是远远不够的。

因此,我们可以将多个神经元连接在一起。

例如:

输入层、输出层和隐藏层由神经元构成

这些神经元组合在一起,就形成了一个

一个简单的神经网络可能看起来像这样:

输入层
     ↓
隐藏层
     ↓
输出层

同一层中的每个神经元都可以将其输出结果发送给下一层的神经元。

正是这一点,使得神经网络具备了更强的功能。

10. 神经网络究竟是如何学习的?

到目前为止,我们都是手动为神经元设置权重的:

0.5
0.2
0.8

但真实的神经网络在开始运行时,并不知道正确的权重值。

相反,它的初始权重通常会被设置为一些随机的小数值。

然后,它会进入一个循环过程:

进行预测
       ↓
将预测结果与正确答案进行比较
       ↓
计算误差
       ↓
确定如何调整权重
       ↓
更新权重
       ↓
重新尝试预测

这个过程会不断重复进行,神经网络会逐渐调整自身的参数,从而在训练数据上做出更准确的预测。

让我们来详细分析一下这个过程的每个步骤。

11. 预测与损失值

假设正确答案是:

1

但我们的神经网络预测的结果却是:

0.3

这个预测结果与正确答案相差甚远。

我们需要一种方法来衡量这种误差的大小,而损失函数正是用来实现这一目的的。

损失函数会接收预测结果和正确答案,然后计算出一个数值,代表模型的误差大小。

以一个简单的例子来说,我们可以使用平方误差作为损失函数:

损失值 = (预测结果 - 正确答案)²

用我们之前的数字来举例:

损失值 = (0.3 - 1)²

首先计算差值:

0.3 - 1 = -0.7

然后对这个差值进行平方:

(-0.7)² = 0.49

因此,损失值为:

0.49

一般来说,损失值越小,说明预测结果与正确答案的差距就越小。

在真实的神经网络中,不同类型的问题会使用不同的损失函数。对于二分类问题,通常会使用二元交叉熵作为损失函数。

12. 什么是梯度?

现在我们遇到了一个问题:

我们知道预测结果是错误的,但应该如何调整权重呢?

这时梯度就派上了用场。梯度能够告诉我们改变某个参数会如何影响损失值的大小。

你可以把这种情况想象成站在一座山上。假设你的目标是到达山的最底部,那么如果你知道哪个方向是上坡路,就可以朝相反的方向前进,从而下山。

训练神经网络的过程也是基于类似的原理。我们的目标就是减少损失值,而梯度则能告诉我们参数应该朝哪个方向进行调整。

13. 什么是梯度下降?

梯度下降其实就是利用梯度来调整神经网络中各参数的值。

一个简化的更新公式如下:

新权重 = 旧权重 - 学习率 × 梯度

在Python中表示为:

重量 = 重量 - 学习率 * 梯度

学习率决定了参数更新的大小。

举个例子:

学习率 = 0.01

如果学习率太大,网络可能会发生剧烈的变化,甚至可能无法找到合适的解决方案;而如果学习率太小,训练过程将会花费很长时间。

因此,在训练神经网络时,我们需要找到既能使模型损失值降低,又不会导致训练过程不稳定的参数更新方法。

14. 什么是反向传播?

还有一个重要的问题需要解答:

如果一个神经网络拥有成千上万的权重,它该如何判断哪些权重是导致误差的原因呢?

这时反向传播就派上了用场。反向传播通过从输出层开始,逐层向回计算梯度,从而确定各个参数应该如何调整。

想象这样一个神经网络结构:

输入 → 隐藏层 → 输出 → 损失值

在正向传播过程中,信息是从输入层逐层传递到输出层的;而在反向传播过程中,梯度信息则是从输出层开始,逐层向回传递的。

神经网络就是利用这些梯度来决定自己的权重和偏置应该如何变化。

在实际构建神经网络时,我们通常不需要手动计算所有这些导数,像PyTorch这样的库可以自动完成这些计算工作。

不过,理解这一基本原理仍然是非常重要的:

反向传播用于确定哪些参数导致了误差,而梯度下降则利用这些信息来更新这些参数的值。

15. 完整的学习周期

现在我们可以把所有这些内容综合起来理解了。

神经网络的学习周期:输入、预测、损失值、梯度计算、参数更新——然后重新开始预测……

更具体地说:

输入网络数据
          ↓
计算预测结果
          ↓
将其与正确答案进行比较
          ↓
计算损失值
          ↓
计算梯度
          ↓
更新权重和偏置
          ↓
重复上述步骤

一次完整地处理所有训练数据的过程通常被称为一个训练周期

例如:

第1个训练周期 → 损失值:0.82
第2个训练周期 → 损失值:0.61
第3个训练周期 → 损失值:0.43
第4个训练周期 → 损失值:0.29
第5个训练周期 → 损失值:0.18

这些数值仅作为示例,理想情况下,随着训练的进行,损失值应该是逐渐降低的。

16. 让我们从零开始构建一个神经网络

恭喜!你现在已经了解了神经网络的基本原理。接下来,是时候将这些知识应用到实际中了。

我们将仅使用以下工具来构建这个小型神经网络:

Python + NumPy

我们的神经网络将用于学习一个经典的机器学习问题——XOR运算

XOR是一种需要两个输入的逻辑运算。

它的运算规则如下:

0 XOR 0 → 0
0 XOR 1 → 1
1 XOR 0 → 1
1 XOR 1 → 0

换句话说,只有当其中一个输入为1时,输出结果才会是1

因此,我们的训练数据将会是这样的:

X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])

而正确的答案则是:

y = np.array([
    [0],
    [1],
    [1],
    [0]
])

我们的目标就是让神经网络学会这种规律。

17. 理解网络架构

我们的神经网络将由以下部分组成:

2个输入神经元
       ↓
4个隐藏神经元
       ↓
1个输出神经元

这两个输入节点分别代表每个XOR例子中的两个数字。

4个隐藏神经元使网络具备足够的灵活性,从而能够学习到XOR运算的规律。

输出神经元会生成一个介于01之间的数值。

18. 准备训练数据

让我们开始编写Python程序吧。

import numpy as np

这段代码用于导入NumPy库。我们会利用NumPy来处理数组、进行矩阵运算以及执行各种数学计算。

接下来:

X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])

X变量中存储了我们的4个训练样本。

每一行代表一个样本:

[0, 0]
[0, 1]
[1, 0]
[1, 1]

现在我们来创建对应的正确答案:

y = np.array([
    [0],
    [1],
    [1],
    [0]
])

X中的第一行对应着y中的第一行。

因此:

[0, 0] → 0
[0, 1] → 1
[1, 0] → 1
[1, 1] → 0

19. 创建权重和偏置值

现在我们需要确定我们网络的各项参数。

首先:

np.random.seed(42)

这样我们生成的随机数就可以被重复使用了。

如果不添加这一行代码,每次运行程序时,网络都会获得不同的随机初始权重值。

接下来创建第一层的权重:

W1 = np.random.randn(2, 4)

为什么是 (2, 4) 呢?

因为:

  • 我们有2个输入值。

  • 隐藏层中有4个神经元。

因此 W1 需要包含2个输入值与4个隐藏神经元之间的连接权重。

这样的权重总数为:

2 × 4 = 8

个。

接下来:

b1 = np.zeros((1, 4))

这样就可以为每个隐藏神经元生成一个偏置值。

现在来看第二层:

W2 = np.random.randn(4, 1)

隐藏层有4个神经元,输出层有1个神经元,因此需要的权重数为:

4 × 1 = 4

个。

最后:

b2 = np.zeros((1, 1))

这样输出神经元也会获得一个偏置值。

因此,我们网络的参数如下:

W1 → 输入层到隐藏层的权重
b1 → 隐藏层的偏置值

W2 → 隐藏层到输出层的权重
b2 → 输出层的偏置值

20. Sigmoid函数

我们的输出结果代表一个概率,因此其数值应该介于 01 之间。

我们可以使用 Sigmoid函数 来实现这一目标。

它的数学表达式为:

sigmoid(x) = 1 / (1 + e⁻ˣ)

在Python中,可以这样定义这个函数:

def sigmoid(x):
    return 1 / (1 + np.exp(-x))
让我们看看它的计算结果:
sigmoid(-5) ≈ 0.007
sigmoid(0)  = 0.5
sigmoid(5)  ≈ 0.993

无论输入值的大小如何,函数的计算结果始终会落在 01 之间。

当我们的输出代表概率时,这种特性非常有用。

21. 前向传播

现在我们可以将数据输入到网络中进行了。这个过程被称为 前向传播

首先计算隐藏层的输出值:

z1 = X @ W1 + b1
这里出现了一个新的符号:
@

在Python中,@ 表示矩阵乘法。

你可以把这种运算理解为同时进行多次加权求和操作。

这样就不需要手动为每一个神经元分别计算输出值了:

输入值 × 权重 + 输入值 × 权重 + 偏置项

NumPy可以同时计算所有这些数值。

计算结果会被存储在z1中。

接下来:

a1 = np.tanh(z1)

在这里,我们对隐藏层使用了tanh激活函数

tanh函数会将输入值转换成介于-11之间的数值。

为什么在这里使用tanh函数呢?

因为XOR运算无法通过简单的线性计算来解决。非线性的激活函数能为隐藏层提供所需的灵活性,从而帮助其学习数据中的规律。

现在来计算输出层的结果:

z2 = a1 @ W2 + b2

这个公式会利用第二组权重,将隐藏层的输出结果进行组合处理。

最后一步:

a2 = sigmoid(z2)

现在a2中就包含了我们的预测结果。

例如,在训练之前,网络可能会产生如下这样的输出结果:

0.52
0.61
0.48
0.55

这些预测结果目前还没有实际意义,这也是意料之中的——因为网络还没有学会任何东西。

22. 计算损失值

现在我们需要评估这些预测结果的准确性。

对于二分类问题,我们会使用二元交叉熵损失函数。这种损失函数常用于机器学习中的二分类任务,它用于衡量那些输出值为0到1之间的概率值的模型的性能。

其计算公式如下:

Loss = -mean(
    y × log(prediction)
    +
    (1 - y) × log(1 - prediction)
)

这个公式看起来比之前讨论的平方误差公式要复杂得多,但实际上我们并不需要记住它。

在Python中实现这个公式如下:

loss = -np.mean(
    y * np.log(a2 + 1e-8) +
    (1 - y) * np.log(1 - a2 + 1e-8)
)

这里的1e-8是一个非常小的数值。

它的作用是防止在a2的值接近01时出现计算错误,因为对0取对数是没有意义的。

在训练初期,损失值通常会比较高。但随着网络的不断学习,这个数值应该会逐渐下降。

23. 代码实现中的反向传播算法

现在到了我们程序中最为数学化的部分了。

我们需要计算各个参数的梯度。

首先进行以下操作:
dz2 = a2 - y

这个表达式表示,在使用sigmoid激活函数和二元交叉熵损失函数的组合时,损失值相对于输出层预激活值的梯度。

接下来计算:
dW2 = (a1.T @ dz2) / len(X)

这个公式用于计算权重W2的梯度。

.T表示转置操作。

我们的隐藏层有四个神经元,而dz2代表了输出层的误差值。通过矩阵乘法,我们可以确定每个隐藏层到输出层的权重对损失值产生了多大的影响。

我们用以下公式进行除法运算:

len(X)

因为我们有四个训练样本,需要计算平均梯度。

现在来计算输出偏置的梯度:

db2 = np.mean(dz2, axis=0, keepdims=True)

这个操作计算了输出偏置的平均梯度。

接下来:

da1 = dz2 @ W2.T

这个操作将梯度信息从输出层传递回隐藏层。

现在我们需要考虑tanh激活函数的导数。

tanh的导数可以表示为:

1 - tanh(x)²

由于我们已经得到了隐藏层的激活值,这些值存储在a1中,因此我们可以写出以下公式:

dz1 = da1 * (1 - a1**2)

这个公式说明了隐藏层在激活之前的数值是如何影响损失函数的值的。

现在来计算第一层权重的梯度:

dW1 = (X.T @ dz1) / len(X)

以及隐藏层的偏置:

db1 = np.mean(dz1, axis=0, keepdims=True)

至此,我们已经得到了所有可训练参数的梯度值。

24. 更新权重

现在我们使用梯度下降算法来进行更新。

首先:

W2 -= learning_rate * dW2

这个操作会更新第二层的权重。

这里的-=表示:

W2 = W2 - learning_rate * dW2

然后:

b2 -= learning_rate * db2

这个操作会更新输出偏置。

接着:

W1 -= learning_rate * dW1

这个操作会更新第一层的权重。

最后:

b1 -= learning_rate * db1

这个操作会更新隐藏层的偏置。

正是这些更新使得网络能够真正进行学习。

25. 完整的NumPy神经网络

现在让我们把所有内容整合起来。

import numpy as np

# 1. 训练数据

X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])

y = np.array([
    [0],
    [1],
    [1],
    [0]
])

# 2. 初始化参数

np.random.seed(42)

W1 = np.random.randn(2, 4)
b1 = np.zeros((1, 4))

W2 = np.random.randn(4, 1)
b2 = np.zeros((1, 1))

learning_rate = 0.1

# 3. 激活函数

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

# 4. 训练过程

for epoch in range(10000):

    # 前向传播

    z1 = X @ W1 + b1
    a1 = np.tanh(z1)

    z2 = a1 @ W2 + b2
    a2 = sigmoid(z2)

    # 计算损失值

    loss = -np.mean(
        y * np.log(a2 + 1e-8) +
        (1 - y) * np.log(1 - a2 + 1e-8)
    )

    # 反向传播

    dz2 = a2 - y

    dW2 = (a1.T @ dz2) / len(X)
    db2 = np.mean(dz2, axis=0, keepdims=True)

    da1 = dz2 @ W2.T

    dz1 = da1 * (1 - a1**2)

    dW1 = (X.T @ dz1) / len(X)
    db1 = np.mean(dz1, axis=0, keepdims=True)

    # 更新参数

    W2 -= learning_rate * dW2
    b2 -= learning_rate * db2

    W1 -= learning_rate * dW1
    b1 -= learning_rate * db1

    # 显示训练进度

    if epoch % 1000 == 0:
        print(f"第{epoch}个训练周期,损失值:{loss:.4f}")

让我们从上到下依次分析这个程序。

完整代码的逐行解释

导入NumPy:

import numpy as np

我们之所以要导入NumPy,是因为我们的神经网络会涉及到数组和矩阵运算。

创建输入数据

X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])

每一行代表一个异或运算示例。

总共有四个示例,每个示例包含两个输入值。

因此,变量X的形状为:

4 × 2

创建输出结果

y = np.array([
    [0],
    [1],
    [1],
    [0]
])

一共有四个正确的输出结果,每个X中的行对应一个正确答案。

确保随机初始化的可重复性

np.random.seed(42)

这样,每次运行程序时,NumPy都会生成相同的随机初始值。

数字42并没有什么特殊意义,你也可以使用其他数值。

创建第一个权重矩阵

W1 = np.random.randn(2, 4)

这个代码生成了一个包含随机数的矩阵。

它的形状为2×4,意味着有2个输入和4个隐藏神经元。

创建第一个偏置值

b1 = np.zeros((1, 4))

这会生成一个包含四个0的数组:

[0, 0, 0, 0]

每个隐藏神经元都有一个对应的偏置值。

创建第二个权重矩阵

W2 = np.random.randn(4, 1)

这里有4个隐藏神经元和1个输出神经元,因此权重矩阵的形状为4×1。

创建输出偏置值

b2 = np.zeros((1, 1))

由于输出层只有一个神经元,因此需要一个偏置值。

设置学习率

learning_rate = 0.1

学习率决定了梯度对每次参数更新的影响程度。

创建Sigmoid函数

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

这个函数会将输出结果转换成介于0和1之间的数值。

开始训练循环

for epoch in range(10000):

这告诉Python重复执行训练过程10,000次。

每次重复称为一个“ epoch”,也就是让整个训练数据集完整地通过一次神经网络的处理。

计算隐藏层的结果

z1 = X @ W1 + b1

这一操作会对所有4个隐藏神经元以及所有的4个训练样本进行加权求和运算。

应用tanh函数

a1 = np.tanh(z1)

这会将非线性激活函数应用于隐藏层的结果。

计算输出层的结果

z2 = a1 @ W2 + b2

这一操作会利用隐藏层的结果来计算输出神经元的加权求和值。

应用sigmoid函数

a2 = sigmoid(z2)

这会将输出结果转换为介于0和1之间的概率值。

计算损失值

loss = -np.mean(
    y * np.log(a2 + 1e-8) +
    (1 - y) * np.log(1 - a2 + 1e-8)
)

这一计算用于衡量预测结果与正确答案之间的差异。

通常来说,损失值越低,说明预测效果越好。

计算输出层的梯度

dz2 = a2 - y

这一操作用于计算更新输出层参数所需的梯度。

更新第二层的权重梯度

dW2 = (a1.T @ dz2) / len(X)

这一操作可以确定连接隐藏层与输出层的各个权重对损失值的影响程度。

更新输出层的偏置梯度

db2 = np.mean(dz2, axis=0, keepdims=True)

这一操作用于计算输出层偏置的平均梯度值。

反向传播至隐藏层

da1 = dz2 @ W2.T

这一操作会将梯度信息反向传递到输出层。

应用tanh函数的导数

dz1 = da1 * (1 - a1**2)

这一操作考虑了tanh激活函数的特性。

计算第一层的梯度

dW1 = (X.T @ dz1) / len(X)

这一操作可以确定输入层到隐藏层的各个权重对损失值的影响程度。

db1 = np.mean(dz1, axis=0, keepdims=True)

计算隐藏层偏置的梯度。

参数更新

W2 -= learning_rate * dW2
b2 -= learning_rate * db2

W1 -= learning_rate * dW1
b1 -= learning_rate * db1

这四行代码决定了网络会如何改变它所学到的内容。

梯度告诉我们应该朝哪个方向进行调整,而学习率则决定了调整的幅度应该有多大。

输出损失值

if epoch % 1000 == 0:
    print(f"第{epoch}个周期,损失值:{loss:.4f}")

`%`运算符可以用来获取除法运算后的余数。

因此:

epoch % 1000 == 0

这个条件在每1,000个周期时才会成立。

这样我们就不会重复输出同样的信息10,000次,而是会每隔一段时间更新一次结果,例如:

第0个周期,损失值:...
第1000个周期,损失值:...
第2000个周期,损失值:...
...

如果训练进展顺利,损失值通常会逐渐降低。

26. 测试网络

训练完成后,我们可以使用这个网络来进行预测。

z1 = X @ W1 + b1
a1 = np.tanh(z1)

z2 = a1 @ W2 + b2
predictions = sigmoid(z2)

print(predictions)

网络应该会产生接近以下结果的输出:

[[0],
 [1],
 [1],
 [0]]

实际得到的结果可能不会完全是`0`或`1`。

更有可能出现的情况是:

[[0.01],
 [0.98],
 [0.99],
 [0.02]]

这也是正常的。

网络输出的是概率值,我们可以通过设定一个阈值将这些概率转换为具体的类别。

classes = (predictions >= 0.5).astype(int)

print(classes)

最终得到的结果应该会是:

[[0],
 [1],
 [1],
 [0]]

我们的网络已经学会了如何识别XOR逻辑关系。

27. 为什么需要隐藏层?

你可能会想知道,为什么不能直接将两个输入端与输出端连接起来呢?

原因在于XOR逻辑关系无法通过单个线性层来表示。

隐藏层的存在使得网络能够进行额外的信息处理,从而学会这种更为复杂的逻辑关系。

这就是神经网络最重要的原理之一:网络并不一定需要学习一条单一的规则,而是可以通过多层结构逐步处理信息。

对于图像识别系统来说,这个过程可以这样理解:

图像识别系统的示意图

实际上,真正的神经网络并不会真正生成被称为“边缘”、“形状”或“对象”的结构。这种描述只是用来说明如何通过多层结构产生越来越复杂的表示形式而已。

28. 在更大的神经网络中会发生什么?

我们构建的神经网络规模非常小。而现代神经网络可能拥有数百万、数十亿甚至更多的参数。

一个简化的神经网络结构可能如下所示:

简化后的神经网络结构图

每个连接都可以拥有自己的权重。

一个神经网络拥有的神经元和连接越多,它就需要学习更多的参数。

因此,大型神经网络需要大量的计算资源和内存。

但请记住基本的训练流程:

输入
 ↓
计算过程
 ↓
预测结果
 ↓
损失值
 ↓
梯度计算
 ↓
参数更新

虽然神经网络的规模可能会发生巨大变化,但基本的训练原理始终不变。

29. 是否必须从头开始构建神经网络?

不必。从头开始构建神经网络确实有助于学习,因为这能迫使你理解底层库的工作原理。

但在实际开发机器学习应用时,通常不会手动计算每一个梯度值。

这就是机器学习框架的作用所在。一些常用的Python库包括:

  • NumPy

  • PyTorch

  • TensorFlow

  • Keras

  • scikit-learn

对于深度学习来说,PyTorch是最常用的框架之一。它能够自动计算梯度值,并处理训练过程中涉及的许多数学运算。

30. 使用PyTorch构建相同的网络

让我们看看使用PyTorch构建网络时代码会变得多么简洁。

首先,安装PyTorch:

pip install torch

然后导入所需的库:

import torch
import torch.nn as nn

接下来创建模型:

model = nn.Sequential(
    nn.Linear(2, 4),
    nn.Tanh(),
    nn.Linear(4, 1),
    nn.Sigmoid()
)

让我们来详细分析一下这段代码。

nn.Linear(2, 4)

这一行代码创建了一个层,该层接受两个输入,并产生四个输出结果。这就是我们的隐藏层。

接下来是:

nn.Tanh()

这一行代码应用了tanh激活函数。

然后是:

nn.Linear(4, 1)

将这四个隐藏神经元连接到一个输出神经元上。

最后:

nn.Sigmoid()

将输出结果转换为介于 01 之间的数值。

因此,整个神经网络的架构如下:

2个输入节点
   ↓
4个隐藏神经元
   ↓
Tanh函数
   ↓
1个输出神经元
   ↓
Sigmoid函数

请注意,这种实现方式比我们使用NumPy编写的方式要简洁得多。

这是因为PyTorch为我们处理了很多计算工作。

31. 使用PyTorch训练神经网络

首先,创建训练数据:

X = torch.tensor([
    [0., 0.],
    [0., 1.],
    [1., 0.],
    [1., 1.]
])

y = torch.tensor([
    [0.],
    [1.],
    [1.],
    [0.]
])

小数点的存在非常重要,因为神经网络通常会使用浮点数进行运算。

接下来创建模型:

model = nn.Sequential(
    nn.Linear(2, 4),
    nn.Tanh(),
    nn.Linear(4, 1),
    nn.Sigmoid()
)

然后选择损失函数:

loss_function = nn.BCELoss()

BCELoss用于计算二元交叉熵损失。

接下来创建优化器:

optimizer = torch.optim.Adam(
    model.parameters(),
    lr=0.01
)

Adam是一种优化算法,它在训练过程中会更新模型的参数。

model.parameters()指明了优化器应该更新哪些参数。

lr=0.01设置了学习率。

现在我们可以开始训练了:

for epoch in range(5000):

    predictions = model(X)

    loss = loss_function(predictions, y)

    optimizer.zero_grad()

    loss.backward()

    optimizer.step()

    if epoch % 500 == 0:
        print(
            f"训练周期 {epoch}, 损失值:{loss.item():.4f}"
        )

让我们来看一下其中的关键步骤。

首先:

predictions = model(X)

这一步是将训练数据输入到神经网络中。

然后:

loss = loss_function(predictions, y)

将模型的预测结果与正确答案进行比较。

接下来:

optimizer.zero_grad()

清除上一步训练中产生的梯度值。

然后:

loss.backward()

利用反向传播算法自动计算出梯度值。

最后:

optimizer.step()

使用这些梯度值来更新模型的参数。

这个过程与我们之前用NumPy手动实现的过程是一样的。不同之处在于,PyTorch已经帮我们完成了很多计算工作。

32. NumPy与PyTorch的比较

那么,我们为什么要构建两次这样的神经网络呢?原因在于这两个版本所展示的内容是不同的。

在使用NumPy时,我们需要手动处理权重、偏置值、前向传播过程、损失函数计算、梯度计算以及反向传播和参数更新等步骤。这样就能更清楚地理解这些机制的工作原理。

而使用PyTorch时,我们可以用更少的代码来实现相同的功能,因为这个框架会自动完成很多这些计算工作。

你可以这样理解:

NumPy与PyTorch的对比

了解NumPy版本的工作原理后,PyTorch版本就会显得不那么复杂了。

33. 什么是深度学习?

你可能已经听说过“深度学习”这个词。深度学习是机器学习的一个分支,它使用具有多层结构的神经网络来进行数据分析。

举个例子:

输入
  ↓
第1层
  ↓
第2层
  ↓
第3层
  ↓
第4层
  ↓
输出

“深度”这个概念指的是神经网络中层的数量。

并没有一个神奇的临界点,使得神经网络突然变得具有智能。增加更多的层次只会让模型有更多机会将输入数据转化为有用的信息。

34. 神经网络被应用在哪些领域?

神经网络被广泛应用于许多不同的领域。以下是一些例子……

计算机视觉

神经网络可以处理图像数据。

例如:

图像
  ↓
神经网络
  ↓
预测结果

它们可以被用于图像分类、物体检测等任务中。

自然语言处理

神经网络也可以处理文本数据。

例如:

文本
  ↓
神经网络
  ↓
预测结果

现代的语言模型就是利用神经网络来处理和生成文本的。

语音识别

神经网络能够处理音频信号,并帮助将人类的语音转化为文本。

音频
  ↓
神经网络
  ↓
文本结果

推荐系统

神经网络可以从用户的行为模式中学习规律,从而帮助预测哪些内容或产品可能对某人有用。

生成式人工智能

大型神经网络还可以被用来生成文本、图像、音频、代码、视频等多种类型的内容。

这些系统虽然比我们之前构建的那个简单的XOR网络要复杂得多,但它们仍然遵循从数据中学习参数这一基本原理。

35. 整个过程一览

到目前为止,我们已经学习了很多内容。

以下是整个训练过程:

数据
   ↓
神经网络
   ↓
预测结果
   ↓
误差值
  ↓
反向传播
  ↓
更新参数
  ↓
重复上述步骤

当训练完成后,我们就会使用学到的参数来对新数据进行预测:

新数据
   ↓
已训练好的神经网络
   ↓
预测结果

这就是神经网络训练的基本原理。

36. 需要记住的最重要概念

如果你没有记住本教程中的所有公式,也没关系。

先掌握这些基本概念吧。

输入数据

我们输入到神经网络中的数值。

x₁, x₂, x₃...

权重值

这些数值决定了输入数据对神经元的影响程度。

w₁, w₂, w₃...

偏置值

这些额外的数值能够让神经网络具有更好的灵活性。

b

激活函数

这些函数能够转换神经元的输出结果,从而使神经网络能够学习非线性规律。

常见的激活函数包括:

ReLU
Tanh
Sigmoid

前向传播

数据从输入端依次传递到输出端的过程。

输入 → 隐藏层 → 输出

误差值

这个数值用来衡量预测结果与正确答案之间的差异。

反向传播

通过从输出端开始逐层反向计算来得到梯度值。

梯度下降法

利用这些梯度值来更新神经网络的参数。

预测 → 计算误差 → 求导数 → 更新参数 → 重复上述步骤

37. 接下来应该学习什么?

如果你想继续学习使用Python进行神经网络开发,也不必直接去研究复杂的学术论文。

一个实用的学习路径是:

Python
  ↓
NumPy
  ↓
基础线性代数
  ↓>概率与统计
  ↓>机器学习基础知识
  ↓>神经网络
  ↓>PyTorch
  ↓>深度学习
  ↓>计算机视觉 / 自然语言处理 / 生成式人工智能

你也可以通过开发一些小型项目来学习。

  1. XOR分类器

  2. 房价预测模型

  3. 手写数字识别系统

  4. 简单图像分类器

  5. 垃圾邮件检测程序

  6. 能够学习数学函数的神经网络

这些项目并不一定非得规模庞大。一个你完全理解的小项目,往往比那些你不加理解就机械复制代码的大型项目更有用。

总结

神经网络看起来可能令人望而生畏,因为现代人工智能中使用的系统通常包含数量庞大的参数。

但实际上,其基本原理其实非常简单。

神经网络以数字作为输入,通过权重和偏置对这些数字进行运算,然后应用数学函数得出预测结果,测量该预测的准确性,最后调整相关参数。

这个过程可以概括为以下步骤:

输入
  ↓
加权计算
  ↓
激活函数
  ↓
预测结果
  ↓>误差计算
  ↓>梯度计算
  ↓>参数更新
  ↓>重复上述步骤

这就是神经网络的基本工作原理。

我们在本教程中构建的XOR网络,与现代人工智能中使用的神经网络相比简直微不足道。但你们刚刚学到的那些概念——参数、层结构、激活函数、前向传播、误差计算、反向传播、梯度以及优化算法——都是深度学习中反复出现的基础要素。

下次当你听到某个AI模型拥有数百万甚至数十亿个参数时,这些数字可能仍然会让你感到难以理解。

然而,在这一切复杂的结构背后,基本的 learning loop(学习循环)依然是相同的:

  1. 进行预测。

  2. 测量误差。

  3. 找出改进的方法。

  4. 更新参数。

  5. 重新尝试。

而这,正是神经网络的核心理念。

祝大家编程愉快,继续努力学习吧!

相关文章

技术实践

对DRAM控制器寄存器的操作破坏了CPU与内存之间的隔离机制

安全研究员Christopher Domas开发了一款名为“skitter-creek-bath-salts”的开源硬件安全工具,该工具通过操控内存控制器的转换寄存器来破坏CPU的权限边界。这样一来,非特权级别的软件就能够访问受保护的内存区域,从而暴露出现代处理器架构中存在的安全漏洞——这些漏洞可能会影响云计算及保密计算系统的安全性。 作者:Olimpiu Pop

阅读全文
技术实践

完全用C语言从零开始构建一个强化学习框架

像PyTorch和TensorFlow这样的高级机器学习库使得训练智能代理变得非常简单,但依赖这些现代框架往往会掩盖其内部运作的复杂机制。 我们刚刚在 freeCodeCamp.org 的YouTube频道上发布了一门视频课程,该课程演示了如何完全不使用外部库或第三方引擎,仅用标准的C语言从零开始构建一个强化学习框架。 这门课程涵盖的内容包括: 实现自定义的自动微分功能,同时设计动态计算图、内存分配系统以及矩阵数据结构,以便处理前向和反向梯度传播过程。 从基础层面开始编写线性代数运算代码,包括转置矩阵乘法、ReLU激活函数以及数值稳定的Softmax算法实现。 用C语言开发一个功能完备的蛇形游

阅读全文
技术实践

人工智能论文评述:通过估算数据分布的梯度来实现生成建模

如今,扩散模型已成为最具影响力的生成式人工智能系统之一。它们被应用于从图像合成与编辑到视频生成、科学发现以及多模态内容创作等诸多领域。 尽管这些模型具备令人印象深刻的功能,但其背后的基本原理却出奇地简单:它们从纯粹的噪声开始,逐步将其转化为真实的数据。 然而,这个简单的描述立刻引出了一个更深层次的问题:如果模型仅仅以随机噪声为起点,那么它如何知道在每一步该朝哪个方向发展?是什么让模型判断出某个微小的改变是会让图像变得更真实,还是会让它偏离真实数据的分布范围呢? 2019年,杨 Song和Stefano Ermon提出了一种新的视角,以一种简洁且符合数学原理的方式解答了这个问题。他们的框架并非试

阅读全文