第 14 章 · 经典网络结构

RNN 与 LSTM

CNN 专治图像。可另一类数据——一句话、一段语音、一串股价——是有先后顺序序列,要处理它,得能“记住前面”。这一章认识第二位专才: RNN(循环神经网络)和它的升级版 LSTM,并看清它们的先天短板—— 这正是下一部分注意力要来救的场。

读完这一章,你会明白

  • 序列数据为什么需要“记忆”,而 MLP 给不了;
  • RNN 怎么用一个隐藏状态把信息一路带下去(按时间展开);
  • 下一词预测走通一遍:前向按时推进 → 逐步算损失 → BPTT 反传、权重共享下梯度怎么累加;
  • 截断 BPTT、teacher forcing梯度裁剪这些训练时绕不开的工程招;
  • RNN 的两个硬伤:梯度消失/爆炸(长依赖学不到)和没法并行(慢);
  • LSTM 怎么用“门”管理记忆,缓解长依赖;
  • 为什么后来 Transformer 把它取代了(承接第四部分)。

1. 序列:顺序很重要,还得记住前文

“狗咬人”和“人咬狗”用的字一样,顺序不同意思就反了;要判断“它太重了”里的“它”指谁,得回看前文。 这类数据有两个特点:长度不固定元素间有顺序和依赖。固定大小的 MLP(第 3 章)一次只吃固定长度、 还把顺序信息丢了,天然不适合。我们需要一种能“边读边记”的结构。

2. RNN:一个带“隐藏状态”的循环

RNN 的想法非常像人读句子:一个词一个词地读,每读一个,就把“到目前为止记住的东西” 打包成一个向量,叫隐藏状态(hidden state),带到下一步。读下一个词时,它同时看两样东西: 这个新词 + 上一步传来的隐藏状态,更新出新的隐藏状态。

ht = 激活( Wx·xt + Wh·ht−1 + b ) 新记忆 hₜ = 由“当前输入 xₜ”和“上一刻记忆 hₜ₋₁”共同决定
隐藏状态(记忆)一路向右传 h₁ h₂ h₃ h₄ 词₁词₂词₃词₄

同一个 RNN 单元被“按时间展开”:每步吃一个词 + 上一步的隐藏状态,更新记忆再传给下一步。

“循环”和“展开”是一回事

RNN 其实只有一个单元,靠“把输出接回输入”循环使用。为了看清和训练它,我们把这个循环 按时间摊平(unroll)成上图那样一长串——本质是同一套权重重复用了很多次

一步里到底吃什么、吐什么?

如果把 RNN 看成“主干 + 输出头”,那在第 t 步里,主干真正吃进去的是两样东西: 当前输入 xt上一刻记忆 ht−1;真正一路往后传的,是新的隐藏状态 ht。如果任务是“猜下一个词”,再在 ht 上接一个线性层 + softmax, 就得到这一步的输出概率 ŷt

上一刻记忆 ht−1 形状: d_hidden 当前输入 xt one-hot 或 embedding RNN 单元 ht = tanh(Wxxt + Whht−1 + b) 新记忆 ht 传给下一步 输出头 Woht → softmax → ŷt

单步 RNN:输入 xt 和上一刻记忆 ht−1 一起算出新记忆 ht。如果任务是下一词预测,再把 ht 送进输出层得到词表概率 ŷt

名字它是什么典型形状这一章里扮演什么角色
xt当前这个词的表示d_input(one-hot 时常等于词表大小;embedding 时等于词向量维度)“现在读到了什么”
ht−1上一刻隐藏状态d_hidden“到上一刻为止记住了什么”
ht新隐藏状态d_hidden更新后的记忆,继续传给下一步
ot输出层打分 logitsvocab_size对词表里每个候选词打一个分
ŷtsoftmax 后的概率分布vocab_size“下一个词最可能是谁”

真正沿时间一步步往后传的是隐藏状态 h;输出头接不接、接在每一步还是最后一步,取决于具体任务。

src/deeplearning/rnn/mini_rnn_lm.cpp · 输入编码 + 输出层(精简)
input_sequence[i][token_id] = 1.0;                 1
rnn_.Forward(input_sequence, hidden_sequence);    2

logits[pos][token_id] = output_bias_[token_id];
logits[pos][token_id] += output_weight_[token_id][dim] *
                         hidden_sequence[pos][dim];   3
