第 14 章 · 经典网络结构
RNN 与 LSTM
CNN 专治图像。可另一类数据——一句话、一段语音、一串股价——是有先后顺序的 序列,要处理它,得能“记住前面”。这一章认识第二位专才: RNN(循环神经网络)和它的升级版 LSTM,并看清它们的先天短板—— 这正是下一部分注意力要来救的场。
读完这一章,你会明白
- 序列数据为什么需要“记忆”,而 MLP 给不了;
- RNN 怎么用一个隐藏状态把信息一路带下去(按时间展开);
- 用下一词预测走通一遍:前向按时推进 → 逐步算损失 → BPTT 反传、权重共享下梯度怎么累加;
- 截断 BPTT、teacher forcing、梯度裁剪这些训练时绕不开的工程招;
- RNN 的两个硬伤:梯度消失/爆炸(长依赖学不到)和没法并行(慢);
- LSTM 怎么用“门”管理记忆,缓解长依赖;
- 为什么后来 Transformer 把它取代了(承接第四部分)。
1. 序列:顺序很重要,还得记住前文
“狗咬人”和“人咬狗”用的字一样,顺序不同意思就反了;要判断“它太重了”里的“它”指谁,得回看前文。 这类数据有两个特点:长度不固定、元素间有顺序和依赖。固定大小的 MLP(第 3 章)一次只吃固定长度、 还把顺序信息丢了,天然不适合。我们需要一种能“边读边记”的结构。
2. RNN:一个带“隐藏状态”的循环
RNN 的想法非常像人读句子:一个词一个词地读,每读一个,就把“到目前为止记住的东西” 打包成一个向量,叫隐藏状态(hidden state),带到下一步。读下一个词时,它同时看两样东西: 这个新词 + 上一步传来的隐藏状态,更新出新的隐藏状态。
同一个 RNN 单元被“按时间展开”:每步吃一个词 + 上一步的隐藏状态,更新记忆再传给下一步。
RNN 其实只有一个单元,靠“把输出接回输入”循环使用。为了看清和训练它,我们把这个循环 按时间摊平(unroll)成上图那样一长串——本质是同一套权重重复用了很多次。
一步里到底吃什么、吐什么?
如果把 RNN 看成“主干 + 输出头”,那在第 t 步里,主干真正吃进去的是两样东西: 当前输入 xt 和上一刻记忆 ht−1;真正一路往后传的,是新的隐藏状态 ht。如果任务是“猜下一个词”,再在 ht 上接一个线性层 + softmax, 就得到这一步的输出概率 ŷt。
单步 RNN:输入 xt 和上一刻记忆 ht−1 一起算出新记忆 ht。如果任务是下一词预测,再把 ht 送进输出层得到词表概率 ŷt。
| 名字 | 它是什么 | 典型形状 | 这一章里扮演什么角色 |
|---|---|---|---|
| xt | 当前这个词的表示 | d_input(one-hot 时常等于词表大小;embedding 时等于词向量维度) | “现在读到了什么” |
| ht−1 | 上一刻隐藏状态 | d_hidden | “到上一刻为止记住了什么” |
| ht | 新隐藏状态 | d_hidden | 更新后的记忆,继续传给下一步 |
| ot | 输出层打分 logits | vocab_size | 对词表里每个候选词打一个分 |
| ŷt | softmax 后的概率分布 | vocab_size | “下一个词最可能是谁” |
真正沿时间一步步往后传的是隐藏状态 h;输出头接不接、接在每一步还是最后一步,取决于具体任务。
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
- 这个最小字符级实现里,每个 token 先被写成 one-hot 向量——也就是当前步的输入
x_t。 SimpleRNN::Forward吃完整条输入序列,吐出每一步的隐藏状态h_1, h_2, ...。- 输出头把某一步的隐藏状态
h_t乘上W_o、加偏置,变成对词表里每个词的打分o_t。 - 最后过 softmax,就得到“下一个词是谁”的概率分布
ŷ_t。
抓住一个最重要的区分就够了: RNN 主干负责维护“记忆” h,而输出头负责把这份记忆翻译成具体任务的答案。 做语言模型时,几乎每一步都接输出头去猜下一个词;做句子分类时,常常只拿最后一步的 hT 去做一次判断。
3. 这个 RNN 语言模型怎么训练、怎么推理?
§2 讲清了“单步里怎么算一个新的隐藏状态”。可真正跑起来时,模型吃进去的不是单独一个 xt, 而是一整段前缀序列;吐出来的也不是一个数,而是每个时间步各一份“下一个词概率”。 先把这座最小 RNN 语言模型的全貌看清,再去拆训练和推理就容易很多。
① 先看清整座网络
最小 RNN 语言模型 = 输入编码层 + RNN 主干 + 输出头。主干维护记忆 h,输出头把每一步的记忆翻译成词表概率。
| 模块 | 吃进去什么 | 吐出来什么 | 主要可训练参数 |
|---|---|---|---|
| 输入编码层 | token id 序列 | x1..xT | 最小实现里直接用 one-hot(无额外参数);若换 embedding,就是一张词向量表 |
| RNN 主干 | xt 与 ht−1 | ht | Wx、Wh、b |
| 输出头 | ht | logits / 概率 ŷt | Wo、bo |
本书配套的最小实现里,输入维度就等于词表大小(vocab_size),因为每个词先写成 one-hot 再送入 RNN。若换成第 15 章的 embedding,主干和训练流程并不会变。
| 场景 | 喂给模型什么 | 拿哪一步输出 | 接下来做什么 |
|---|---|---|---|
| 训练 | 真实前缀序列 | 几乎每一步的 ŷt | 和真实下一个词算交叉熵,再 BPTT 更新参数 |
| 推理 | prompt + 已生成前缀 | 只看最后一步的 ŷT | 挑一个新词,接回输入末尾继续 |
训练和推理共用同一座网络。区别不在“网络换了”,而在“拿哪些输出、后面怎么处理”。
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
- 先把 token id 序列编码成每一步的输入
x_t。这个最小实现里用的是 one-hot。 - RNN 主干顺着时间推进,得到整条隐藏状态序列
h_1..h_T。 - 输出头把每一步的隐藏状态翻译成词表大小的打分(logits)。
- 每一步各自过 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。
训练第 2 步时,输入用的是数据里真实的「喜欢」,而不是模型第 1 步猜出来的词——这叫 teacher forcing(教师强制)。这样每步的输入都靠谱,网络先专心学“给定正确前文时怎么预测”。 推理生成时没有标准答案,只能把自己上一步的输出接回去,误差会累积,那是另一回事(第 19 章)。
③ 前向:按时推进,并把中间结果存下来
一条样本的训练步,前向可以写成下面这个循环——和第 3 章前向一样,只是“层”换成了“时间步”:
- 初始化:h0 常设为全 0,或学一个可训练的初始向量。
- 对每个时间步 t = 1 … T:
- 用 §2 的式子算 ht = 激活( Wx·xt + Wh·ht−1 + b );
- 用输出层算 ŷt = Softmax( Wo·ht + bo );
- 把 ht、xt、ŷt 存进缓存——反向时要靠它们(第 6 章存 z 是同一道理)。
展开后,RNN 就像一条有 T 段的深链:每段结构相同,共用同一套 Wx、Wh、b。 第 t 步必须等第 t−1 步的 h 算完,所以前向天然串行——这也是后面“没法并行”的根源。
④ 损失:每一步各算一次,再汇总
第 t 步的预测 ŷt 和真实下一个词 yt 比,用 交叉熵(第 4 章)打分。整句的损失通常是各步之和或平均:
不同任务只是在“哪几步算损失、输出头接在哪”上换一下:
| 任务 | 损失算在哪 | 直觉 |
|---|---|---|
| 语言建模 | 几乎每一步 | 每读一个词,都要会猜下一个 |
| 序列标注(词性、NER) | 每一步 | 每个词一个标签,边读边标 |
| 句级分类(情感正负) | 通常只用最后一步 hT | 读完整句,最后一个记忆做判断 |
| 序列到序列(翻译) | 编码器 + 解码器各有一套 RNN | 先读源句,再按步生成目标句(第 17 章注意力版更常见) |
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
- 先把每一步真正该猜的目标词准备好:前几个位置猜“下一个真实词”,最后一个位置猜样本外面接着的那个目标词。
- 整条前缀先走一遍前向,拿到所有时间步的隐藏状态。
- 每一步各算一次 softmax + 交叉熵,把损失累起来。
- 把所有时间步的梯度一起交给 BPTT,让误差沿时间倒着传回去。
- 最后更新 RNN 主干和输出头参数。也就是说,训练不是“只改最后一步”,而是整条序列一起学。
⑤ 反向:BPTT 与“同一套权重,梯度要相加”
损失 L 对参数求导,还是第 6 章反向传播的三步: 从输出层误差出发 → 沿计算图往回传 → 得到每个参数的梯度。只不过这里的“深”是沿时间展开的, 所以叫 BPTT(Backpropagation Through Time,按时间反向传播)。
前向:ht−1 → ht 一路向右;反向:各步损失产生的梯度,沿隐藏状态连线从后往前传(BPTT)。
和 MLP 相比,BPTT 多出来两个必须记住的点:
- 梯度要穿过“记忆连线”:h4 的误差会经 Wh 传到 h3、h2…… 每传一步都乘一次 Wh 和激活导数——乘多了就消失或爆炸(下一节 §4)。
- 同一套权重,各步梯度要相加:Wh 在每一步都被复用, 所以 ∂L/∂Wh = Σt (∂Lt/∂Wh), Wx、b 同理。实现上就是:从最后一个时间步往前循环,每步算出局部梯度,累加到同一块参数上。
反传结束后,用第 5 章的优化器(常配合第 8 章的 Adam) 按学习率更新 Wx、Wh、Wo 等——和全连接网络没有本质区别。
⑥ 工程上绕不开的两招
- 截断 BPTT (Truncated BPTT):句子很长时,既不现实也不稳定把梯度从句末一路传回句首。 常见做法是只反传最近 K 步(例如 20~50),更早的时间步当“起点”截断。 这样省显存、减轻消失梯度,代价是更远的依赖在这一轮里学不到——LSTM 的门、后来的注意力,都是为补这块短板。
- 梯度裁剪:RNN 特别容易梯度爆炸。 反传后若全局梯度范数超过阈值,就整体缩放(第 9 章),避免一步更新把权重炸飞。
前向:h0 起步 → 逐步算 ht、ŷt 并缓存 → 各步交叉熵求和得 L。
反向:从 t=T 往前做 BPTT → 每步对共享权重累加梯度 → (可选)裁剪 → 优化器更新。
外圈:多个句子组成 minibatch,每条序列各自前向/反传,梯度再在 batch 上平均(第 9 章)。
如果你已经看过第 23 章里 MLP 的训练循环,会发现这里的骨架其实没变:仍然是“前向 → 损失 → 反传 → 更新”,只是把这套流程沿着时间步展开,并让同一组参数在每一步反复复用。
⑦ 推理:只看最后一步,再把输出接回去
推理时没有标准答案可抄,所以不会像训练那样对每一步都算损失。做法是:先把当前前缀整条跑一遍, 再只看最后一个时间步的输出,挑一个新词,把它接回输入末尾,然后整条链路再跑一遍。 这就是字符级生成、聊天模型逐字往外蹦的共同骨架。
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 步
- 先把当前前缀完整跑一遍前向。
- 虽然前向会产出每一步的 logits,但生成时真正关心的只有最后一步。
- 从最后一步里挑出概率最高的词(或用采样挑一个词)。
- 把这个新词接回输入末尾。下一轮的前缀更长,模型就继续往下写。
训练和推理最大的差别就浓缩在这里: 训练时每一步都拿真实答案监督,目标是把整条序列的参数都学好; 推理时没有答案,只能相信自己刚吐出的词,因此错误会一步步累积。这也是为什么 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 之间的数。 这些数不是一个标量,而是一整条向量,和隐藏维度一样长——也就是每个记忆维度都能被单独控制。
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 的核心更新只有两行,却把“保留旧记忆 + 写入新记忆 + 对外输出”全做完了:
ht = ot ⊙ tanh(ct) ⊙ 是逐元素相乘。第一行先更新长期记忆 c,第二行再决定这一步把多少记忆暴露成输出 h。
- 先看旧仓库要留多少:遗忘门 ft 去乘旧记忆 ct−1。某一维若 f≈1,这维旧记忆基本原样保留;若 f≈0,这维旧记忆基本被清掉。
- 再看新信息要写多少:输入门 it 控制候选内容 gt 写进来多少。这样“写什么”和“写多少”被拆成了两件事。
- 两者相加,形成新记忆:于是新的细胞状态 ct 既保留了该留的旧信息,又写入了该写的新信息。
- 最后决定往外拿多少:输出门 ot 再从 ct 里筛出这一步要暴露给下一层/输出头的那部分,得到 ht。
你可以把这四步想成一套非常朴素的流程:先清理旧档案,再把新内容写进档案,最后从档案里抽一份摘要交给当前这一步使用。
| 量(只看 1 个维度) | 数值 | 解释 |
|---|---|---|
| 旧记忆 ct−1 | 0.90 | 上一刻这维记忆很强 |
| 遗忘门 ft | 0.80 | 保留 80% 旧记忆 |
| 输入门 it | 0.30 | 只写入一部分新信息 |
| 候选内容 gt | 0.70 | 准备写入的新内容 |
| 新记忆 ct | 0.80×0.90 + 0.30×0.70 = 0.93 | 旧的没丢太多,新的也补进来一点 |
| 输出门 ot | 0.60 | 这一步拿出 60% |
| 输出 ht | 0.60 × tanh(0.93) ≈ 0.44 | 当前对外可见的状态 |
哪怕只看一维,也能看出 LSTM 不是“全改掉”或“全保留”,而是在每一步细粒度地调节。
④ 为什么这样就更能记住远处?
普通 RNN 的记忆更新更像“整块重写”:每一步都要重新算一个新的 ht,旧信息很容易在反复乘矩阵、过激活里被磨没。 LSTM 则把长期记忆那条主线改成了加法更新:
这意味着:如果模型觉得“这个信息很重要,暂时别动”,它完全可以让某些维度的 f≈1、i≈0, 于是那部分记忆几乎沿着 c 这条线原样直通。反向传播时,梯度也更容易顺着这条线传回来, 不会像普通 RNN 那样在长链上一路越乘越小。它当然不是“永不衰减”的魔法,但比每步都重写整块记忆要稳得多。
普通 RNN 每步都把记忆整个重写一遍,信息很容易在反复改写中丢失。LSTM 的记忆传送带则可以 大部分原样往前带,只在门允许时才增删——重要的信息因此能“搭直通车”走很远而不衰减, 长依赖就保住了。GRU 是它的简化版(两个门),更轻但思路一致。
6. 承上启下:为什么 Transformer 取代了它
LSTM 缓解了“失忆”,但串行、慢这条硬伤仍在:再怎么改门,它还是得一步一步顺着读。 当数据和模型都要爆炸式放大时,“不能并行”成了无法忍受的瓶颈。
注意力 / Transformer(第四部分)换了个思路:不再一站站传话,而是让每个词直接看全序列—— 长依赖一步直达(治“失忆”),而且所有位置可以同时算(治“串行”)。于是它几乎全面取代了 RNN/LSTM, 成了今天大模型的地基。带着 RNN 这两个短板去读第 16 章,你会立刻明白注意力“妙在哪”。
7. 对照真实代码:最小 RNN 怎么展开和回传
如果把这一章的式子落到代码,核心其实很朴素:一个 SimpleRNN 负责按时间推进隐藏状态,
一个小输出层负责“根据当前隐藏状态猜下一个词”。真正关键的,反而是把整条序列缓存下来,好让 BPTT 倒着走回来。
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
}
- 先放一个全 0 的 h0 进去,后面每个时间步都从这里往后接。也就是说,“记忆链”在代码里就是一整条
hidden_sequence。 - 这一行正对应第 2 节的公式:
tanh(Wx·x + Wh·h_prev + b)。当前输入和上一刻记忆一起决定新的隐藏状态。 - 算完的 ht 不只拿去喂下一步,还要缓存下来。没有这条缓存,后面就没法做 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;
}
- 从最后一个时间步往前循环,这就是 BPTT 的本体:时间上“倒着做反向传播”。
- 当前步自己的梯度,和“从未来时间步传回来的梯度”要先加在一起。因为后面的记忆也依赖现在这一步。
1 - h²是 tanh 的导数。链式法则到这里,和第 6 章完全同一个套路。- 同一套 Wx / Wh / b 在每个时间步都被复用,所以梯度必须累加,不能覆盖。
- 这一项把误差继续沿“记忆连线”往回传到上一刻隐藏状态。也正因为它会反复相乘,长序列才容易梯度消失或爆炸。
配套项目里有个最小 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:展开后按时间反传;共享的 Wh、Wx 在各步的梯度要累加;长序列常用截断 BPTT + 梯度裁剪。
- 代码里的最小实现通常就是缓存整条隐藏状态序列,再从后往前做 BPTT;“按时间展开”在实现上真的就是一个正向循环加一个反向循环。
- 两大硬伤:长依赖梯度消失/爆炸(记不住远处)、严格串行不能并行(慢)。
- LSTM 用遗忘/输入/输出三个门 + 记忆传送带,让重要信息走直通车,缓解长依赖(GRU 是简化版)。
- 但“串行慢”没解决——这正是 Transformer 用注意力取而代之的原因(第四部分)。
RNN 也好、注意力也好,处理文字前都得先把词变成向量。可“词”本是离散符号, 怎么变成有含义的数字?下一章讲词嵌入与 word2vec——让词第一次拥有“语义坐标”。