注意力機制與 Transformer
遞迴網路一步一步處理序列,有兩個根本的限制:不能平行計算(要等前一步算完),而且相隔很遠的兩個位置之間,資訊要經過很多步才傳得到,梯度也要經過很多步才傳得回來。注意力機制(attention)讓每個位置直接去看所有其他位置,任兩個位置之間只隔一步。2014 年注意力先被加在 RNN 上改善機器翻譯;2017 年的 Transformer(論文名稱就叫 “Attention Is All You Need”)乾脆拿掉遞迴,只用注意力,成為今天大型語言模型的基礎。
縮放點積注意力 Scaled Dot-Product Attention
把每個位置的向量 \(\mathbf x_i\) 各乘上三個學出來的矩陣,得到 query \(\mathbf q_i = \mathbf W_Q\mathbf x_i\)(我要找什麼)、key \(\mathbf k_j = \mathbf W_K\mathbf x_j\)(我有什麼)、value \(\mathbf v_j = \mathbf W_V\mathbf x_j\)(我能提供的內容)。位置 i 對位置 j 的注意力權重由 \(\mathbf q_i\) 與 \(\mathbf k_j\) 的相似度決定,輸出則是各 value 的加權平均:
$$\operatorname{Attention}(\mathbf Q, \mathbf K, \mathbf V) = \operatorname{softmax}\!\left(\frac{\mathbf Q\mathbf K^{\mathsf T}}{\sqrt{d_k}}\right)\mathbf V,\qquad \alpha_{ij} = \frac{\exp(\mathbf q_i\cdot\mathbf k_j/\sqrt{d_k})}{\sum_m \exp(\mathbf q_i\cdot\mathbf k_m/\sqrt{d_k})}$$這就像一個「軟性」的查表:一般的字典是完全符合的 key 才取出它的 value;注意力則依相似程度,從每個 key 都取一點。
為什麼除以 \(\sqrt{d_k}\):若 q、k 的各分量互相獨立、平均 0、變異數 1,內積 \(\mathbf q\cdot\mathbf k = \sum_{m=1}^{d_k} q_m k_m\) 的變異數是 \(d_k\)。維度一大,分數的差距跟著變大,softmax 幾乎變成只選最大的那一個(one-hot),梯度也跟著消失。除以 \(\sqrt{d_k}\) 把變異數拉回 1:
# 縮放點積注意力:照公式自己寫,和 PyTorch 內建的版本比對;並看為什麼要除以 √d
import math
import torch
import torch.nn.functional as F
torch.manual_seed(0)
n, d = 5, 16 # 5 個位置,每個向量 16 維
Q, K, V = torch.randn(n, d), torch.randn(n, d), torch.randn(n, d)
def attention(Q, K, V, causal=False):
S = Q @ K.T / math.sqrt(K.shape[-1]) # 每個 query 與每個 key 的相似度
if causal: # 因果遮罩:只能看自己與之前的位置
S = S.masked_fill(torch.ones(n, n).triu(1).bool(), float("-inf"))
A = torch.softmax(S, dim=-1) # 每一列加總為 1:注意力權重
return A @ V, A
out, A = attention(Q, K, V)
print("自己寫的 = PyTorch 內建:", torch.allclose(out, F.scaled_dot_product_attention(Q, K, V), atol=1e-6))
out_c, A_c = attention(Q, K, V, causal=True)
print("因果遮罩也相同:", torch.allclose(out_c, F.scaled_dot_product_attention(Q, K, V, is_causal=True), atol=1e-6))
print("因果遮罩的權重(上三角為 0):\n", A_c.round(decimals=2))
# 為什麼除以 √d:分量為標準常態時,q·k 的變異數等於 d,d 越大 softmax 越接近 one-hot
for d in (4, 64, 512):
q, k = torch.randn(10000, d), torch.randn(10000, d)
dots = (q * k).sum(1)
S = torch.randn(1000, 8, d) @ torch.randn(1000, d, 8) # 1000 組、每組 8×8 的分數
top = lambda s: torch.softmax(s, -1).max(-1).values.mean().item()
print(f"d = {d:3d}:q·k 變異數 {dots.var().item():6.1f},最大權重平均 不縮放 {top(S):.2f}/除以 √d {top(S / math.sqrt(d)):.2f}")⬇ 下載 attention_check.py(Python 3+PyTorch)
實際執行的輸出:
自己寫的 = PyTorch 內建: True
因果遮罩也相同: True
因果遮罩的權重(上三角為 0):
tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.1200, 0.8800, 0.0000, 0.0000, 0.0000],
[0.5000, 0.1800, 0.3100, 0.0000, 0.0000],
[0.3000, 0.5000, 0.1200, 0.0800, 0.0000],
[0.0900, 0.0500, 0.1700, 0.1300, 0.5600]])
d = 4:q·k 變異數 4.0,最大權重平均 不縮放 0.53/除以 √d 0.34
d = 64:q·k 變異數 64.4,最大權重平均 不縮放 0.87/除以 √d 0.36
d = 512:q·k 變異數 498.2,最大權重平均 不縮放 0.95/除以 √d 0.36
不縮放時,d = 512 的最大權重平均達 0.95,幾乎只看一個位置;縮放後不論維度都維持在約 0.36。
多頭注意力 Multi-Head Attention
一組 \(\mathbf W_Q, \mathbf W_K, \mathbf W_V\) 只能表達一種「相關」。多頭注意力同時用 h 組(例如 8 組)較小的矩陣,各自算一次注意力,再把結果接起來乘上 \(\mathbf W_O\):
$$\operatorname{MultiHead}(\mathbf X) = \left[\operatorname{head}_1, \dots, \operatorname{head}_h\right]\mathbf W_O,\qquad \operatorname{head}_i = \operatorname{Attention}(\mathbf X\mathbf W_Q^{(i)}, \mathbf X\mathbf W_K^{(i)}, \mathbf X\mathbf W_V^{(i)})$$不同的頭可以各自學會不同的關係,例如一個頭看前一個字、另一個頭看句子的主詞。
位置編碼 Positional Encoding
注意力本身不管順序:把輸入打亂,每個位置得到的加權平均完全一樣。所以要把「位置」的資訊加進輸入向量。原始 Transformer 使用不同頻率的正弦與餘弦:
$$\mathrm{PE}(p, 2i) = \sin\frac{p}{10000^{2i/d}},\qquad \mathrm{PE}(p, 2i+1) = \cos\frac{p}{10000^{2i/d}}$$這個設計有一個好性質:把每一對 \((\sin, \cos)\) 分量旋轉 \(k\omega_i\),\(\mathrm{PE}(p)\) 就變成 \(\mathrm{PE}(p+k)\),而且這個旋轉與 \(p\) 無關,是一個固定的線性變換。所以網路只要讓 \(\mathbf W_Q\) 學會這個旋轉,就能做到「每個位置都去看它的前一個位置」這類依相對位置的注意力:
Transformer 區塊 The Transformer Block
每個區塊是「自注意力」加上「每個位置各自的前饋網路」,兩者都包著殘差連接與層正規化(layer normalization,對每個位置自己的向量做正規化)。把區塊疊 N 層就是 Transformer。用於生成文字時(例如 GPT),注意力加上因果遮罩,每個位置只能看自己與前面的位置,否則訓練時就偷看到答案了。
| RNN/LSTM | Transformer | |
|---|---|---|
| 任兩位置的距離 | 相隔 n 步 | 1 步(直接相連) |
| 計算 | 沿時間逐步,不能平行 | 所有位置同時算,適合 GPU |
| 每層的運算量 | \(O(n\,d^2)\) | \(O(n^2 d)\):序列很長時注意力成為瓶頸 |
範例:把序列倒過來 Example
輸入 10 個隨機數字,要網路輸出倒過來的序列。這個任務需要每個輸出位置去拿遠處的某一個輸入,正好考驗「直接存取任意位置」的能力。比較一層的小型 Transformer 與雙向 LSTM(每個位置都看得到整個序列):
# 把 10 個數字倒過來:小型 Transformer(自注意力)與 LSTM 比較,並畫出 Transformer 學到的注意力
import json
import math
from pathlib import Path
import torch
from torch import nn
L, V, D = 10, 10, 32 # 序列長 10、數字 0~9、向量 32 維
def batch(n, g):
x = torch.randint(0, V, (n, L), generator=g)
return x, x.flip(1) # 目標:倒過來的序列
def pos_encoding(L, d):
"""正弦位置編碼:PE[p, 2i] = sin(p / 10000^(2i/d)),PE[p, 2i+1] = cos(…)"""
p = torch.arange(L).unsqueeze(1)
w = torch.exp(-math.log(10000.0) * torch.arange(0, d, 2) / d)
pe = torch.zeros(L, d)
pe[:, 0::2], pe[:, 1::2] = torch.sin(p * w), torch.cos(p * w)
return pe
class TinyTransformer(nn.Module):
def __init__(self):
super().__init__()
self.emb = nn.Embedding(V, D)
self.register_buffer("pe", pos_encoding(L, D))
self.att = nn.MultiheadAttention(D, num_heads=1, batch_first=True)
self.ff = nn.Sequential(nn.Linear(D, 64), nn.ReLU(), nn.Linear(64, D))
self.n1, self.n2 = nn.LayerNorm(D), nn.LayerNorm(D)
self.out = nn.Linear(D, V)
def forward(self, x, need_att=False):
h = self.emb(x) + self.pe # 數字的向量+位置
a, w = self.att(h, h, h, need_weights=need_att)
h = self.n1(h + a) # 殘差+層正規化
h = self.n2(h + self.ff(h))
return (self.out(h), w) if need_att else self.out(h)
class TinyLSTM(nn.Module):
def __init__(self):
super().__init__()
self.emb = nn.Embedding(V, D)
self.rnn = nn.LSTM(D, 48, batch_first=True, bidirectional=True) # 雙向:每個位置看得到整個序列
self.out = nn.Linear(96, V)
def forward(self, x):
return self.out(self.rnn(self.emb(x))[0])
g = torch.Generator().manual_seed(0)
xt, yt = batch(2000, torch.Generator().manual_seed(99)) # 測試資料
result = {}
for name, Net in [("Transformer", TinyTransformer), ("雙向 LSTM", TinyLSTM)]:
torch.manual_seed(0)
net = Net()
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
for step in range(1500):
x, y = batch(64, g)
loss = nn.functional.cross_entropy(net(x).reshape(-1, V), y.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
with torch.no_grad():
pred = net(xt).argmax(-1)
tok, seq = (pred == yt).float().mean().item(), (pred == yt).all(1).float().mean().item()
n_par = sum(p.numel() for p in net.parameters())
print(f"{name:11s}:參數 {n_par:5d},每個數字的正確率 {tok:.1%},整串全對 {seq:.1%}")
if name == "Transformer":
with torch.no_grad():
_, w = net(xt[:1], need_att=True)
result["att"] = w[0].tolist() # 第一筆測試資料的注意力權重(10×10)
result["x"] = xt[0].tolist()
Path(__file__).with_name("reverse_seq.json").write_text(json.dumps(result), encoding="utf-8")⬇ 下載 reverse_seq.py(Python 3+PyTorch)
實際執行的輸出:
Transformer:參數 9194,每個數字的正確率 100.0%,整串全對 100.0%
雙向 LSTM :參數 32778,每個數字的正確率 95.0%,整串全對 61.1%
Transformer 的參數不到 LSTM 的三分之一,卻完全做對;LSTM 每個數字有 95% 的正確率,但整串 10 個全對只有 61%。畫出訓練後 Transformer 的注意力權重,網路自己學會了「第 i 個輸出去看第 9 − i 個輸入」:
在控制上的應用 In Control
- 時間序列預測:Transformer 可用來預測負載、能源需求、設備狀態等,作為預測控制或預知保養的輸入。
- 把強化學習當成序列預測:Decision Transformer(2021)把「過去的狀態、動作與回報」當成一串序列,用 Transformer 預測下一個動作。
- 機器人基礎模型:把影像、語言指令與機器人動作都當成詞元,用大型 Transformer 一起訓練(例如 RT-2,2023),讓機器人能依語言指令執行沒看過的任務。
下一頁介紹用 Transformer 生成文字的大型語言模型。