查看全课大纲(第 82 / 93 节)
  1. 1.计算机视觉概述
  2. 2.计算机视觉应用场景
  3. 3.困难与挑战
  4. 4.开源库介绍
  5. 5.图像显示与存储
  6. 6.CLAHE
  7. 7.腐蚀膨胀与开闭运算
  8. 8.滤波与边缘填充
  9. 9.各种滤波
  10. 10.图像金字塔
  11. 11.傅里叶变换
  12. 12.短时傅里叶变换与小波变换
  13. 13.颜色特征与颜色直方图
  14. 14.几何特征_边缘
  15. 15.几何特征-角点与斑点
  16. 16.局部特征-SIFT特征描述子
  17. 17.局部特征-SURF特征描述子
  18. 18.局部特征-ORB特征描述子
  19. 19.其他特征描述子
  20. 20.传统图像分割技术
  21. 21.GraphCut
  22. 22.GrabCut
  23. 23.人脸检测的传统方法
  24. 24.行人检测的传统方法HOG+SVM
  25. 25.行人检测DPM
  26. 26.人工神经网络的架构
  27. 27.目标函数与梯度下降
  28. 28.误差反向传播
  29. 29.可手算的神经网络
  30. 30.改进目标函数与克服梯度消散
  31. 31.神经网络之传统神经网络与深度网络的差异
  32. 32.各种梯度下降算法
  33. 33.如何选择合适的梯度下降算法
  34. 34.避免过适应
  35. 35.CNN的架构
  36. 36.CNN的误差反向传播_卷积层
  37. 37.CNN的误差反向传播_池化层
  38. 38.分类所用的数据集
  39. 39.Alexnet详解
  40. 40.VGG详解
  41. 41.GoogLeNet_v1
  42. 42.BatchNormalization
  43. 43.GoogLeNet_v3v4
  44. 44.ResNet
  45. 45.ResNeXt
  46. 46.CNN设计准则
  47. 47.图像检索与推土机距离
  48. 48.基于颜色相似性
  49. 49.基于形状相似性
  50. 50.基于纹理相似性
  51. 51.基于局部特征相似性
  52. 52.DL的二值哈希编码
  53. 53.高维空间中检索KDTree
  54. 54.高维空间中检索LSH
  55. 55.目标识别任务综述
  56. 56.RCNN
  57. 57.SPP-Net
  58. 58.Fast R-CNN
  59. 59.Faster R-CNN
  60. 60.R-CNN系列模型回顾
  61. 61.R-FCN的设计思路
  62. 62.R-FCN的实现
  63. 63.YOLOv1
  64. 64.YOLOv2_9000
  65. 65.YOLOv3
  66. 66.图像分割应用场景与数据集
  67. 67.上采样的实现方式
  68. 68.FCN网络架构的由来
  69. 69.FCN构建细节
  70. 70.DeepLabv1
  71. 71.CRF
  72. 72.DeepLabv2
  73. 73.DeepLabv3
  74. 74.特定的医学影像分割任务
  75. 75.U-Net
  76. 76.3D U-Net
  77. 77.V-Net
  78. 78.DenseNet
  79. 79.病理切片分析任务及数据集
  80. 80.病理切片分析
  81. 81.图说任务及其传统处理方法
  82. 82.深度语言模型与RNN
  83. 83.LSTM及其变种
  84. 84.NIC模型
  85. 85.SAT模型
  86. 86.图说的性能指标
  87. 87.判别模型与生成模型
  88. 88.KL散度与JS散度
  89. 89.VAE
  90. 90.GAN的基本原理
  91. 91.DCGAN与CGAN
  92. 92.Wasserstein GAN
  93. 93.SRGAN

深度语言模型与RNN

深度语言模型与循环神经网络(RNN)

小象实战讲义 · 基于深度学习的计算机视觉

