从零开始打造一个简单 Transformer 语言模型 [译]
嗨,大家好!今天我们要一起动手,用 PyTorch 这个强大的工具,打造一个简单但完整的 Transformer 语言模型。别担心,我会一步步拆解每个部分,详细解释它的作用和原理。到最后,你不仅能搞懂 Transformer 是怎么回事,还能在 Google Colab 这个免费的在线平台上自己跑一个模型——就算你是第一次接触编程或者 AI,也没问题!
Transformer 是什么?简单聊聊它的背景
Transformer 是一种特别厉害的工具,专门用来处理语言,比如翻译、生成文章等。它在 2017 年由一篇名叫「Attention Is All You Need」的论文提出后,迅速成了自然语言处理(简称 NLP)的明星。像 GPT(生成对话的模型)还有 BERT(理解文本的模型)这些大名鼎鼎的家伙,都是基于 Transformer 构建的。
它的核心“魔法”在于一个叫「注意力机制」的东西。简单来说,这个机制能让模型在处理一句话时,聪明地“关注”到最重要的词,而不是一视同仁地看待每个词。比如在“我喜欢吃苹果”这句话里,模型可能会特别注意“喜欢”和“苹果”,因为它们决定了句子的意思。
先准备好工具:设置 Google Colab 环境
在我们写代码之前,得先把“工作台”搭好。我们用 Google Colab——一个免费的在线工具,能让你在浏览器里写代码,还自带 PyTorch,不用自己装软件。操作很简单:
- 打开浏览器,进入 Google Colab
- 点击“新建笔记本”,就像新建一个 Word 文档一样
- PyTorch 已经预装好了,但如果你需要其他工具(比如 numpy),可以运行下面这行命令:
# 如果需要额外安装工具,运行这行代码就行 #
!pip install torch numpy
好了,环境搭好了!接下来,我们一步步把 Transformer 模型搭起来。
第一步:准备好编程“零件”——导入库
就像盖房子需要砖头和水泥,我们写模型也需要一些基础工具。这些工具藏在 Python 的“库”里,我们先把它们“拿出来”:
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from torch.utils.data import Dataset, DataLoader
math
:数学计算的小帮手,比如开平方啥的torch
:PyTorch 的核心库,专门用来造神经网络torch.nn
(简称nn
):神经网络的“零件箱”,里面有各种现成的模块torch.nn.functional
(简称F
):一些方便的函数,比如激活函数numpy
:处理数字和数组的好工具Dataset
和DataLoader
:帮我们整理数据,喂给模型用
这些“零件”齐了,我们就可以开始组装模型了。
第二步:打造核心部件——多头注意力机制
Transformer 的“心脏”就是「多头注意力机制」。这个名字听起来有点复杂,但其实不难理解。想象你在读一本书,有些词(比如主角的名字)比其他词更重要,注意力机制就是让模型学会“挑重点”。
“多头”是什么意思呢?就是模型可以同时从不同角度看问题。比如,一个“头”关注句子的主语和动词,另一个“头”关注修饰词和名词。这样,模型就能更全面地理解句子。我们来写代码实现它:
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super(MultiHeadAttention, self).__init__()
assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除"
self.d_model = d_model # 嵌入向量的维度,比如每个词用多长的数字表示
self.num_heads = num_heads # 有几个“头”来分担注意力
self.d_k = d_model // num_heads # 每个头的维度
# 为查询(Q)、键(K)、值(V)准备线性变换工具
self.w_q = nn.Linear(d_model, d_model)
self.w_k = nn.Linear(d_model, d_model)
self.w_v = nn.Linear(d_model, d_model)
self.w_o = nn.Linear(d_model, d_model) # 最后的输出层
这段代码在干什么呢?我们先初始化一个类:
d_model
是词向量的长度(比如 512)num_heads
是注意力的“头数”(比如 8)d_k
是每个头负责的维度(512 ÷ 8 = 64)w_q
、w_k
、w_v
是三个线性层,把输入变成“查询”、“键”和“值”(简称 Q、K、V),这是注意力机制的关键概念w_o
是把结果整合输出的层
接下来,我们要把输入分成多个“头”:
def split_heads(self, x, batch_size):
# 把输入从 (batch_size, seq_len, d_model) 变成 (batch_size, num_heads, seq_len, d_k)
x = x.view(batch_size, -1, self.num_heads, self.d_k)
return x.permute(0, 2, 1, 3) # 调整顺序,方便计算
这里我们把输入“切块”,让每个“头”都能独立处理一部分信息。比如原来是个 512 维的向量,分成 8 个头后,每个头看 64 维。
现在进入核心计算部分:
def forward(self, q, k, v, mask=None):
batch_size = q.size(0)
# 把 Q、K、V 分成多个头
q = self.split_heads(self.w_q(q), batch_size) # 输出:(batch_size, num_heads, seq_len_q, d_k)
k = self.split_heads(self.w_k(k), batch_size)
v = self.split_heads(self.w_v(v), batch_size)
# 计算注意力分数(“缩放点积注意力”)
scores = torch.matmul(q, k.transpose(-2, -1)) # 形状:(batch_size, num_heads, seq_len_q, seq_len_k)
scores = scores / math.sqrt(self.d_k) # 缩放一下,防止数字太大
这部分是注意力机制的“灵魂”:
- 把输入变成 Q、K、V 三组向量
- 用 Q 和 K 做点积(
torch.matmul
),算出每个词对其他词的“重要性分数” - 除以
sqrt(d_k)
是为了让数字稳定,不然太大会影响后续计算
# 如果有掩码,就用上(比如不想让模型偷看未来的词)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 用 softmax 把分数变成概率
attn_weights = F.softmax(scores, dim=-1)
# 用概率“加权”值 V
context = torch.matmul(attn_weights, v) # 输出:(batch_size, num_heads, seq_len_q, d_k)
这里:
- 如果有
mask
(掩码),就把不该看的地方设成一个超级小的数(-1e9),这样 softmax 后几乎是 0 softmax
把分数变成概率,加起来等于 1- 用这些概率去“加权” V,得出每个位置的“关注结果”
# 把多个头的输出拼回去
context = context.permute(0, 2, 1, 3).contiguous()
context = context.view(batch_size, -1, self.d_model)
# 最后通过一个线性层输出
output = self.w_o(context)
return output
最后:
- 把多个头的结果重新拼成一个完整的向量
- 通过
w_o
输出最终结果
这个多头注意力机制就像模型的“眼睛”,能同时从不同角度看输入,找出最重要的信息。
第三步:加个“加工厂”——逐位置前馈网络
注意力机制算完后,我们需要一个“加工厂”来进一步处理信息。这个部分叫「逐位置前馈网络」,它会对序列里的每个位置(比如每个词)单独处理一遍。
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff):
super(PositionwiseFeedForward, self).__init__()
self.fc1 = nn.Linear(d_model, d_ff) # 第一层
self.fc2 = nn.Linear(d_ff, d_model) # 第二层
def forward(self, x):
return self.fc2(F.relu(self.fc1(x))) # 先放大,再缩小,中间加个激活
这个网络很简单:
d_model
是输入的维度(比如 512)d_ff
是中间层的维度(通常更大,比如 2048)- 输入经过
fc1
变“大”,用relu
激活(让负数变成 0),再通过fc2
变回原来大小
它就像一个“放大镜”,先把信息“放大”看细节,再“缩小”整合输出。
第四步:给词加上“位置标签”——位置编码
Transformer 有个小问题:它不像人,能自然看出词的顺序。为了解决这个,我们得给每个词加个“位置标签”,告诉模型“这是第几个词”。这就靠「位置编码」来实现。
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_seq_length=5000):
super(PositionalEncoding, self).__init__()
# 创建一个位置编码表
pe = torch.zeros(max_seq_length, d_model)
position = torch.arange(0, max_seq_length, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term) # 偶数位置用正弦
pe[:, 1::2] = torch.cos(position * div_term) # 奇数位置用余弦
# 注册为缓冲区(不会被训练更新)
self.register_buffer('pe', pe.unsqueeze(0))
def forward(self, x):
# 把位置信息加到输入上
return x + self.pe[:, :x.size(1)]
这里用了点数学“魔法”:
- 用正弦(
sin
)和余弦(cos
)函数生成一堆数字,每个位置的数字都不一样 - 偶数维度用正弦,奇数维度用余弦,这样每个位置的“标签”都有独特模式
- 这些标签直接加到词向量上,模型就能知道哪个词在前面,哪个在后面
第五步:组装“信息加工站”——编码器层
现在,我们把注意力机制和前馈网络装进一个“加工站”,叫「编码器层」。它会处理输入的句子,把原始信息变成更有用的形式。
class EncoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super(EncoderLayer, self).__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads) # 自注意力
self.feed_forward = PositionwiseFeedForward(d_model, d_ff) # 前馈网络
self.norm1 = nn.LayerNorm(d_model) # 归一化
self.norm2 = nn.LayerNorm(d_model)
self.dropout1 = nn.Dropout(dropout) # 随机丢弃,防止过拟合
self.dropout2 = nn.Dropout(dropout)
这里我们准备了:
- 自注意力模块(看句子内部的关系)
- 前馈网络(加工每个位置的信息)
- 两个归一化层(让数字稳定)
- 两个 dropout(随机丢掉一些数据,增强模型鲁棒性)
def forward(self, x, mask=None):
# 自注意力 + 残差连接 + 归一化
attn_output = self.self_attn(x, x, x, mask)
x = self.norm1(x + self.dropout1(attn_output))
# 前馈网络 + 残差连接 + 归一化
ff_output = self.feed_forward(x)
x = self.norm2(x + self.dropout2(ff_output))
return x
处理流程是:
- 用自注意力看一遍输入(Q、K、V 都是同一个 x)
- 加个“残差连接”(把原始输入加回来,防止信息丢失)
- 归一化(调整数字大小)
- 再过一遍前馈网络,重复残差和归一化
这些“残差”和“归一化”就像安全带,保证模型训练时不会“跑偏”。
第六步:打造“翻译官”——解码器层
解码器层和编码器差不多,但多了一个任务:它不仅要看自己的输入,还要“偷看”编码器的输出。这就像翻译时既要看原文,又要根据翻译目标调整。
class DecoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super(DecoderLayer, self).__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads) # 自注意力
self.cross_attn = MultiHeadAttention(d_model, num_heads) # 交叉注意力
self.feed_forward = PositionwiseFeedForward(d_model, d_ff) # 前馈网络
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
self.dropout3 = nn.Dropout(dropout)
多出来的 cross_attn
是用来关注编码器输出的。
def forward(self, x, enc_output, src_mask=None, tgt_mask=None):
# 自注意力
self_attn_output = self.self_attn(x, x, x, tgt_mask)
x = self.norm1(x + self.dropout1(self_attn_output))
# 交叉注意力(看编码器输出)
cross_attn_output = self.cross_attn(x, enc_output, enc_output, src_mask)
x = self.norm2(x + self.dropout2(cross_attn_output))
# 前馈网络
ff_output = self.feed_forward(x)
x = self.norm3(x + self.dropout3(ff_output))
return x
流程多了个“交叉注意力”:
- 自注意力(看自己的输入,用
tgt_mask
防止偷看未来) - 交叉注意力(结合编码器的输出)
- 前馈网络处理
每个步骤都有残差和归一化,确保信息流畅。
第七步:堆叠起来——完整的编码器和解码器
一个层可能不够,我们把多个编码器层和解码器层叠起来,增强模型能力。
class Encoder(nn.Module):
def __init__(self, d_model, num_heads, d_ff, num_layers, dropout=0.1):
super(Encoder, self).__init__()
self.layers = nn.ModuleList([
EncoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
def forward(self, x, mask=None):
for layer in self.layers:
x = layer(x, mask)
return x
编码器就是把多个编码器层串起来,依次处理输入。
class Decoder(nn.Module):
def __init__(self, d_model, num_heads, d_ff, num_layers, dropout=0.1):
super(Decoder, self).__init__()
self.layers = nn.ModuleList([
DecoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
def forward(self, x, enc_output, src_mask=None, tgt_mask=None):
for layer in self.layers:
x = layer(x, enc_output, src_mask, tgt_mask)
return x
解码器也是堆叠多个层,但每次都要带上编码器的输出。
第八步:组装语言模型——完整的 TransformerLM
我们这次的目标是做一个语言模型(像 GPT 那样生成文本),所以只用解码器就够了。它会接收一段文字,预测下一个词。
class TransformerLM(nn.Module):
def __init__(self, vocab_size, d_model, num_heads, d_ff, num_layers, dropout=0.1):
super(TransformerLM, self).__init__()
self.d_model = d_model
# 词嵌入和位置编码
self.embedding = nn.Embedding(vocab_size, d_model)
self.positional_encoding = PositionalEncoding(d_model)
# 解码器堆栈
self.decoder = Decoder(d_model, num_heads, d_ff, num_layers, dropout)
# 输出层
self.fc_out = nn.Linear(d_model, vocab_size)
self.dropout = nn.Dropout(dropout)
这里:
vocab_size
是词汇表大小(有多少个不同的词或字符)- 嵌入层把词变成向量,位置编码加顺序信息
- 解码器处理信息,输出层把结果变回词的概率
def forward(self, x, mask=None):
# 嵌入 + 位置编码
x = self.embedding(x) * math.sqrt(self.d_model)
x = self.positional_encoding(x)
x = self.dropout(x)
# 通过解码器(仅用解码器架构)
x = self.decoder(x, x, None, mask)
# 输出词概率
output = self.fc_out(x)
return output
流程是:
- 把词变成向量,放大后加位置信息
- 通过解码器(这里 Q 和 K 用同一个 x,因为是语言模型)
- 输出每个词的概率
放大(* math.sqrt(d_model)
)是为了让嵌入和位置编码的规模匹配。
第九步:别偷看未来——因果掩码
语言模型预测下一个词时,不能“作弊”看后面的词。我们用「因果掩码」来限制它:
def create_causal_mask(seq_len):
"""创建因果掩码,防止模型看到未来的词"""
mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1)
return mask == 0 # True 的地方是可以关注的
这会生成一个三角形掩码,确保每个位置只看前面的词和自己。
第十步:文本变数字——简单字符分词器
模型只认识数字,我们得把文本变成数字。这靠「分词器」完成,我们用一个简单的字符级分词器:
class SimpleTokenizer:
def __init__(self, texts=None):
self.char_to_idx = {}
self.idx_to_char = {}
if texts:
self.fit(texts)
def fit(self, texts):
# 收集所有独特字符
unique_chars = set()
for text in texts:
unique_chars.update(text)
# 创建字符到数字的映射
self.char_to_idx = {char: idx for idx, char in enumerate(sorted(unique_chars))}
self.idx_to_char = {idx: char for char, idx in self.char_to_idx.items()}
def encode(self, text):
return [self.char_to_idx[char] for char in text] # 文本变数字
def decode(self, indices):
return ''.join([self.idx_to_char[idx] for idx in indices]) # 数字变文本
@property
def vocab_size(self):
return len(self.char_to_idx) # 词汇表大小
它会:
- 找出文本里所有不同的字符
- 给每个字符一个编号
- 提供文本和数字的互转功能
简单,但够用(比起高级的分词器稍微慢点)。
第十一步:准备“教材”——数据集
模型要学习,得有“教材”。我们把文本切成小段,告诉模型“看这个,猜下一个”。
class TextDataset(Dataset):
def __init__(self, texts, tokenizer, seq_length):
self.tokenizer = tokenizer
self.seq_length = seq_length # 一次看多长的序列
# 把所有文本变成数字并连起来
self.data = []
for text in texts:
self.data.extend(tokenizer.encode(text))
# 算能切出多少段
self.num_sequences = max(0, len(self.data) - seq_length)
这里把文本编码后连成一个长序列。
def __len__(self):
return self.num_sequences
def __getitem__(self, idx):
# 取一段输入和对应的目标
input_seq = self.data[idx:idx + self.seq_length]
target_seq = self.data[idx + 1:idx + self.seq_length + 1]
return (
torch.tensor(input_seq, dtype=torch.long),
torch.tensor(target_seq, dtype=torch.long)
)
每次取一段(比如 20 个字符),目标是往后移一位的序列,教模型预测下一个字符。
第十二步:开始学习——训练函数
现在让模型“上学”,通过反复练习提高预测能力。
def train_transformer_lm(model, dataloader, num_epochs, learning_rate, device):
model.to(device) # 搬到 GPU 或 CPU
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) # 优化器
criterion = nn.CrossEntropyLoss() # 损失函数
for epoch in range(num_epochs): # 训练几轮
model.train()
total_loss = 0
我们准备:
- 把模型放对地方(GPU 更快)
- 用 Adam 优化器调整参数
- 用交叉熵损失衡量预测的好坏
for batch_idx, (inputs, targets) in enumerate(dataloader):
inputs, targets = inputs.to(device), targets.to(device)
# 创建因果掩码
seq_len = inputs.size(1)
causal_mask = create_causal_mask(seq_len).to(device)
# 前向传播
optimizer.zero_grad()
outputs = model(inputs, causal_mask)
每次处理一批数据:
- 搬到设备上
- 生成掩码,防止偷看
- 清零梯度,跑一遍模型
# 计算损失
loss = criterion(outputs.view(-1, outputs.size(-1)), targets.view(-1))
# 反向传播,更新参数
loss.backward()
optimizer.step()
total_loss += loss.item()
然后:
- 计算预测和目标的差距(损失)
- 根据差距调整模型
- 累加损失,跟踪进度
if (batch_idx + 1) % 10 == 0:
print(f'第 {epoch+1}/{num_epochs} 轮, 批次 {batch_idx+1}/{len(dataloader)}, '
f'损失: {total_loss / (batch_idx + 1):.4f}')
print(f'第 {epoch+1}/{num_epochs} 轮, 平均损失: {total_loss / len(dataloader):.4f}')
return model
每隔 10 个批次汇报一次,最后返回训练好的模型。
第十三步:展示才艺——生成文本
模型学好了,我们让它“写”点东西看看。
def generate_text(model, tokenizer, start_text, max_length, temperature=1.0, device='cpu'):
model.eval() # 评估模式
# 把起始文本变成数字
input_seq = tokenizer.encode(start_text)
input_tensor = torch.tensor([input_seq], dtype=torch.long).to(device)
先把开头准备好。
# 一个个生成新字符
for _ in range(max_length):
seq_len = input_tensor.size(1)
causal_mask = create_causal_mask(seq_len).to(device)
# 预测下一个
with torch.no_grad():
output = model(input_tensor, causal_mask)
next_token_logits = output[0, -1, :] / temperature
每次:
- 用掩码限制视线
- 预测下一个字符的概率(
logits
) - 用
temperature
控制随机性(越大越随机)
# 概率化并采样
probabilities = F.softmax(next_token_logits, dim=-1)
next_token = torch.multinomial(probabilities, 1).item()
# 加到序列里
input_tensor = torch.cat([
input_tensor,
torch.tensor([[next_token]], dtype=torch.long).to(device)
], dim=1)
- 把概率变成分布,随机挑一个字符
- 加到当前序列,继续预测
# 解码成文本
generated_tokens = input_tensor[0].tolist()
generated_text = tokenizer.decode(generated_tokens)
return generated_text
最后把数字变回文字,展示成果。
第十四步:大功告成——整合所有步骤
我们写个 main()
函数,把所有步骤串起来:
def main():
# 一些示例文本
texts = [
"Hello, how are you doing today?",
"Transformers are powerful neural network architectures.",
"Language models can generate coherent text.",
"PyTorch is a popular deep learning framework."
]
先准备点“教材”。
# 设置参数
seq_length = 20 # 一次看 20 个字符
batch_size = 4 # 每次训练 4 组
d_model = 64 # 词向量长度
num_heads = 4 # 4 个注意力头
d_ff = 256 # 前馈网络维度
num_layers = 2 # 2 层解码器
dropout = 0.1 # 丢弃率
num_epochs = 10 # 训练 10 轮
learning_rate = 0.001 # 学习速度
这些参数决定了模型的大小和训练方式。
# 选设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {device}")
# 初始化分词器
tokenizer = SimpleTokenizer(texts)
print(f"词汇表大小: {tokenizer.vocab_size}")
检查用 CPU 还是 GPU(有 GPU 更快),准备分词器。
# 准备数据
dataset = TextDataset(texts, tokenizer, seq_length)
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
# 创建模型
model = TransformerLM(
vocab_size=tokenizer.vocab_size,
d_model=d_model,
num_heads=num_heads,
d_ff=d_ff,
num_layers=num_layers,
dropout=dropout
)
把数据和模型准备好。
# 看看模型长啥样
print(model)
print(f"参数数量: {sum(p.numel() for p in model.parameters())}")
# 开始训练
model = train_transformer_lm(model, dataloader, num_epochs, learning_rate, device)
# 生成文本试试
start_text = "Hello"
generated_text = generate_text(model, tokenizer, start_text, max_length=50, device=device)
print(f"生成文本:\n{generated_text}")
最后:
- 打印模型结构和参数量
- 训练模型
- 用“Hello”开头,生成一段文本
它是怎么工作的?
简单说,Transformer 就像一个“预测大师”。你给它几个词,它猜下一个词。它的步骤是:
- 词变数字:把文字变成向量
- 加位置:告诉模型词的顺序
- 找重点:用注意力机制挑重要信息
- 加工信息:用前馈网络处理
- 猜词:输出每个词的概率,选一个
- 重复:不断预测下一个
训练时,我们给它看很多例子,让它学会“猜得准”。
恭喜你!一个 Transformer 语言模型诞生了
你从零开始造了个模型!虽然它比不上 GPT 那样的“大块头”,但核心原理是一样的。你现在明白了注意力机制、位置编码这些“黑科技”是怎么回事。
Transformer 是 NLP 的“万能钥匙”,能用来翻译、写文章、做摘要……学会它,你就打开了 AI 世界的大门。继续加油吧!
想看完整代码?点这里:> https://colab.research.google.com/drive/1KKQA70Pscp4UXazUJEDRzV1fMwgw6vjr?usp=sharing