随着ChatGPT的爆火以及最近各种爆发的大模型竞争,人工智能行业逐渐走入了大众的眼球。作为喜欢折腾各种技术的爱好者,自然也希望能了解一些其中的原理。但想要更好的了解AI领域的知识,我想从深度学习开始是不为过的,因为早前已经学习过吴恩达教授的Machine Learning课程,因此本次也是通过他的另一门专项课程,Deep Learning Specialization来学习深度学习。本文主要以第一门课为参考,尝试通俗的带大家入门深度学习。
1. 神经网络简介及基础概念
神经网络是一种模拟人脑神经元网络进行分布式并行信息处理的算法模型。它由大量的节点(或称为神经元)相互连接构成,每个神经元可以使用简单的信号处理函数处理输入信号,并将处理结果传递给后续神经元。这些计算的目标是最小化网络的预测错误,这是通过不断调整网络中的参数(即神经元之间的连接权重)来实现的。如图是几个常见神经网络的例子,包括标准神经网络,卷积神经网络CNN以及循环神经网络RNN。
为了更好的深入研究这个主题,我们先介绍一下神经网络中的几个关键概念。
1.1 损失函数(Loss Function)
损失函数度量了我们的模型对单个样本的预测结果与真实结果之间的差异。举个例子,对于二分类问题,常用的损失函数是交叉熵损失函数(Cross Entropy Loss)。假设我们的模型对一个样本属于正类的预测概率为$\hat{y}$,而该样本的真实标签为$y$($y$为0或1),那么交叉熵损失函数可以定义为:
$$L(\hat{y}, y) = -[ylog(\hat{y}) + (1-y)log(1-\hat{y})]$$
这个函数刻画了真实标签和预测概率之间的差异:当真实标签与预测概率接近时,损失接近于零;当真实标签与预测概率相差较大时,损失将变得非常大。
1.2 代价函数(Cost Function)
虽然损失函数衡量了单个样本的预测误差,但我们通常会对整个训练集的预测误差进行度量,这就需要用到代价函数。代价函数是所有样本损失函数值的平均。对于交叉熵损失函数,假设我们有$m$个样本,那么代价函数$J$可以定义为:
$$J(w, b) = -\frac{1}{m}\sum_{i=1}^{m}[y^{(i)}log(\hat{y}^{(i)}) + (1-y^{(i)})log(1-\hat{y}^{(i)})]$$
在神经网络中,我们的目标就是找到最佳的$w$和$b$,使得代价函数$J(w, b)$最小,这通常通过梯度下降等优化算法来实现。
1.3 梯度下降(Gradient Descent)
为了找到最小化成本函数的参数,我们通常使用一种被称为梯度下降的优化算法。在每一次迭代中,我们先计算成本函数关于每个参数的梯度,然后更新参数:
$$w = w - \alpha \frac{\partial J}{\partial w}$$
$$b = b - \alpha \frac{\partial J}{\partial b}$$
其中,$\alpha$是学习率(learning rate),控制更新步长的大小。$\frac{\partial J}{\partial w}$和$\frac{\partial J}{\partial b}$是代价函数$J$关于参数$w$和$b$的偏导数,表示$J$在当前参数位置的变化率。
通过多次迭代,我们可以逐步逼近最小化代价函数的参数,这样我们的模型也就得到了优化。
1.3.1 批量梯度下降,随机梯度下降和小批量梯度下降
根据数据集的使用方式,我们可以将梯度下降分为批量梯度下降(Batch Gradient Descent)、随机梯度下降(Stochastic Gradient Descent)和小批量梯度下降(Mini-batch Gradient Descent)。
- 批量梯度下降:在每一次迭代中,我们使用整个数据集来计算梯度和更新参数。这种方式的优点是方向准确,不容易陷入局部最优,但缺点是当数据集很大时,每次迭代的计算量就会很大,计算速度慢。
- 随机梯度下降:在每一次迭代中,我们只使用一个样本来计算梯度和更新参数。这种方式的优点是计算速度快,可以快速收敛,但缺点是由于每次只使用一个样本,更新的方向会有很大的波动,可能会错过全局最优。
- 小批量梯度下降:这是上述两种方式的折中,每次迭代使用一小批样本来计算梯度和更新参数。这种方式结合了批量梯度下降和随机梯度下降的优点,既能保证一定的计算速度,又能保证梯度方向的准确性,是实际应用中最常用的方式。
1.3.2 梯度下降的挑战及应对策略
虽然梯度下降是一种强大的优化工具,但在实践中,我们可能会遇到一些挑战:
- 局部最优:梯度下降可能会陷入局部最优,而无法找到全局最优。解决此问题的一种常见策略是使用随机初始化,或者使用更复杂的优化策略,如带动量的梯度下降,Adam等。
- 梯度消失和梯度爆炸:在深度神经网络中,梯度可能会在反向传播过程中变得非常小(梯度消失)或非常大(梯度爆炸)。解决这个问题的策略包括使用ReLU等非饱和激活函数,或者使用批量归一化,残差结构等。
- 选择合适的学习率:如果学习率过大,可能会导致梯度下降无法收敛;如果学习率过小,梯度下降的速度可能会非常慢。解决此问题的策略包括学习率衰减,或者使用自适应学习率的优化器,如Adam。
通过深入理解梯度下降,我们可以更好地理解神经网络的训练过程,并能够对训练过程进行更好的控制和优化。
1.4 神经网络中的数学符号
神经网络中涉及到许多数学符号,下面我们来了解一些最基本的符号。
- $x^{(i)}$:表示第$i$个样本的特征向量。
- $y^{(i)}$:表示第$i$个样本的真实标签。
- $\hat{y}^{(i)}$:表示第$i$个样本的预测标签。
- $w^{[l]}$:表示第$l$层的权重矩阵。
- $b^{[l]}$:表示第$l$层的偏置向量。
- $a^{[l]}$:表示第$l$层的激活值。
- $z^{[l]}$:表示第$l$层的线性输出,即$z^{[l]} = w^{[l]}a^{[l-1]}+b^{[l]}$。
- $m$:表示样本数量。
- $n^{[l]}$:表示第$l$层的节点数量。
- $n_x$:表示输入层的节点数量,也即特征数量。
- $n_y$:表示输出层的节点数量,也即标签类别数量。 其实主要要记住就这3点:
- 右上角中括号,表示第$l$层
- 右上角圆括号,表示第$i$个example
- 右下角数字,表示第$n$个神经元
这些符号构成了神经网络的基本语言,理解了它们,你就能够理解神经网络的工作原理,以及如何通过数学方式将其实现。这些公式现在记不住也没关系,后文中看到后可以再过来查询。
2. 激活函数
在神经网络中,激活函数扮演着重要的角色。它为神经网络添加了非线性因素,使得神经网络可以拟合更复杂的模型。在本章中,我们将详细介绍几种常见的激活函数及其特性。
2.1 Sigmoid函数
Sigmoid函数是最早被使用在神经网络中的激活函数,表达式为:
$$a = \sigma(z) = \frac{1}{1+e^{-z}}$$
它的特点是其输出在(0, 1)之间,因此可以被解释为概率。并且,当z趋向于正无穷,a趋近于1,而当z趋向于负无穷,a趋近于0。,因此在二分类问题中常常被用作输出层的激活函数,将输出解释为概率。
Sigmoid函数在分类算法Logistic回归中被广泛使用,这个算法主要用于二分类问题。它的目标是找到一个模型,可以根据输入的特征预测出一个事件发生的概率。这个预测的概率就是Logistic回归模型的输出。
然而,Sigmoid函数在输入值的绝对值较大时,函数的梯度接近于0,这将导致梯度消失,使得神经网络的训练变得困难。此外,Sigmoid函数的输出不是以0为中心的,这可能会导致训练过程中的收敛速度变慢。
2.2 Tanh函数
Tanh函数可以看作是Sigmoid函数的扩展,它将输入压缩到(-1,1)。Tanh函数的表达式为:
$$a = \tanh(z) = \frac{e^z-e^{-z}}{e^z+e^{-z}}$$
由于Tanh函数的输出是以0为中心的,因此在实践中,Tanh函数通常比Sigmoid函数的表现要好。
然而,Tanh函数仍然存在梯度消失的问题,当输入值的绝对值较大时,函数的梯度接近于0。
2.3 ReLU函数(Rectified Linear Unit 、线性整流函数、修正线性单元)
ReLU函数是目前在神经网络中常用的激活函数。ReLU函数的表达式为:
$$a = \max(0,z)$$
ReLU函数在x>0时保持输入不变,在x<0时输出为0。由于ReLU函数在x>0时梯度为1,在x<0时梯度为0,因此ReLU函数在一定程度上缓解了梯度消失的问题。
然而,ReLU函数在x<0时完全不激活,这可能会导致一些神经元死亡,即在训练过程中永远不会被激活。此外,ReLU函数的输出也不是以0为中心的。
2.4 Leaky ReLU函数
为了解决ReLU函数在x<0时可能导致的神经元死亡问题,人们提出了Leaky ReLU函数。Leaky ReLU函数的表达式为:
$$a = \max(0.01z, z)$$
与ReLU函数相比,Leaky ReLU函数在x<0时的斜率为0.01,这意味着Leaky ReLU函数在x<0时仍然有小的梯度,因此可以缓解神经元死亡的问题。
激活函数是神经网络中的重要组成部分,理解不同激活函数的性质和适用场景,可以帮助我们更好地设计和优化神经网络。
3. 前向传播
在深度学习中,前向传播(Forward Propagation)是一种非常基础且关键的过程。前向传播涉及将输入数据传递通过神经网络并计算出结果。本章将详细阐述前向传播的过程,并给出一些相关的数学细节。
3.1 前向传播的概念
首先,我们来理解什么是前向传播。在神经网络中,每一层的节点都是由前一层的节点通过某种计算得来的,这个计算的过程就是前向传播。具体地说,对于每一层,我们都会首先计算一个线性组合(即,对输入进行加权求和),然后再通过一个激活函数进行非线性变换。
在数学上,对于第$l$层的每个节点$j$,其线性组合可以写作$z_j^{[l]} = \sum_i w_{ij}^{[l]} a_i^{[l-1]} + b_j^{[l]}$,其中$w_{ij}^{[l]}$是连接第$l-1$层的第$i$个节点和第$l$层的第$j$个节点的权重,$a_i^{[l-1]}$是第$l-1$层的第$i$个节点的激活值,$b_j^{[l]}$是第$l$层的第$j$个节点的偏置。接下来,我们会将$z_j^{[l]}$输入到一个激活函数$g^{[l]}$中,得到该节点的激活值$a_j^{[l]} = g^{[l]}(z_j^{[l]})$。
3.2 线性部分的计算
为了更有效地处理多个节点和多个样本,我们通常使用矩阵和向量的形式来表示和计算线性部分。假设我们现在有$m$个样本,那么我们可以将这些样本的输入表示为一个$n_x \times m$的矩阵$X$,其中$n_x$是输入的大小(即,第0层,也就是输入层的节点数),$m$是样本的数量。
对于第$l$层,我们可以将其权重表示为一个$n^{[l]} \times n^{[l-1]}$的矩阵$W^{[l]}$,将其偏置表示为一个$n^{[l]} \times 1$的向量$b^{[l]}$,其中$n^{[l]}$是第$l$层的节点数,$n^{[l-1]}$是第$l-1$层的节点数。这样,对于所有的样本,我们可以一次性计算第$l$层所有节点的线性组合,即$Z^{[l]} = W^{[l]} A^{[l-1]} + b^{[l]}$,其中$A^{[l-1]}$是第$l-1$层所有节点的激活值。
3.3 非线性部分的计算
得到线性部分$Z^{[l]}$后,我们需要通过一个激活函数进行非线性变换。这里的激活函数可以是任何非线性函数,常见的有Sigmoid函数、tanh函数、ReLU函数、Leaky ReLU函数等。使用激活函数的目的是为了引入非线性,使得神经网络可以逼近任何函数。如果我们不使用激活函数,那么无论神经网络有多少层,其总是等价于一个线性模型,这大大限制了神经网络的表达能力。
对于第$l$层,我们将$Z^{[l]}$输入到激活函数$g^{[l]}$中,得到激活值$A^{[l]} = g^{[l]}(Z^{[l]})$。同样,为了一次性处理所有的样本,我们可以将$A^{[l]}$看作是一个$n^{[l]} \times m$的矩阵,其中每一列对应一个样本,每一行对应一个节点。
3.4 结果的计算
当我们计算到最后一层,也就是输出层时,我们就得到了神经网络的输出结果。对于二分类问题,我们通常使用Sigmoid函数作为输出层的激活函数,这样得到的结果可以看作是正类的概率;对于多分类问题,我们通常使用softmax函数作为输出层的激活函数,这样得到的结果可以看作是每个类别的概率。
在前向传播结束后,我们会计算损失函数,比较预测结果和真实标签的差距。然后在后向传播中,我们会根据这个差距来更新网络的参数,使得预测结果更接近真实标签。这就是神经网络的训练过程。
$$ Z^{[1]} = W^{[1]}x + b^{[1]} $$ $$ A^{[1]} = g^{[1]}(Z^{[1]}) $$ $$ Z^{[2]} = W^{[2]}A^{[1]} + b^{[2]} $$ $$ A^{[2]} = g^{[2]}(Z^{[2]}) $$
以上就是前向传播的过程,虽然涉及到了一些线性代数和微积分的知识,但总的来说,前向传播只是一种将输入数据转换为输出结果的过程,而这个过程是完全确定的,只依赖于输入数据和网络参数。理解了前向传播,我们就理解了神经网络如何从输入得到输出,也就理解了神经网络的“前半部分”。接下来我们将讨论后向传播,也就是神经网络的“后半部分”。
4. 后向传播
在前向传播过程中,我们了解了如何通过神经网络将输入数据转换为输出。然而,如果我们希望优化神经网络的性能,让其能更好地预测或分类,我们需要一个能够衡量神经网络输出与期望输出之间差异的标准,这就是损失函数。当损失函数确定后,我们的目标就是找到一种方法来减小这个损失。这个方法就是后向传播,也就是通过求解损失函数的梯度,然后根据这个梯度来更新神经网络的参数。本章我们将深入讨论后向传播的原理和过程。
4.1 梯度的计算
上文介绍过代价函数,我们的目标就是找到一种方法来最小化这个代价函数。因为代价函数是神经网络参数的函数,所以我们可以通过调整这些参数来改变代价函数的值。这就是梯度下降的基本思想:我们计算代价函数关于每个参数的偏导数,也就是梯度,然后按照梯度的反方向更新参数。
具体地说,对于第$l$层的权重$W^{[l]}$和偏置$b^{[l]}$,我们需要计算代价函数关于它们的偏导数$\frac{\partial J}{\partial W^{[l]}}$和$\frac{\partial J}{\partial b^{[l]}}$。由于神经网络输出是通过一系列复杂的计算得到的,所以这个偏导数的计算需要使用链式法则。这个过程被称为反向传播,因为我们是从最后一层开始,然后向前一层一层地传递梯度。
为了方便计算,我们可以先计算一个中间量$dZ^{[l]} = \frac{\partial J}{\partial Z^{[l]}}$,这样我们就可以写出
$$\frac{\partial J}{\partial W^{[l]}} = \frac{1}{m} dZ^{[l]} A^{[l-1]T}$$
$$\frac{\partial J}{\partial b^{[l]}} = \frac{1}{m} np.sum(dZ^{[l]}, axis=1, keepdims=True)$$其中$np.sum$表示对矩阵进行求和,axis=1
表示沿着行的方向求和,keepdims=True
表示保持原有的维度。
对于$dZ^{[l]}$,我们可以通过链式法则计算得到: $$dZ^{[l]} = dA^{[l]} * g’^{[l]}(Z^{[l]})$$其中$*$表示元素级别的乘法,$g’^{[l]}$表示激活函数的导数。 $$dA^{[l]} = \frac{\partial J}{\partial A^{[l]}} = dZ^{[l+1]} W^{[l+1]T}$$
通过这种方式,我们可以一层一层地向前传递梯度,直到第一层。这个过程就是后向传播。
4.2 参数的更新
得到了梯度后,我们就可以更新参数了。具体地说,我们按照以下的公式来更新第$l$层的权重和偏置:
$$ W^{[l]} = W^{[l]} - \alpha \frac{\partial J}{\partial W^{[l]}} $$ $$ b^{[l]} = b^{[l]} - \alpha \frac{\partial J}{\partial b^{[l]}} $$
其中,$\alpha$是学习率,是一个超参数(Hyperparameters),用于控制参数更新的步长。这里的 $\frac{\partial J}{\partial W^{[l]}}$ 和 $\frac{\partial J}{\partial b^{[l]}}$ 就是我们在反向传播中计算出的梯度。通过这种方式,我们可以一点点地降低损失函数的值,提升神经网络的性能。
4.3 后向传播公式总结
$$ dZ^{[L]} = A^{[L]} - Y $$ $$ dW^{[L]} = \frac{1}{m}dZ^{[L]}A^{{[L-1]}^T} $$ $$ db^{[L]} = \frac{1}{m}np.sum(dz^{[L]}, axis=1, keepdim=True) $$ $$ dZ^{[L-1]} = dW^{{[L]}^T}dZ^{[L]} * g’^{[L]}(Z^{[L-1]}) $$ $$ … $$ $$ dZ^{[1]} = dW^{{[L]}^{T}}dZ^{[2]} * g’^{[1]}(Z^{[1]}) $$ $$ dW^{[1]} = \frac{1}{m}dZ^{[1]}A^{{[1]}^T} $$ $$ db^{[1]} = \frac{1}{m}np.sum(dZ^{[1]}, axis=1, keepdims=True) $$
以上就是后向传播的过程。可以看到,后向传播主要是关于求解梯度和更新参数的过程。虽然涉及到一些微积分和线性代数的知识,但总的来说,只要理解了梯度下降和链式法则,就能理解后向传播。理解了后向传播,我们就理解了神经网络如何根据输入和输出之间的差异来调整自身的参数,也就理解了神经网络的“后半部分”。在下一章中,我们将使用python来实现一个简单的神经网络,通过这个实例,我们将更深入地理解前向传播和后向传播。
5. Python实现神经网络
在了解了神经网络的基本原理后,我们将在本章中用Python实现一个简单的神经网络Demo。
5.1 神经网络的构建
这个过程包括初始化参数、前向传播和后向传播、参数更新、优化函数,模型定义。首先,我们需要初始化参数。注意这里的W要使用random初始化,而不是zeros,否则会导致第一层神经元计算出的结果都是0。
def initialize_parameters(n_x, n_h, n_y):
np.random.seed(1)
W1 = np.random.randn(n_h, n_x) * 0.01
b1 = np.zeros((n_h, 1))
W2 = np.random.randn(n_y, n_h) * 0.01
b2 = np.zeros((n_y, 1))
parameters = {"W1": W1,
"b1": b1,
"W2": W2,
"b2": b2}
return parameters
然后,我们需要定义前向传播和后向传播的函数。
# 定义sigmoid函数
def sigmoid(Z):
A = 1 / (1 + np.exp(-Z))
cache = Z
return A, cache
# 前向传播函数
def forward_propagation(X, parameters):
W1 = parameters["W1"]
b1 = parameters["b1"]
W2 = parameters["W2"]
b2 = parameters["b2"]
Z1 = np.dot(W1, X) + b1
A1 = np.tanh(Z1)
Z2 = np.dot(W2, A1) + b2
A2, cache = sigmoid(Z2)
cache = {"Z1": Z1,
"A1": A1,
"Z2": Z2,
"A2": A2}
return A2, cache
# 计算CostFunction
def compute_cost(A2, Y):
m = Y.shape[1]
logprobs = np.multiply(np.log(A2), Y) + np.multiply((1 - Y), np.log(1 - A2))
cost = - np.sum(logprobs) / m
cost = np.squeeze(cost)
return cost
# 后向传播函数
def backward_propagation(parameters, cache, X, Y):
m = X.shape[1]
W1 = parameters["W1"]
W2 = parameters["W2"]
A1 = cache["A1"]
A2 = cache["A2"]
dZ2 = A2 - Y
dW2 = (1 / m) * np.dot(dZ2, A1.T)
db2 = (1 / m) * np.sum(dZ2, axis=1, keepdims=True)
dZ1 = np.multiply(np.dot(W2.T, dZ2), 1 - np.power(A1, 2))
dW1 = (1 / m) * np.dot(dZ1, X.T)
db1 = (1 / m) * np.sum(dZ1, axis=1, keepdims=True)
grads = {"dW1": dW1,
"db1": db1,
"dW2": dW2,
"db2": db2}
return grads
# 更新参数的函数
def update_parameters(parameters, grads, learning_rate=1.2):
W1 = parameters["W1"]
b1 = parameters["b1"]
W2 = parameters["W2"]
b2 = parameters["b2"]
dW1 = grads["dW1"]
db1 = grads["db1"]
dW2 = grads["dW2"]
db2 = grads["db2"]
W1 = W1 - learning_rate * dW1
b1 = b1 - learning_rate * db1
W2 = W2 - learning_rate * dW2
b2 = b2 - learning_rate * db2
parameters = {"W1": W1,
"b1": b1,
"W2": W2,
"b2": b2}
return parameters
接下来,我们需要定义一个优化函数,用于进行多轮的训练,每轮训练中都会执行一次前向传播、计算损失、执行一次后向传播和更新参数。
def optimize(parameters, X, Y, num_iterations=10000, learning_rate=1.2, print_cost=False):
costs = []
for i in range(0, num_iterations):
A2, cache = forward_propagation(X, parameters)
cost = compute_cost(A2, Y)
grads = backward_propagation(parameters, cache, X, Y)
parameters = update_parameters(parameters, grads, learning_rate)
if print_cost and i % 1000 == 0:
print ("Cost after iteration %i: %f" %(i, cost))
costs.append(cost)
return parameters, costs
最后,我们需要定义一个模型函数,用于整合以上的所有步骤,包括初始化参数、进行多轮的训练,得到训练好的参数。
def model(X_train, Y_train, X_test, Y_test, n_h=4, num_iterations=10000, learning_rate=1.2, print_cost=True):
np.random.seed(3)
n_x = X_train.shape[0]
n_y = Y_train.shape[0]
parameters = initialize_parameters(n_x, n_h, n_y)
parameters, costs = optimize(parameters, X_train, Y_train, num_iterations, learning_rate, print_cost)
return parameters, costs
通过以上的步骤,我们就得到了一个简单的三层神经网络模型。虽然这个模型很简单,但它涵盖了神经网络的核心思想,包括前向传播、计算损失、后向传播和更新参数。希望通过这个实例,能够帮助大家更好地理解神经网络的原理和运行机制。
当然,真实的深度学习任务要比这个实例复杂得多,比如可能会涉及到更复杂的网络结构、更复杂的损失函数、正则化、优化器、批归一化等等。但是,只要你理解了这个实例,就已经迈出了深度学习的第一步。