1. Embedding matrix:每个词对应一行
先假设每个单词就是一个 token。真实 tokenizer 不一定按完整单词切分,这里用单词只是为了方便手算。
词表顺序为:
[I,like,cats,dogs,you]
token ID 从 0 开始,所以 cats 的 ID 是 2。词表大小为 V=5,每个 embedding 有 d=3 个数字:
E∈R5×3
例如:
E=0.20.5−0.40.60.0−0.10.30.8−0.50.20.7−0.20.10.9−0.3
Embedding lookup 就是根据 token ID 取出对应行:
E[2]=[−0.4, 0.8, 0.1]
这里是索引 2,也就是写出来的第 3 行。
句子 I like cats 有 3 个 token,查表后得到 3×3 的矩阵。两个 3 分别代表 token 数量与 embedding dimension,只是恰好相同。
| 输入 | Shape | 维度含义 |
|---|
| 整张 embedding matrix | V×d | 词表大小、向量维度 |
| 一个长度为 T 的句子 | T×d | 序列长度、向量维度 |
| B 个补齐到长度 T 的句子 | B×T×d | batch、序列长度、向量维度 |
因此 2 个句子、每句补齐到 4 个 token、每个向量长度为 3,输入 tensor 就是 2×4×3。这是常见的 batch-first 排列约定。
2. One-hot 乘矩阵,就是 embedding lookup
把 cats 的 one-hot 写成行向量:
x=[0,0,1,0,0]∈R1×5
则:
1×5x5×3E=1×3e=E[2]
其他行都乘以 0,只有 cats 那行乘以 1。程序通常直接查表,不需要真的创建 one-hot 再做大量包含零的乘法。
Embedding matrix 通常是可学习参数:后面的预测产生 loss,梯度反向传回来,再更新对应的词向量。
3. 为什么 embedding 的梯度是 xTge?
当时看到这个转置,我又有点不明白:
这里 x 的转置是怎么求出来的?能给我推一遍吗?
先从一个元素开始。前向计算为 e=xE,把第 j 个输出分量展开:
ej=i=1∑5xiEij
这里公式下标使用从 1 开始的数学记法,因此 cats 对应 i=3;它的程序索引仍是 2。
设后面传回来的梯度为行向量:
ge=∂e∂L=[g1,g2,g3]∈R1×3
其中 gj=∂L/∂ej。参数 Eij 只直接影响 ej,而:
∂Eij∂ej=xi
链式法则给出:
∂Eij∂L=∂ej∂L∂Eij∂ej=gjxi
把所有元素排回矩阵:
∂E∂L=x1g1x2g1⋮x5g1x1g2x2g2⋮x5g2x1g3x2g3⋮x5g3=xTge
Shape 也吻合:
(5×1)(1×3)=5×3
这叫外积。转置的根据是逐元素求导得到的 xigj;shape 是检查结果的工具。直接写 xge 时,中间维度 5=1,连矩阵乘法都做不了。
例如 ge=[2,−1,3],则:
∂E∂L=0020000−10000300
若 learning rate 为 η=0.1,普通 Gradient Descent 更新为:
E[2]new=[−0.4,0.8,0.1]−0.1[2,−1,3]=[−0.6,0.9,−0.2]
我第一次把第一个数写成了 0.6。实际上 −0.4−0.2=−0.6;第二个数增加,是因为减去负梯度:0.8−0.1(−1)=0.9。
“只更新被查到的行”在这里有条件:E 仅用于输入 lookup,使用普通 Gradient Descent,且没有正则化等其他更新来源。若有 weight decay、优化器历史状态,或与输出层共享 embedding 权重,就不能简单断言其他行一定不变。
如果同一个词出现两次,两次 lookup 使用的是同一行参数,因此两处传回来的梯度贡献相加。
4. 下一个词预测的正确答案从哪里来?
我当时的问题是:
以前有确定的标签,这里要怎么给答案?
先区分一下:回归任务的标签通常是数值;one-hot 是之前分类任务里的标签表示。语言模型的下一个 token 预测,也可以看成词表上的分类任务。
原始文本本身就提供了答案。对于:
[I,like,cats,today]
把序列错开一位:
| 时间步 | 当前输入 token | 已看到的上下文 | Target |
|---|
| 1 | I | I | like |
| 2 | like | I like | cats |
| 3 | cats | I like cats | today |
所以训练用的 input 是 [I, like, cats],target 是 [like, cats, today]。第二步虽然只新读入 like,但 hidden state 还携带前面的 I 的信息。
这种从数据本身构造监督信号的方式叫 self-supervised learning。Target 可以概念性地写成 one-hot,程序里通常直接保存 token ID。
也可以加入 <BOS> 和 <EOS> 表示序列开始、结束:
| Input | <BOS> | I | like | cats | today |
|---|
| Target | I | like | cats | today | <EOS> |
特殊 token 的具体使用方式依赖模型与数据处理约定,并不是所有实现都必须使用这两个名字。加入新 token 时,词表大小也要相应计算。
训练时,这个例子每一步喂入原文中的正确 token,称为 teacher forcing。生成时,则把前一步选出的 token 喂回下一步。RNN 的 hidden state 依然需要按时间顺序计算;知道全部标签不等于能并行算出所有时间步的 hidden state。
5. Embedding 相似,不是人工规定每个数字的含义
Embedding 中每个坐标通常没有人工指定的单独语义。向量通过完成训练目标逐步调整,经常出现在相似上下文中的词,可能学到相似表示。
例如 cats 和 dogs 的用法经常相近,但这是一种训练结果的趋势,并非由一两个句子保证。
尤其要区分输入 embedding 与输出层权重:在 I like → cats 这条样本中,如果两者没有共享参数,输入 lookup 用到的是 I、like,不会因为 target 是 cats 就直接查找并更新输入端的 cats 那一行。输出层则会通过词表上的分类梯度学习预测 cats。
因此,“相似上下文对应相似 embedding”不能直接从这一个样本推出,还要看完整数据、训练目标与参数共享方式。比较词向量时常用 cosine similarity,它主要衡量方向是否相近。
6. 为什么不能把一句话的 embedding 直接相加?
比较:
dog bites man
man bites dog
我最开始觉得它们相加后的表示会不同,但向量加法满足交换律:
edog+ebites+eman=eman+ebites+edog
对于这种固定的 token embedding,直接求和会丢掉词序。按词序把向量排成矩阵,才能在输入表示中保留这种差异。
如果每个 embedding 长度为 3,三个词展平后长度为 9,五个词展平后长度为 15。同一个普通 MLP 的第一层权重 shape 固定,不能直接接收这两种长度。
Padding 到固定长度可以配合 MLP 使用,也能通过固定位置保留词序,但需要预先处理长度范围。RNN 提供了另一种方式:每次读一个 token,把之前的信息传给下一步。
7. RNN:当前输入加上之前的 hidden state
基础 RNN 的计算是:
ht=tanh(Wxxt+Whht−1+bh)
从这一节开始,xt 表示当前 token 的 embedding 列向量,不是前面 lookup 推导中的 one-hot 行向量。若 E 每行存一个词向量,则 xt=E[IDt]T。
设 embedding dimension 为 d=3,hidden dimension 为 H=4:
| 符号 | Shape | 含义 |
|---|
| xt | 3×1 | 当前 token 的 embedding |
| ht−1、ht | 4×1 | 上一步、当前的 hidden state |
| Wx | 4×3 | 将输入映射到 hidden space |
| Wh | 4×4 | 将历史状态映射到当前计算 |
| bh | 4×1 | 偏置 |
三项都得到 4×1,因此可以相加;tanh 逐元素作用,不改变 shape。
每个时间步共享同一组 Wx,Wh,bh。序列变长会增加计算步数,但不会因此增加这组参数的数量。Hidden state 是历史信息的学习表示,不保证完整记住所有前文。
8. 手算验证:RNN 为什么能感知顺序?
暂时去掉 tanh,把输入和 hidden state 都简化成标量:
ht=2xt+0.5ht−1,h0=0
输入顺序是 [1,3]:
h1=2(1)+0.5(0)=2
h2=2(3)+0.5(2)=7
交换顺序,输入 [3,1]:
h1=2(3)+0.5(0)=6
h2=2(1)+0.5(6)=5
相同的两个输入,最终状态分别为 7 和 5。较早的输入会经过 recurrent connection,和当前输入对最终状态的作用不同,因此顺序可以影响结果。
这说明 RNN 能表达词序差异,不代表任何参数设置下都一定能区分所有不同序列。
9. 已经能区分顺序,为什么还要 tanh?
我想到之前的 ReLU:全是线性层的 MLP,不管叠多少层,都能合并成一个线性变换。RNN 也有类似的问题。
去掉激活函数和偏置:
h1=Wxx1+Whh0
h2=Wxx2+Whh1=Wxx2+WhWxx1+Wh2h0
对于固定步数,它仍是输入和初始状态的线性组合;保留偏置时则是仿射变换。虽然有顺序信息,但这段状态计算无法表达非线性关系。
加入 tanh 后会出现嵌套:
h2=tanh(Wxx2+Whtanh(Wxx1+Whh0+bh)+bh)
这通常不能再合并成一个线性层。
tanh 还把每个 hidden 分量限制在 (−1,1) 内。在线性例子 ht=2ht−1、h0=1 中,状态会变成 2,4,8,…;经过 tanh 的 hidden state 不会这样无限增长。但前向状态有界,不意味着反向梯度一定稳定。
10. 从 hidden state 输出下一个词
输出层接回了上次学过的 Softmax 分类:
zt=Woht+bo,pt=softmax(zt)
若 H=4、词表大小 V=5:
5×4Wo4×1ht+5×1bo=5×1zt
pt 同样是 5×1,每个元素是对应词的概率,所有元素非负且总和为 1。
沿用五词词表,如果 target 是 cats:
pt=[0.1,0.1,0.4,0.3,0.1]T,yt=[0,0,1,0,0]T
单个位置的 Cross-entropy 是:
Lt=−logpt(cats)=−log0.4
梯度为:
∂zt∂Lt=pt−yt=[0.1,0.1,−0.6,0.3,0.1]T
正确词的概率越高,loss 越小。把 logits 看作独立变量时,负梯度方向会提高正确词的 logit、降低其他词的 logit。但实际训练更新的是共享参数,不能保证整个模型或一个 batch 更新后,每个样本的 logits 都严格按这个方向变化。
多个位置的目标可以相加:
L=t=1∑TLt
也可以取平均;取平均时,对应总梯度也要除以 T。有 padding 时,通常只对有效 target 计算 loss。
11. BPTT:沿时间展开后,仍然是链式法则
假设只看三个时间步,总 loss 为:
L=L1+L2+L3
h1 会影响 L1,还会通过 h2 影响 L2、通过 h2,h3 影响 L3。因此:
∂h1∂L=∂h1∂L1+∂h1∂L2+∂h1∂L3
而 h2 只收到 L2,L3 的贡献。对基础单向 RNN,前向图中没有从 h2 返回去影响 L1 的路径。
这就是 Backpropagation Through Time(BPTT):把 RNN 沿时间展开,再使用普通反向传播。
在标量情况下,来自 L3 的一条路径为:
∂h1∂L3=∂h3∂L3∂h2∂h3∂h1∂h2
仍然是熟悉的规则:沿路径相乘,多路径相加。
向量情况下不能随意交换矩阵顺序。定义 Jt=∂ht/∂ht−1∈RH×H,并把 loss 对状态的梯度写成列向量,则从 L3 传回 h1 的贡献是:
∇h1L3=J2TJ3T∇h3L3
12. 共享参数为什么要把梯度相加?
同一个 Wh 在不同时间步被重复使用。反向传播时,各次使用对总 loss 的贡献需要累加,最终更新同一个参数。
为了把“每步的贡献”写清楚,定义激活前的量:
at=Wxxt+Whht−1+bh
令 δt=∂L/∂at∈RH×1,它已经包含当前及后续 loss 传来的贡献,则:
∂Wh∂L=t=1∑Tδtht−1T
每项 shape 为 (H×1)(1×H)=H×H。这里与 embedding 一样,又出现了外积。
在这次完整序列反向传播结束后,普通 Gradient Descent 更新:
Wh←Wh−η∂Wh∂L
不会因为展开了三个时间步,就变成三个独立学习的 Wh。同样,也不要把已经包含后续影响的梯度再重复计入。
13. 梯度消失与爆炸:连续相乘的后果
先用最简单的标量 RNN:
ht=wht−1
每一步的局部导数都是 w。假设末端 loss 对 h3 的梯度为 1,那么:
∂h0∂L3=1×w3
当 w=0.5:
0.53=0.125,0.510≈0.00098
当 w=2:
210=1024
前者展示 vanishing gradient,后者展示 exploding gradient。这里用 h0 观察信号跨越时间后的变化,并不表示固定为零的初始状态必须作为参数更新。
梯度消失意味着,后面 loss 对早期计算的学习信号可能非常弱,因此长期依赖难学。早期位置如果还有自己的 loss,仍可能收到较强的局部梯度;消失的是我们关注的长距离贡献,不是所有梯度都必然为零。
真实的 tanh RNN 中:
Jt=∂ht−1∂ht=diag(1−ht2)Wh
这里 ht2 表示逐元素平方,diag 把这些激活导数排到对角线上。整个 Jt 的 shape 是 H×H。
因为:
tanh′(a)=1−tanh2(a)
当 ht 接近 1 或 −1,导数接近 0,这叫进入 saturation(饱和区)。梯度经过这些方向时容易被压小。
最后的手算题是:末端梯度为 1,连续五步的局部导数都是 0.2:
1×0.25=0.00032
这次算对了。一个看起来不算特别小的局部导数,重复相乘后也会让远处的学习信号迅速变弱。
矩阵情况取决于 Jacobian 连乘,不能只看 Wh 某个元素是否大于 1。虽然每一步共享 Wh,但激活导数随状态变化,因此各时间步的完整 Jacobian 通常不同。