auto probs = Softmax(logits[pos]);                    4
  1. 这个最小字符级实现里,每个 token 先被写成 one-hot 向量——也就是当前步的输入 x_t
  2. SimpleRNN::Forward 吃完整条输入序列,吐出每一步的隐藏状态 h_1, h_2, ...
  3. 输出头把某一步的隐藏状态 h_t 乘上 W_o、加偏置,变成对词表里每个词的打分 o_t
  4. 最后过 softmax,就得到“下一个词是谁”的概率分布 ŷ_t

抓住一个最重要的区分就够了: RNN 主干负责维护“记忆” h,而输出头负责把这份记忆翻译成具体任务的答案。 做语言模型时,几乎每一步都接输出头去猜下一个词;做句子分类时,常常只拿最后一步的 hT 去做一次判断。

3. 这个 RNN 语言模型怎么训练、怎么推理?

§2 讲清了“单步里怎么算一个新的隐藏状态”。可真正跑起来时,模型吃进去的不是单独一个 xt, 而是一整段前缀序列;吐出来的也不是一个数,而是每个时间步各一份“下一个词概率”。 先把这座最小 RNN 语言模型的全貌看清,再去拆训练和推理就容易很多。

① 先看清整座网络

输入 token 序列例如 a b c
输入表示 x₁..xₜone-hot 或 embedding
SimpleRNN 主干逐步算 h₁..hₜ
输出头Wₒhₜ + bₒ
每步的下一个词概率ŷ₁..ŷₜ

最小 RNN 语言模型 = 输入编码层 + RNN 主干 + 输出头。主干维护记忆 h,输出头把每一步的记忆翻译成词表概率。

模块吃进去什么吐出来什么主要可训练参数
输入编码层 token id 序列 x1..xT 最小实现里直接用 one-hot(无额外参数);若换 embedding,就是一张词向量表
RNN 主干 xtht−1 ht WxWhb
输出头 ht logits / 概率 ŷt Wobo

本书配套的最小实现里,输入维度就等于词表大小(vocab_size),因为每个词先写成 one-hot 再送入 RNN。若换成第 15 章的 embedding,主干和训练流程并不会变。

场景喂给模型什么拿哪一步输出接下来做什么
训练 真实前缀序列 几乎每一步的 ŷt 和真实下一个词算交叉熵,再 BPTT 更新参数
推理 prompt + 已生成前缀 只看最后一步的 ŷT 挑一个新词,接回输入末尾继续

训练和推理共用同一座网络。区别不在“网络换了”,而在“拿哪些输出、后面怎么处理”。

src/deeplearning/rnn/mini_rnn_lm.cpp · Forward(精简)
EncodeTokenIds(token_ids, input_sequence);         1
rnn_.Forward(input_sequence, hidden_sequence);    2

for (pos : 0..T)
  logits[pos] = W_o · hidden_sequence[pos] + b_o; 3
// 之后: Softmax(logits[pos]) 得到 ŷ_t             4
  1. 先把 token id 序列编码成每一步的输入 x_t。这个最小实现里用的是 one-hot。
  2. RNN 主干顺着时间推进,得到整条隐藏状态序列 h_1..h_T
  3. 输出头把每一步的隐藏状态翻译成词表大小的打分(logits)。
  4. 每一步各自过 softmax,就得到“下一个词是谁”的概率分布 ŷ_t

② 训练时的任务:每一步猜“下一个词”

拿句子「我 / 喜欢 / 深度 / 学习」当训练样本。RNN 从左到右读,读到第 t 个词时, 手里握着隐藏状态 ht(“到目前为止的理解”),再接一个小头 输出层去预测下一个词是谁:

时间步 t输入 xt(已读到的词)隐藏状态 ht要预测的目标(下一个词)
1「我」的向量h1「喜欢」
2「喜欢」的向量h2「深度」
3「深度」的向量h3「学习」
4「学习」的向量h4句末 / 标点 / 特殊结束符

每个词先变成向量 xt(第 15 章的 embedding 或 one-hot 再乘矩阵), 再喂进 RNN。输出层通常是线性层 + Softmax(第 4 章), 在词表上给出概率分布 ŷt

Teacher forcing:训练时“偷看答案”

训练第 2 步时,输入用的是数据里真实的「喜欢」,而不是模型第 1 步猜出来的词——这叫 teacher forcing(教师强制)。这样每步的输入都靠谱,网络先专心学“给定正确前文时怎么预测”。 推理生成时没有标准答案,只能把自己上一步的输出接回去,误差会累积,那是另一回事(第 19 章)。

