上一章,我们搭建了一个可以运行的 GPT 模型基本骨架,同时也指出了它的问题。本章,我们着手解决第一个问题:没有位置信息。
嵌入层目前只能告诉模型每个词元是什么,却不能告诉模型每个词元在哪里。对模型来说,“老鼠爱大米”和“大米爱老鼠”是同一句话:无论词元出现在哪个位置,同一个词元算出来的词向量都一模一样。
但是语序恰恰是语言的灵魂。同一个词,出现在句首还是句尾,含义可能完全不同。对比一下:RNN 模型是逐个词元顺序处理的,位置信息天然蕴含在处理顺序里,不需要额外编码。而 GPT 模型一次性并行处理所有词元,效率高,代价就是丢失了语序信息。因此我们必须把语序信息显式地加入编码。
位置编码¶
如何表示词序信息?最直接的想法是:依次给每个位置一个编号,比如第 1 个词元是 1,第 2 个词元是 2,等等。我们称之为位置编码(Position Encoding)。
是不是很眼熟?和词元的索引编码很像?只不过索引编码是词元在词表中的位置,位置编码是词元在文本中的位置。
位置嵌入¶
显然,我们可以借鉴词元嵌入的思路:既然我们可以用一个可以训练的词向量来代替索引编码,位置编码为什么不可以?
位置嵌入(Position Embedding)就是用一个可学习的位置向量(Position Vector)来代替位置编码。将每个词元的词向量和位置向量一起提供给 GPT 模型,就可以解决 GPT 模型天然忽略语序的问题。
import numpy as np
from src.core import *np.random.seed(42)class CharDataset(Dataset):
def __init__(self, filename, batch_size=1, context_size=64, stride=None, split=0.9):
self.filename = filename
self.context_size = context_size
self.stride = stride if stride is not None else context_size // 2
self.split = split
super().__init__(batch_size)
def load(self):
with open(self.filename, encoding="utf-8") as f:
text = f.read()
self.vocab = sorted(set(text))
self.vocab_size = len(self.vocab)
self.stoi = {ch: i for i, ch in enumerate(self.vocab)}
self.itos = {i: ch for i, ch in enumerate(self.vocab)}
self.tokens = self.encode(text)
split = int(len(self.tokens) * self.split)
self.train_data = self._pack(self.tokens[:split])
self.test_data = self._pack(self.tokens[split:])
def _pack(self, tokens):
x, y = [], []
for i in range(0, len(tokens) - self.context_size - 1, self.stride):
x.append(tokens[i: i + self.context_size])
y.append(tokens[i + 1: i + self.context_size + 1])
return x, y
def encode(self, symbols):
return [self.stoi[s] for s in symbols]
def decode(self, tokens):
return "".join(self.itos[t] for t in tokens)class GELU(Layer):
def __init__(self):
super().__init__()
self.c = np.sqrt(2.0 / np.pi)
def forward(self, x: Tensor):
tanh = np.tanh(self.c * (x.data + 0.044715 * x.data ** 3))
a = Tensor(0.5 * x.data * (1.0 + tanh))
def gradient_fn():
grad = 0.5 * (1.0 + tanh) + 0.5 * x.data * (1.0 - tanh ** 2) * self.c * (1.0 + 3.0 * 0.044715 * x.data ** 2)
x.grad += a.grad * grad
return a.attach(gradient_fn, parents=[x])class GPTLoss(Loss):
def loss(self, p: Tensor, y: Tensor):
exp = np.exp(p.data - np.max(p.data, axis=-1, keepdims=True))
softmax = exp / np.sum(exp, axis=-1, keepdims=True)
flat_softmax = softmax.reshape(-1, softmax.shape[-1])
flat_y = y.data.reshape(-1).astype(np.int64)
n = len(flat_y)
rows = np.arange(n)
log = np.log(np.clip(flat_softmax[rows, flat_y], 1e-10, 1))
ce = Tensor(0 - np.sum(log) / n)
def gradient_fn():
flat_grad = flat_softmax.copy()
flat_grad[rows, flat_y] -= 1
p.grad += ce.grad * flat_grad.reshape(softmax.shape) / n
return ce.attach(gradient_fn, parents=[p])模型¶
GPT 嵌入层¶
我们在 GPT 嵌入层加入位置嵌入 positional_embedding,形成“词元嵌入 + 位置嵌入”的组合:
embedding(词元嵌入):用词元索引编码查表,产生词向量,格式为(批大小, 上下文尺寸, 嵌入尺寸);positional_embedding(位置嵌入):用词元位置编码查表,产生位置向量,格式为(上下文尺寸, 嵌入尺寸)。
嵌入层需要一个新的初始化参数:上下文尺寸 context_size,用于确定位置嵌入权重矩阵的大小。
最终的输出结果是“词向量 + 位置向量”。
这里要注意:这是两个张量的加法,使用了我们的模型训练框架中扩展的张量加法,保证了计算图和反向传播的延续,而不是简单地将两个向量的数值加在一起。
同时,词向量(3 维)和位置向量(2 维)的格式不同,两个向量可以直接相加,用到了张量中支持的广播机制(Broadcasting):位置向量会被自动扩展到和词向量相同的维度,然后相加。

