生成模型與大型語言模型
前面的網路都是判別模型:給輸入,判斷類別或預測數值。生成模型則學習資料本身的機率分布,再從中取樣產生新的資料:影像、聲音、文字。大型語言模型(large language model,LLM)就是文字的生成模型。
三種主要的生成模型 Generative Models
- 變分自編碼器(VAE):把資料壓成低維的潛在向量再還原,並讓潛在向量服從簡單的分布,之後從這個分布取樣、解碼就得到新資料。
- 生成對抗網路(GAN):生成器想騙過判別器,判別器想抓出假資料,兩者對抗訓練。產生的影像很逼真,但訓練不太穩定。
- 擴散模型:訓練網路把加了雜訊的資料一步步還原;生成時從純雜訊出發反覆去雜訊。這是目前文字生成影像的主流做法。
語言模型 Language Models
一段文字 \(x_1, x_2, \dots, x_n\) 的機率可以用連鎖律拆成「每個字在前面的字之下的條件機率」相乘:
$$p(x_1, \dots, x_n) = \prod_{t=1}^{n} p(x_t \mid x_1, \dots, x_{t-1})$$所以語言模型只要學一件事:給前文,預測下一個詞元(token:一個字、一個詞或詞的一部分)。訓練時最小化交叉熵 \(-\frac1n\sum_t \log p(x_t \mid x_{<t})\);常用的評估指標困惑度(perplexity)是交叉熵的指數,可以理解成「模型平均在幾個候選字之間猶豫」。生成時則一次取樣一個詞元,接到前文後面,再預測下一個。
範例:在本課程的文字上訓練迷你 GPT A Tiny GPT
把本網站各頁的中文說明(不含程式與公式)合併成約 5 萬字的語料(下載 corpus.txt),訓練一個字元級的小型 Transformer:每個「詞元」就是一個字,2 層、因果遮罩、上下文 64 個字。為了避免過擬合,語料切成 500 字一段,每 10 段取 1 段當模型沒看過的驗證文字,並在驗證誤差最低時停止:
# 迷你語言模型:在本課程網站的文字(corpus.txt,約 5 萬字)上,訓練一個字元級的 Transformer 預測「下一個字」
import json
import math
from pathlib import Path
import torch
from torch import nn
HERE = Path(__file__).parent
text = (HERE / "corpus.txt").read_text(encoding="utf-8")
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
data = torch.tensor([stoi[c] for c in text])
chunks = data.split(500) # 每 500 字一段,每 10 段取 1 段當驗證文字(模型沒看過)
train = torch.cat([c for i, c in enumerate(chunks) if i % 10 != 9])
val = torch.cat([c for i, c in enumerate(chunks) if i % 10 == 9])
cut = len(train)
V, T, D = len(chars), 64, 64 # 字元種類、上下文長度、向量維度
# 基準:unigram(只看字頻)與 bigram(只看前一個字,加一平滑)
uni = torch.bincount(train, minlength=V).float() + 1
bi = torch.ones(V, V)
bi.index_put_((train[:-1], train[1:]), torch.ones(cut - 1), accumulate=True)
ppl_uni = math.exp(-(uni / uni.sum()).log()[val].mean().item())
ppl_bi = math.exp(-(bi / bi.sum(1, keepdim=True)).log()[val[:-1], val[1:]].mean().item())
class Block(nn.Module):
def __init__(self):
super().__init__()
self.n1, self.n2 = nn.LayerNorm(D), nn.LayerNorm(D)
self.att = nn.MultiheadAttention(D, 4, dropout=0.1, batch_first=True)
self.ff = nn.Sequential(nn.Linear(D, 4 * D), nn.GELU(), nn.Linear(4 * D, D), nn.Dropout(0.1))
def forward(self, h, mask):
a = self.n1(h)
h = h + self.att(a, a, a, attn_mask=mask, need_weights=False)[0] # 因果遮罩:看不到後面的字
return h + self.ff(self.n2(h))
class GPT(nn.Module):
def __init__(self):
super().__init__()
self.tok, self.pos = nn.Embedding(V, D), nn.Embedding(T, D) # 位置用可學習的向量
self.blocks = nn.ModuleList([Block(), Block()])
self.norm, self.head = nn.LayerNorm(D), nn.Linear(D, V)
def forward(self, x):
n = x.shape[1]
mask = torch.ones(n, n, dtype=torch.bool).triu(1)
h = self.tok(x) + self.pos(torch.arange(n))
for b in self.blocks:
h = b(h, mask)
return self.head(self.norm(h))
def get_batch(src, n, g):
i = torch.randint(0, len(src) - T - 1, (n,), generator=g)
x = torch.stack([src[j:j + T] for j in i])
return x, torch.stack([src[j + 1:j + T + 1] for j in i]) # 目標:每個位置的「下一個字」
torch.manual_seed(0)
g = torch.Generator().manual_seed(0)
net = GPT()
opt = torch.optim.AdamW(net.parameters(), lr=2e-3)
curve, best = [], (1e9, 0, None)
for step in range(1, 2001):
net.train()
x, y = get_batch(train, 32, g)
loss = nn.functional.cross_entropy(net(x).reshape(-1, V), y.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
if step % 100 == 0:
net.eval() # 評估時關掉 dropout
with torch.no_grad():
xv, yv = get_batch(val, 256, torch.Generator().manual_seed(1))
vl = nn.functional.cross_entropy(net(xv).reshape(-1, V), yv.reshape(-1)).item()
curve.append([step, loss.item(), vl])
if vl < best[0]: # 提早停止:記下驗證誤差最低的模型
best = (vl, step, {k: v.clone() for k, v in net.state_dict().items()})
net.load_state_dict(best[2]); net.eval()
print(f"字元 {len(text)} 個、{V} 種;模型參數 {sum(p.numel() for p in net.parameters())} 個")
print(f"驗證誤差最低在第 {best[1]} 步(之後開始過擬合),採用這時的模型")
print(f"驗證文字的困惑度(越小越好):unigram {ppl_uni:.0f}、bigram {ppl_bi:.0f}、Transformer {math.exp(best[0]):.0f}")
def generate(prompt, n, temp, seed):
g = torch.Generator().manual_seed(seed)
x = torch.tensor([[stoi[c] for c in prompt]])
with torch.no_grad():
for _ in range(n):
p = torch.softmax(net(x[:, -T:])[0, -1] / temp, -1) # 溫度:越低越保守
x = torch.cat([x, torch.multinomial(p, 1, generator=g).view(1, 1)], 1)
return "".join(chars[i] for i in x[0].tolist())
for temp in (0.5, 1.0):
print(f"\n溫度 {temp}:", generate("類神經網路", 60, temp, 0).replace("\n", " "))
nxt = {} # 給網頁動畫:幾個開頭之後,機率最高的 12 個字與其 logit
for prompt in ("類神經網", "倒單擺的", "梯度"):
with torch.no_grad():
top = net(torch.tensor([[stoi[c] for c in prompt]]))[0, -1].topk(12)
nxt[prompt] = [[chars[i], round(v, 4)] for v, i in zip(top.values.tolist(), top.indices.tolist())]
(HERE / "char_lm.json").write_text(json.dumps({"curve": curve, "ppl": [ppl_uni, ppl_bi], "best": best[1], "next": nxt}, ensure_ascii=False), encoding="utf-8")⬇ 下載 char_lm.py(Python 3+PyTorch)
實際執行的輸出:
字元 51971 個、1157 種;模型參數 253445 個
驗證誤差最低在第 800 步(之後開始過擬合),採用這時的模型
驗證文字的困惑度(越小越好):unigram 280、bigram 169、Transformer 46
溫度 0.5: 類神經網路的特定。 感知器,只有「Sute Inondurit 模擬結論合 FIS tenstion Foletion Ed se
溫度 1.0: 類神經網路的特定在控制速度,只能只是回授一旦Inp PyTit 模擬合元增益,又用力的結論是否則是 0.5,粒、後仍有螞蟻走過去的
- 只看字頻的 unigram 困惑度 280,只看前一個字的 bigram 169;看前 64 個字的 Transformer 降到 46。
- 驗證誤差在第 800 步最低,之後訓練誤差繼續下降、驗證誤差卻回升:5 萬字對 25 萬個參數太少,模型開始背誦訓練文字(見過擬合)。
- 產生的文字用詞與語氣很像本站(「感知器」「模擬」「增益」),局部也大致通順,但整句沒有意義。這就是「只學會預測下一個字」的樣子;要寫出有意義的長文,需要大得多的模型與資料。
(補充)作為對照,2020 年的 GPT-3 有 1750 億個參數,約為這個迷你模型的 70 萬倍,訓練了約 3000 億個詞元。研究也發現,模型、資料與運算量一起放大時,誤差會照冪次律穩定下降(scaling law),這是大型語言模型越做越大的主要原因。
溫度與取樣 Sampling
模型輸出的是下一個字的機率分布。生成時常用溫度 T 調整:\(p_i \propto \exp(z_i / T)\)。T 小,機率集中在最可能的字,文字保守但容易重複;T 大,機率攤平,文字多樣但容易出錯。top-k 只從機率最高的 k 個字中抽樣,避免抽到極不合理的字。下方動畫使用上面迷你模型的真實輸出:
從語言模型到聊天助理 From LM to Assistant
- 預訓練:用大量網路文字訓練「預測下一個詞元」,模型因此學到語言與大量知識。
- 指令微調:用人工撰寫的「指令—回答」配對繼續訓練,讓模型學會依指令回答,而不是只會接續文字。
- 人類回饋強化學習(RLHF):請人比較模型的不同回答,用這些偏好訓練一個「獎勵模型」,再用強化學習調整語言模型,讓回答更有用、更安全(強化學習見後續章節)。
限制與在控制上的角色 Limitations
- 幻覺:模型產生的是「看起來合理」的文字,不保證正確,可能自信地說出錯誤的數字或不存在的文獻。
- 不適合直接閉迴路控制:推論時間長、無法保證穩定性與安全限制,所以通常不直接計算控制量,而是用在較高層:把語言指令轉成任務規劃、撰寫與檢查控制程式、解讀感測資料與異常報告,再交給傳統控制器或經過驗證的模組執行。
- 用於工程時,輸出一定要經過驗證。本網站所有數字都由驗證腳本實際計算核對,就是同樣的道理。