③ 前向:按时推进,并把中间结果存下来

一条样本的训练步,前向可以写成下面这个循环——和第 3 章前向一样,只是“层”换成了“时间步”:

  1. 初始化:h0 常设为全 0,或学一个可训练的初始向量。
  2. 对每个时间步 t = 1 … T:
    • 用 §2 的式子算 ht = 激活( Wx·xt + Wh·ht−1 + b );
    • 用输出层算 ŷt = Softmax( Wo·ht + bo );
    • htxtŷt 存进缓存——反向时要靠它们(第 6 章z 是同一道理)。

展开后,RNN 就像一条有 T 段的深链:每段结构相同,共用同一套 WxWhb。 第 t必须等t−1 步的 h 算完,所以前向天然串行——这也是后面“没法并行”的根源。

④ 损失:每一步各算一次,再汇总

t 步的预测 ŷt 和真实下一个词 yt 比,用 交叉熵(第 4 章)打分。整句的损失通常是各步之和或平均:

L = (1/T) Σt=1..T CrossEntropy( ŷt, yt ) 每个时间步都有一份“猜词”的错题本,最后合在一起

不同任务只是在“哪几步算损失、输出头接在哪”上换一下:

任务损失算在哪直觉
语言建模几乎每一步每读一个词,都要会猜下一个
序列标注(词性、NER)每一步每个词一个标签,边读边标
句级分类(情感正负)通常只用最后一步 hT读完整句,最后一个记忆做判断
序列到序列(翻译)编码器 + 解码器各有一套 RNN先读源句,再按步生成目标句(第 17 章注意力版更常见)
src/deeplearning/rnn/mini_rnn_lm.cpp · TrainNextToken(精简)
position_targets[i] = sample[i + 1];               1
position_targets.back() = target_tokens[sample_idx];

rnn_.Forward(input_sequence, hidden_sequence);     2
for (int t = 0; t < seq_len; t++) {
  probs = Softmax(logits_t);
  loss += -log(probs[position_targets[t]]);        3
  grad_logits[target] -= 1.0;
}
rnn_.Backward(grad_hidden, grad_inputs);           4
rnn_.ApplyGradient(lr);                            5
  1. 先把每一步真正该猜的目标词准备好:前几个位置猜“下一个真实词”,最后一个位置猜样本外面接着的那个目标词。
  2. 整条前缀先走一遍前向,拿到所有时间步的隐藏状态。
  3. 每一步各算一次 softmax + 交叉熵,把损失累起来。
  4. 把所有时间步的梯度一起交给 BPTT,让误差沿时间倒着传回去。
  5. 最后更新 RNN 主干和输出头参数。也就是说,训练不是“只改最后一步”,而是整条序列一起学。

⑤ 反向:BPTT 与“同一套权重,梯度要相加”

损失 L 对参数求导,还是第 6 章反向传播的三步: 从输出层误差出发 → 沿计算图往回传 → 得到每个参数的梯度。只不过这里的“深”是沿时间展开的, 所以叫 BPTT(Backpropagation Through Time,按时间反向传播)

前向 → 反向 ← (沿时间) h₁ h₂ h₃ h₄ ∂L/∂h 沿 Wₕ 一路往回乘(链式法则) L₁L₂L₃L₄ 每步损失 Lₜ ↓ 反传到 hₜ 再经 Wₕ 传到 hₜ₋₁

前向:ht−1 → ht 一路向右;反向:各步损失产生的梯度,沿隐藏状态连线从后往前传(BPTT)。

和 MLP 相比,BPTT 多出来两个必须记住的点:

反传结束后,用第 5 章的优化器(常配合第 8 章的 Adam) 按学习率更新 WxWhWo 等——和全连接网络没有本质区别。

⑥ 工程上绕不开的两招

和一次 MLP 训练步对齐

前向:h0 起步 → 逐步算 htŷt 并缓存 → 各步交叉熵求和得 L
反向:从 t=T 往前做 BPTT → 每步对共享权重累加梯度 → (可选)裁剪 → 优化器更新。
外圈:多个句子组成 minibatch,每条序列各自前向/反传,梯度再在 batch 上平均(第 9 章)。 如果你已经看过第 23 章里 MLP 的训练循环,会发现这里的骨架其实没变:仍然是“前向 → 损失 → 反传 → 更新”,只是把这套流程沿着时间步展开,并让同一组参数在每一步反复复用。