class GPTEmbedding(Composite):
def __init__(self, vocab_size, context_size, embedding_size):
# 词元嵌入层
self.embedding = Embedding(vocab_size, embedding_size)
# 位置嵌入层
self.positional_embedding = Embedding(context_size, embedding_size)
super().__init__([self.embedding,
self.positional_embedding])
def forward(self, x: Tensor):
token = self.embedding(x)
position = self.positional_embedding(Tensor(range(x.shape[1])))
return token + positionGPT 前馈网络层¶
class GPTFeedForward(Composite):
def __init__(self, embedding_size):
self.input = Linear(embedding_size, embedding_size * 4)
self.gelu = GELU()
self.output = Linear(embedding_size * 4, embedding_size)
super().__init__([self.input,
self.gelu,
self.output])
def forward(self, x: Tensor):
h = self.gelu(self.input(x))
return self.output(h)GPT Transformer 模块¶
class GPTTransformer(Composite):
def __init__(self, embedding_size):
self.feed_forward = GPTFeedForward(embedding_size)
super().__init__([self.feed_forward])
def forward(self, x: Tensor):
return self.feed_forward(x)GPT 输出层¶
class GPTOutput(Composite):
def __init__(self, embedding_size, vocab_size):
self.output = Linear(embedding_size, vocab_size)
super().__init__([self.output])
def forward(self, x: Tensor):
return self.output(x)GPT 模型¶
创建 GPT 模型也需要增加一个额外的初始化参数:上下文尺寸 context_size,用于创建嵌入层。
class GPT(Composite):
def __init__(self, vocab_size, context_size, embedding_size, blocks):
self.embedding = GPTEmbedding(vocab_size, context_size, embedding_size)
self.transformers = [GPTTransformer(embedding_size) for _ in range(blocks)]
self.output = GPTOutput(embedding_size, vocab_size)
super().__init__([self.embedding] + self.transformers + [self.output])
def forward(self, x: Tensor):
x = self.embedding(x)
for layer in self.transformers:
x = layer(x)
return self.output(x)GPT 训练器¶
class GPTTrainer(Trainer):
def train(self, dataset, epochs, scheduler=None):
dataset.train()
self.model.train()
steps = 0
for epoch in range(epochs):
for i in range(len(dataset)):
if scheduler is not None:
self.optimizer.lr = scheduler.step(steps)
feature, label = dataset[i]
self.optimizer.zero_grad()
prediction = self.model(feature)
loss = self.loss_fn(prediction, label)
loss.backward()
self.optimizer.clip_grad_norm()
self.optimizer.step()
steps += 1
def test(self, dataset):
dataset.eval()
self.model.eval()
predictions = []
total_loss = 0.0
with Tensor.no_grad():
for i in range(len(dataset)):
feature, label = dataset[i]
prediction = self.model(feature)
loss = self.loss_fn(prediction, label)
predictions.append(prediction)
total_loss += float(loss.data)
loss = total_loss / len(dataset)
dataset.train()
self.model.train()
return predictions, loss
def generate(self, dataset, prompt, steps=256):
self.model.eval()
tokens = dataset.encode(prompt)
with Tensor.no_grad():
for _ in range(steps):
feature = Tensor([tokens[-dataset.context_size:]])
logits = self.model(feature)
last_logits = logits.data[0, -1]
exp = np.exp(last_logits - np.max(last_logits))
probs = exp / np.sum(exp)
token = np.random.choice(len(probs), p=probs)
tokens.append(token)
return dataset.decode(tokens)LEARNING_RATE = 0.001批大小¶
BATCH_SIZE = 4轮数¶
EPOCHS = 2上下文尺寸¶
CONTEXT_SIZE = 32嵌入尺寸¶
EMBEDDING_SIZE = 64Transformer 模块数¶
BLOCKS = 2建模¶
创建模型时,需要额外传入 CONTEXT_SIZE 作为初始化参数。
DATA_FILE = "../../../../data/tinyshakespeare.txt"
dataset = CharDataset(DATA_FILE, BATCH_SIZE, CONTEXT_SIZE)
model = GPT(dataset.vocab_size, CONTEXT_SIZE, EMBEDDING_SIZE, BLOCKS)
loss_fn = GPTLoss()
optimizer = AdamWOptimizer(model.parameters, lr=LEARNING_RATE)
trainer = GPTTrainer(model, loss_fn, optimizer)训练¶
scheduler = WarmupCosineScheduler(LEARNING_RATE, EPOCHS * len(dataset), 100, LEARNING_RATE / 10)
trainer.train(dataset, EPOCHS, scheduler)评估¶
prediction, loss = trainer.test(dataset)
print(f'prediction:\t{len(prediction)} steps, each {prediction[0].data.shape}\nloss:\t{loss}')prediction: 1742 steps, each (4, 32, 65)
loss: 2.462666325331008
推理¶
print(trainer.generate(dataset, prompt="ROMEO:"))ROMEO:
PELUCENone iat werachald l d:
ANMyerther, itik he
TIO:
Ife.
DUKayo in, y dld s m f s pllorislds hine try be ou 'te noungeltwiomavear whe
Opld migrarthemaxhayorthe t; llleakash lu aru g d, harenout watllllicinchiowe f s my, boive
I he y a fod, gheltl I her
本章,我们通过位置嵌入,给模型提供“词向量 + 位置向量”,让模型既能分辨“是什么”,也能分辨“在哪里”。
至此,我们完成了 GPT 模型的输入表示部分。
但是我们从推理结果中并不能看到明显的进步,是因为还有一个更根本的问题没有解决:词元之间“互不来往”。每个位置的预测只取决于当前这个词元和它的位置,模型完全不知道上下文还有什么。
下一章,我们将开始引入 GPT 模型最核心的机制:自注意力(Self-Attention),让词元之间真正地“看见彼此”。
课后练习¶
尝试统计一下 GPT 模型的参数规模,包括词元嵌入、位置嵌入的权重矩阵,以及其他模块里线性层的权重和偏置等。看看主要和哪些超参数相关?