上一课我们学了 CNN 和 RNN:CNN 看图像,RNN 读序列。但它们各有硬伤:

  • RNN 不能并行:必须一个词一个词地读,第 100 个词要等前面 99 个词都算完。句子一长就慢,而且距离远的词之间的信息传着传着就"忘"了。
  • CNN 视野有限:卷积核只有 3×3、5×5 那么大,要叠很多层才能看到全局。

2017 年,一篇论文《Attention Is All You Need》提出了 Transformer一次性解决了这两个问题:既能并行,又能让任意两个词之间直接"看到"对方。GPT、BERT、Claude、Gemini……今天几乎所有大语言模型,都是 Transformer 的后代。

这一课,我们搞懂它的心脏——注意力机制(Attention)


一、先看 RNN 的病根:信息要"慢慢传"

翻译这句话:"The cat sat on the mat because it was tired"。

这里的 it 指谁?是 cat 还是 mat?人一看就知道是 cat。但 RNN 要一个字一个字读,读到 it 时,它只带着"前一个时刻的记忆",中间隔了好几个词,cat 的信息可能已经被冲淡了。

RNN 的问题:词与词之间的关系,要通过"隐藏状态"一步步传递,传得越远越模糊。

Attention 的答案别慢慢传了,让每个词直接"看"所有其他词,自己决定该关注谁。

读到 it 时,直接算 it 和句子里每一个词的关联度,发现和 cat 关联最高,就把注意力集中到 cat 上。一步到位,不用层层传递。


二、注意力机制的直觉:三个向量 Q、K、V

注意力机制可以浓缩成一句话:

"拿一个问题 Q,去和每个候选 K 比对打分,按分数加权,把对应的 V 汇总起来。"

对应到语言模型,每个词都会生成三个向量:

向量全称通俗理解
Q(Query)查询"我在找什么?" —— 当前词想找的信息
K(Key)"我是什么?" —— 每个词能提供什么
V(Value)"我的内容" —— 每个词实际的信息

过程三步:

  1. 打分:拿当前词的 Q,和所有词的 K 做点积(内积),分数越高越"相关"。
  2. 归一化:softmax 把分数变成权重(每行和为 1)。
  3. 加权求和:用权重把所有词的 V 加权平均,得到当前词的"新表示"。

类比:你在图书馆找书(Q = 你想找什么),每本书有个标签(K),书的内容是 V。你拿 Q 去比对每个标签,给每本书打个"相关度",然后按相关度"综合"读几本书——这就是 Attention。


三、为什么叫"缩放点积注意力"

第 1 步的"打分"用的是 点积Q · K。但点积值会随向量维度增大而变大,导致 softmax 的梯度变小、训练困难。所以除以一个因子 √d_k(d_k 是 K 的维度)来"缩放"。

于是完整的注意力公式:

Attention(Q, K, V) = softmax( Q·Kᵀ / √d_k ) · V

这就是论文里最核心的一行公式。看懂它,你就看懂了 Transformer 的心脏。


四、从零手写注意力机制

不调库,纯 NumPy 实现上面那行公式:

import numpy as np

def softmax(x, axis=-1):
    x = x - np.max(x, axis=axis, keepdims=True)  # 减去最大值,防溢出
    e = np.exp(x)
    return e / np.sum(e, axis=axis, keepdims=True)

# 三个"词",每个用 4 维向量表示(embedding)
X = np.array([
    [1.0, 0.0, 1.0, 0.0],   # 词 0
    [0.0, 1.0, 0.0, 1.0],   # 词 1
    [1.0, 1.0, 1.0, 1.0],   # 词 2
])

d_k = 4
np.random.seed(0)
Wq = np.random.randn(4, d_k) * 0.5   # 把 X 映射成 Q
Wk = np.random.randn(4, d_k) * 0.5   # 把 X 映射成 K
Wv = np.random.randn(4, d_k) * 0.5   # 把 X 映射成 V