从本节开始,我们将进入计算机视觉与自然语言处理交叉的前沿领域——图像描述(Image Captioning),俗称“图说”。其核心目标是让计算机能够“看懂”一张图片,并自动生成一句描述图片内容的自然语言句子。为了实现这一目标,我们首先需要掌握一种能够处理序列数据的神经网络模型——循环神经网络(RNN)。本节将系统介绍RNN的起源、核心结构、工作原理及其在语言建模中的应用,为后续构建图说模型打下坚实的理论基础。

💡 核心导读

  • RNN的定位:理解RNN作为处理时序/序列数据(如语言、语音)的核心网络,与处理固定维度输入(如图像)的CNN的根本区别。
  • 核心结构:掌握Vanilla RNN(普通循环神经网络)的展开图表示,理解其通过隐藏状态(Hidden State) 在时间步之间传递信息的机制。
  • 训练挑战:认识RNN训练中的核心难题——梯度消失(Gradient Vanishing) 与梯度爆炸(Gradient Explosion),并了解其训练算法BPTT(时序反向传播)。
  • 双向扩展:了解双向RNN(Bi-RNN) 的结构与动机,它通过结合过去与未来的上下文信息来提升序列建模能力。
  • 从原理到实例:通过一个简单的字母序列预测示例,直观理解RNN的前向计算过程与训练目标。

从固定输入到序列输入:为何需要RNN?

在之前的章节中,我们处理的计算机视觉任务,如图像分类、目标检测,其输入通常是一张固定尺寸的图片。我们可以通过缩放、裁剪等手段,将任意图片预处理成统一的尺寸(例如224×224),然后输入到卷积神经网络(CNN)中。这种处理方式近似认为网络的输入维度是固定的。

然而,对于自然语言这类数据,情况截然不同。一句话的长度是不固定的,短则几个词,长则数十上百个词。我们无法为所有可能长度的句子都设计一个独立的网络。更重要的是,语言的理解具有强烈的时序依赖关系。要预测句子中的下一个词,不仅依赖于当前词,更依赖于前面所有词构成的上下文信息。

例如,在句子“我昨天上学迟到了”中,要预测“迟到了”之后的词,模型需要记住前面“我昨天上学”这个事件。这种对前面信息的“记忆”能力,是传统前馈神经网络(包括CNN)所不具备的。

循环神经网络(RNN)正是为解决这类序列建模问题而设计的。它的核心思想是:网络不仅接收当前时刻的外部输入,还会接收上一个时刻自身的内部状态(隐藏状态)作为输入。通过这种循环连接,信息得以在时间维度上传递和累积,从而使网络具备了“记忆”过去信息的能力。

RNN的演进与基本结构

历史脉络

RNN的思想源远流长。其前身可追溯到1982年提出的霍普菲尔德网络(Hopfield Network)。1986年,机器学习领域的大牛迈克尔·乔丹(Michael I. Jordan,与篮球明星同名)对霍普菲尔德网络进行了改造,提出了乔丹网络(Jordan Network)。直到1990年,Jeffrey Elman对乔丹网络进行了简化,并成功应用了误差反向传播(Backpropagation) 算法,使得真正意义上的循环神经网络训练成为可能,因此最早的RNN常被称为Elman Network。

早期的RNN效果并不理想,主要受限于训练困难。直到1997年,两项里程碑式的改进诞生:

  1. 长短时记忆网络(LSTM):由 Sepp Hochreiter 和 Jürgen Schmidhuber 提出,通过引入“门控”机制,有效缓解了长序列训练中的梯度消失问题。
  2. 双向RNN(Bi-RNN):由 Mike Schuster 提出,允许网络同时利用过去和未来的上下文信息。

这两项突破使得RNN模型逐渐受到重视,并被广泛应用于语音识别、机器翻译、文本生成等领域。

Vanilla RNN:单层网络在时间上的展开

我们首先介绍最基础的循环神经网络,常被称为 Vanilla RNN(或普通RNN)。这里的“Vanilla”并非“香草”之意,而是指“原味的”、“普通的”。

Vanilla RNN可以看作是一个单层神经网络在时间序列上的扩展。为了便于理解,我们通常将其按时间步展开绘制。