⑦ 推理:只看最后一步,再把输出接回去

推理时没有标准答案可抄,所以不会像训练那样对每一步都算损失。做法是:先把当前前缀整条跑一遍, 再只看最后一个时间步的输出,挑一个新词,把它接回输入末尾,然后整条链路再跑一遍。 这就是字符级生成、聊天模型逐字往外蹦的共同骨架。

src/deeplearning/rnn/mini_rnn_lm.cpp · PredictNextToken + Generate(精简)
Forward(token_ids, logits);                        1
last_logits = logits.back();                      2
token_id = argmax(last_logits);                   3

generated_token_ids.push_back(token_id);          4
// 然后带着更长的前缀,再回到第 1 步
  1. 先把当前前缀完整跑一遍前向。
  2. 虽然前向会产出每一步的 logits,但生成时真正关心的只有最后一步
  3. 从最后一步里挑出概率最高的词(或用采样挑一个词)。
  4. 把这个新词接回输入末尾。下一轮的前缀更长,模型就继续往下写。

训练和推理最大的差别就浓缩在这里: 训练时每一步都拿真实答案监督,目标是把整条序列的参数都学好; 推理时没有答案,只能相信自己刚吐出的词,因此错误会一步步累积。这也是为什么 RNN 一旦前面接错几个词, 后面就容易越写越偏。

4. 两个硬伤

正是“链条特别长 + 严格按顺序”,带来了 RNN 的两个致命短板:

硬伤一:长依赖会“失忆”(梯度消失/爆炸)

信息像一场传话游戏:每传一步,梯度都要乘一次权重和激活的导数(第 7 章)。乘的次数一多, 梯度要么越乘越小、趋近 0(消失),要么越乘越大、炸掉(爆炸)。结果就是 隔得远的词之间,依赖关系几乎学不到——而“它 ↔ 前文某个名词”这种长距离关联恰恰最重要。

硬伤二:没法并行,训练慢

要算第 5 个词的隐藏状态,必须先等第 4 个算完——天生串行。这就没法把工作切开 丢给一堆 GPU 同时算(第 21 章),在超长序列、超大数据上慢得要命。这一点,后来直接决定了它的命运

5. LSTM:给记忆装上“门”

为了缓解“失忆”,LSTM(长短期记忆网络)做的第一件事,不是简单把 RNN 变深或变宽, 而是把“记忆”拆成了两条通道:一条负责把长期信息尽量稳稳往前送,一条负责向外暴露“当前这一步该输出什么”。 然后它又加上三个“门”,按需决定“旧记忆留多少、新信息写多少、这一步拿多少出来用”。

① 先分清:它其实有两份“状态”

名字记号它像什么主要干嘛
细胞状态 / 记忆传送带 ct 一条尽量平稳往前流的长期记忆 保存“哪些重要信息要带很久”
隐藏状态 / 当前输出 ht 这一步真正对外可见的表示 拿去传给下一步、或接输出层做预测

普通 RNN 基本只有一份 h 在兼任“记忆”和“输出”;LSTM 则把这两件事拆开了: c 偏长期记忆,h 偏当前输出。

你可以把 ct 想成“仓库里的长期库存”,把 ht 想成“这一步柜台上拿出来给人看的东西”。 LSTM 的精妙之处就在于:柜台上不一定把仓库全搬出来,仓库里的东西也不必每次全部重写。

② 三个门和一个“候选内容”,到底各管什么?

门不是魔法,本质上就是三个小神经层:把当前输入 xt 和上一刻隐藏状态 ht−1 拼起来,过一层线性变换,再过 Sigmoid 变成 0~1 之间的数。 这些数不是一个标量,而是一整条向量,和隐藏维度一样长——也就是每个记忆维度都能被单独控制。

ft = σ(Wf[xt;ht−1] + bf)
it = σ(Wi[xt;ht−1] + bi)
gt = tanh(Wg[xt;ht−1] + bg)
ot = σ(Wo[xt;ht−1] + bo) f = forget 遗忘门, i = input 输入门, g = 候选写入内容, o = output 输出门。方括号表示“把 x 和 h 拼在一起”。
中文直觉接近 0 时接近 1 时
ft 遗忘门 旧记忆这一维大多丢掉 旧记忆这一维大多保留
it 输入门 候选新信息几乎不写入 候选新信息大量写入
gt 候选内容 “如果允许写,准备往记忆里写什么”
ot 输出门 这一步少往外暴露记忆 这一步多往外暴露记忆