# 1. 生成 Q、K、V
Q = X @ Wq
K = X @ Wk
V = X @ Wv

# 2. 打分 + 缩放
scores = Q @ K.T / np.sqrt(d_k)

# 3. softmax 归一化成权重
attn = softmax(scores, axis=-1)

# 4. 加权求和
out = attn @ V

print("注意力权重(每行是一个词对三个词的关注度):")
print(attn.round(3))
print("每行之和(应该都是 1):", attn.sum(axis=1).round(4))
print("输出形状:", out.shape)

跑起来你会看到attn 是一个 3×3 的矩阵,每一行代表一个词对三个词的关注权重,每行加起来正好是 1。这就是"每个词都在有选择地关注其他词"。


五、词向量从哪来:Embedding 与 Word2Vec

第四节代码里,每个"词"已经是一个 4 维向量(X 数组),注意力直接拿它算 Q、K、V。但这个向量是哪来的? 这是理解一切 LLM 的关键一环,必须补上。

为什么不能"数着"表示词

最朴素的表示是 one-hot:假设词典有 10000 个词,第 i 个词就是一个"长度 10000、只有第 i 位是 1"的向量。但 one-hot 有两个致命问题:

  1. 维度爆炸:一个词就占 1 万维,词典一大就没法用。
  2. 没有语义:"猫"和"狗"的 one-hot 向量点积是 0(完全无关),但它们明明语义相近。

分布式表示:让"语义相近"的词,向量也相近

解法是把每个词映射到一个低维稠密向量(比如 300 维),并且让语义相近的词,向量在空间里也靠得近。这个向量就叫词向量(word embedding)

经典例子:

向量("国王") - 向量("男人") + 向量("女人") ≈ 向量("女王")

向量不只是"编号",它编码了语义关系——这是 one-hot 永远做不到的。

Word2Vec:把"学词向量"变成自监督任务

词向量是怎么学出来的?关键洞察是分布假说一个词的含义,由它周围的词决定。

Word2Vec(2013)据此设计了两个自监督任务:

  • CBOW:用上下文词预测中心词("___ sat _ the mat" 猜中间的 "on")。
  • Skip-gram:用中心词预测它的上下文(给 "cat",猜周围会出现 "the""sat")。

训练方法和第 22 课一模一样:喂一堆(词 → 它的邻居)样本,用梯度下降调词向量,让"语义相近的词"向量靠近。不需要人工标注——答案就藏在文本里。

# 概念示意(真 Word2Vec 需要大语料 + 负采样)
import numpy as np

def cosine(a, b):
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))

# 假设已经学到(真实值是训练出来的):
cat   = np.array([0.9, 0.1, 0.2, 0.8])
dog   = np.array([0.8, 0.2, 0.1, 0.7])   # 和 cat 相近
apple = np.array([0.1, 0.9, 0.8, 0.1])   # 和 cat 无关

print("cat·dog 相似度:", round(cosine(cat, dog), 3))    # 高(≈0.99)
print("cat·apple 相似度:", round(cosine(cat, apple), 3)) # 低(≈0.4)

体会:向量方向接近 = 语义接近。这就是"词向量让语义可计算"的含义。

现代 LLM:embedding 是"第一层"

今天的大模型不再单独跑 Word2Vec,而是把词向量直接作为网络的第一层参数,随整个模型一起端到端训练。分词器把文字变成 token id,第一层 Embedding 把每个 id 查表成一个向量,后面再接 Transformer。

import torch
import torch.nn as nn

# 词典 10000 个 token,每个映射成 300 维向量
embedding = nn.Embedding(num_embeddings=10000, embedding_dim=300)
ids = torch.tensor([12, 456, 7890, 12, 3, 9999, 42, 7])
vecs = embedding(ids)
print(vecs.shape)   # torch.Size([8, 300])

记住:词向量(embedding)把离散的文字变成连续的、带语义的向量空间,让"语义相近的词"天然靠拢——这是 Transformer 能"理解"语言的前提。第四节那个 4 维的 X,本质上就是一张极简版的 embedding 表。