下图展示了Vanilla RNN的经典展开图:

                  (输出层)          (输出层)          (输出层)
                     ^                 ^                 ^
                     | V               | V               | V
                 (h_t-1) --W---->    (h_t) --W---->   (h_t+1)   (隐藏层)
                     ^                 ^                 ^
                     | U               | U               | U
                  (x_t-1)            (x_t)            (x_t+1)   (输入层)
                 时刻t-1           时刻t            时刻t+1

关键解读:

  1. 输入(x):在每一个时间步 t,网络接收一个输入向量 x_t。在语言模型中,x_t 通常是对应词的词向量(如one-hot编码)。
  2. 隐藏状态(h):这是RNN的“记忆”所在。在时间步 t,隐藏状态 h_t 由当前输入 x_t 和上一时刻隐藏状态 h_{t-1} 共同计算得出。其计算公式为: ht=σ(W⋅ht−1+U⋅xt+b)h_t = \sigma(W \cdot h_{t-1} + U \cdot x_t + b) 其中,σ 是激活函数(如tanh或ReLU),W 是隐藏层到隐藏层的权重矩阵,U 是输入层到隐藏层的权重矩阵,b 是偏置项。
  3. 输出(o):在时间步 t,可以根据隐藏状态 h_t 计算输出 o_t,通常用于预测下一个词的概率分布。 ot=softmax(V⋅ht+c)o_t = \text{softmax}(V \cdot h_t + c) 其中,V 是隐藏层到输出层的权重矩阵,c 是输出层偏置,softmax 函数将输出转换为概率分布。
  4. 参数共享:请注意,在整个时间序列上,权重矩阵 U、W、V 和偏置 b、c 是共享的。这意味着无论序列多长,我们都使用同一套参数来处理每个时间步,这是RNN能够处理变长序列的关键。

另一种更贴近神经网络原型的画法如下,它明确展示了每个时间步的隐藏层神经元之间存在全连接(包括自连接):

       时刻 t-1                时刻 t                时刻 t+1
      +-------+              +-------+              +-------+
      |       |              |       |              |       |
 x -> | 隐藏层 | -> h    x -> | 隐藏层 | -> h    x -> | 隐藏层 | -> h
      |       |              |       |              |       |
      +---+---+              +---+---+              +---+---+
          |      W(全连接)      |      W(全连接)      |
          +----------------------+----------------------+

这种画法强调了:每个隐藏层神经元都会连接到下一时刻的所有隐藏层神经元(包括它自己),连接权重由矩阵 W 定义。

RNN的训练:时序反向传播(BPTT)与梯度问题

BPTT算法原理

RNN的训练采用时序反向传播算法(Backpropagation Through Time, BPTT)。它是标准反向传播(BP)算法在时间序列上的扩展。

其核心步骤与BP算法一致:

  1. 前向计算:沿着时间轴,从 t=1 到 t=T,依次计算每个时间步的隐藏状态 h_t 和输出 o_t。
  2. 误差计算:计算每个时间步的预测输出 o_t 与真实标签 y_t 之间的损失(如交叉熵损失),并将所有时间步的损失求和,得到总损失 L。
  3. 反向传播:关键的一步。将总损失 L 的梯度,同时沿着网络层级(从输出层反向传播到隐藏层)和时间维度(从最后时刻 t=T 反向传播到最初时刻 t=1)进行传播,以计算所有权重(U, W, V)的梯度。
  4. 参数更新:使用梯度下降法更新所有权重参数。

梯度消失与梯度爆炸

BPTT算法在长序列上会面临严峻的挑战:梯度消失(Gradient Vanishing) 和梯度爆炸(Gradient Explosion)。

