上一课我们欠了一笔账:注意力被当黑盒用了一整课,它自己却还没正式出场。这课连本带利还上——从它诞生前的一串失败方案讲起 (Vizuara 1.7),你 会看到注意力不是天才拍脑袋,而是被"上下文"这个刚需一步步逼出来的。本课打完地基(概念 + Q/K/V 投影),下一课把打分、缩放、softmax、上下文向量一步拆完。
一、注意力之前:不够用的方案们
方案零:前馈网络,一个词一个词独立处理
最原始的做法:每个词的向量各自进网络,互不打扰。死穴立刻出现——没有上下文 。「The dog chased the ball but it could not catch it」里的 it 指什么?孤立地看 it 就是个代词;不看全句,任何网络都无法判断。一词多义、指代消解、搭配关系——全部无解。
方案一:循环神经网络(RNN)——有了顺序,来了瓶颈
RNN 的做法是按顺序逐词读入,维护一个"隐藏状态" h:每读一个词,就更新一次 $h = \tanh\left(W_h \cdot h + W_{in} \cdot x\right)$。读完全句,整个句子的信息都要挤进这最后一个 h 里 ——这就是瓶颈 。瓶颈到底多严重?直接测:
公式 + 逐符号讲解(RNN 递推式——每个符号什么意思、数字从哪来)
$$h \;=\; \tanh\big(W_h \cdot h \;+\; W_{in} \cdot x\big)$$
先拆最容易卡住的一点:左右两个 h 不是同一个。 左边是更新后 的记忆(马上要写上白板的),右边那个是上一词留下 的记忆(白板上原有的)。教材常写成 $h_t = \tanh(W_h \, h_{t-1} + W_{in} \, x_t)$——下标 $t$ 就是"第 t 个词的时刻",标出来两边就分清了。逐个符号读:
$h$(hidden state,隐藏状态) :RNN 的记忆本体,一个 $d_h$ 维向量(本课 demo 取 8 维)——就是上图那块白板 。
$x$(当前词向量) :刚读进来的那个词的嵌入,$d_{in}$ 维(demo 里也是 8)——第 4 课查表查到的那一行数。
$W_{in}$ :输入 权重(in = input),形状 ($d_h \times d_{in}$)——把新词 投影到记忆的坐标系:"这个词往白板上写什么"。可训练。
$W_h$ :递推 权重(h = hidden,不是"时刻 h"!),形状 ($d_h \times d_h$) 的方阵 ——把旧记忆 投影一遍:"旧账保留多少、怎么变形"。输入输出都是 h 自己,所以是方阵。可训练。
两个 $\cdot$ 与那个 $+$ :矩阵乘法(§四会补课:行配列,乘后加)各算出一个 $d_h$ 维向量,再逐维相加 ——旧记忆和新词汇流成一份"草稿"。
$\tanh$ :压扁器。把草稿的每个分量压进 −1 到 +1(曲线是 S 形:输入越大越接近 +1,越小越接近 −1)。为什么必须有:不压的话,h 每步都被改写、数值会越滚越大直至爆炸;tanh 保证白板永远写在安全范围里。
一句话人话:新记忆 = 压扁(加工过的旧记忆 + 加工过的新词)。
手算 2 维迷你版 (每个数都可心算):取 $W_{in}=\begin{bmatrix}1 & 0\\ 0 & 1\end{bmatrix}$(新词原样放行)、$W_h=\begin{bmatrix}0.5 & 0\\ 0 & 0.5\end{bmatrix}$(旧记忆减半),旧记忆 $h=[0.8,\ 0]$、新词 $x=[1,\ 0]$:
$W_h h = [0.4,\ 0]$(减半),$W_{in} x = [1,\ 0]$(放行),相加 $[1.4,\ 0]$,$\tanh(1.4) \approx 0.885$ → 新记忆 $[\,0.885,\ 0\,]$。新词的"1"把旧记忆的 0.8 冲淡成了 0.885 ——这就是"白板被整块重写"的最小案例;再读下去,句首的痕迹只会更淡(下面的实测和冲刷动画,量的正是这件事)。
下标防混淆:$W_{in}$ 的 in = input、$W_h$ 的 h = hidden——下标记的是"这个矩阵服务谁",与第 8 课 $d_k$ 的 k = key 同一套命名习惯,和时刻 t 无关。下方 demo 里的 h = np.tanh(W_h @ h + W_in @ x) 与本公式逐符号一一对应。
# ============================================================
# 【演示目标】实测 RNN 瓶颈:两个句子"只差第 1 个词",读完整句后,
# 它们的最终隐藏状态几乎一模一样——句首的信息被后面的词冲掉了
# 【思路】
# 1. 搭一个迷你 RNN:h = tanh(W_h @ h + W_in @ x),逐词递推
# 2. 造一对句子:词完全相同,只有第 1 个词不同
# 3. 读完整句,比较两者的最终 h(余弦相似度;→1 = 变得一模一样 = 信息丢失)
# 4. 对 30 对随机句子取平均,扫不同句长 L
# 读法: L=4 时 cos≈0.91 已经很退休,L≥16 直接 ≈1.000——句首信息只剩零点千分之几
# ============================================================
import numpy as np
rng = np.random.RandomState(3)
d_in, d_h = 8, 8 # 词向量 8 维,隐藏状态 8 维(玩具尺寸)
W_in = rng.randn(d_h, d_in) * 0.6 # 词 → 隐藏 的投影
W_h = rng.randn(d_h, d_h) * 0.6 # 上一时刻隐藏状态 的递推权重
def cos(a, b): # 余弦相似度(第 5 课):1 = 一模一样
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
def run(Xs):
# 逐词读入:每读一个词,更新一次隐藏状态 h(这就是 RNN 的"按序处理")
h = np.zeros(d_h)
for x in Xs:
h = np.tanh(W_h @ h + W_in @ x)
return h
print("句长 L -> 只差第 1 个词的两句,最终 h 的余弦(→1 = 句首信息被冲掉):")
for L in [2, 4, 8, 16, 32]:
sims = []
for _ in range(30): # 30 对随机句子取平均,消运气
Xs = rng.randn(L, d_in)
a = Xs.copy(); a[0] = rng.randn(d_in) # 只有第 1 个词不同
b = Xs.copy(); b[0] = rng.randn(d_in)
sims.append(cos(run(a), run(b)))
print(" L=%2d: 平均 cos = %+.3f" % (L, np.mean(sims)))
# 读法:16 个词之后 cos≈1.000——不管句首是"猫"还是"飞机",整句摘要几乎一样。
# 翻译、指代消解全靠句首细节时,RNN 就在这瓶颈上翻车。
词1
词2
…
词n
h₁ 记着词1
h₂ 记着词1+2
hₙ = 整句摘要?
最后一个 h
瓶颈:全句挤进一个定长向量
下游(翻译/分类)
本课实测:L=16 时,两个只差句首词的句子,最终 h 余弦 ≈ 1.000(句首信息归零)
注意力方案:不做摘要——让下游直接回头看每一个词的原始状态
RNN 瓶颈:逐词递推,全句信息挤进最后一个定长向量;注意力方案直接绕开摘要这一步。
数字之外,把"冲刷"的过程 本身演出来——词一格格流入,h 每次被整块重写,句首词的痕迹一路走低;结尾把只差第 1 个词的句子 B 的最终 h 叠上来对照:
动画 · RNN 的"冲刷":句首信息如何被后面的词冲淡
方案一的补丁:LSTM(1997)——记性确实修好了,但死穴不在记性
你可能会问:RNN 健忘是老问题,后来不是有 LSTM (长短期记忆网络,Hochreiter & Schmidhuber, 1997 )专治健忘吗?对,先给它记一功,再看它输在哪。RNN 的 h 像一块白板 :每读一个词,擦掉重写一遍——读得越久,最早写的字被擦得越干净(上面的 demo 实测的正是这个)。LSTM 的办法是给白板旁边加一条传送带 (细胞状态 c):词的内容可以放上传送带直通句尾;再配三个闸门 管搬运——遗忘门 (带上的旧货扔几成)、输入门 (新词写几成上带)、输出门 (带上的货此刻交付几成给 h)。传送带上的内容不被 tanh 反复冲刷,梯度也能沿着它一路流回句首——训练"一学句首就断梯度"的老毛病被治好了大半。2014–2017 年 LSTM 统治 NLP,2016 年谷歌上线的神经机器翻译(GNMT)就是 8 层 LSTM。
那它为什么还是被 Transformer 淘汰?注意,败因不在记性 ——先实测验证这一点:
# ============================================================
# 【演示目标】回答"那 LSTM 呢?它不是有记忆吗?"——三行对比看清:
# (a) LSTM 确实能修记性:遗忘门拉满时,32 个词后句首信息还活着
# (RNN 在 L=16 就已归零)
# (b) 但它输给注意力,冤枉不在记性——在结构:必须逐词串行 +
# 全句信息仍要过"一条定长走廊"(见代码最后的读法)
# 【思路】
# 1. 沿用上一块的协议:两句只差第 1 个词,比较最终 h 的余弦
# 2. 写一个迷你 LSTM:细胞状态 c 走传送带,三个 sigmoid 闸门管扔/写/读
# 传送带: c = 遗忘门⊙c + 输入门⊙新货 (旧货保留 + 新货上带)
# 白板: h = 输出门⊙tanh(c) (h 只是带上的货的出口)
# 3. 测两版 LSTM:门随机初始化 / 遗忘门偏置拉满(≈"全部记住"档)
# 4. 三行对比:记性可以被门修好 → 所以 LSTM 的败因必须到别处找
# 读法: 第三行 L=32 仍 +0.746,句首信息还在——LSTM 的门确实有效。
# 但看清三行的"步数":串行步数 = 句长 L,一步没少也一步不能少
# (第 t 步必须等第 t-1 步的 h)。记性修好了,串行和定长走廊没治。
# ============================================================
import numpy as np
rng = np.random.RandomState(3)
d_in, d_h = 8, 8
sigmoid = lambda z: 1/(1+np.exp(-z)) # 闸门用 sigmoid:输出 0~1,正好当"开合比例"
def cos(a, b): # 余弦相似度(第 5 课):→1 = 变得一模一样
return float(np.dot(a, b) / (np.linalg.norm(a)*np.linalg.norm(b) + 1e-9))
# --- ① RNN:一块白板,每读一词擦掉重写(上一块的结论:L=16 句首归零) ---
W_in = rng.randn(d_h, d_in) * 0.6 # 词 → 隐藏 的投影
W_h = rng.randn(d_h, d_h) * 0.6 # 上一时刻隐藏状态 的递推权重
def rnn(Xs):
h = np.zeros(d_h)
for x in Xs:
h = np.tanh(W_h @ h + W_in @ x) # 只有 h 这一块白板,每步整体覆盖
return h
# --- ② 迷你 LSTM:c 走传送带,三个闸门决定"扔几成/写几成/交付几成" ---
Wz = rng.randn(4*d_h, d_in + d_h) * 0.3 # 一个矩阵一次算出全部 4 路门信号(玩具版)
def make_lstm(f_bias): # f_bias:遗忘门的初始偏好(6.0 ≈ 拉满)
bz = np.zeros(4*d_h); bz[:d_h] = f_bias
def lstm(Xs):
h = np.zeros(d_h); c = np.zeros(d_h) # 白板 h 与传送带 c 分开记账
for x in Xs: # 注意:仍是逐词串行,一步都省不掉
z = Wz @ np.concatenate([x, h]) + bz
f = sigmoid(z[0:d_h]) # 遗忘门:传送带上旧货保留几成
i = sigmoid(z[d_h:2*d_h]) # 输入门:新词的内容写几成上带
o = sigmoid(z[2*d_h:3*d_h]) # 输出门:带上的货此刻交付几成给 h
c = f*c + i*np.tanh(z[3*d_h:]) # 传送带 = 保留旧货 + 写入新货
h = o*np.tanh(c) # 白板这回只是传送带的"出货口"
return h
return lstm
rows = [("RNN(白板) ", rnn),
("LSTM(门随机) ", make_lstm(1.0)), # 门的偏好随机(≈刚初始化)
("LSTM(遗忘门拉满) ", make_lstm(6.0))] # sigmoid(6)≈0.998 ≈ 全记住
print("只差第 1 个词的两句,最终 h 余弦(→0 = 句首信息还在,→1 = 被冲光):")
for name, run in rows:
cells = []
for L in [2, 4, 8, 16, 32]:
sims = []
for _ in range(30): # 30 对随机句子取平均,消运气
Xs = rng.randn(L, d_in)
a = Xs.copy(); a[0] = rng.randn(d_in) # 只有第 1 个词不同
b = Xs.copy(); b[0] = rng.randn(d_in)
sims.append(cos(run(a), run(b)))
cells.append("L=%2d:%+.3f" % (L, np.mean(sims)))
print(" %s| %s" % (name, " ".join(cells)))
# 读法:第三行 32 词后仍 +0.746 —— 门确实能把记性修好。
# 但三种模型的串行步数都 = 句长 L:第 t 步必须等第 t-1 步,GPU 大部分核心在干等。
# 记性不是败因;败因是"必须逐词"与"全句信息仍要从一条定长走廊过"。
LSTM 的两个真死穴(都不在记性上)
死穴一:必须逐词算,没法并行。 第 t 步要等第 t−1 步的 h,句子多长就串行多少步——GPU 上万个核心大部分只能干等。Transformer 把整句所有位置一次同时算 (下一课你会看到,那就是一次矩阵乘法),训练速度差出数量级。GPT 级语料规模下,这是生死线。
死穴二:全句信息仍要过"一条定长走廊"。 闸门再聪明,编码器交给解码器的还是一个固定维度的向量,翻译仍从"整句摘要"起步。Bahdanau 注意力(马上讲)2014 年就是作为门控循环网络(LSTM/GRU)的外挂补丁 发明的——注意力最初不是循环网络的对手,是它的配件;Transformer 做的事,是把循环整个扔掉,让注意力自己当家。
二、注意力的诞生:让下游"回头看"全部状态
注意力的第一版(Bahdanau, 2014)用在机器翻译里:解码器生成每个法语词之前,回头扫一眼编码器的全部 h₁…hₙ ,给每个位置打一个"对齐分",softmax 成权重,按权重把 h 加权汇总成"当前最该参考的内容"(上下文向量 )。翻译时这个权重矩阵就是一张对齐热图 :
# ============================================================
# 【演示目标】看懂注意力的第一张脸:对齐热图
# 翻译 "deep learning is fun" → "l'apprentissage profond est amusant"
# 注意英法语序不同:deep learning 要翻译成 l'apprentissage profond(词序对调!)
# 【思路】
# 1. 手造一个 4×4 的注意力权重矩阵(行=法语目标词,列=英语源词;
# 权重是"示意值",真实模型由训练学出——但对齐模式就是这样的)
# 2. 每行 softmax 过,行内加和 = 1(每一步生成时注意力分配 100%)
# 3. 热图:亮格 = 强对应。主对角线之外还有 (0,1) 和 (1,0) 一对互换的亮格
# —— 那就是语序重排被注意力"看见"了
# ============================================================
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
en = ["deep", "learning", "is", "fun"] # 源语言(列)
fr = ["l'apprentissage", "profond", "est", "amusant"] # 目标语言(行)
W = np.full((4, 4), 0.08) # 底色:人人有一点权重
W[0, 1] = 0.82 # l'apprentissage ← learning(注意:不是 deep!)
W[1, 0] = 0.82 # profond ← deep(互换!)
W[2, 2] = 0.85 # est ← is
W[3, 3] = 0.85 # amusant ← fun
W = W / W.sum(axis=1, keepdims=True) # 每行归一化 = 概率分布(softmax 的效果)
print("行和检查:", W.sum(axis=1).round(2), "(每行 = 1.00)")
print("l'apprentissage 那一步的注意力分配:", dict(zip(en, W[0].round(2).tolist())))
fig, ax = plt.subplots(figsize=(4.6, 4.2))
im = ax.imshow(W, cmap="viridis", vmin=0, vmax=1)
ax.set_xticks(range(4)); ax.set_xticklabels(en, rotation=45, fontsize=9)
ax.set_yticks(range(4)); ax.set_yticklabels(fr, fontsize=9)
for i in range(4):
for j in range(4):
ax.text(j, i, "%.2f" % W[i, j], ha="center", va="center",
color="w" if W[i, j] > 0.5 else "#c9c4b8", fontsize=9)
ax.set_title("Bahdanau alignment (schematic)")
fig.colorbar(im, ax=ax, fraction=0.046)
plt.tight_layout()
plt.show()
对齐热图:亮格 = 生成该法语词时注意力盯住的英语词。l'apprentissage 对 learning、profond 对 deep ——主对角线外的一对互换亮格,就是"语序重排被注意力解决"的直接证据(Vizuara 图 1.38 的 European Economic Area 例子同理)。
静态热图是"结果"。下面把它演成"过程" ——解码器逐个生成法语词,看每一步的权重怎么在英语词之间转移、热图怎么一行行长出来:
动画 · 对齐热图活起来:逐词生成,看注意力怎么"回头看"
把定义立住:两种注意力
上面的注意力连接的是两个不同序列 (英语 ↔ 法语),叫交叉注意力 (cross-attention)。把"回头看的对象"换成同一个序列 ——每个词环顾同一句里的所有词(包括自己)——就是自注意力 (self-attention)。GPT/Transformer 的每一层用的都是自注意力;翻译那种跨语言的注意力只在原论文的编码-解码连接处出现。 下一课起我们只跟自注意力打交道。
三、自注意力的目标:从"静态嵌入"到"上下文向量"
自注意力要解决的是第 4 课埋的最后一个问题。嵌入查表是静态 的:「bank」在 "river bank" 和 "investment bank" 里查到的是同一行。但词的意思由语境决定——自注意力的目标可以一句话说死:
自注意力的使命
输入:每个词一个静态嵌入向量(互相不知道对方存在)。
输出:每个词一个上下文向量 ——把全句相关词的信息按相关度混合进自己的新表示。
「The dog chased the ball but it could not catch it 」:it 的静态嵌入毫无指向;它的上下文向量里按权重混进了 ball、catch……于是"它指什么"变成了向量里可计算的成分。
怎么"按相关度混合"?每个词的嵌入要变换出三个角色 (Vizuara 1.10):
Query(Q,查询) :我此行想找什么——当前词发出的"提问向量"
Key(K,键) :我这个词能提供什么——每个词挂出的"标签向量",供别人的 Q 来匹配
Value(V,值) :匹配上之后,真正混进对方表示里的"内容向量"
类比一次就够(细节靠下面的矩阵):图书馆里,你的 Q 是检索词,每本书书脊的 K 是索书号,书里的 V 是内容。Q 扫一遍所有 K,匹配度高的书,内容 V 被你带走的比例就高。"怎么找"(Q/K)和"找什么"(V)用两套向量,这是注意力设计里最重要的一次解耦。
后端视角还有个更贴身的类比(源自 nano-ai.tech 的从零手写系列 ,经核实后吸收):自注意力就是一次模糊匹配的 JOIN 查询 ——Q 是查询条件,K 是每行数据的索引字段,V 是索引对应的实际数据。区别在于:普通 SQL JOIN 是精确匹配(WHERE a.id = b.id,命中或不命中,0 或 1);自注意力对每个 K 都算一个连续的匹配分,不是取分数最高的那一行,而是按分数比例把所有 V 加权混合——每个都要一点,权重不同 。
四、投影:一次矩阵乘法,换一副眼镜
动手之前,先把一个必问的问题说透:为什么非要投影?直接拿原始嵌入 x 当 Q、K、V 用,不行吗? ——不行,而且卡住的是两件致命的事:
不投影,注意力就是一台"死机器"
问题一:三个角色挤同一个向量。 嵌入表里每个词只有一行。不投影,Q=K=V=x,所谓"检索词 / 索引标签 / 内容"其实是同一个向量的三个别名 ——图书馆类比当场垮掉:你递出的检索词、书脊上的索书号、书里的内容,全是同一张纸。it 需要"会问名词的提问者",ball 需要"声明自己是事物的标签",职责不同,一份向量干不好三份工。
问题二:分数被焊死,训练没有抓手。 不投影时打分 = xi ·xj ,完全由嵌入表决定,注意力层一个可学习的参数都没有 ——想学"代词该多看名词"也没有旋钮可调。W_q、W_k、W_v 这三副"眼镜"的度数就是三组可训练参数:梯度流进来,"谁该看谁"从写死的常数变成学出来的能力。
空口无凭,直接看"眼镜怎么改写命运":还是 it 找指代对象的老例子(2 维玩具嵌入,数字小到可心算)。裸打分的冠军是动词 chased——找错了;换两副眼镜,冠军立刻翻成 ball:
动画 · 换眼镜,看注意力排名翻转(it 在找它指代的对象)
三副眼镜都是手工挑的示意;真实模型里 W_q/W_k/W_v 从随机初始化出发、由训练自动学出(第 15 课亲手训)。要点不是这副眼镜本身,而是"有了参数可调"。
# ============================================================
# 【演示目标】用可手算的 2 维小数,验证"投影改写注意力排名":
# 裸嵌入打分,it 最关注动词 chased(指代找错方向);
# 换两副眼镜(W_q/W_k),冠军翻转为 ball——
# "谁该被看见"从嵌入表里的常数,变成可调可学的参数。
# 【思路】
# 1. 手设 4 个词的 2 维嵌入(与左边动画同一组数,全程可心算)
# 2. 模式一:不投影,Q=K=X,打分 = 点积 → 打印得分与排名
# 3. 模式二:换眼镜 q = x@W_q、k = x@W_k → 再打印得分与排名
# 4. 对比两行排名:眼镜没加任何新信息,只是"换个角度看",排名翻转
# 读法: 这里的 W_q/W_k 是手工挑的示意;真实模型随机初始化、由训练学出
# (第 15 课)。关键在"有参数可调",而不是这两副眼镜本身。
# ============================================================
import numpy as np
X = { # 2 维玩具嵌入:it 要找它指代的对象
"it": np.array([0.1, 1.0]),
"ball": np.array([1.0, 0.15]),
"chased": np.array([0.9, 0.9]),
"the": np.array([0.4, 0.6]),
}
cands = ["ball", "chased", "the"]
def rank(q, keys): # 打分(点积)+ 按分排序
scores = {w: float(q @ keys[w]) for w in cands}
order = sorted(cands, key=lambda w: -scores[w])
return scores, order
# ---- 模式一:不投影,Q=K=原始嵌入 → 分数焊死在嵌入表里 ----
s1, r1 = rank(X["it"], X)
print("裸嵌入 it 的打分:", {w: round(v, 2) for w, v in s1.items()})
print(" 排名:", " > ".join(r1), "← 冠军是动词,指代找错方向\n")
# ---- 模式二:两副眼镜(手工挑的示意;真实模型由训练学出) ----
W_q = np.array([[10., 0.], [0., 1.]]) # 维度 0 拉伸 10 倍:提问只看重维度 0
W_k = np.array([[1., 0.], [0., -1.]]) # 维度 1 上下翻转:抵消它在维 1 上的干扰
q = X["it"] @ W_q
keys = {w: X[w] @ W_k for w in cands}
s2, r2 = rank(q, keys)
print("换眼镜 q_it =", q.round(2), " k =", {w: keys[w].round(2).tolist() for w in cands})
print(" it 的打分:", {w: round(v, 2) for w, v in s2.items()})
print(" 排名:", " > ".join(r2), "← 冠军翻成 ball,指代方向被掰正")
想清楚"为什么"之后,"怎么做"反而朴素:三套可训练的权重矩阵 W_q、W_k、W_v,把每个词的嵌入 x(d_in 维)分别投影成 q、k、v(d_out 维,设计选择,常取等于 d_in)。投影就是一次矩阵乘法——先把乘法本身手算吃透:
手算:矩阵乘法 = 行配列,乘后加
A = [[1, 2], [3, 4]],B = [[5, 6], [7, 8]]:结果第 (0,0) 位 = A 的第 0 行 · B 的第 0 列 = 1×5 + 2×7 = 19 ;第 (0,1) 位 = 1×6 + 2×8 = 22 。
形状规则:(m×n) @ (n×p) → (m×p)——中间两个数必须相等 (A 的列数 = B 的行数)。
# ============================================================
# 【演示目标】吃透两件事:① 矩阵乘法"行配列"的逐元素过程与形状规则;
# ② 同一个嵌入 X 过三副不同"眼镜"(W_q/W_k/W_v),得到三套角色 Q/K/V
# 【思路】
# 1. 先手算 2×2 @ 2×2:打印每个结果元素 = "哪一行 · 哪一列"的展开式
# 2. 再上真家伙:5 个词("The next day is bright")× 8 维嵌入,Vizuara 1.9 同款
# 3. 三套投影 W_q/W_k/W_v(各 8×4,d_out=4 是设计选择)→ Q/K/V 各 (5,4)
# 读法: X 是同一个,三副眼镜(三套权重)读出三种角色——投影 = 换眼镜
# ============================================================
import numpy as np
# ---- 第 1 步·手算级矩阵乘法:每个元素 = 行·列 ----
A = np.array([[1., 2.], [3., 4.]])
Bm = np.array([[5., 6.], [7., 8.]])
print("2×2 @ 2×2,逐元素展开:")
for i in range(2):
for j in range(2):
expr = " + ".join("%g×%g" % (A[i, k], Bm[k, j]) for k in range(2))
print(" 结果[%d,%d] = %s = %g" % (i, j, expr, A[i] @ Bm[:, j]))
print("numpy 一步到位:\n", (A @ Bm).round(1), "\n")
# ---- 第 2 步·真家伙:5 词 × 8 维,三副眼镜 ----
rng = np.random.RandomState(42)
words = ["The", "next", "day", "is", "bright"]
X = rng.rand(5, 8).round(2) # 输入嵌入矩阵 (5 词, 8 维)——Vizuara 1.9 同款形状
d_in, d_out = 8, 4 # d_out 是设计选择(真实模型里常 = d_in)
W_q = rng.randn(d_in, d_out) * 0.5 # 三副"眼镜",训练时学出来
W_k = rng.randn(d_in, d_out) * 0.5
W_v = rng.randn(d_in, d_out) * 0.5
Q, K, V = X @ W_q, X @ W_k, X @ W_v # 同一个 X,三种角色
print("形状链条:X(5,8) @ W(8,4) → (5,4)")
print(" Q:", Q.shape, " K:", K.shape, " V:", V.shape)
print("「The」的 q =", Q[0].round(2))
print("「The」的 k =", K[0].round(2), "(同一个词,不同眼镜,读数完全不同)")
# 下一课:Q 和 K 怎么打分(Q@K.T)、为什么缩放、softmax 怎么变权重、V 怎么被混合
维度账本:W_q/W_k/W_v 的尺寸怎么定,和嵌入向量什么关系
记一条总规则:中间数必须相等 ——$X(L \times d_{in}) \,@\, W_q(d_{in} \times d_k) \to Q(L \times d_k)$,W 的行数必须等于嵌入维度 。
输入侧 $d_{in}$:嵌入表说了算。 每个词的嵌入向量 x 有 $d_{in}$ 个数(GPT-2 是 768;上面玩具取 8)——它是"语义坐标系的轴数",投影层无权更改。
输出侧 $d_k$、$d_v$:设计选择(超参数)。 两条约束:$W_k$ 的输出维必须 与 $W_q$ 相同($q \cdot k$ 点积要求同维);$W_v$ 的输出维 $d_v$ 自由,常取 $= d_k$。单头注意力常取 $d_k = d_v = d_{model}$;多头时取 $d_{model} \div$ 头数,总计算量保持不变(第 10 课展开)。
W 到底对 x 做了什么: $W_q$ 的每一列 是一组 $d_{in}$ 个权重——把嵌入的 $d_{in}$ 个分量按这组权重加权求和,得到新向量的一个 分量。所以投影 = 换坐标系 :从语义坐标系($d_{in}$ 根轴)搬到提问坐标系($d_k$ 根轴),可升维可降维(上面 demo 是 8→4)。词数 L 全程不变,变的只是"每个词用几个数描述"。三个 W 的参数量 = $3 \times d_{in} \times d_k$——注意力层可学习参数的大头,全在这儿。
本课胜利
① 注意力之前:FFN 无上下文,RNN 有瓶颈(实测 L=16 句首信息归零);LSTM 把记性修好了(门拉满 L=32 仍 +0.746),但"逐词串行 + 定长走廊"两个死穴没治;② Bahdanau 让下游回头看全部状态,对齐热图就是"语序重排被解决"的证据;③ cross(跨序列)/self(同序列)分家,Transformer 主力是 self;④ 自注意力使命 = 静态嵌入 → 上下文向量;⑤ Q/K/V 三件套 = 投影解决两件事——让"怎么找 / 怎么被找到 / 给什么"三个角色真正分化 + 给注意力装上可训练的旋钮(不投影时分数被嵌入表焊死),形状 (5,8)@(8,4)→(5,4) 你已亲手算过。下一课:Q·K 打分 → 缩放(余弦的亲戚回场)→ softmax → 加权 V,拼出完整注意力。
检索练习
本课主读材料
我是你的老师,别客气
"缩放为什么除 √d_k""有了 W_q 为什么还需要 W_k,一套矩阵转两次行不行"——这类问题都在正道上,贴回来问。下一课把注意力的算盘打到每一颗珠子。