③ 一步里到底按什么顺序更新?

上面四个量算出来后,LSTM 的核心更新只有两行,却把“保留旧记忆 + 写入新记忆 + 对外输出”全做完了:

ct = ftct−1 + itgt
ht = ot ⊙ tanh(ct) ⊙ 是逐元素相乘。第一行先更新长期记忆 c,第二行再决定这一步把多少记忆暴露成输出 h。
  1. 先看旧仓库要留多少:遗忘门 ft 去乘旧记忆 ct−1。某一维若 f≈1,这维旧记忆基本原样保留;若 f≈0,这维旧记忆基本被清掉。
  2. 再看新信息要写多少:输入门 it 控制候选内容 gt 写进来多少。这样“写什么”和“写多少”被拆成了两件事。
  3. 两者相加,形成新记忆:于是新的细胞状态 ct 既保留了该留的旧信息,又写入了该写的新信息。
  4. 最后决定往外拿多少:输出门 ot 再从 ct 里筛出这一步要暴露给下一层/输出头的那部分,得到 ht

你可以把这四步想成一套非常朴素的流程:先清理旧档案,再把新内容写进档案,最后从档案里抽一份摘要交给当前这一步使用。

量(只看 1 个维度)数值解释
旧记忆 ct−10.90上一刻这维记忆很强
遗忘门 ft0.80保留 80% 旧记忆
输入门 it0.30只写入一部分新信息
候选内容 gt0.70准备写入的新内容
新记忆 ct0.80×0.90 + 0.30×0.70 = 0.93旧的没丢太多,新的也补进来一点
输出门 ot0.60这一步拿出 60%
输出 ht0.60 × tanh(0.93) ≈ 0.44当前对外可见的状态

哪怕只看一维,也能看出 LSTM 不是“全改掉”或“全保留”,而是在每一步细粒度地调节。

④ 为什么这样就更能记住远处?

普通 RNN 的记忆更新更像“整块重写”:每一步都要重新算一个新的 ht,旧信息很容易在反复乘矩阵、过激活里被磨没。 LSTM 则把长期记忆那条主线改成了加法更新:

ct = ftct−1 + ... 当某些维度的遗忘门 f 接近 1、输入门 i 接近 0 时,这几维记忆几乎能原样穿过去。

这意味着:如果模型觉得“这个信息很重要,暂时别动”,它完全可以让某些维度的 f≈1、i≈0, 于是那部分记忆几乎沿着 c 这条线原样直通。反向传播时,梯度也更容易顺着这条线传回来, 不会像普通 RNN 那样在长链上一路越乘越小。它当然不是“永不衰减”的魔法,但比每步都重写整块记忆要稳得多。

像一条能“选择性记忆”的传送带

普通 RNN 每步都把记忆整个重写一遍,信息很容易在反复改写中丢失。LSTM 的记忆传送带则可以 大部分原样往前带,只在门允许时才增删——重要的信息因此能“搭直通车”走很远而不衰减, 长依赖就保住了。GRU 是它的简化版(两个门),更轻但思路一致。

6. 承上启下:为什么 Transformer 取代了它

LSTM 缓解了“失忆”,但串行、慢这条硬伤仍在:再怎么改门,它还是得一步一步顺着读。 当数据和模型都要爆炸式放大时,“不能并行”成了无法忍受的瓶颈。

下一部分的主角就是来解决这两点的

注意力 / Transformer(第四部分)换了个思路:不再一站站传话,而是让每个词直接看全序列—— 长依赖一步直达(治“失忆”),而且所有位置可以同时算(治“串行”)。于是它几乎全面取代了 RNN/LSTM, 成了今天大模型的地基。带着 RNN 这两个短板去读第 16 章,你会立刻明白注意力“妙在哪”。

7. 对照真实代码:最小 RNN 怎么展开和回传

如果把这一章的式子落到代码,核心其实很朴素:一个 SimpleRNN 负责按时间推进隐藏状态, 一个小输出层负责“根据当前隐藏状态猜下一个词”。真正关键的,反而是把整条序列缓存下来,好让 BPTT 倒着走回来。

src/deeplearning/rnn/simple_rnn.cpp · Forward(精简)
last_hidden_sequence_.assign(input_sequence.size() + 1,
                             std::vector<double>(hidden_dim_, 0.0));   1

