译者 | 朱先忠
审校 | 重楼
在本文中,我们将全面了解神经网络,这是几乎所有尖端人工智能系统的基础技术。我们将首先探索人类大脑中的神经元,然后探索它们如何形成人工智能神经网络的基本灵感。然后,我们将探索反向传播,即用于训练神经网络执行酷炫操作的算法。最后,在形成彻底的概念理解之后,我们将从头开始自己实现一个神经网络,并训练它解决一个玩具问题。
来自大脑的灵感
神经网络直接从人类大脑中获取灵感,人类大脑由数十亿个极其复杂的细胞(称为“神经元”)组成。
神经元图
人类大脑中的思考过程是神经元之间交流的结果。你可能会以所见事物的形式接收刺激,然后该信息通过电化学信号传播到大脑中的神经元。
使用Midjourney生成的眼睛图像
大脑中的第一个神经元接收某种刺激,然后每个神经元可以根据其接收到的刺激量选择是否“激发”。在这种情况下,“激发”是神经元决定向其连接的神经元发送信号。
来自眼睛的信号直接输入到三个神经元中;其中,两个决定激发
然后,这些神经元所连接的神经元可能会或可能不会选择激发。
神经元从先前的神经元接收刺激,然后根据刺激的强度选择是否激发
因此,“想法”可以概念化为大量神经元根据来自其他神经元的刺激选择激发或不激发。
当一个人环游世界时,他可能会比其他人有更多特定的想法。例如,大提琴手可能比数学家更多地使用某些神经元。
不同的任务需要使用不同的神经元(使用Midjourney生成的图像)
当我们更频繁地使用某些神经元时,它们的连接会变得更强,从而增加这些连接的强度。当我们不使用某些神经元时,这些连接就会减弱。这个一般规则启发了“一起激发的神经元会连接在一起”这句话,它是大脑负责学习过程的高级品质。
使用某些神经元的过程会加强它们的连接
我不是神经学家;所以,这是对大脑的一个极其简化的描述。然而,这足以帮助我们来理解神经网络的基本概念。
神经网络的直觉
神经网络本质上是大脑中的神经元在数学上的方便且简化的版本。神经网络由称为“感知器”的元素组成,这些元素直接受到神经元的启发。
左侧是感知器,右侧是神经元
感知器像神经元一样接收数据:
像神经元一样聚合数据:
感知器聚合数字以产生输出,而神经元聚合电化学信号以产生输出
然后根据输入输出信号,就像神经元一样:
感知器输出数字,而神经元输出电化学信号
神经网络可以概念化为这些感知器的大型网络,就像大脑是一个巨大的神经元网络一样。
神经网络(左)与大脑(右)
当大脑中的神经元激发时,它会以二元决策的方式进行。或者换句话说,神经元要么激发,要么不激发。另一方面,感知器本身并不“激发”,而是根据感知器的输入输出一系列数字。
感知器输出一系列连续的数字,而神经元要么激发,要么不激发
大脑内的神经元可以使用相对简单的二进制输入和输出,因为思想会随着时间而存在。神经元本质上以不同的速率脉动,较慢和较快的脉冲传达不同的信息。
因此,神经元以开或关脉冲的形式具有简单的输入和输出,但它们脉动的速率可以传达复杂的信息。感知器每通过网络只能看到一次输入,但它们的输入和输出可以是一系列连续的值。如果你熟悉电子学,你可能会思考这与数字信号和模拟信号之间的关系有何相似之处。
感知器的数学计算方式其实非常简单。标准神经网络由一组权重组成,这些权重将不同层的感知器连接在一起。
神经网络,其中突出显示了进入和离开特定感知器的权重
你可以通过将所有输入相加并乘以各自的权重来计算特定感知器的值。
感知器值的计算方法示例:(0.3×0.3) + (0.7×0.1) +(-0.5×0.5)=-0.0
许多神经网络还具有与每个感知器相关的“偏差”,该偏差被添加到输入的总和中以计算感知器的值。
当模型中包含偏差项时,感知器的值可能的计算方法示例:(0.3×0.3) + (0.7×0.1) +(-0.5×0.5) + 0.01 =-0.08。
因此,计算神经网络的输出只是进行一系列加法和乘法来计算所有感知器的值。
有时,数据科学家将这种一般操作称为“线性投影”,因为我们通过线性运算(加法和乘法)将输入映射到输出。这种方法的一个问题是,即使你将十亿个这样的层连接在一起,得到的模型仍然只是输入和输出之间的线性关系,因为它们只是加法和乘法。
这是一个严重的问题,因为输入和输出之间的关系并非都是线性的。为了解决这个问题,数据科学家采用了一种叫做“激活函数”的概念。这些是非线性函数,可以注入整个模型中,本质上是加入一些非线性。
给定一些输入,产生一些输出的各种函数的例子。前三个是线性的,而后三个是非线性的
通过在线性投影之间交织非线性激活函数,神经网络能够学习非常复杂的函数:
通过在神经网络中放置非线性激活函数,神经网络能够对复杂关系进行建模
在人工智能中,有许多流行的激活函数,但业界已基本集中在三种流行的激活函数上:ReLU、Sigmoid和Softmax,它们分别适用于各种不同的应用场景。在所有这些函数中,ReLU是最常见的,因为它简单且能够泛化以模仿几乎任何其他函数。
ReLU激活函数:如果输入小于零,则输出等于零;如果输入大于零,则输出等于输入
所以,这就是人工智能模型进行预测的本质。它是一堆加法和乘法,中间夹杂一些非线性函数。
神经网络的另一个定义特征是,它们可以通过训练更好地解决某个问题,我们将在下一节中探讨这一点。
反向传播
人工智能的基本思想之一是你可以“训练”一个模型。这是通过要求神经网络(它最初是一大堆随机数据)执行某些任务来实现的。然后,你以某种方式根据模型输出与已知良好答案的比较情况更新模型。
训练神经网络的基本思想示意图(你给它一些你知道你想要输出的数据,将神经网络输出与你想要的结果进行比较,然后使用神经网络的错误程度来更新参数,使其错误更少)
在本节中,我们设想一个具有输入层、隐藏层和输出层的神经网络。
一个具有两个输入和一个输出的神经网络(中间有一个隐藏层,允许模型进行更复杂的预测)
这些层中的每一个都连接在一起,最初具有完全随机的权重。
神经网络(具有随机定义的权重和偏差)
我们将在隐藏层上使用ReLU激活函数。
我们将ReLU激活函数应用于隐藏感知器的值
假设我们有一些训练数据,其中期望的输出是输入的平均值。
我们将要用来训练的数据示例
我们将训练数据的一个示例传递给模型,生成预测。
根据输入计算隐藏层和输出的值,包括所有主要的中间步骤
为了使我们的神经网络更好地完成计算输入平均值的任务,我们首先将预测输出与期望输出进行比较。
训练数据的输入为0.1和0.3,期望输出(输入的平均值)为0.2。模型的预测为-0.1。因此,输出和期望输出之间的差异为0.3
现在,我们知道输出的大小应该增加,我们可以回顾模型来计算我们的权重和偏差如何变化以促进这种变化。
首先,让我们看看直接导致输出的权重:w₇、w₈、w₉。由于第三个隐藏感知器的输出为-0.46,因此ReLU的激活为0.00。
第三个感知器的最终激活输出为0.00
因此,w₉没有任何变化可以使我们更接近期望的输出,因为在这个特定示例中,w₉的每个值都会导致零的变化。
然而,第二个隐藏神经元确实有一个大于零的激活输出,因此调整w₈将对本例的输出产生影响。
我们实际计算w₈应该改变多少的方法是将输出应该改变的量乘以w₈的输入。
计算权重应该如何变化的计算方法展示:这里的符号Δ(delta)表示“变化”,因此Δw₈表示“w₈的变化”
我们这样做的原因最简单的解释是“因为微积分”,但如果我们看看最后一层的所有权重是如何更新的,我们就可以形成一种有趣的直觉。
计算导致输出的权重应该如何变化
注意两个“激发”(输出大于零)的感知器是如何一起更新的。另外,注意感知器的输出越强,其对应的权重更新就越多。这有点类似于人脑中“一起激发的神经元会连接在一起”的想法。
计算输出偏差的变化非常简单。事实上,我们已经做到了。因为偏差是感知器输出应该改变的程度,所以偏差的变化就是期望输出的变化。所以,Δb₄=0.3。
输出的偏差应该如何更新
现在,我们已经计算出输出感知器的权重和偏差应该如何变化,我们可以通过模型“反向传播”我们期望的输出变化。让我们从反向传播开始,这样我们就可以计算出我们应该如何更新w₁。
首先,我们计算第一个隐藏神经元的激活输出应该如何变化。我们通过将输出变化乘以w₇来实现这一点。
通过将输出的期望变化乘以w₇来计算第一个隐藏神经元的激活输出应该如何变化
对于大于零的值,ReLU只需将这些值乘以1。因此,对于此示例,我们希望第一个隐藏神经元的未激活值的变化等于激活输出的期望变化。
基于从输出反向传播,我们想要改变第一个隐藏感知器的未激活值
回想一下,我们计算了如何根据将其输入乘以其期望输出的变化来更新w₇。我们可以做同样的事情来计算w₁的变化。
现在,我们已经计算出第一个隐藏神经元应该如何变化,我们可以计算应该如何更新w₁,就像我们之前计算w₇应该如何更新一样。
需要注意的是,我们实际上并没有在整个过程中更新任何权重或偏差。相反,我们正在计算应该如何更新每个参数,假设没有其他参数更新。
因此,我们可以进行这些计算来计算所有参数变化。
通过反向传播模型,使用来自前向传播的值和来自模型各个点的反向传播的期望变化的组合,我们可以计算出所有参数应该如何变化。
反向传播的一个基本思想称为“学习率”,它涉及我们根据特定数据批次对神经网络所做的更改的大小。为了解释为什么这很重要,我想打个比方。
想象一下,有一天你出门,每个戴帽子的人都用奇怪的眼神看着你。你可能不想仓促得出结论说“戴帽子=奇怪”的眼神,但你可能会对戴帽子的人有点怀疑。三、四、五天、一个月甚至一年后,如果看起来绝大多数戴帽子的人都用奇怪的眼神看着你,你可能会开始认为这是一种强烈的趋势。
同样,当我们训练神经网络时,我们不想根据单个训练示例完全改变神经网络的思维方式。相反,我们希望每个批次仅逐步改变模型的思维方式。当我们将模型暴露给许多示例时,我们希望模型能够学习数据中的重要趋势。
在我们计算出每个参数应该如何变化(就好像它是唯一要更新的参数)之后,我们可以将所有这些变化乘以在将这些更改应用于参数之前,我们先将其设置为一个小数,例如0.001。这个小数通常称为“学习率”,其确切值取决于我们正在训练的模型。这有效地缩小了我们的调整范围,然后再将它们应用于模型。
到目前为止,我们几乎涵盖了实现神经网络所需了解的所有内容。让我们试一试吧!
从头开始实现神经网络
通常,数据科学家只需使用PyTorch之类的库,用几行代码即可实现神经网络。但是,我们现在打算使用数值计算库NumPy从头开始定义一个神经网络。
首先,让我们从定义神经网络结构的方法开始。
""" 构建神经网络结构。
"""
import numpy as np
class SimpleNN:
def __init__(self, architecture):
self.architecture = architecture
self.weights = []
self.biases = []
#初始化权重和偏差
np.random.seed(99)
for i in range(len(architecture) - 1):
self.weights.append(np.random.uniform(
low=-1, high=1,
size=(architecture[i], architecture[i+1])
))
self.biases.append(np.zeros((1, architecture[i+1])))
architecture = [2, 64, 64, 64, 1] # 两个输入,两个隐藏层,一个输出
model = SimpleNN(architecture)
print('weight dimensions:')
for w in model.weights:
print(w.shape)
print('nbias dimensions:')
for b in model.biases:
print(b.shape)
示例神经网络中定义的权重和偏差矩阵
虽然我们通常将神经网络绘制为密集网络,但实际上我们将其连接之间的权重表示为矩阵。这很方便,因为矩阵乘法相当于通过神经网络传递数据。
将密集网络视为左侧的加权连接,对应右侧的矩阵乘法。在右侧图中,左侧的向量表示输入,中间的矩阵表示权重矩阵,右侧的向量表示输出。
我们可以通过将输入传递到每一层,让我们的模型根据某些输入做出预测。
"""实现前向传播
"""
import numpy as np
class SimpleNN:
def __init__(self, architecture):
self.architecture = architecture
self.weights = []
self.biases = []
# 初始化权重和偏差
np.random.seed(99)
for i in range(len(architecture) - 1):
self.weights.append(np.random.uniform(
low=-1, high=1,
size=(architecture[i], architecture[i+1])
))
self.biases.append(np.zeros((1, architecture[i+1])))
@staticmethod
def relu(x):
#实现relu激活函数
return np.maximum(0, x)
def forward(self, X):
#遍历所有层
for W, b in zip(self.weights, self.biases):
#应用该层的权重和偏差
X = np.dot(X, W) + b
#为除最后一层之外的所有层进行ReLU激活
if W is not self.weights[-1]:
X = self.relu(X)
#返回结果
return X
def predict(self, X):
y = self.forward(X)
return y.flatten()
#定义模型
architecture = [2, 64, 64, 64, 1] # 两个输入,两个隐藏层,一个输出
model = SimpleNN(architecture)
# 生成预测
prediction = model.predict(np.array([0.1,0.2]))
print(prediction)
将数据传递给模型的打印结果(我们的模型是随机定义的,因此这不是一个有用的预测,但它证实了模型正在发挥作用)
我们需要能够训练这个模型;为此,我们首先需要一个问题来训练模型。我定义了一个随机函数,它接受两个输入并产生一个输出:
"""定义我们希望模型要学习的内容
"""
import numpy as np
import matplotlib.pyplot as plt
# 定义一个具有两个输入的随机函数
def random_function(x, y):
return (np.sin(x) + x * np.cos(y) + y + 3**(x/3))
# 生成一个包含x和y值对的网格
x = np.linspace(-10, 10, 100)
y = np.linspace(-10, 10, 100)
X, Y = np.meshgrid(x, y)
#计算随机函数的输出
Z = random_function(X, Y)
#创建二维图
plt.figure(figsize=(8, 6))
contour = plt.contourf(X, Y, Z, cmap='viridis')
plt.colorbar(contour, label='Function Value')
plt.title('2D Plot of Objective Function')
plt.xlabel('X-axis')
plt.ylabel('Y-axis')
plt.show()
建模目标:给定两个输入(此处绘制为x和y),模型需要预测输出(此处表示为颜色)。这里给出的是一个完全任意的函数
在现实世界中,我们不知道底层函数。我们可以通过创建由随机点组成的数据集来模拟现实:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 定义一个具有两个输入的随机函数
def random_function(x, y):
return (np.sin(x) + x * np.cos(y) + y + 3**(x/3))
# 定义要生成的随机样本数
n_samples = 1000
#生成指定范围内的随机X和Y值
x_min, x_max = -10, 10
y_min, y_max = -10, 10
# 生成X和Y生成随机值
X_random = np.random.uniform(x_min, x_max, n_samples)
Y_random = np.random.uniform(y_min, y_max, n_samples)
# 在生成的X和Y值上计算随机函数
Z_random = random_function(X_random, Y_random)
#创建数据集
dataset = pd.DataFrame({
'X': X_random,
'Y': Y_random,
'Z': Z_random
})
#显示数据集
print(dataset.head())
#创建采样数据的二维散点图
plt.figure(figsize=(8, 6))
scatter = plt.scatter(dataset['X'], dataset['Y'], c=dataset['Z'], cmap='viridis', s=10)
plt.colorbar(scatter, label='Function Value')
plt.title('Scatter Plot of Randomly Sampled Data')
plt.xlabel('X-axis')
plt.ylabel('Y-axis')
plt.show()
这是我们将用来训练以尝试学习函数的数据
回想一下,反向传播算法根据前向传播中发生的情况更新参数。因此,在实现反向传播本身之前,让我们跟踪前向传播中的几个重要值:整个模型中每个感知器的输入和输出。
import numpy as np
class SimpleNN:
def __init__(self, architecture):
self.architecture = architecture
self.weights = []
self.biases = []
#在此代码块中跟踪这些值
#以便我们可以观察它们
self.perceptron_inputs = None
self.perceptron_outputs = None
#初始化权重和偏差
np.random.seed(99)
for i in range(len(architecture) - 1):
self.weights.append(np.random.uniform(
low=-1, high=1,
size=(architecture[i], architecture[i+1])
))
self.biases.append(np.zeros((1, architecture[i+1])))
@staticmethod
def relu(x):
return np.maximum(0, x)
def forward(self, X):
self.perceptron_inputs = [X]
self.perceptron_outputs = []
for W, b in zip(self.weights, self.biases):
Z = np.dot(self.perceptron_inputs[-1], W) + b
self.perceptron_outputs.append(Z)
if W is self.weights[-1]: # Last layer (output)
A = Z # 回归线性输出
else:
A = self.relu(Z)
self.perceptron_inputs.append(A)
return self.perceptron_inputs, self.perceptron_outputs
def predict(self, X):
perceptron_inputs, _ = self.forward(X)
return perceptron_inputs[-1].flatten()
#定义模型
architecture = [2, 64, 64, 64, 1] # 两个输入,两个隐藏层,一个输出
model = SimpleNN(architecture)
#生成预测
prediction = model.predict(np.array([0.1,0.2]))
#查看临界优化值
for i, (inpt, outpt) in enumerate(zip(model.perceptron_inputs, model.perceptron_outputs[:-1])):
print(f'layer {i}')
print(f'input: {inpt.shape}')
print(f'output: {outpt.shape}')
print('')
print('Final Output:')
print(model.perceptron_outputs[-1].shape)
由于前向传播,模型各个层中的值都会发生变化,这将使我们能够计算更新模型所需的更改
现在,我们已经在网络中存储了关键中间值的记录,我们可以使用这些值以及模型对特定预测的误差来计算我们应该对模型进行的更改。
import numpy as np
class SimpleNN:
def __init__(self, architecture):
self.architecture = architecture
self.weights = []
self.biases = []
#初始化权重和偏差
np.random.seed(99)
for i in range(len(architecture) - 1):
self.weights.append(np.random.uniform(
low=-1, high=1,
size=(architecture[i], architecture[i+1])
))
self.biases.append(np.zeros((1, architecture[i+1])))
@staticmethod
def relu(x):
return np.maximum(0, x)
@staticmethod
def relu_as_weights(x):
return (x > 0).astype(float)
def forward(self, X):
perceptron_inputs = [X]
perceptron_outputs = []
for W, b in zip(self.weights, self.biases):
Z = np.dot(perceptron_inputs[-1], W) + b
perceptron_outputs.append(Z)
if W is self.weights[-1]: #最后一层(输出)
A = Z # 回归线性输出
else:
A = self.relu(Z)
perceptron_inputs.append(A)
return perceptron_inputs, perceptron_outputs
def backward(self, perceptron_inputs, perceptron_outputs, target):
weight_changes = []
bias_changes = []
m = len(target)
dA = perceptron_inputs[-1] - target.reshape(-1, 1) # 输出层梯度
for i in reversed(range(len(self.weights))):
dZ = dA if i == len(self.weights) - 1 else dA * self.relu_as_weights(perceptron_outputs[i])
dW = np.dot(perceptron_inputs[i].T, dZ) / m
db = np.sum(dZ, axis=0, keepdims=True) / m
weight_changes.append(dW)
bias_changes.append(db)
if i > 0:
dA = np.dot(dZ, self.weights[i].T)
return list(reversed(weight_changes)), list(reversed(bias_changes))
def predict(self, X):
perceptron_inputs, _ = self.forward(X)
return perceptron_inputs[-1].flatten()
#定义模型
architecture = [2, 64, 64, 64, 1] #两个输入,两个隐藏层,一个输出
model = SimpleNN(architecture)
#定义样本输入和目标输出
input = np.array([[0.1,0.2]])
desired_output = np.array([0.5])
#进行正向和反向传播来计算变化
perceptron_inputs, perceptron_outputs = model.forward(input)
weight_changes, bias_changes = model.backward(perceptron_inputs, perceptron_outputs, desired_output)
#用于打印的较小数字
np.set_printoptions(precisinotallow=2)
for i, (layer_weights, layer_biases, layer_weight_changes, layer_bias_changes)
in enumerate(zip(model.weights, model.biases, weight_changes, bias_changes)):
print(f'layer {i}')
print(f'weight matrix: {layer_weights.shape}')
print(f'weight matrix changes: {layer_weight_changes.shape}')
print(f'bias matrix: {layer_biases.shape}')
print(f'bias matrix changes: {layer_bias_changes.shape}')
print('')
print('The weight and weight change matrix of the second layer:')
print('weight matrix:')
print(model.weights[1])
print('change matrix:')
print(weight_changes[1])
这可能是最复杂的实施步骤,所以我想花点时间深入了解一些细节。基本思想正如我们在前面几节中描述的一样:我们从后到前迭代所有层,并计算每个权重和偏差的哪些变化会产生更好的输出。
# 计算输出误差
dA = perceptron_inputs[-1] - target.reshape(-1, 1)
#一个批处理大小的缩放因子。
#希望更改是所有批次的平均值,所以一旦聚合了所有更改,我们就除以m。
m = len(target)
for i in reversed(range(len(self.weights))):
dZ = dA #现已简化
# 计算权重变化
dW = np.dot(perceptron_inputs[i].T, dZ) / m
#计算偏差的变化
db = np.sum(dZ, axis=0, keepdims=True) / m
# 跟踪所需的变更
weight_changes.append(dW)
bias_changes.append(db)
...
计算偏差的变化非常简单。如果你看看给定神经元的输出应该如何影响所有未来的神经元,那么你就可以将所有这些值(正值和负值)相加,以了解神经元是否应该偏向正方向或负方向。
我们使用矩阵乘法来计算权重的变化,这在数学上有点复杂。
dW = np.dot(perceptron_inputs[i].T, dZ) / m
基本上来说,这一行代码表示权重的变化应该等于进入感知器的值乘以输出应该改变的量。如果感知器有一个大的输入值,其输出权重的变化应该很大;相反,如果感知器有一个小的输入值,其输出权重的变化将很小。此外,如果权重指向应该发生很大变化的输出,则权重本身也应该发生很大变化。
在我们的反向传播实现中,还有如下所示的另一行代码值得讨论:
dZ = dA if i == len(self.weights) - 1 else dA * self.relu_as_weights(perceptron_outputs[i])
在这个特定的网络中,整个网络层都应用了激活函数,除了最终输出外。当我们进行反向传播时,我们需要通过这些激活函数进行反向传播,以便更新它们之前的神经元。我们对除最后一层之外的所有层都执行此操作,最后一层没有应用激活函数,这就是为什么上面使用了条件判断dZ = dA if i == len(self.weights) - 1。
用数学术语来说,我们将其称为导数,但因为我不想涉及微积分,所以我将该函数称为relu_as_weights。基本上,我们可以将每个ReLU激活视为一个微型神经网络,其权重是输入的函数。如果ReLU激活函数的输入小于零,那么这就像将该输入通过权重为0的神经网络;如果ReLU的输入大于零,那么这就像将输入通过权重为1的神经网络。
回想一下ReLU激活函数
这正是relu_as_weights函数的作用。
def relu_as_weights(x):
return (x > 0).astype(float)
使用这种逻辑,我们可以将通过ReLU的反向传播视为我们通过神经网络的其余部分反向传播一样。
同样,我将很快从更强大的数学角度介绍这个概念,但这是从概念角度来看的基本思想。
现在,我们已经实现了前向和后向传播。接下来,我们可以实现对模型的训练。
import numpy as np
class SimpleNN:
def __init__(self, architecture):
self.architecture = architecture
self.weights = []
self.biases = []
#初始化权重和偏差
np.random.seed(99)
for i in range(len(architecture) - 1):
self.weights.append(np.random.uniform(
low=-1, high=1,
size=(architecture[i], architecture[i+1])
))
self.biases.append(np.zeros((1, architecture[i+1])))
@staticmethod
def relu(x):
return np.maximum(0, x)
@staticmethod
def relu_as_weights(x):
return (x > 0).astype(float)
def forward(self, X):
perceptron_inputs = [X]
perceptron_outputs = []
for W, b in zip(self.weights, self.biases):
Z = np.dot(perceptron_inputs[-1], W) + b
perceptron_outputs.append(Z)
if W is self.weights[-1]: # 最后一层(输出)
A = Z # 回归线性输出
else:
A = self.relu(Z)
perceptron_inputs.append(A)
return perceptron_inputs, perceptron_outputs
def backward(self, perceptron_inputs, perceptron_outputs, y_true):
weight_changes = []
bias_changes = []
m = len(y_true)
dA = perceptron_inputs[-1] - y_true.reshape(-1, 1) # 回归线性梯度
for i in reversed(range(len(self.weights))):
dZ = dA if i == len(self.weights) - 1 else dA * self.relu_as_weights(perceptron_outputs[i])
dW = np.dot(perceptron_inputs[i].T, dZ) / m
db = np.sum(dZ, axis=0, keepdims=True) / m
weight_changes.append(dW)
bias_changes.append(db)
if i > 0:
dA = np.dot(dZ, self.weights[i].T)
return list(reversed(weight_changes)), list(reversed(bias_changes))
def update_weights(self, weight_changes, bias_changes, lr):
for i in range(len(self.weights)):
self.weights[i] -= lr * weight_changes[i]
self.biases[i] -= lr * bias_changes[i]
def train(self, X, y, epochs, lr=0.01):
for epoch in range(epochs):
perceptron_inputs, perceptron_outputs = self.forward(X)
weight_changes, bias_changes = self.backward(perceptron_inputs, perceptron_outputs, y)
self.update_weights(weight_changes, bias_changes, lr)
if epoch % 20 == 0 or epoch == epochs - 1:
loss = np.mean((perceptron_inputs[-1].flatten() - y) ** 2) # MSE
print(f"EPOCH {epoch}: Loss = {loss:.4f}")
def predict(self, X):
perceptron_inputs, _ = self.forward(X)
return perceptron_inputs[-1].flatten()
训练函数train实现了:
- 对所有数据进行一定次数的迭代(由变量epoch定义)
- 将数据进行前向传播
- 计算权重和偏差应如何变化
- 通过按学习率(lr)缩放其变化来更新权重和偏差
这样,我们就实现了一个神经网络!接下来,让我们开始训练它。
训练和评估神经网络
首先,我们来回想一下,我们定义了一个我们想要学习如何模拟的任意2D函数:
我们用一些点对该空间进行采样,我们用这些点来训练模型。
在将这些数据输入我们的模型之前,首先“规范化”数据至关重要。数据集的某些值非常小或非常大,这会使训练神经网络变得非常困难。神经网络中的值可以快速增长到非常大的值,或者减小到零,这可能会抑制训练。规范化将我们所有的输入和期望的输出压缩到一个更合理的范围内,平均在零附近,标准化分布也称为“正态”分布。
# 数据扁平化处理
X_flat = X.flatten()
Y_flat = Y.flatten()
Z_flat = Z.flatten()
# 把X和Y入栈,作为输入特性
inputs = np.column_stack((X_flat, Y_flat))
outputs = Z_flat
#规范化输入和输出
inputs_mean = np.mean(inputs, axis=0)
inputs_std = np.std(inputs, axis=0)
outputs_mean = np.mean(outputs)
outputs_std = np.std(outputs)
inputs = (inputs - inputs_mean) / inputs_std
outputs = (outputs - outputs_mean) / outputs_std
如果我们想从原始数据集中获取实际数据范围内的预测,我们可以使用这些值来“取消压缩”数据。
完成此操作后,我们就可以定义和训练我们的模型。
# 定义体系结构:[input_dim, hidden1, ..., output_dim]
architecture = [2, 64, 64, 64, 1] #两个输入,两个隐藏层,一个输出
model = SimpleNN(architecture)
# 训练模型
model.train(inputs, outputs, epochs=2000, lr=0.001)
可以看出,损失值一直在下降,这意味着模型正在改进
然后,我们可以将神经网络的预测输出与实际函数进行可视化。
import matplotlib.pyplot as plt
# 将预测重新调整为网格格式,以进行可视化
Z_pred = model.predict(inputs) * outputs_std + outputs_mean
Z_pred = Z_pred.reshape(X.shape)
#True函数图和模型预测图比较
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# 绘制True函数
axes[0].contourf(X, Y, Z, cmap='viridis')
axes[0].set_title("True Function")
axes[0].set_xlabel("X-axis")
axes[0].set_ylabel("Y-axis")
axes[0].colorbar = plt.colorbar(axes[0].contourf(X, Y, Z, cmap='viridis'), ax=axes[0], label="Function Value")
# 绘制预测函数
axes[1].contourf(X, Y, Z_pred, cmap='plasma')
axes[1].set_title("NN Predicted Function")
axes[1].set_xlabel("X-axis")
axes[1].set_ylabel("Y-axis")
axes[1].colorbar = plt.colorbar(axes[1].contourf(X, Y, Z_pred, cmap='plasma'), ax=axes[1], label="Function Value")
plt.tight_layout()
plt.show()
这个方法还不错,但不如我们所想的那么好。很多数据科学家都在这方面投入了时间,而且有很多方法可以让神经网络更好地适应某个问题。其他一些显而易见的方法包括:
- 使用更多数据
- 调整学习率
- 训练更多轮次
- 改变模型结构
我们很容易就能增加训练数据量。让我们看看这会给我们带来什么。在这里,我对数据集进行了10,000次采样,这比我们之前的数据集多10倍训练样本。
然后,我像以前一样训练模型,只是这次花费的时间更长,因为现在每个轮次分析10,000个样本,而不是1,000个。
# 定义体系结构: [input_dim, hidden1, ..., output_dim]
architecture = [2, 64, 64, 64, 1] # 两个输入,两个隐藏层,一个输出
model = SimpleNN(architecture)
# 训练模型
model.train(inputs, outputs, epochs=2000, lr=0.001)
然后,我同之前一样渲染了这个模型的输出,但看起来输出并没有好多少。
回顾训练的损失输出,似乎损失仍在稳步下降。也许我只需要训练更长时间。我们试试吧。
# 定义体系结构: [input_dim, hidden1, ..., output_dim]
architecture = [2, 64, 64, 64, 1] # Two inputs, two hidden layers, one output
model = SimpleNN(architecture)
# 训练模型
model.train(inputs, outputs, epochs=4000, lr=0.001)
结果似乎好了一点,但并不令人吃惊。
我就不多说细节了。我运行了几次,得到了一些不错的结果,但从来没有1比1的结果。我将在以后的文章中介绍数据科学家使用的一些更高级的方法,如退火和Dropout,这将产生更一致、更好的输出。不过,本文中我们从头开始创建了一个神经网络,并训练它做一些事情,它做得很好!
结论
在本文中,我们避免了提及微积分,同时加深了对神经网络的理解。我们探索了它们的理论,加上一点数学知识,还有反向传播的概念,然后从头开始实现了一个神经网络。然后,我们将神经网络应用于一个玩具级问题,并探索了数据科学家用来实际训练神经网络以擅长某些事情的一些简单想法。
在未来的文章中,我们将探索一些更高级的神经网络方法,敬请期待!现在,你可能会对梯度的更彻底分析(反向传播背后的基本数学知识)感兴趣吧。
译者介绍
朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。
原文标题:Neural Networks – Intuitively and Exhaustively Explained,作者:Daniel Warfield