这是由于在反向传播时,梯度需要沿着时间步连续相乘。考虑隐藏状态梯度传播的核心项: ∂ht∂ht−1=WT⋅diag(σ′(...))\frac{\partial h_t}{\partial h_{t-1}} = W^T \cdot \text{diag}(\sigma'(...)) 其中 σ' 是激活函数的导数。

  • 梯度消失:如果 W 的谱范数(可理解为最大奇异值)小于1,并且使用sigmoid/tanh这类导数最大值为0.25/1的激活函数时,梯度在连续相乘后会指数级衰减到接近0。这意味着远距离时间步(t=1)的参数几乎接收不到有效的梯度信号,无法被更新,模型无法学习到长距离依赖。
    import numpy as np
    # 模拟梯度连续相乘的衰减
    gradient = 0.25  # 假设每一步的梯度传递系数
    steps = 10
    final_gradient = gradient ** steps
    print(f"经过 {steps} 步传播后的梯度: {final_gradient:.6f}")
    # 输出: 经过 10 步传播后的梯度: 0.000001
经过 10 步传播后的梯度: 0.000001
  • 梯度爆炸:反之,如果 W 的谱范数大于1,梯度在连续相乘后会指数级增长,变得异常巨大,导致参数更新步长过大,训练不稳定甚至发散。

针对梯度爆炸,实践中常用梯度裁剪(Gradient Clipping):当梯度的范数超过设定阈值时,对梯度整体按比例缩放,使参数更新步长保持在可控范围内。它与梯度消失所依赖的 LSTM、GRU 等特殊结构设计,构成了两类问题的标准对策。

梯度消失是Vanilla RNN在实际应用中处理长序列时的主要瓶颈,这也催生了LSTM、GRU等更先进的循环单元结构。

双向RNN(Bi-RNN)

在有些任务中,当前时刻的输出不仅依赖于过去的序列信息,也依赖于未来的信息。例如,在句子“我昨天上学迟到了”中,要预测“上学”这个词,如果只知道前面的“我昨天”,可能性很多(上学、上班、逛街…)。但如果也知道后面的“迟到了”,那么“上学”或“上班”的可能性就大大增加。

双向RNN正是为了同时利用过去和未来的上下文信息而设计的。它由两个独立的RNN层组成:

  • 前向RNN层:按正常时间顺序(从左到右)处理序列,编码过去的信息。
  • 后向RNN层:按逆序时间顺序(从右到左)处理序列,编码未来的信息。

在每一个时间步 t,双向RNN的输出由前向隐藏状态 ht→\overrightarrow{h_t} 和后向隐藏状态 ht←\overleftarrow{h_t} 共同决定,通常是将两者拼接起来: ht=[ht→;ht←]h_t = [\overrightarrow{h_t}; \overleftarrow{h_t}]

其结构示意图如下:

        前向 RNN(从左到右)
  x1 → [h1] → [h2] → [h3] → [h4]
         ↓      ↓      ↓      ↓
        后向 RNN(从右到左)
  x1 ← [h1] ← [h2] ← [h3] ← [h4]
         ↓      ↓      ↓      ↓
  输出: [→h_t ; ←h_t](拼接后送入下一层)

双向RNN在需要全局上下文理解的任务中,如命名实体识别、语义角色标注等,表现通常优于单向RNN。

RNN实战演练:一个简单的序列预测

让我们通过一个极简的例子,直观感受RNN的前向计算过程。假设我们的词汇表只有4个字母:{h, e, l, o}。我们想训练一个RNN,当输入序列为 "h" -> "e" -> "l" -> "l" 时,它能依次预测出下一个字母 "e" -> "l" -> "l" -> "o",即学会拼写 "hello"。

首先,我们需要对字母进行编码。这里使用最简单的 one-hot编码:

  • h -> [1, 0, 0, 0]
  • e -> [0, 1, 0, 0]
  • l -> [0, 0, 1, 0]
  • o -> [0, 0, 0, 1]

假设我们的RNN已经过训练(此处省略训练过程),拥有了一组理想的权重。我们来看前向计算过程。为了演示,我们使用numpy实现一个简化版的前向传播。

import numpy as np

# 定义词汇表大小和one-hot编码
vocab = {'h':0, 'e':1, 'l':2, 'o':3}
index_to_char = {0:'h', 1:'e', 2:'l', 3:'o'}

# 假设的“理想”权重 (为了演示,随机初始化并稍作调整,并非真实训练所得)
np.random.seed(42)
input_size = 4
hidden_size = 3
output_size = 4

# 权重矩阵
W = np.random.randn(hidden_size, hidden_size) * 0.1  # 隐藏层到隐藏层
U = np.random.randn(hidden_size, input_size) * 0.1   # 输入层到隐藏层
V = np.random.randn(output_size, hidden_size) * 0.1  # 隐藏层到输出层
# 偏置
b = np.zeros((hidden_size, 1))
c = np.zeros((output_size, 1))

# 激活函数
def tanh(x):
    return np.tanh(x)

# 输入序列 "h", "e", "l", "l"
input_seq = ['h', 'e', 'l', 'l']
# 初始化隐藏状态
h_prev = np.zeros((hidden_size, 1))

print("RNN前向计算过程(输入序列: hello):")
print("-" * 40)

for i, char in enumerate(input_seq):
    # 1. 将字符转换为one-hot向量 (列向量)
    x = np.zeros((input_size, 1))
    x[vocab[char]] = 1
    
    # 2. 计算当前隐藏状态: h_t = tanh(W * h_{t-1} + U * x_t + b)
    h_current = tanh(np.dot(W, h_prev) + np.dot(U, x) + b)
    
    # 3. 计算输出层(未经过softmax): o_t = V * h_t + c
    o_current = np.dot(V, h_current) + c
    
    # 4. 应用softmax得到概率分布
    exp_o = np.exp(o_current - np.max(o_current)) # 数值稳定性处理
    prob = exp_o / np.sum(exp_o)
    
    # 5. 打印结果
    predicted_index = np.argmax(prob)
    predicted_char = index_to_char[predicted_index]
    
    print(f"时间步 {i+1}: 输入 '{char}'")
    print(f"  隐藏状态 h_{i+1} (shape {h_current.shape}):\n", h_current.round(3))
    print(f"  输出层原始值 o_{i+1}:\n", o_current.round(3))
    print(f"  下一个字母的概率分布: {prob.flatten().round(3)}")
    print(f"  预测的下一个字母: '{predicted_char}' (目标: '{'e' if i==0 else 'l' if i<3 else 'o'}')")
    print("-" * 40)
    
    # 6. 更新隐藏状态,用于下一时间步
    h_prev = h_current
RNN前向计算过程(输入序列: hello):
----------------------------------------
时间步 1: 输入 'h'
  隐藏状态 h_1 (shape (3, 1)):
 [[ 0.054]
 [-0.189]
 [ 0.031]]
  输出层原始值 o_1:
 [[-0.007]
 [-0.009]
 [ 0.012]
 [-0.038]]
  下一个字母的概率分布: [0.251 0.25  0.256 0.243]
  预测的下一个字母: 'l' (目标: 'e')
----------------------------------------
时间步 2: 输入 'e'
  隐藏状态 h_2 (shape (3, 1)):
 [[-0.039]
 [-0.159]
 [-0.098]]
  输出层原始值 o_2:
 [[ 0.014]
 [ 0.012]
 [ 0.011]
 [-0.027]]
  下一个字母的概率分布: [0.253 0.252 0.252 0.243]
  预测的下一个字母: 'h' (目标: 'l')
----------------------------------------
时间步 3: 输入 'l'
  隐藏状态 h_3 (shape (3, 1)):
 [[-0.053]
 [-0.056]
 [-0.154]]
  输出层原始值 o_3:
 [[ 0.023]
 [ 0.02 ]
 [ 0.006]
 [-0.007]]
  下一个字母的概率分布: [0.253 0.252 0.249 0.246]
  预测的下一个字母: 'h' (目标: 'l')
----------------------------------------
时间步 4: 输入 'l'
  隐藏状态 h_4 (shape (3, 1)):
 [[-0.058]
 [-0.059]
 [-0.146]]
  输出层原始值 o_4:
 [[ 0.022]
 [ 0.019]
 [ 0.006]
 [-0.007]]
  下一个字母的概率分布: [0.253 0.252 0.249 0.246]
  预测的下一个字母: 'h' (目标: 'o')
----------------------------------------

注意:上面的代码使用了随机初始化的权重,因此输出是均匀分布,预测不正确。这正说明了训练的必要性。一个训练良好的RNN,其输出层中对应目标字母的 logit 值应该显著大于其余字母的 logit 值,经过softmax后,目标字母的概率会接近1。训练过程就是通过BPTT算法,不断调整 U, W, V, b, c 这些参数,使得网络的预测越来越准。

📝 动手练一练

  1. 概念辨析:请简述Vanilla RNN与传统的三层全连接前馈神经网络在结构上的核心区别。这种区别赋予了RNN什么独特的能力?
  2. 梯度问题分析:假设一个RNN使用sigmoid作为隐藏层激活函数,其权重矩阵 W 的最大奇异值约为0.8。当序列长度为50时,估算梯度从最后一步反向传播到第一步时大约会衰减为原来的多少倍?这会导致什么问题?
👉 点击查看参考答案
  1. 核心区别与能力:

    • 结构区别:传统前馈神经网络的层与层之间只有前向连接,信息单向流动。而Vanilla RNN在隐藏层内部增加了循环连接,使得当前时刻的隐藏状态会作为输入的一部分,参与到下一个时刻的计算中。
    • 独特能力:这种循环结构使得RNN具备了处理变长序列数据和捕获序列中的时序依赖关系(即“记忆”过去信息) 的能力。这对于语言、语音、时间序列等数据至关重要。
  2. 梯度衰减估算:

    • Sigmoid函数的导数最大值是0.25。
    • 每一步梯度反向传播的衰减系数大约为 0.8 * 0.25 = 0.2(这是一个非常粗略的估计,实际计算涉及矩阵连乘)。
    • 经过50步传播,梯度大约衰减为原来的 0.2^50 ≈ 1.13e-35 倍。
    • 导致的问题:这会导致严重的梯度消失。位于序列开始部分的参数接收到的梯度几乎为零,无法得到有效更新,因此模型难以学习到长距离的依赖关系。

本章小结

本节我们迈出了从纯视觉任务走向“视觉-语言”多模态任务的第一步,深入探讨了图像描述(图说)任务的基础组件——循环神经网络(RNN)。

  • RNN的定位:作为处理序列数据的利器,RNN通过其内部的循环连接,使网络能够保留对过去信息的“记忆”,从而解决了传统前馈网络无法处理变长输入和时序依赖的问题。
  • 核心机制:我们深入剖析了Vanilla RNN的结构,理解了其按时间步展开的计算过程,以及参数共享的关键设计。隐藏状态 h_t 是信息传递的载体。
  • 训练挑战:RNN的训练采用BPTT算法,但面临梯度消失和梯度爆炸的固有难题,这限制了基础RNN处理长序列的能力。
  • 结构扩展:双向RNN通过结合前向和后向两个RNN层,能够同时利用过去和未来的上下文信息,提升了序列建模的容量。
  • 从原理到感知:通过一个简单的字母序列预测示例,我们直观感受了RNN的前向计算流程,并明确了训练的目标是调整参数,使得网络输出正确的概率分布。

RNN,特别是其改进型LSTM和GRU,构成了现代序列建模的基石。在下一节中,我们将介绍强大的长短时记忆网络(LSTM),看它是如何通过精巧的“门控”机制来克服梯度消失,从而真正实现对长序列的有效建模。

今天,序列建模的主流已转向 Transformer/自注意力机制,RNN 在长序列任务上逐渐被取代;但 RNN、BPTT 与梯度消失/爆炸的分析,仍是理解序列建模与训练稳定性的经典基础。

📋 行动清单

  • 在白纸上画出Vanilla RNN在三个时间步上的展开图,并标注出输入 x_t、隐藏状态 h_t、输出 o_t 以及权重矩阵 U、W、V。
  • 编写一个简单的numpy程序,模拟一个只有2个时间步、标量输入/输出的超简RNN,手动计算其BPTT过程,体会梯度沿时间传播的概念。
  • 思考并列举出至少三个除了自然语言处理之外,RNN可能适用的序列数据任务场景。

—— 小象教研组