for (int t = 0; t < input_sequence.size(); t++) {
  const auto &input = input_sequence[t];
  const auto &prev_hidden = last_hidden_sequence_[t];
  for (int h = 0; h < hidden_dim_; h++) {
    double sum = bias_[h];
    for (int i = 0; i < input_dim_; i++)  sum += input_weight_[h][i] * input[i];
    for (int i = 0; i < hidden_dim_; i++) sum += hidden_weight_[h][i] * prev_hidden[i];
    hidden[h] = std::tanh(sum);                                        2
  }
  last_hidden_sequence_[t + 1] = hidden;                              3
}
  1. 先放一个全 0 的 h0 进去,后面每个时间步都从这里往后接。也就是说,“记忆链”在代码里就是一整条 hidden_sequence
  2. 这一行正对应第 2 节的公式:tanh(Wx·x + Wh·h_prev + b)。当前输入和上一刻记忆一起决定新的隐藏状态。
  3. 算完的 ht 不只拿去喂下一步,还要缓存下来。没有这条缓存,后面就没法做 BPTT。
src/deeplearning/rnn/simple_rnn.cpp · Backward(BPTT,精简)
for (int t = grad_hidden_sequence.size() - 1; t >= 0; t--) {       1
  std::vector<double> grad_hidden_total = grad_hidden_sequence[t];
  for (int h = 0; h < hidden_dim_; h++)
    grad_hidden_total[h] += grad_from_future[h];                    2

  for (int h = 0; h < hidden_dim_; h++) {
    double dz = grad_hidden_total[h] * (1.0 - hidden[h] * hidden[h]); 3
    grad_bias_[h] += dz;
    for (int i = 0; i < input_dim_; i++)
      grad_input_weight_[h][i] += dz * input[i];
    for (int i = 0; i < hidden_dim_; i++) {
      grad_hidden_weight_[h][i] += dz * prev_hidden[i];            4
      grad_from_prev[i] += hidden_weight_[h][i] * dz;              5
    }
  }
  grad_from_future = grad_from_prev;
}
  1. 从最后一个时间步往前循环,这就是 BPTT 的本体:时间上“倒着做反向传播”。
  2. 当前步自己的梯度,和“从未来时间步传回来的梯度”要先加在一起。因为后面的记忆也依赖现在这一步。
  3. 1 - h² 是 tanh 的导数。链式法则到这里,和第 6 章完全同一个套路。
  4. 同一套 Wx / Wh / b 在每个时间步都被复用,所以梯度必须累加,不能覆盖。
  5. 这一项把误差继续沿“记忆连线”往回传到上一刻隐藏状态。也正因为它会反复相乘,长序列才容易梯度消失或爆炸。
自己跑一个

配套项目里有个最小 demo: src/demo/rnn_char。在 src/ 目录编译后运行 ./bin/rnn_char --prompt "abc" --generate-num 12 --epochs 300 --hidden-dim 16 --gradient-clip-norm 1.0, 你会看到它用一小段字符语料做“下一字预测”,训练完再把自己预测出来的字一个个接回去生成。 这就是本章说的 teacher forcing 训练 + 自回归生成,只是规模压到了最容易看懂的版本。

小结

  • 序列数据有顺序、需记忆,MLP 不合适;RNN 用隐藏状态边读边记,按时间展开成很深的网络。
  • 下一词预测:每步用 ht 猜下一个词;训练用 teacher forcing;损失多为各步交叉熵之和。
  • BPTT:展开后按时间反传;共享的 WhWx 在各步的梯度要累加;长序列常用截断 BPTT + 梯度裁剪。
  • 代码里的最小实现通常就是缓存整条隐藏状态序列,再从后往前做 BPTT;“按时间展开”在实现上真的就是一个正向循环加一个反向循环。
  • 两大硬伤:长依赖梯度消失/爆炸(记不住远处)、严格串行不能并行(慢)。
  • LSTM 用遗忘/输入/输出三个门 + 记忆传送带,让重要信息走直通车,缓解长依赖(GRU 是简化版)。
  • 但“串行慢”没解决——这正是 Transformer 用注意力取而代之的原因(第四部分)。

RNN 也好、注意力也好,处理文字前都得先把词变成向量。可“词”本是离散符号, 怎么变成有含义的数字?下一章讲词嵌入与 word2vec——让词第一次拥有“语义坐标”。