六、多头注意力:多派几个"专家"去查

单头注意力只有一套 Q/K/V,关注的角度单一。多头注意力(Multi-Head Attention) 就是并行跑多套,让不同的"头"关注不同的关系:

  • 有的头关注"语法"(哪个是主语)
  • 有的头关注"指代"(it 指谁)
  • 有的头关注"语义"(词义相近)

各头算完再把结果拼接起来。这就是为什么 Transformer 能同时捕捉多种关系。


七、位置编码:Transformer 怎么"知道"顺序

注意力机制本身是无序的——它把句子当成"一袋子词"。但"我爱你"和"你爱我"意思完全不同,顺序不能丢。

Transformer 的解法:在输入向量上加一个"位置编码(Positional Encoding)",让每个位置带上独一无二的"位置信号"。用的是正弦/余弦函数(不同频率):

import numpy as np

def pos_encoding(seq_len, d_model):
    pe = np.zeros((seq_len, d_model))
    for pos in range(seq_len):
        for i in range(0, d_model, 2):
            pe[pos, i]   = np.sin(pos / 10000 ** (i / d_model))
            pe[pos, i+1] = np.cos(pos / 10000 ** (i / d_model))
    return pe

pe = pos_encoding(5, 8)   # 5 个位置,8 维
print(pe.round(3))

要点:每个位置编码成一个固定向量,直接加到词向量上。这样即使注意力"无序",位置信息也已经嵌进输入里了。


八、Transformer 整体结构

把前面拼起来,就是一个 Transformer 的 Encoder 层:

输入 (词向量 + 位置编码)
    ↓
多头自注意力  ←── 词与词直接互相看
    ↓
残差连接 + 层归一化
    ↓
前馈网络(全连接)
    ↓
残差连接 + 层归一化
    ↓
输出(每个词的新表示)

三个关键设计:

设计作用
多头自注意力词与词直接、并行地互相看
残差连接让梯度能顺畅传回,训练更深
层归一化稳定训练

把这样的层叠 N 层,就是 Transformer。"注意力 + 前馈 + 残差 + 归一化"这个模块重复堆叠,就是今天的 GPT 们。


九、复杂度与要点

概念说明
自注意力O(n² · d),n 是序列长度。瓶颈在 n²(长序列代价高)
并行性所有词同时算,不依赖前一个 → 可以并行(对比 RNN 的 O(n) 顺序)
多头多个 Q/K/V 并行,关注不同关系
位置编码正弦/余弦,给无序的注意力注入顺序信息

十、动手时间 🎯

实验 1:观察注意力权重

跑第四节代码,然后手动改 X 里某个词向量,看 attn 权重怎么变。你能让"词 0"把大部分注意力集中到"词 2"上吗?

实验 2:验证 softmax 的性质

# 任给一组分数,验证 softmax 输出:① 都是正数 ② 加起来等于 1
s = np.array([2.0, 1.0, 0.1, 3.0])
def softmax(x):
    x = x - np.max(x)
    e = np.exp(x)
    return e / e.sum()
print(softmax(s).round(4))

实验 3(挑战):给注意力加"掩码"(mask)

把某些位置的分数设成 -inf,再走 softmax,看它们权重是不是变成 0。这就是 GPT 生成时"只能看到前面的词"的实现原理——因果掩码(causal mask)


十一、小结

  1. 注意力 = Q 查 K、按分数加权取 Vsoftmax(Q·Kᵀ/√d_k)·V,让每个词直接、有选择地关注其他词。
  2. Transformer 解决 RNN 两大病:能并行(不依赖前一个)、能直接看任意远的词(不用层层传递)。
  3. Transformer = 多头自注意力 + 前馈 + 残差 + 归一化,这个模块重复堆叠,就是今天的 GPT、BERT、Claude。

标签: none

添加新评论