2026.10.15 09:54
学习路线
rust
Python -> Python数据分析 -> LangChain -> LangGraph -> FastAPI + SQLAIchemy -> 机器学习 -> 深度学习 -> NLP -> Coze -> Dify -> 大模型应用基础 -> 大模型微调 -> 多模态 -> vibeCoding -> Hermes -> Docker
复习巩固总结
1. Seq2Seq架构 - 英译法案例
python
import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
import re # 正则表达式相关
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import torch.optim as optim
import time
import random
import matplotlib.pyplot as plt
from tqdm import tqdm # 进度条
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# todo 1.指定特殊的token
SOS_token = 0
EOS_token = 1
MAX_LENGTH = 10
data_path = os.path.join(os.path.dirname(__file__), 'data', 'eng-fra-v2.txt')
# todo 2.定义数据清洗函数 → 即: 字符串规范化处理函数, 用于: 文本预处理.
def normalizeString(s):
"""
字符串规范化函数
:param s: 需要处理的字符串
:return: 处理后的字符串
"""
s = s.lower().strip()
s = re.sub('([.!?])', r' \1', s)
s = re.sub('[^a-zA-Z.!?]+', ' ', s)
return s
# todo 3.数据预处理 → 清洗文本 和 构建文本字典.
def my_getdata():
with open(data_path, 'r', encoding='utf-8') as src_f:
lines = src_f.readlines() # 格式为: ['第1行\n', '第2行\n', ...]
my_pairs = [[normalizeString(s) for s in line.split('\t')] for line in lines]
english_word2index = {'SOS': 0, 'EOS': 1}
english_word_n = 2
french_word2index = {'SOS': 0, 'EOS': 1}
french_word_n = 2
for pair in my_pairs: # pair的数据格式: ['英语句子', '法语句子'], 例如: ['i m not doing it anymore . ', 'je ne vais plus le faire .']
for word in pair[0].split(' '):
if word not in english_word2index:
english_word2index[word] = english_word_n
english_word_n += 1
for word in pair[1].split(' '):
if word not in french_word2index:
french_word2index[word] = french_word_n
french_word_n += 1
english_index2word = {v: k for k, v in english_word2index.items()}
french_index2word = {v: k for k, v in french_word2index.items()}
print(f'英语词汇表大小: {english_word_n}') # 2803
print(f'法语词汇表大小: {french_word_n}') # 4345
return english_word2index, english_index2word, english_word_n, french_word2index, french_index2word, french_word_n, my_pairs
# todo 4. 数据预处理 → 构建数据集对象(DataSet)
# todo 4.1 调用 my_getdata()函数, 获取: 预处理好的数据结构.
english_word2index, english_index2word, english_word_n, french_word2index, french_index2word, french_word_n, my_pairs = my_getdata()
# todo 4.2 定义 MyPairsDataset类 → 表示: 数据集类.
class MyPairsDataset(Dataset):
def __init__(self, my_pairs):
self.my_pairs = my_pairs
self.sample_len = len(my_pairs)
def __len__(self):
return self.sample_len
def __getitem__(self, index):
index = min(max(index, 0), self.sample_len - 1)
x = self.my_pairs[index][0] # 'she s hot .'
y = self.my_pairs[index][1] # 'elle est chaude .'
x = [english_word2index[word] for word in x.split(' ')]
x.append(EOS_token)
tensor_x = torch.tensor(x, dtype=torch.long, device=device)
y = [french_word2index[word] for word in y.split(' ')]
y.append(EOS_token)
tensor_y = torch.tensor(y, dtype=torch.long, device=device)
return tensor_x, tensor_y
# todo 5. 数据预处理 → 定义函数, 获取DataLoader(数据加载器)对象.
def get_dataloader():
my_dataset = MyPairsDataset(my_pairs)
my_dataloader = DataLoader(my_dataset, batch_size=1, shuffle=True)
return my_dataloader
# todo 6.构建基于GRU的编码器.
"""
EncoderRNN类 实现思路分析:
1. __init__函数, 定义 2 个层.
self.embedding
self.gru
2. forward() 前向传播.
3. 初始化 隐藏层输入数据.
"""
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size):
"""
初始化属性信息的.
:param input_size: 编码器词嵌入层的输入维度, 即: 词汇表的大小(2803个英语单词)
:param hidden_size: 编码器的隐藏层维度, 即: 隐藏层单元的个数(256个隐藏单元)
"""
super().__init__()
self.input_size = input_size
self.hidden_size = hidden_size
self.embedding = nn.Embedding(input_size, hidden_size)
# 实例化GRU层.
# 参1: hidden_size: 输入的特征维度, 即: 词嵌入维度.
# 参2: hidden_size: 隐藏层的维度, 即: 256
# 参3: batch_first: 批次维数是否为第一个维度, 即(格式为): [批次大小, 句子长度, 词向量维度], 而不是默认的[句子长度, 批次大小, 词向量维度]
self.gru = nn.GRU(hidden_size, hidden_size, batch_first=True)
def forward(self, input, hidden):
"""
前向传播函数.
:param input: 输入的单词索引序列, 即: [batch_size, seq_len] → [1, 6]
:param hidden: 初始的隐藏状态, 即: [num_layer, batch_size, hidden_size] → [1, 1, 256]
:return:
"""
output = self.embedding(input)
output, hidden = self.gru(output, hidden)
return output, hidden
def init_hidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
# todo 7. 测试基于GRU的编码器.
# todo 8. 构建基于GRU的解码器 → 版本1: 无Attention(注意力机制)
# todo 9. 测试基于GRU的解码器 → 测试版本1: 无Attention(注意力机制)
# todo 10. 构建基于GRU的解码器 → 测试版本2: 带Attention(注意力机制)
class AttnDecoderRNN(nn.Module):
# todo 10.1 初始化函数.
# 参1: 目标(法语)词汇表大小, 参2: 隐藏层维度(和编码器一致), 参3: 随机失活概率, 参4: (句子)最大长度
def __init__(self, output_size, hidden_size, dropout_p=0.1, max_length=MAX_LENGTH):
super().__init__()
self.output_size = output_size
self.hidden_size = hidden_size
self.dropout_p = dropout_p
self.max_length = max_length
self.embedding = nn.Embedding(self.output_size, self.hidden_size)
self.attn = nn.Linear(self.hidden_size * 2, self.max_length)
self.attn_combine = nn.Linear(self.hidden_size * 2, self.hidden_size)
self.dropout = nn.Dropout(self.dropout_p)
self.gru = nn.GRU(self.hidden_size, self.hidden_size, batch_first=True)
self.out = nn.Linear(self.hidden_size, self.output_size)
self.softmax = nn.LogSoftmax(dim=-1)
# todo 10.2 前向传播函数.
# 参1: input 当前时间步的输入词索引 → [batch_size, 1]
# 参2: hidden 上一个时间步的隐藏状态 → [1, batch_size, hidden_size]
# 参3: encoder_outputs 编码器所有时间步的输出 → [batch_size, seq_len, hidden_size]
def forward(self, input, hidden, encoder_outputs):
embedded = self.embedding(input)
embedded = self.dropout(embedded)
attn_weights = F.softmax(self.attn(torch.cat((embedded[0], hidden[0]), 1)), dim=1)
attn_applied = torch.bmm(attn_weights.unsqueeze(0), encoder_outputs.unsqueeze(0))
output = torch.cat((embedded[0], attn_applied[0]), 1) # [1, 1, 512]
output = self.attn_combine(output).unsqueeze(0) # [1, 1, 256]
output = F.relu(output)
output, hidden = self.gru(output, hidden) # [1, 1, 256]
output = self.softmax(self.out(output[0])) # [1, 4345]
return output, hidden, attn_weights
def init_hidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
# todo 12. 构建模型内部迭代训练函数 → 即: 完成单批次的训练过程.
# todo 12.1 定义模型训练参数.
# 学习率, 训练轮数, Teacher_Forcing比例, 输出信息打印间隔(每训练1000条打印一次), 绘图间隔(每训练100条绘图一次)
my_lr, epochs, teacher_forcing_ratio, print_interval_num, plot_interval_num = 1e-4, 5, 0.5, 1000, 100
# todo 12.2 定义函数, 实现: 单批次训练, 完成1个样本的 编码 → 解码 → 反向传播 → 优化参数...
def train_iters(x, y, my_encoder_rnn, my_attn_decoder_rnn, myadam_encode, myadam_decode, my_crossentropy_loss):
"""
函数作用, 实现: 单批次训练, 完成1个样本的 编码 → 解码 → 反向传播 → 优化参数...
:param x: 输入序列, 即: 英语句子, 形状为: [batch_size = 1, seq_len]
:param y: 目标序列, 即: 法语句子, 形状为: [batch_size = 1, seq_len]
:param my_encoder_rnn: 编码器对象
:param my_attn_decoder_rnn: 解码器对象(带注意力机制)
:param myadam_encode: 编码器优化器
:param myadam_decode: 解码器优化器
:param my_crossentropy_loss: 损失函数
:return:
"""
encoder_hidden = my_encoder_rnn.init_hidden()
encoder_output, encoder_hidden = my_encoder_rnn(x, encoder_hidden)
encoder_output_c = torch.zeros(MAX_LENGTH, my_encoder_rnn.hidden_size, device=device)
for idx in range(x.shape[1]):
encoder_output_c[idx] = encoder_output[0, idx]
decoder_hidden = encoder_hidden # [1, 1, 256]
input_y = torch.tensor(data=[[SOS_token]], device=device)
my_loss = 0.0
y_len = y.shape[1] # 目标序列长度(要预测的法语句子长度), 例如: 9
use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False
if use_teacher_forcing:
for i in range(y_len):
# 输入: input_y → [1, 1], decoder_hidden → [1, 1, 256], encoder_output_c → [10, 256]
# 输出: output_y → [1, 4345], decoder_hidden → [1, 1, 256], attn_weights → [1, 10]
output_y, decoder_hidden, attn_weights = my_attn_decoder_rnn(input_y, decoder_hidden, encoder_output_c)
target_y = y[0][i].view(1)
my_loss += my_crossentropy_loss(output_y, target_y)
input_y = y[0][i].view(1, -1) # [1, 1]
else:
for i in range(y_len):
output_y, decoder_hidden, attn_weights = my_attn_decoder_rnn(input_y, decoder_hidden, encoder_output_c)
target_y = y[0][i].view(1)
my_loss += my_crossentropy_loss(output_y, target_y)
topv, topi = output_y.topk(1)
if topi.squeeze().item() == EOS_token:
break
input_y = topi.detach() # [1, 1]
myadam_encode.zero_grad() # 梯度清零, 编码器.
myadam_decode.zero_grad() # 梯度清零, 解码器.
my_loss.backward() # 反向传播.
myadam_encode.step() # 参数更新, 编码器.
myadam_decode.step() # 参数更新, 解码器.
return my_loss.item() / y_len
# todo 13. 构建模型训练函数 → 即: 完成所有批次的训练过程, 即: 多轮, 多批次训练过程
def train_seq2seq():
my_dataloader = get_dataloader()
my_encoder_rnn = EncoderRNN(english_word_n, hidden_size=256).to(device)
my_attn_decoder_rnn = AttnDecoderRNN(french_word_n, hidden_size=256, dropout_p=0.1, max_length=10).to(device)
myadam_encode = optim.Adam(my_encoder_rnn.parameters(), lr=my_lr) # 编码器优化器.
myadam_decode = optim.Adam(my_attn_decoder_rnn.parameters(), lr=my_lr) # 解码器优化器.
my_crossentropy_loss = nn.NLLLoss()
plot_loss_list = [] # 存储绘图用的损失值.
for epoch_idx in range(1, epochs + 1):
print_loss_total, plot_loss_total = 0.0, 0.0
start_time = time.time()
for item, (x, y) in enumerate(tqdm(my_dataloader), start=1):
myloss = train_iters(x, y, my_encoder_rnn, my_attn_decoder_rnn, myadam_encode, myadam_decode, my_crossentropy_loss)
print_loss_total += myloss
plot_loss_total += myloss
if item % print_interval_num == 0:
print_loss_avg = print_loss_total / print_interval_num
print_loss_total = 0.0
print(f'轮次: {epoch_idx}, 平均损失: {print_loss_avg:.4f}, 耗时: {time.time() - start_time:.4f} s(秒)!')
if item % plot_interval_num == 0:
plot_loss_avg = plot_loss_total / plot_interval_num
plot_loss_list.append(plot_loss_avg)
plot_loss_total = 0.0
torch.save(my_encoder_rnn.state_dict(), os.path.join(os.path.dirname(__file__), 'model', f'my_encoder_rnn_{epoch_idx}.pth'))
torch.save(my_attn_decoder_rnn.state_dict(), os.path.join(os.path.dirname(__file__), 'model', f'my_attn_decoder_rnn_{epoch_idx}.pth'))
plt.figure()
plt.plot(plot_loss_list)
plt.savefig(os.path.join(os.path.dirname(__file__), 'img', 'seq2seq_loss.png'))
plt.show()
return plot_loss_list # 训练损失列表(每训练100个样本的平均损失)
# todo 14. 构建模型评估 → 用训练好的seq2seq模型进行 翻译.
def evaluate_seq2seq(x, my_encoder_rnn, my_attn_decoder_rnn): # 英语句子, 编码器, 解码器(带注意力机制)
with torch.no_grad():
encode_hidden = my_encoder_rnn.init_hidden()
encode_output, encode_hidden = my_encoder_rnn(x, encode_hidden)
encode_output_c = torch.zeros(MAX_LENGTH, my_encoder_rnn.hidden_size, device=device)
for idx in range(x.shape[1]):
encode_output_c[idx] = encode_output[0, idx]
decode_hidden = encode_hidden
input_y = torch.tensor(data=[[SOS_token]], device=device)
decode_words = []
decoder_attentions = torch.zeros(MAX_LENGTH, MAX_LENGTH) # [[单词1的概率, 单词2注意力概率...], []]
for idx in range(MAX_LENGTH):
output_y, decode_hidden, attn_weights = my_attn_decoder_rnn(input_y, decode_hidden, encode_output_c)
decoder_attentions[idx] = attn_weights
topv, topi = output_y.topk(1)
if topi.squeeze().item() == EOS_token:
break
else:
decode_words.append(french_index2word[topi.squeeze().item()])
input_y = topi.detach()
return decode_words, decoder_attentions[:idx + 1]
# todo 15. 模型评估函数调用, 加载模型, 对比 自定义样本进行 翻译.
PATH1 = os.path.join(os.path.dirname(__file__), 'model', 'my_encoder_rnn_5.pth')
PATH2 = os.path.join(os.path.dirname(__file__), 'model', 'my_attn_decoder_rnn_5.pth')
def dm_test_seq2seq_evaluate():
my_dataloader = get_dataloader()
my_encoder_rnn = EncoderRNN(english_word_n, hidden_size=256).to(device)
my_encoder_rnn.load_state_dict(torch.load(PATH1, map_location=device, weights_only=True), strict=False)
my_attn_decoder_rnn = AttnDecoderRNN(french_word_n, hidden_size=256).to(device)
my_attn_decoder_rnn.load_state_dict(torch.load(PATH2, map_location=device, weights_only=True), strict=False)
my_sample_pairs = [
['i m the spokesperson for this organization .', 'je suis le porte parole de cette institution .'],
['i am thinking about buying a new parasol .', 'je songe a acheter un nouveau parasol .'],
['he is able to swim very fast .', 'il est capable de nager tres vite .']
]
for index, pair in enumerate(my_sample_pairs):
x = pair[0] # 英语句子
y = pair[1] # 法语句子
tmpx = [english_word2index[word] for word in x.split(' ')]
tmpx.append(EOS_token) # 添加结束标记.
tensor_x = torch.tensor(tmpx, dtype=torch.long, device=device).view(1, -1) # [[10, 30, 21...]]
decode_words, attentions = evaluate_seq2seq(tensor_x, my_encoder_rnn, my_attn_decoder_rnn)
output_sentence = ' '.join(decode_words)
print(f'输入(原始英文句子): {x}')
print(f'输入(原始法语句子): {y}')
print(f'输出(翻译后的法语句子): {output_sentence}')
print(' -.- ' * 10, '\n')
2. masked_fill()
python
"""
案例:
演示下 masked_fill()函数的用法 → 掩码操作.
"""
# 导包
import torch
# 1. 生成1个随机整数张量, 取值范围: [1, 10), shape: [3, 5]
input = torch.randint(1, 10, (3, 5))
print(f'input: \n{input}, shape: {input.shape}')
# 2. 定义掩码张量mask
mask = torch.tensor([
[1, 2, 3, 0, 0],
[2, 3, 4, 0, 0],
[2, 3, 4, 1, 0]
])
# 3. 处理mask, 把非0的值都改成1
mask[mask != 0] = 1
print(f'mask: \n{mask}, shape: {mask.shape}')
# 4. 用 masked_fill()函数处理input, 进行掩码操作.
# 参1: 要被处理的张量
# 参2: mask == 0: 布尔条件, 找到mask中值为0的位置.
# 参3: 把input中对应mask为0位置的元素, 替换成 -1e9(一个非常小的数, 常用于注意力机制的遮挡)
result = torch.masked_fill(input, mask == 0, value=-1e9)
# result = torch.masked_fill(input, mask == 1, value=-1e9)
# result = torch.masked_fill(input, mask == 0, value=1e-9)
# 5. 打印结果.
print(f'result: \n{result}, shape: {result.shape}')
3. Transformer
在 Transformer 之前,占市场的是 LSTM 和 GRU。Transformer 多出来两件事:
- 能用分布式 GPU 并行训练。RNN 要一个词接一个词算,Transformer 一次把一句话里的词都看完,训练效率更高。
- 句子变长以后,隔得远的词之间的语义关系抓得更好。
基于 seq2seq 架构的 Transformer 可以做 NLP 里的典型任务,机器翻译、文本生成都算。也可以先做成预训练语言模型,再迁到别的任务上。
3.1 输入
3.1.1 Input Embedding
Input Embedding,源文本嵌入层。 把输入的文字转成数值向量,一个词对应一个词向量。计算机看不懂文字,这一步把它转成数。例如「你好」变成 [0.1, -0.3, 0.21]。
3.1.2 Output Embedding
Output Embedding,目标文本嵌入层。 和源文本嵌入层同一类,把目标侧的词转成词向量,送给解码器。英译法里,就是把法语句子里的单词变成向量。送进去的序列是右移一位之后的目标序列。
3.1.3 Positional Encoding
Positional Encoding,位置编码。 给词向量加上位置信息。Transformer 本身不分词的先后,「我打你」和「你打我」用词可以一样,顺序一反,意思就反了。所以每个位置额外带一个坐标,告诉模型谁在前、谁在后。源文本加一套,目标文本也加一套,后一套是为了让模型知道输出词的顺序。
位置信息通过位置编码(Position Encoding)加进去。函数的输入是当前元素在序列里的位置,再用一组事先定好的位置参数,生成这个位置对应的向量。偶数维用正弦,奇数维用余弦:
PE(pos, 2i)=sin(100002i/dmodelpos)
PE(pos, 2i+1)=cos(100002i/dmodelpos)
| 符号 | 含义 |
|---|---|
| (PE) | 位置编码函数。它同时看单词位置,以及这一维是奇数还是偶数 |
| (pos) | 词在句子里的位置,可以从 0 数,也可以从 1 数。「我爱吃西瓜」从 0 数,我是 0,爱是 1 |
| (i) | 维度成对往后数的序号。512 维时 (i) 取 0 到 255,因为 (2i) 和 (2i+1) 要盖住 0 到 511 维 |
| (d_{model}) | 词向量的维度。实际开发里常用 512 |
| (10000^{2i/d_{model}}) | 周期缩放因子。(i) 越大,这一维的周期越长 |
每个输入元素的嵌入向量都加上自己的位置信息。训练时,模型从这些向量里学元素之间的相对位置关系。
- 记住词的顺序。周期函数给每个位置一张独特的标签,模型能区分「我爱你」和「你爱我」。
- 计算高效。新位置靠线性组合推出来,三角函数不用重复算,省算力。句子越长,省得越多。
- 适应任意长度。句子多长都能算出编码,泛化能力强。
- 模型处理语言时更聪明,也更灵活。顺序记得住,新位置推得快,长短句都能接。
3.1.4 代码实现
python
import torch
import torch.nn as nn # neural network: 神经网络
import numpy as np
import math
import matplotlib.pyplot as plt
class Embedding(nn.Module):
def __init__(self, vocab_size, d_model):
"""
该函数数目: 初始化参数用的
:param vocab_size: 词汇表大小(去重后的单词个数)
:param d_model: 词嵌入的维度.
"""
super().__init__()
self.vocab_size = vocab_size
self.d_model = d_model
self.embed = nn.Embedding(vocab_size, d_model)
def forward(self, x):
return self.embed(x) * math.sqrt(self.d_model)
def dm01_embedding():
vocab_size, d_model = 1000, 512 # 1000个单词, 每个单词的维度为512.
my_embed = Embedding(vocab_size, d_model)
x = torch.tensor([
[100, 2, 421, 300],
[500, 888, 306, 509]
])
result = my_embed(x)
print(f'result: {result}, result.shape: {result.shape}') # shape: [2, 4, 512]
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=60):
"""
该函数目的: 初始化参数用的
:param d_model: 词向量维度, 例如: 512
:param dropout: 随机失活概率
:param max_len: 最大句子长度
"""
super().__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model) # shape: [60个词, 512维]
position = torch.arange(0, max_len).unsqueeze(1) # shape: [60个词, 1维]
print(f'position:{ position}, position.shape: {position.shape}')
div_term = torch.exp(torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model)) # 形状: [1, 256]
print(f'div_term: {div_term.shape}') # [256]
position_value = position * div_term
pe[:, 0::2] = torch.sin(position_value)
pe[:, 1::2] = torch.cos(position_value)
pe = pe.unsqueeze(0) # 位置编码.
self.register_buffer('pe', pe)
def forward(self, x):
x = x + self.pe[:, :x.size(1)]
return self.dropout(x)
# todo 4. 测试PositionalEncoding(位置编码层)
def use_position():
# 1. 定义词汇表大小 和 词嵌入维度.
vocab_size, d_model = 1000, 512
# 2. 实例化Embedding层.
my_embed = Embedding(vocab_size, d_model) # [1000, 512]
# 3. 创建输入张量, 形状是: [batch_size, seq_len], 例如: [2, 4], 2个句子, 每个句子4个单词.
x = torch.tensor([
# 单词索引
[100, 2, 421, 300],
[500, 888, 306, 509]
])
# 4. 计算词向量结果.
embed_x = my_embed(x)
# print(f'embed_x: {embed_x}, embed_x.shape: {embed_x.shape}') # 词向量结果: torch.Size([2, 4, 512])
# 5. 创建位置编码层对象.
my_position = PositionalEncoding(d_model=d_model, dropout=0.1)
# 6. 计算位置编码结果.
position_x = my_position(embed_x) # [2, 4, 512]
# 7. 返回结果.
return position_x
# todo n. 测试代码
if __name__ == '__main__':
# 1. 测试词嵌入
# dm01_embedding()
# 2. 测试位置编码
result = use_position()
print(f'result: {result}, result.shape: {result.shape}') # torch.Size([2, 4, 512]) 结合了词向量和位置编码信息的最终结果.
3.2 编码器
3.2.1 Multi-Head Attention
Multi-Head Attention,多头注意力。 让模型同时从多个角度看句子里的词。以「猫追老鼠」为例,既要看「猫」和「追」的关系,也要看「猫」和「老鼠」的关系,还要比这两对关联。
3.2.1.1 注意力机制
编码器使用自注意力。自注意力里,查询、键、值都取自同一段输入,所以 (Q = K = V)。
Attention(Q,K,V)=Softmax(dk Q⋅KT)⋅V
(Q) 和 (K) 的转置相乘,得到每个词和其他词的相关分数。再除以 (\sqrt{d_k}),把分数的尺度压下来,避免点积太大,后面的 Softmax 挤成接近 0 和 1。Softmax 把分数变成权重,权重和是 1。用这组权重去乘 (V),就是按相关程度把各个词的信息加在一起。
python
# todo 4. 定义函数, 进行注意力的计算.
def attention(query, key, value, mask=None, dropout=None):
"""
函数功能: 自定义代码, 模拟: 注意力计算.
:param query: 查询张量, 形状通常是: [batch_size, seq_len, d_model]
:param key: 键张量, 形状通常是: [batch_size, seq_len, d_model]
:param value: 值张量, 形状通常是: [batch_size, seq_len, d_model]
:param mask: 掩码张量, 形状一般和 scores 匹配.
:param dropout: 随机失活, 防止过拟合.
:return:
"""
# 1. 定义查询张量Q的特征维度.
# 例如: query的形状是[2, 4, 512] 每批2句话, 每句话有4个单词, 每个单词的维度是512. d_k = 512
d_k = query.size(-1)
# 2. 计算原始注意力分数, 即: Q * K^t / √d_k
# key.transpose(-2, -1): 从原来的维度[batch_size, seq_len, d_model] → [batch_size, d_model, seq_len]
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
# 3. 掩码处理(可选)
if mask is not None:
# 将scores中, 遮挡的部分, 设置为负无穷(极小值), 这样softmax后这些位置的权重会接近0(对应: Decoder中'不能看未来位置'的需求)
scores = scores.masked_fill(mask == 0, -1e9)
# 4. 计算注意力权重.
# dim=-1, 在最后1个维度上进行归一化, 让每个query位置的权重总和为 1
p_attn = F.softmax(scores, dim=-1)
# 5. 随机失活(可选)
if dropout is not None:
p_attn = dropout(p_attn)
# 6. 计算最终的注意力输出: 权重加权求和.
# 返回两个结果: 注意力输出(融合信息) 和 注意力权重(用于可视化, 调试)
return torch.matmul(p_attn, value), p_attn
# todo 5. 测试注意力机制.
def use_attention():
# 1. 获取位置编码处理后的结果(词嵌入层结果 + 位置编码结果)
position_x = use_position()
# 2. 因为是自注意力机制, Q,K,V都用同一个张量.
query = key = value = position_x
# 3. 没有掩码, 调用 attention()
result1, p_attn = attention(query, key, value)
print(f'result1: {result1.shape}, {result1}') # 注意力输出张量的形状: [batch_size, seq_len, d_model] → [2, 4, 512]
print(f'p_attn: {p_attn.shape}, {p_attn}') # 注意力权重张量的形状: [batch_size, seq_len, seq_len] → [2, 4, 4], 表示每个词对其他词的权重.
print(' -.- ' * 10)
# 4. 构造1个全0的掩码张量(简单玩儿玩儿, 看看效果即可)
mask = torch.zeros(2, 4, 4)
# 带掩码, 调用 attention()
result2, p_attn = attention(query, key, value, mask)
print(f'result2: {result2.shape}, {result2}')
print(f'p_attn: {p_attn.shape}, {p_attn}')
# todo n. 测试代码
if __name__ == '__main__':
# 1. 测试 上三角矩阵.
# dm01_test_triu()
# 2. 测试 下三角矩阵.
# print(dm02_test_triu(size=5))
# print(dm02_test_triu(size=5)[0])
# 3. 测试: 掩码张量的可视化.
# dm03_test_mask()
# 4. 测试: 注意力机制.
use_attention()
3.2.1.2 多头注意力机制
- 线性变换。 Q、K、V 各自过一个线性层,相当于切丝、切片、切段。形状还是
[2, 4, 512],这一步是在准备分头。 - view 切分。 512 维切成 8 个头,每头 64 维,形状变成
[2, 4, 8, 64]。再把「头」这一维换到前面,得到[2, 8, 4, 64]。h就是头数。每个头单独做后面的注意力。 - attention。 每个头调用前面的缩放点积注意力,提取这一头的特征。8 个锅分开煮,番茄、麻辣、清汤各看各的。
- Concat。 把头拼回去,煮好后装盘,形状回到
[2, 4, 512]。 - 再过一个线性层。 相当于淋汁,把拼好的结果映射成最终要用的形状,这里仍是
[2, 4, 512]。
大白话就是:拆分,多视角计算,再合并。单头注意力是 1 只眼睛,只能注意到一种细节。多头注意力是 8 只眼睛,每只眼睛看不同的细节,最后汇总成更全面的理解。
python
# todo 6. 定义1个克隆函数 → 实现克隆指定的模块N次, 返回N个相同模块的 列表.
def clones(module, N):
"""
函数功能: 创建N个相同的模块, 返回一个列表. 深拷贝, 每个模块拥有独立的参数.
:param module: 要被克隆的模块.
:param N: 克隆的次数.
:return: 具有N个相同模块的列表
"""
# return nn.ModuleList([具体的深拷贝动作 for _ in range(N)])
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
# todo 7. 定义函数, 实现多头注意力机制 → 核心: 把词向量维度分成N个头, 并行计算'小维度'的注意力, 增强模型表达能力.
class MultiHeadAttention(nn.Module):
# 1. 参数初始化.
# 参1: 词向量维度(例如: 512), 参2: 多头数量(例如: 8), 参3: 随机失活概率(例如: 0.1)
def __init__(self, embed_dim, head, dropout_p=0.1):
# 1. 初始化父类成员.
super().__init__()
# 2. 分头动作, 确保能整除.
assert embed_dim % head == 0 # 只有 embed_dim % head == 0 条件成立, 代码才会继续向下执行.
# 3. 计算每个头的词嵌入维度.
self.d_k = embed_dim // head # 512 // 8 = 64
self.head = head # 8
# 4. 定义4个线性层, 前3个负责: Q,K,V的投影(映射), 最后1个负责: 用于输出(多头注意力机制)的结果.
self.linears = clones(nn.Linear(embed_dim, embed_dim), N=4)
# 5. 定义随机失活层.
self.dropout = nn.Dropout(p=dropout_p)
# 6. 保存注意力权重, 用于可视化 或者 分析.
self.attn = None
# 2. 前向传播 → 实现多头注意力计算流程.
def forward(self, query, key, value, mask=None):
# 1. 判断是否要掩码.
# Q, K, V的形状: [batch_size, seq_len, d_model], 例如: [2, 4, 512]
# mask的形状: [batch_size, seq_len, seq_len], 例如: [1, 4, 4]
if mask is not None:
mask = mask.unsqueeze(0)
# 2. 获取batch_size的批量大小,
self.batch = query.size(0)
# 3. 线性变换: Q, K, V → 多头注意力计算.
# mode(x): 通过线性层 将输入映射到 embed_dim的维度, 即: 512
# view(...): 把输入张量转成 [batch_size, seq_len, head, d_k] → [2, 4, 8, 64]
# transpose(1, 2): 把 [batch_size, seq_len, head, d_k] → [batch_size, head, seq_len, d_k] → [2, 8, 4, 64]
query, key, value = [
model(x).view(self.batch, -1, self.head, self.d_k).transpose(1, 2)
for model, x in zip(self.linears, (query, key, value))
]
# 4. 多头注意力计算
# x的形状(注意力输出): [batch_size, head, seq_len, d_k] → [2, 8, 4, 64]
# self.attn(注意力权重): [batch_size, head, seq_len, seq_len] → [2, 8, 4, 4]
x, self.attn = attention(query, key, value, mask, self.dropout)
# 5. 将多头注意力结果 → 合并.
# 例如: [2, 8, 4, 64] → [2, 4, 8, 64] → [2, 4, 512]
attn_x = x.transpose(dim0=1, dim1=2).contiguous().view(self.batch, -1, self.head * self.d_k)
# 6. 通过最后1个线性层处理, 返回结果.
return self.linears[-1](attn_x)
# todo 8. 测试多头注意力机制.
def use_multihead():
# 1. 创建多头注意力层 对象.
my_attention = MultiHeadAttention(512, 8)
# 2. 获取位置编码处理后的结果(词嵌入层结果 + 位置编码结果)
position_x = use_position()
# 3. 定义遍历, 表示Q, K, V, 因为是自注意力机制, Q,K,V都用同一个张量.
query = key = value = position_x
# 4. 创建掩码张量, 形状是: [batch_size, seq_len, seq_len]
mask = torch.zeros(8, 4, 4)
# 5. 将数据送给模型 → 获取多头注意力结果.
result = my_attention(query, key, value, mask)
# 6. 打印输出形状, 要与输入的形状保持一致, 即: [2, 4, 512]
print(f'(多头注意力层)result: {result.shape}')
# 7. 返回多头注意力结果, 后续可以直接用.
return result
# todo n. 测试代码
if __name__ == '__main__':
# 1. 测试 上三角矩阵.
# dm01_test_triu()
# 2. 测试 下三角矩阵.
# print(dm02_test_triu(size=5))
# print(dm02_test_triu(size=5)[0])
# 3. 测试: 掩码张量的可视化.
# dm03_test_mask()
# 4. 测试: 注意力机制.
# use_attention()
# 5. 测试: 多头注意力机制.
use_multihead()
3.2.2 Feed Forward
Feed Forward,前馈全连接层。 对每个位置的词向量单独强化特征。注意力看完词和词的关系之后,再用全连接网络加工每个词,补上更细的属性。例如强化「猫」的动物属性、「追」的动作属性。
3.2.3 Add & Norm
Add & Norm,残差连接加规范化层。 让训练更稳,也更高效。Add 把这一子层的输入和输出加在一起,避免加工之后把原来的信息丢太多。Norm 把数值收到一个范围内,训练时不容易跑偏,梯度消失和梯度爆炸都会轻一些。多头注意力外面有一套,前馈网络外面也有一套。
3.2.4 Nx
Nx,重复堆叠。 把「多头注意力 + Add & Norm + 前馈网络」重复 N 次。论文里 N 是 6。一层不够深,多堆几层,模型一层比一层往下理解,从表面语义走到深层逻辑。
3.3 解码器
解码器里的 Add & Norm、前馈网络、Nx 和编码器是同一套逻辑:残差和归一化稳住训练,前馈网络强化每个词的特征,Nx 把这一层重复堆深。多出来的是两块注意力,分工不同。
Masked Multi-Head Attention,掩码多头注意力。 防止偷看还没生成的词。解码器一个词一个词往外写,当前位置只能用已经生成的词,不能用它右边还没出现的词。
Multi-Head Attention,多头注意力。 这一块让解码器去看编码器的输出。英译法里,生成某个法语词时,盯着已经处理过的英语词义,找出该和哪个英语词对应。
3.4 输出
Linear,线性层。 把解码器输出的向量改成词表大小 vocab_size 那么多维。词表里有 1 万个词,向量就变成 1 万维,每一维是一个词的分数。
Softmax,激活层。 把线性层给出的分数变成概率,全部词的概率加起来是 1。模型按这个概率决定下一个输出哪个词。
3.5 自己画图理顺思路
测试里的形状:2 句话,每句 4 个词,词向量 512 维,8 个头,每头 64 维。自注意力里 Q、K、V 是同一份位置编码结果。
| 节点 | 形状 | 解释 |
|---|---|---|
| INPUT | [2, 4] |
词的编号。2 句话,每句 4 个词。例如 [[100, 2, 421, 300], [500, 888, 306, 509]] |
向量化 nn.Embedding |
[2, 4] → [2, 4, 512] |
按编号把每个词查成 512 维。代码里还会乘 (\sqrt{d_{model}}),也就是 (\sqrt{512}),形状不变 |
| EMBEDDED | [2, 4, 512] |
词向量。这一步还没有位置,模型分不清「我爱你」和「你爱我」 |
| 可 dropout(EMBEDDED → POSITION) | [2, 4, 512] |
图上这个箭头旁标了可 dropout。代码里这一步是相加:词向量加上位置编码,dropout 在加完之后 |
| sin / cos | 加到 [2, 4, 512] 上 |
位置编码。偶数维填 (\sin(pos / 10000^{2i/d_{model}})),奇数维填 (\cos)。max_len=60 的表事先算好,这里每句 4 个词,只取前 4 行 |
| POSITION | [2, 4, 512] |
词向量和位置编码相加之后的结果。批次那一维从 1 广播到 2,两句话共用同一张位置表 |
| 可 dropout / 输入 | [2, 4, 512] |
PositionalEncoding 返回 self.dropout(x)。随机丢掉一些值,形状不变。这份张量送进多头注意力,同时当作 Q、K、V |
| Q、K、V | 各 [2, 4, 512] |
自注意力,三者是同一份张量。三个线性层参数各算各的:Q 查什么,K 拿什么被查,V 匹配上之后交出什么 |
nn.Linear + 转为多头 |
[2, 4, 512] → [2, 4, 8, 64] → [2, 8, 4, 64] |
前 3 个线性层先投影,维度仍是 512。view 切成 8 个头、每头 64 维,transpose(1, 2) 把头换到词的前面 |
可 MASK masked_fill |
作用在 [2, 8, 4, 4] 上 |
掩码可选。有 mask 时,mask == 0 的位置填成 -1e9,后面 Softmax 这些位置的权重接近 0。测试里 mask 是 torch.zeros(8, 4, 4),进 forward 还会 unsqueeze(0) |
| (Q \cdot K^{T} / \sqrt{d_k}) | [2, 8, 4, 64] → [2, 8, 4, 4] |
每个头里,查询的 64 维和键的 64 维做点积,这一维消掉。剩下两个 4 都是词的个数:每一行是一个词,看向句中 4 个词的分数。此时 (d_k = 64),除以 (\sqrt{64}) 把分数尺度压下来 |
TEMP / F.softmax |
[2, 8, 4, 4] |
在最后一维上归一化,每个词看向 4 个词的权重加起来是 1。这份表存进 self.attn |
| 可 dropout(TEMP 之后) | [2, 8, 4, 4] |
对注意力权重做 dropout,形状不变。attention(..., dropout=self.dropout) 里,有 dropout 才走这一步 |
| RES(TEMP @ V) | [2, 8, 4, 4] × [2, 8, 4, 64] → [2, 8, 4, 64] |
用权重去乘 V,64 维特征回来。8 个头各自按相关程度把词的信息加在一起 |
| MERGED | [2, 8, 4, 64] → [2, 4, 8, 64] → [2, 4, 512] |
transpose(1, 2) 把头换回词后面,contiguous().view 把 8 × 64 拼成 512。transpose 只改步长,view 之前要先 contiguous() |
RES(最后的 nn.Linear) |
[2, 4, 512] |
第 4 个线性层。8 个头拼好的 512 维再混合一次,形状和送进来时一样,可以直接交给后面的 Add & Norm |
NLP 学习
121. 前馈全连接层_代码实现及测试(掌握)
python
# todo 9. 初始化前馈全连接层(它包含2个全连接层) → 对注意力层的结果进一步加工(强化特征)
class FeedForward(nn.Module):
# 1. 初始化函数
# 参1: 输入数据的词向量维度(例如: 512), 参2: 前馈全连接层维度(例如: 2048), 参3: 随机失活概率(例如: 0.1)
def __init__(self, d_model, d_ff, dropout_p=0.1):
# 1. 初始化父类成员.
super().__init__()
# 2. 定义第1个全连接层, 输入维度: d_model, 输出维度: d_ff
self.linear1 = nn.Linear(d_model, d_ff) # 512维 → 2048维
# 3. 定义第2个全连接层, 输入维度: d_ff, 输出维度: d_model
self.linear2 = nn.Linear(d_ff, d_model) # 2048维 → 512维
# 4. 创建随机失活层.
self.dropout = nn.Dropout(p=dropout_p)
# 2. 前向传播 → 实现前馈全连接层的计算流程.
def forward(self, x):
# 1. 通过第1个全连接层, 映射到 d_ff的维度.
x = self.linear1(x)
# 2. 通过激活函数处理(ReLu会把负值变为0, 保留正数, 增加了非线性功能) → 通过随机失活处理.
x = self.dropout(F.relu(x))
# 3. 通过第2个全连接层, 映射到 d_model的维度.
x = self.linear2(x)
# 4. 返回结果.
return x
# todo 10. 测试前馈全连接层 → 先拿到多头注意力层结果, 把结果送给前馈全连接层处理.
def use_ff():
# 1. 获取多头注意力层结果.
attn_x = use_multihead() # 形状: [2, 4, 512]
# 2. 创建前馈全连接层对象.
my_ff = FeedForward(512, 2048)
# 3. 将多头注意力层结果, 送给前馈全连接层处理.
result = my_ff(attn_x)
# 4. 输出结果.
print(f'(前馈全连接层)result: {result.shape}') # torch.Size([2, 4, 512])
# 5. 返回结果.
return result
# todo n. 测试代码
if __name__ == '__main__':
# 1. 测试 上三角矩阵.
# dm01_test_triu()
# 2. 测试 下三角矩阵.
# print(dm02_test_triu(size=5))
# print(dm02_test_triu(size=5)[0])
# 3. 测试: 掩码张量的可视化.
# dm03_test_mask()
# 4. 测试: 注意力机制.
# use_attention()
# 5. 测试: 多头注意力机制.
# use_multihead()
# 6. 测试: 前馈全连接层.
use_ff()
122. 规范化层_代码实现(掌握)
规范化层,Normalization Layer。 紧跟在每个子层的输出后面,把这一步算出来的数收成稳定的分布。多头注意力外面有一套,前馈网络外面也有一套。编码器、解码器各自堆 N 层,每一层里的子层都带着它。
它做的是归一化。先让每个位置上的特征均值接近 0,标准差接近 1。网络往下传的时候,每一层拿到的数还在差不多的范围内,不会一层比一层散开。
122.1 为什么需要它
- 稳住梯度。 层数一多,梯度容易变得极小,也容易变得极大。前者是梯度消失,后者是梯度爆炸。先把输入收到均值 0、标准差 1 附近,梯度往回传时幅度更稳。
- 加快收敛。 每层的数值尺度接近,参数更新更顺,训练更快走到能用的结果。
122.2 计算方式
按位置算,每个词自己的全部特征维一起算。测试里一个词是 512 维,所以均值和方差都在这 512 维上求。两句话、每句 4 个词,就是 8 个位置各算各的。
- 求出这个位置 512 维的均值 (\mu) 和方差 (\sigma^2)。
- 做线性变换,把这 512 维收成均值 0、方差 1。标准差就是 (\sqrt{\sigma^2}),方差为 1 时标准差也是 1。
- 再缩放、再平移。缩放因子和平移因子是学出来的参数,每个特征维各有一个。模型可以自己决定归一化之后还要不要把某些维再拉开。
\\text{LayerNorm}(x) = \\gamma \\odot \\frac{x - \\mu}{\\sqrt{\\sigma\^2 + \\epsilon}} + \\beta
| 符号 | 形状 | 含义 |
|---|---|---|
| (x) | [2, 4, 512] |
子层输出。2 句话,每句 4 个词,每个词 512 维 |
| (\mu)、(\sigma^2) | [2, 4, 1] |
每个词在自己的 512 维上算出的均值和方差 |
| (\epsilon) | 标量 | 极小的正数,加在根号里,避免方差为 0 时除以 0 |
| (\gamma)、(\beta) | [512] |
缩放因子和平移因子,训练时会更新。乘、加时广播到每个词 |
(\gamma) 一开始是全 1,(\beta) 一开始是全 0。这一步刚开始时,输出就是标准化之后的数。训练往后走,这两个参数被改掉,模型可以让分布离开「均值 0、方差 1」。
122.3 复习 - 批量归一化
批量归一化,Batch Normalization。 计算机视觉里用得比较多。先把数据标准化,再重构一次:缩放,然后平移。
一批数据先过一层线性变换,得到 (s_1)。再按列减均值、除标准差,得到 (s_2)。最后乘缩放、加平移,得到 (s_3)。
写成一个式子:
f(x) = \\lambda \\cdot \\frac{x - E(x)}{\\sqrt{\\mathrm{Var}(x) + \\epsilon}} + \\beta
图上的 (\gamma) 和公式里的 (\lambda) 都是缩放系数。(\beta) 是平移系数。红字是同一件事:缩放乘上「当前值减该列均值」,再除以「该列的标准差加一个小常数」,最后加平移。
| 符号 | 含义 |
|---|---|
| (E(x))、(\mu) | 该列的均值。同一列的样本放在一起求,这一列共用一个均值 |
| (\mathrm{Var}(x))、(\sigma^2) | 该列的方差。标准差是方差的平方根 |
| (\epsilon) | 小常数,批量归一化里通常取 (10^{-5}),避免分母变成 0 |
| (\lambda)、(\gamma) | 可学习的缩放系数。标准化之后,用它把数值再拉开 |
| (\beta) | 可学习的平移系数。标准化之后,用它把数值再挪一挪 |
122.4 扩展_BN和LN的区别(理解)
下面是 3 个样本、4 个特征。每一行是一个样本,每一列是一个特征。Batch Norm 把同一列的三个样本放在一起算,Layer Norm 把同一行的四个特征放在一起算。
| 特征1 | 特征2 | 特征3 | 特征4 | |
|---|---|---|---|---|
| 样本1 | 1 | 2 | 3 | 4 |
| 样本2 | 3 | 4 | 5 | 6 |
| 样本3 | 4 | 5 | 6 | 7 |
Batch Norm,批量归一化。 竖着算,同一列放在一起。特征 1 用的是 1、3、4,这一列共用一个均值、一个标准差。特征 2、特征 3、特征 4 各自再算一列。它针对的是同一个特征上的不同样本,图片里用得比较多。
Layer Norm,层规范化。 横着算,同一行放在一起。样本 1 用的是 1、2、3、4,这一行共用一个均值、一个标准差。样本 2、样本 3 各自再算一行。它针对的是一个样本上的不同特征,文本里用的是这一种。LayerNorm 里的 x.mean(-1) 就是在最后一维上求均值,一个词自己的 512 个特征算一份。
| 和谁一起算 | 拿表里的哪一组 | 这组数的均值 | 常用在 | |
|---|---|---|---|---|
| Batch Norm | 同一个特征上的不同样本 | 特征 1 这一列:1、3、4 | ((1+3+4)/3 = 8/3) | 图片 |
| Layer Norm | 一个样本上的不同特征 | 样本 1 这一行:1、2、3、4 | ((1+2+3+4)/4 = 2.5) | 文本 |
两边都是先标准化,再乘缩放、加平移。参与均值和方差的那一组数不同:Batch Norm 按列,Layer Norm 按行。
122.5 代码实现
python
# todo 11. 定义1个规范化层, 初始化规范化层(Layer Normalization), 用于对张量进行标准化处理, 让模型训练更稳定.
# 大白话: 车开久了要保养, 装修房子的时候有管家(监工), 规范化层 = 数值监工.
class LayerNorm(nn.Module):
# 1. 初始化函数.
def __init__(self, features, eps=1e-6):
"""
函数作用: 初始化参数, 搭建网络环境的.
:param features: 词嵌入维度(词向量维度: 512)
:param eps: 小常数, 防止分母变为0
"""
# 1. 初始化父类成员.
super().__init__()
# 回顾线性公式: y = kx + b → ax + b
# 2. 定义可学习的缩放系数a, 初始化为1, 形状为: [features]
# a的作用: 对标准化后的数据进行缩放.
self.a = nn.Parameter(torch.ones(features))
# 3. 定义可学习的偏置系数b(平移系数), 初始化为0, 形状为: [features]
# b的作用: 对标准化后的数据进行平移.
self.b = nn.Parameter(torch.zeros(features))
# 4. 定义eps: 小常数, 防止分母变为0.
self.eps = eps
# 2. 前向传播 → 实现对张量的标准化处理.
def forward(self, x):
# x的形状: [batch_size, seq_len, d_model] → [2, 4, 512]
# 1. 计算最后1维(词向量维度)的均值.
# 参1: -1表示最后1维, keepdim=True 表示保持维度.
x_mean = x.mean(-1, keepdim=True) # 形状: [2, 4, 512] → [2, 4, 1]
# 2. 计算最后1维的标准差
x_std = x.std(-1, keepdim=True) # 形状: [2, 4, 512] → [2, 4, 1]
# 3. 标准化处理 → 回顾线性公式: y = kx + b → ax + b
return self.a * (x - x_mean) / (x_std + self.eps) + self.b
# todo 12. 测试规范化层.
122.6 拓展QA
之前学过 nn.Parameter 吗?
笔记里没有单独讲过这个类。相关的已经用过两处。DAY4 里,张量要 requires_grad=True 才会算梯度。DAY5 里,model.named_parameters() 和 optim.SGD(model.parameters(), lr=0.01) 遍历的是要训练的参数。那时候参数是 nn.Linear 自己建好的 weight 和 bias,没有手写 nn.Parameter。
它是干什么的?
nn.Parameter 把一个张量登记成模块里要训练的参数。LayerNorm 里这两行就是在登记缩放和平移:
python
self.a = nn.Parameter(torch.ones(features)) # 形状 [512],初始全 1
self.b = nn.Parameter(torch.zeros(features)) # 形状 [512],初始全 0
挂到 self 上之后会发生两件事。requires_grad 变成 True,反向传播会给 a、b 算梯度。model.parameters() 也能遍历到它们,Adam、SGD 会更新这两个张量。
self.eps = eps 是普通小数,默认 1e-6。它不进 parameters(),训练过程中保持不动,只用来避免分母变成 0。
nn.Linear 的 weight、bias 同样是 Parameter,写在层里面。这里的公式是自己写的 a * (x - mean) / (std + eps) + b,没有现成的层包住 a 和 b,所以要自己用 nn.Parameter 交给优化器。torch.ones(features) 直接赋给 self.a 时,requires_grad 仍是 False,parameters() 里也没有它,训练不会改这个缩放系数。
123. 规范化层_代码测试(理解)
python
# todo 12. 测试规范化层, 思路: 拿到多头注意力机制结果 → 把该结果传入前馈全连接进行处理 → 把处理结果传入规范化层进行处理 → 返回结果.
def use_layer_norm():
# 1. 获取 前馈全连接层 结果.
ff_x = use_ff()
# 2. 创建规范化层对象.
my_layer_norm = LayerNorm(512)
# 3. 将前馈全连接层结果, 传给 规范化层.
result = my_layer_norm(ff_x)
# 4. 输出结果.
print(f'(规范化层)result: {result.shape}') # [2, 4, 512]
# 5. 返回结果.
return result
# todo n. 测试代码
if __name__ == '__main__':
# 1. 测试 上三角矩阵.
# dm01_test_triu()
# 2. 测试 下三角矩阵.
# print(dm02_test_triu(size=5))
# print(dm02_test_triu(size=5)[0])
# 3. 测试: 掩码张量的可视化.
# dm03_test_mask()
# 4. 测试: 注意力机制.
# use_attention()
# 5. 测试: 多头注意力机制.
# use_multihead()
# 6. 测试: 前馈全连接层.
# use_ff()
# 7. 测试: 规范化层.
use_layer_norm()
use_layer_norm() 先拿到前馈层的 [2, 4, 512],再交给 LayerNorm(512)。出来仍是 [2, 4, 512]。
123.1 概念、作用、计算方式
概念。 规范化就是把输入收到一个稳定的分布里。它紧跟在子层后面。多头注意力后面有一套,前馈网络后面也有一套,网络往下传时每一层拿到的数还在差不多的范围内。
作用。 防止梯度爆炸,训练也更快收敛。
计算方式。 按位置算。每个词在自己的 512 维上求均值和方差,先把这 512 维收成均值 0、方差 1,再做缩放和平移。缩放因子和平移因子是学出来的参数,代码里就是 a 和 b。
y = a \\odot \\frac{x - \\mu}{\\sigma + \\epsilon} + b
123.2 为什么要缩放因子和平移因子
标准化会把每个位置收成均值 0、方差 1,这一批原来的尺度被抹平。a 和 b 是所有批次共用的参数,反向传播按各批的损失去改它们。模型是在「已经标准化」的数上,重新学一个适合这些数据的缩放和平移。换一批数时,同一套 a、b 还能用,泛化更好。
举个例子。每个批次有 8 个数,测试里就是 2 句话 × 4 个词。第 1 批有自己的均值和方差,第 2 批也有,第 n 批也有。只做标准化,每一批出来都是均值 0、方差 1。a、b 跟着所有批次一起更新,学的是这些数据共同需要的缩放和平移。
a 初始全 1,b 初始全 0,这一步刚开始时输出就是标准化之后的数。它们是 nn.Parameter,训练才会改。eps 不参与学习。
123.3 拓展QA
到这里是不是少讲了一个 Add?Add 是干什么的?
组件一共四块:多头注意力、前馈全连接、Add、Norm。到第 123 节,前三块里的代码写了多头注意力、前馈和 Norm。Add 还停在前面那句「把这一子层的输入和输出加在一起」。
Add 是残差连接,就是把子层加工前的向量和加工后的向量按元素相加。多头注意力是一个子层,前馈网络是另一个子层,每个子层外面都有一套 Add,然后再进 Norm。
\\text{输出} = \\mathrm{Norm}(x + \\mathrm{Sublayer}(x))
(x) 是这个子层的输入,(\mathrm{Sublayer}(x)) 是注意力或多头之后的前馈结果。两边都是 [2, 4, 512],相加还是 [2, 4, 512],再交给已经写好的 LayerNorm。
加工会改写每个词的向量。加上原来的 (x),这一层新算出来的内容还在,进来时的信息也还在。反向传播时,梯度可以沿着这个加号直接走回 (x),堆到 N 层(论文里是 6)时,梯度不容易一层层衰减光。
编码器一层里有两次 Add:注意力外面一次,前馈外面一次。解码器一层里有三次,三块注意力和前馈各跟一套。把 x + Sublayer(x) 和 LayerNorm 套在一起,就是子层连接结构。
124. 子层结构搭建_代码实现(掌握)
子层连接结构。 把三件事套在一起:子层、残差相加、规范化。子层是多头注意力,或者前馈全连接。外面这一套是同一份,换的只是里面那个子层。
功能上就是 (x + x_2)。(x) 是进这个子层之前的向量,(x_2) 是子层算完的向量。两者按元素相加,再做 Norm。形状都是 [2, 4, 512],加完、规范化之后还是 [2, 4, 512]。
\\text{输出} = \\mathrm{Norm}(x + x_2), \\quad x_2 = \\mathrm{Sublayer}(x)
编码器一层里这个结构出现两次:下面一次包住多头注意力,上面一次包住前馈。解码器一层里出现三次,掩码多头注意力、多头注意力、前馈各包一次。旁路都是把进子层前的 (x) 加到子层输出上。
设计上,子层函数的入口当作参数传进来,效果像回调。连接结构自己只负责相加和规范化,具体用注意力还是前馈,调用时再决定。
python
def forward(self, x, sublayer):
# sublayer 是传进来的函数,多头注意力或者前馈
x2 = sublayer(x)
return self.norm(x + x2)
同一套 forward,传入多头注意力就是注意力外面的 Add & Norm,传入前馈就是前馈外面的 Add & Norm。
python
"""
案例:
演示Transformer框架中的 编码器 两个子层的代码实现.
回顾: 编码器(层)是由 两个子层组成的:
子层1:
多头注意力子层(Multi-Head Attention) + 残差连接(Add) + 层规范化(Norm)
子层2:
前馈全连接子层(Feed Forward) + 残差连接(Add) + 层规范化(Norm)
"""
# 导包
from dm02_encoder_element import *
from dm01_input import *
# todo 1. 初始化子层连接结构, 核心是: 残差连接 + 规范化层, 让模型训练更稳定, 避免梯度消失或者梯度爆炸.
class SublayerConnection(nn.Module):
# 1. 初始化函数.
def __init__(self, d_model, dropout=0.1):
"""
函数功能: 初始化子层连接结构(参数的)
:param d_model: 输入的维度, 即: 词向量维度.
:param dropout: 随机失活的概率
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 定义规范化层(LayerNorm)
self.norm = LayerNorm(d_model)
# 3. 定义随机失活层(Dropout)
self.dropout = nn.Dropout(dropout)
# 2. 前向传播函数.
def forward(self, x, sublayer):
"""
函数功能: 完整具体的 子层的 计算动作.
:param x: 输入张量, 形状一般是: [batch_size, seq_len, d_model] → [2, 4, 512]
:param sublayer: 输入的子层对象, 例如: 多头注意力层对象(Multi-Head Attention), 前馈全连接层对象(Feed Forward)
:return:
"""
# 子层的核心逻辑: 有两种常见的实现方式.
# 方式1(大多数场景都用这个): 先子层处理, 再残差连接, 层规范化
# 随机失活 层规范化 子层处理 残差连接
my_result = self.dropout(self.norm(sublayer(x))) + x
# 方式2: 先规范化层, 再子层处理, 残差连接
# 感兴趣, 代码自己写, 代码一样, 就是顺序不同.
# 返回结果.
return my_result
# todo 2. 测试子层连接结构.
拓展QA:
1154 的注释和 1156 的代码,哪个为准?
以代码为准。这一行实际跑的是:
python
my_result = self.dropout(self.norm(sublayer(x))) + x
括号从里往外算,顺序是:
sublayer(x):子层处理,多头注意力或前馈。self.norm(...):层规范化。self.dropout(...):随机失活。+ x:残差连接,加上进子层之前的输入。
\\text{输出} = \\mathrm{Dropout}(\\mathrm{Norm}(\\mathrm{Sublayer}(x))) + x
注释「先子层处理,再残差连接,层规范化」是另一套顺序。按那句话写,残差在规范化前面:
python
my_result = self.norm(self.dropout(sublayer(x)) + x)
\\text{输出} = \\mathrm{Norm}(\\mathrm{Dropout}(\\mathrm{Sublayer}(x)) + x)
上一行「随机失活、层规范化、子层处理、残差连接」是照着源码从左往右标的。嵌套调用从最里面的 sublayer(x) 开始,所以那行标注也不是执行顺序。
方式 2 的注释是再换一种顺序:先规范化,再子层,再残差,写成 x + dropout(sublayer(norm(x)))。当前函数没有走方式 2,返回的是方式 1 这行代码的结果。
125. 子层结构搭建_代码测试(理解)
python
# todo 2. 测试子层连接结构.
def use_sublayer():
"""
测试子层连接结构的完整流程
1. 准备输入数据(词嵌入 + 位置编码)
2. 实例化子层连接结构.
3. 定义子层函数 (这里用 多头注意力 作为示例)
4. 通过子层连接处理输入, 验证残差连接和规范化的效果
:return:
"""
# 1. 准备输入数据(词嵌入 + 位置编码)
x = use_position()
# 2. 实例化子层连接结构.
sublayer_conn = SublayerConnection(d_model=512)
# 3. 定义子层函数 (这里用 多头注意力 作为示例)
# 定义子层对象 → 充当子层处理, 可以是: 多头注意力层对象(Multi-Head Attention), 前馈全连接层对象(Feed Forward)
# 思路1: 采用函数嵌套(闭包写法)实现, 定义子层函数 → 一个可调用的对象, 接受x, 返回处理结果.
# def sublayer(x):
# # 3.1 创建 多头注意力层对象(Multi-Head Attention)
# # multi_attn = MultiHeadAttention(embed_dim=512, head=8)
# # 3.2 计算 注意力, 并返回, 因为是自注意力, 所以: Q=K=V=x
# # return multi_attn(x, x, x)
#
# # 合并版写法
# return MultiHeadAttention(embed_dim=512, head=8)(x, x, x)
# 4. 通过子层连接处理输入, 验证残差连接和规范化的效果
# result = sublayer_conn(x, sublayer)
# 思路2: 采用匿名函数(Lambda表达式实现)
# 多头注意力子层
# result = sublayer_conn(x, lambda x: MultiHeadAttention(512, 8)(x, x, x))
# 前馈全连接子层
result = sublayer_conn(x, lambda x: FeedForward(512, 2048)(x))
# 5. 打印子层处理结果.
print(f'子层处理结果: {result.shape}') # 子层处理结果: torch.Size([2, 4, 512])
# todo 3. 测试代码.
if __name__ == '__main__':
use_sublayer()
126. 编码器层_代码实现(掌握)
编码器层。 编码器的组成单元。整段编码器把这样的一层叠 N 次,论文里 N 是 6。过完一层,就是对输入特征做了一次提取。
一层里有两个子层连接,从下往上走。先是多头注意力外面套 Add & Norm,再是前馈外面套 Add & Norm。每套都是已经写过的 SublayerConnection:子层算完,规范化,失活,再加上原来的输入。
进来是 [2, 4, 512],两个子层连接各走一遍,出去还是 [2, 4, 512]。词的个数和向量维度不变,变的是向量里的数:注意力看过词和词的关系,前馈再把每个词的特征加工一次。
整段编码器就是把这一层重复 N 次,后一层的输入是前一层的输出。解码器的一层多一块掩码多头注意力,所以是三个子层连接,再同样叠 N 次。
python
"""
案例:
自定义代码 演示: 编码器层.
流程:
词向量 + 位置编码 → 多头注意力 → 残差连接 + 规范化层 → 前馈全连接层 → 残差连接 + 规范化层 → ...
"""
# 导包
from dm01_input import * # 输入部分
from dm02_encoder_element import * # 组件
from dm03_encoder_sublayer import * # 子层
# todo 1. 初始化编码器层 → 所谓的编码器层, 就是把刚才的两个子层合二为一串起来.
class EncoderLayer(nn.Module):
# 1. 初始化函数.
def __init__(self, d_model, self_attn, feed_forward, dropout=0.1):
"""
函数功能: 初始化参数.
:param d_model: 词嵌入维度, 例如: 512
:param self_attn: 多头注意力(对象)
:param feed_forward: 前馈全连接层(对象)
:param dropout: 随机失活概率, 例如: 0.1
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 保存子层实例.
self.d_model = d_model
self.self_attn = self_attn
self.feed_forward = feed_forward
# 3. 克隆 两个子层连接结构.
self.sublayer = clones(SublayerConnection(d_model, dropout), N=2) # [子层对象1, 子层对象2]
# 2. 前向传播函数.
def forward(self, x, mask):
# 1. 第1层 子层连接: 自注意力层.
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
# 2. 第2层 子层连接: 前馈全连接层.
x = self.sublayer[1](x, lambda x: self.feed_forward(x))
# 3. 返回结果
return x
127. 编码器层_代码测试(理解)
python
# todo 2. 测试 编码器层的 流程.
def use_encoder_layer():
# 1. 准备数据 → 位置编码层处理后的数据.
x = use_position() # 形状: [batch_size, seq_len, d_model] → [2, 4, 512]
# 2. 实例化子层对象.
# 2.1 多头注意力对象.
multi_head = MultiHeadAttention(512, 8)
# 2.2 前馈全连接对象.
feed_forward = FeedForward(512, 2048)
# 3. 创建编码器层对象.
encoder_layer = EncoderLayer(512, multi_head, feed_forward)
# 4. 构建掩码张量, 形状: [batch_size, seq_len, seq_len] → [2, 4, 4]
mask = torch.zeros(8, 4, 4)
# 5. 执行编码器层 → 前向传播.
output = encoder_layer(x, mask)
# 6. 打印结果
print(f'编码器层输出形状: {output.shape}') # [batch_size, seq_len, d_model] → [2, 4, 512]
print(f'编码器层的输出内容: {output}')
print(f'编码器层的输出内容: {output[:1, :2, :5]}') # 第1个句子, 前2个单词, 每个单词的前5个特征
print(' -.- ' * 10)
print(f'编码器层对象: \n{encoder_layer}')
# todo 3. 测试代码
if __name__ == '__main__':
use_encoder_layer()
print(encoder_layer) 打出来的是这一层里登记过的子模块。一层编码器从下往上是:多头注意力外面一套 Add & Norm,前馈外面再一套 Add & Norm。
ini
EncoderLayer(
(self_attn): MultiHeadAttention(
(linears): ModuleList(
(0-3): 4 x Linear(in_features=512, out_features=512, bias=True)
)
(dropout): Dropout(p=0.1, inplace=False)
)
(feed_forward): FeedForward(
(linear1): Linear(in_features=512, out_features=2048, bias=True)
(linear2): Linear(in_features=2048, out_features=512, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(sublayer): ModuleList(
(0-1): 2 x SublayerConnection(
(norm): LayerNorm()
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
| 打印出来的名字 | 里面有什么 | 对应这一层的哪一块 |
|---|---|---|
self_attn |
ModuleList 里 4 个 Linear(512 → 512),再加一个 Dropout(0.1) |
多头注意力。4 个线性层是 Q、K、V 各一个,输出再混合一次 |
feed_forward |
linear1 是 512 → 2048,linear2 是 2048 → 512,再加一个 Dropout(0.1) |
前馈全连接。先升维,再压回 512 |
sublayer |
ModuleList 里 2 个 SublayerConnection,每个里面是 LayerNorm 和 Dropout(0.1) |
两套 Add & Norm。sublayer[0] 包住多头注意力,sublayer[1] 包住前馈 |
多头注意力和前馈是单独存的。forward 里才用 lambda 把它们送进对应的 SublayerConnection。所以 sublayer 下面只看得到规范化和失活,看不到注意力和前馈的线性层。打印里的 LayerNorm() 也没有列出 a、b,这两个是参数,不是子模块。
128. 编码器_代码实现及测试(掌握)
编码器。 由 N 个编码器层堆叠而成。论文里 N 是 6。它接在词嵌入和位置编码后面,对输入做一整段特征提取,这一段就叫编码。
一层编码器只提取一次:下面是多头注意力加 Add & Norm,上面是前馈加 Add & Norm。编码器把这一层重复 N 次,后一层的输入是前一层的输出。
每一层进来和出去都是 [2, 4, 512]。叠完 N 层,句子长度和词向量维度还是这两个数,变的是向量里积累下来的特征。编码器的输出交给解码器,生成某个词时用来对照源句。
写出来就是按层往下传:
python
for layer in self.layers: # N 个 EncoderLayer
x = layer(x, mask)
mask 每一层都带着。自注意力里要挡掉的位置,从第 1 层到第 N 层是同一张掩码。
python
"""
案例:
代码演示 编码器的代码实现.
总结:
1. 编码器 默认有6个 编码器层.
2. 每个 编码器层 默认有2个子层.
多头注意力子层
前向传播子层
3. 还有 残差连接 + 规范化层
"""
# 导包
from dm04_encoder_layer import *
# todo 1. 定义编码器类.
class Encoder(nn.Module):
# 1. 初始化函数.
def __init__(self, layer, N):
"""
参数初始化函数
:param layer: 单个编码器层
:param N: 编码器层数量
"""
# 1. 初始化父类成员
super().__init__()
# 2. 克隆N个 编码器层对象.
self.layers = clones(layer, N) # [第1个编码器层, 第2个编码器层, ..., 第N个编码器层]
# 3. 定义最终的规范化层.
self.norm = LayerNorm(layer.d_model) # 512维
# 2. 定义前向传播函数
def forward(self, x, mask):
"""
前向传播函数
:param x: 输入数据, 维度: [batch_size, seq_len, d_model] → [2, 4, 512]
:param mask: 掩码矩阵, 维度: [batch_size, seq_len, seq_len] → [8, 4, 4]
:return:
"""
# 1. x(待处理的数据) 依次经过 N个 编码器层的处理.
for layer in self.layers:
# x通过每一个 编码器层的处理.
x = layer(x, mask)
# 2. 最终规范化. 提升模型的稳定性.
return self.norm(x)
# todo 2. 测试编码器对象.
def use_encoder():
# 1. 获取数据 → 词向量 + 位置编码.
x = use_position()
# 2. 构建单个编码器层(作为基础单元)
# 2.1 创建多头注意力层的对象.
multi_head = MultiHeadAttention(512, 8)
# 2.2 创建前向传播层(前馈全连接层)的对象.
feed_forward = FeedForward(512, 2048)
# 2.3 把 多头注意力层 和 前馈全连接层 组合起来 → 编码器层对象.
encoder_layer = EncoderLayer(512, multi_head, feed_forward)
# 3. 实例化编码器(堆叠 3个 编码器层), 论文默认是: 6个.
encoder = Encoder(encoder_layer, 3)
# 4. 构建掩码张量.
mask = torch.zeros(8, 4, 4)
# 5. 执行编码过程.
encoder_output = encoder(x, mask)
# 6. 打印结果.
print(f'编码器的输出形状: {encoder_output.shape}') # [2, 4, 512]
# 7. 可选, 观察输入 和 输出结果.
print(f'输入示例: \n {x[0, 0, :5]}') # 第1个句子的第1个单词的 前5个词向量.
print(f'输出示例: \n {encoder_output[0, 0, :5]}') # 第1个句子的第1个单词的 前5个词向量.
# 8. 返回编码器处理后的结果, 作为: 解码器的输入, 继续往后执行.
return encoder_output
# todo 3. 测试代码.
if __name__ == '__main__':
use_encoder()
子层连接结构的概念和思想?
概念:子层加上规范化层,再加上残差连接。子层是多头注意力,或者前馈全连接。三块套在一起,就是一个子层连接结构。
思想:数据 (x) 先过子层,再过 Norm,得到 (x_2),然后做 (x + x_2)。代码里是 dropout(norm(sublayer(x))) + x,失活加在 Norm 和相加之间。调用时把子层函数的入口当参数传进来,同一套连接可以接注意力,也可以接前馈。
编码器层的概念和作用?
概念:编码器的组成单元。一层里有两个子层连接,下面是多头注意力,上面是前馈。
作用:完成一次特征抽取。进来和出去都是 [2, 4, 512]。
编码器的概念和作用?
概念:由 N 个编码器层堆叠而成。论文里 N 是 6,测试里叠了 3 层。后一层的输入是前一层的输出。
作用:按目标把输入的特征抽出来,这一整段叫编码。输出形状仍是 [2, 4, 512],交给解码器继续往后走。
129. 解码器层_代码实现(掌握)
解码器层。 解码器的组成单元。整段解码器把这样的一层叠 N 次,论文里 N 是 6。过完一层,就是根据已经有的输入,朝目标句子的方向做了一次特征提取,这一次就叫解码。
一层里有三个子层连接,从下往上走。每一块外面都是 Add & Norm,和编码器层用的是同一套残差加规范化。
- 掩码多头注意力。 看解码器自己已经有的词。当前位置只能用左边已经出现的词,掩码挡住右边还没生成的词。Q、K、V 都来自解码器这一路。
- 多头注意力。 Q 来自上一块的输出,K、V 来自编码器的输出。生成目标词时,对照源句里已经提取好的特征。
- 前馈全连接。 每个位置再单独加工一次,和编码器层最上面那块相同。
进来是 [2, 4, 512],三块各走一遍,出去还是 [2, 4, 512]。
python
"""
案例:
演示解码器层的代码实现.
解码器层由3个子层组成:
子层1: Masked Multi-Head Attention, 掩码多头注意力机制层 + 残差连接 + 规范化层
子层2: Multi-Head Attention, 多头注意力机制层 + 残差连接 + 规范化层
子层3: Feed Forward, 前向传播层(前馈全连接层) + 残差连接 + 规范化层
"""
# 导包
from dm05_encoder import *
# todo 1. 定义解码器层 → 由3个子层组成.
class DecoderLayer(nn.Module):
# 1. 初始化函数.
def __init__(self, d_model, self_attn, src_attn, feed_forward, dropout=0.1):
"""
初始化函数, 给变量赋值的.
:param d_model: 词向量维度.
:param self_attn: 注意力机制, 处理(解码器输入序列内部关系), 即: 解码器的输入.
:param src_attn:
:param feed_forward:
:param dropout:
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 定义属性.
self.d_model = d_model
self.self_attn = self_attn # 解码器层的 自注意力机制层(掩码多头注意力机制层)
self.src_attn = src_attn # 解码器层的 源序列(编码器-解码器) 注意力机制层
self.feed_forward = feed_forward
# 3. 定义3个子层连接结构.
self.layers = clones(SublayerConnection(d_model, dropout), 3) # [第1个子层对象, 第2个子层对象, 第3个子层对象]
# 2. 解码器层的 前向传播.
def forward(self, x, encoder_output, source_mask, target_mask):
"""
解码器层的 前向传播动作.
:param x: 解码器的输入序列(即: 词嵌入 + 位置编码)
:param encoder_output: 编码器的输出序列(词嵌入 + 源序列位置编码)
:param source_mask: 源序列的填充掩码, 用于 编码器-解码器 注意力.
:param target_mask: 目标序列的填充掩码, 用于: 自注意力.
:return:
"""
# 1. 经过第1个子层 → 多头自注意力机制层(掩码多头注意力机制)
x = self.layers[0](x, lambda x: self.self_attn(x, x, x, target_mask))
# 2. 经过第2个子层 → 多头注意力机制层(编码器-解码器)
x = self.layers[1](x, lambda x: self.src_attn(x, encoder_output, encoder_output, source_mask))
# 3. 经过第3个子层 → 前向传播层(前馈全连接层)
x = self.layers[2](x, self.feed_forward)
# 4. 返回结果
return x
130. 解码器层_代码测试(理解)
python
# todo 2. 测试解码器层.
def use_decoder_layer():
# 1. 定义变量, 记录: 解码器的输入 → Q
y = torch.LongTensor([
[1, 2, 3, 4],
[5, 6, 7, 8]
]) # 2个句子, 每个句子4个词.
# 2. 将值传入到Embedding层(词嵌入层)
# 2.1 创建词嵌入层对象.
my_embed = Embedding(1000, 512) # 词表大小: 1000, 词嵌入维度: 512
# 2.2 把上述的y(输入) → 词向量形式.
embed_y = my_embed(y)
print(f'词嵌入层: {embed_y.shape}') # [2, 4, 512]
# 3. 将 embed_y(词嵌入处理结果) → 位置编码.
my_position = PositionalEncoding(512, 0.1)
position_y = my_position(embed_y)
print(f'位置编码层: {position_y}') # [2, 4, 512]
# 4.创建多头注意力层
multi_attn = MultiHeadAttention(512, 8)
self_attn = copy.deepcopy(multi_attn) # 深拷贝 → 确保参数不会共享.
src_attn = copy.deepcopy(multi_attn)
# 5. 创建前向传播层(前馈全连接层)
ff = FeedForward(512, 2048)
# 6. 获取编码器的输出结果.
encoder_output = use_encoder()
# 7. 定义mask(掩码矩阵): source_mask 和 target_mask真实作用不同(要根据你真正的业务来判断), 这里我就随便举例了.
source_mask = torch.zeros(8, 4, 4)
target_mask = torch.zeros(8, 4, 4)
# 8. 实例化 解码器层对象
my_decoder_layer = DecoderLayer(512, self_attn, src_attn, ff)
# 9. 将数据传给解码器层, 获取其输出结果.
result = my_decoder_layer(position_y, encoder_output, source_mask, target_mask)
# 10. 打印结果.
print(f'解码器层的形状: {result.shape}') # [2, 4, 512]
print(f'解码器层对象: \n{my_decoder_layer}')
# todo 3. 测试代码.
if __name__ == '__main__':
use_decoder_layer()
print(my_decoder_layer) 打出来的是这一层里登记过的子模块。输出形状是 [2, 4, 512]。一层解码器从下往上是:掩码多头注意力外面一套 Add & Norm,编码器-解码器注意力外面一套 Add & Norm,前馈外面再一套 Add & Norm。
ini
DecoderLayer(
(self_attn): MultiHeadAttention(
(linears): ModuleList(
(0-3): 4 x Linear(in_features=512, out_features=512, bias=True)
)
(dropout): Dropout(p=0.1, inplace=False)
)
(src_attn): MultiHeadAttention(
(linears): ModuleList(
(0-3): 4 x Linear(in_features=512, out_features=512, bias=True)
)
(dropout): Dropout(p=0.1, inplace=False)
)
(feed_forward): FeedForward(
(linear1): Linear(in_features=512, out_features=2048, bias=True)
(linear2): Linear(in_features=2048, out_features=512, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(layers): ModuleList(
(0-2): 3 x SublayerConnection(
(norm): LayerNorm()
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
| 打印出来的名字 | 里面有什么 | 对应这一层的哪一块 |
|---|---|---|
self_attn |
ModuleList 里 4 个 Linear(512 → 512),再加一个 Dropout(0.1) |
最下面的掩码多头注意力。4 个线性层是 Q、K、V 各一个,输出再混合一次。Q、K、V 都来自解码器输入,掩码用 target_mask |
src_attn |
同样是 4 个 Linear(512 → 512),再加一个 Dropout(0.1) |
中间的多头注意力。Q 来自解码器这一路,K、V 来自编码器输出,掩码用 source_mask |
feed_forward |
linear1 是 512 → 2048,linear2 是 2048 → 512,再加一个 Dropout(0.1) |
最上面的前馈全连接。先升维,再压回 512 |
layers |
ModuleList 里 3 个 SublayerConnection,每个里面是 LayerNorm 和 Dropout(0.1) |
三套 Add & Norm。layers[0] 包住掩码多头注意力,layers[1] 包住编码器-解码器注意力,layers[2] 包住前馈 |
self_attn 和 src_attn 是从同一个 MultiHeadAttention(512, 8) 深拷贝出来的,结构一样,参数不共享。
注意力和前馈是单独存的。forward 里 self_attn、src_attn 用 lambda 送进对应的 SublayerConnection,feed_forward 只接收一个 x,所以直接传进去。layers 下面只看得到规范化和失活,看不到注意力和前馈的线性层。打印里的 LayerNorm() 也没有列出 a、b,这两个是参数,不是子模块。
拓展QA:
测试里的 source_mask 和 target_mask 各挡什么?
注意力里 mask == 0 的位置,分数被填成 -1e9,Softmax 之后权重接近 0。1 表示这个位置可以看。
测试两张都是 torch.zeros(8, 4, 4)。全是 0,等于每个位置都被挡住。注释里写了,这只是随便举例,让前向能跑通。形状也沿用前面编码器测试的 [8, 4, 4]。业务里按 batch 和句子长度来做。
source_mask交给src_attn。Q 是解码器当前词,K、V 是编码器输出。它挡的是源句里补齐用的 PAD。生成目标词时,不去看这些空位。target_mask交给self_attn。Q、K、V 都是解码器自己的序列。一张矩阵里放两件事:右边还没生成的词,以及目标句里的 PAD。
项目里怎么做。 以中译英为例。一个 batch 里句子长短不一样,DataLoader 补到同样长度,PAD 的编号和真词分开。掩码用 token != pad_id 做出来。
源句补齐到 4。句 1 是「我 爱 武汉 PAD」,能看的一行是 [1, 1, 1, 0]。解码器生成任何一个英文词,看编码器输出时都用这一行,第 4 列是 0,注意力不会分给 PAD。句 2 若四个都是真词,这一行就是 [1, 1, 1, 1]。常见形状是 [batch, 1, src_len],广播到每个解码位置。
目标句是训练时已经右移过的句子,例如 <sos> I love wuhan。行是当前要写的位置,列是它能回看的词:
<sos> |
I | love | wuhan | |
|---|---|---|---|---|
<sos> |
1 | 0 | 0 | 0 |
| I | 1 | 1 | 0 | 0 |
| love | 1 | 1 | 1 | 0 |
| wuhan | 1 | 1 | 1 | 1 |
写到 love 这一行时,只能看见 <sos> 和 I。love、wuhan 还是后面的答案,上三角这些 0 就是不让偷看。句子更短时,例如 <sos> Hi PAD PAD,PAD 那两列再整列写成 0。常见形状是 [batch, tgt_len, tgt_len]。
推理时目标是一个词一个词长出来的,上三角遮挡和「还没写出来」是同一件事。PAD 主要出现在训练组 batch 的时候。只有解码器的生成模型,用的就是这张 target_mask 里的上三角;带编码器的翻译模型,两张都要。
131. 解码器_代码实现及测试(掌握)
解码器。 由 N 个解码器层堆叠而成,是整段解码器部分。论文里 N 是 6。它接在目标侧的词嵌入和位置编码后面。输入是上一次已经有的预测,训练时就是右移过的目标句,同时还看着编码器的输出。
一层解码器从下往上三块:掩码多头注意力、编码器-解码器注意力、前馈,每块外面一套 Add & Norm。解码器把这一层重复 N 次,后一层的输入是前一层的输出。编码器的输出每层都带进去,作为中间那块注意力的 K、V。
作用有两步。先根据编码器的结果,再加上已经预测出来的词,对下一个可能出现的值做特征表示。叠完 N 层,交出去的就是这句的最终特征。句子长度和词向量维度还是 [2, 4, 512],变的是向量里积累下来的特征。这组特征再往后才接 Linear 和 Softmax,变成下一个词的概率。
两张掩码每一层都带着。target_mask 挡解码器自己序列里不该看的位置,source_mask 挡源句里的 PAD。
python
for layer in self.layers: # N 个 DecoderLayer
x = layer(x, encoder_output, source_mask, target_mask)
python
"""
案例:
代码演示 解码器.
解码器介绍:
由 解码器层组成, 论文中默认是: 6个.
"""
# 导包
from dm06_decoder_layer import *
# todo 1.定义解码器 → 把多个解码器层(默认: 6个)进行堆叠, 最后加一层规范化层, 让输出更稳定.
class Decoder(nn.Module):
# 1. 初始化函数.
def __init__(self, layer, N):
"""
初始化参数的
:param layer: 单个解码器层(DecoderLayer)对象, 要被复制N次
:param N: 解码器层的堆叠数量
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 克隆N个解码器层.
self.layers = clones(layer, N)
# 3. 定义最终的规范化层.
self.norm = LayerNorm(layer.d_model)
# 2. 前向传播函数.
def forward(self, x, encoder_output, source_mask, target_mask):
"""
解码器的前向传播动作.
:param x: 解码器的输入序列(即: 词向量 + 位置编码)
:param encoder_output: 编码器的输出序列(词嵌入 + 源序列位置编码)
:param source_mask: 源序列的填充掩码, 用于: 编码器-解码器 注意力
:param target_mask: 目标序列的填充掩码, 用于 自注意力.
:return:
"""
# 1. 数据x经过 多个 解码器层的处理即可.
for layer in self.layers:
# layer变量: 表示某一个具体的 解码器层.
x = layer(x, encoder_output, source_mask, target_mask)
# 2. 全局规范化, 把所有层的输出再统一标准化, 然后返回.
return self.norm(x)
# todo 2. 测试解码器, 从输入到输出走一遍流程, 验证每层的维度是否正常.
def use_decoder():
# 1. 定义变量, 记录: 解码器的输入 → Q
y = torch.LongTensor([
[1, 2, 3, 4],
[5, 6, 7, 8]
]) # 2个句子, 每个句子4个词.
# 2. 将值传入到Embedding层(词嵌入层)
# 2.1 创建词嵌入层对象.
my_embed = Embedding(1000, 512) # 词表大小: 1000, 词嵌入维度: 512
# 2.2 把上述的y(输入) → 词向量形式.
embed_y = my_embed(y)
print(f'词嵌入层: {embed_y.shape}') # [2, 4, 512]
# 3. 将 embed_y(词嵌入处理结果) → 位置编码.
my_position = PositionalEncoding(512, 0.1)
position_y = my_position(embed_y)
print(f'位置编码层: {position_y}') # [2, 4, 512]
# 4.创建多头注意力层
multi_attn = MultiHeadAttention(512, 8)
self_attn = copy.deepcopy(multi_attn) # 深拷贝 → 确保参数不会共享.
src_attn = copy.deepcopy(multi_attn)
# 5. 创建前向传播层(前馈全连接层)
ff = FeedForward(512, 2048)
# 6. 获取编码器的输出结果.
encoder_output = use_encoder()
# 7. 定义mask(掩码矩阵): source_mask 和 target_mask真实作用不同(要根据你真正的业务来判断), 这里我就随便举例了.
source_mask = torch.zeros(8, 4, 4)
target_mask = torch.zeros(8, 4, 4)
# 8. 实例化 解码器层对象
my_decoder_layer = DecoderLayer(512, self_attn, src_attn, ff)
# 9. 堆叠6层解码器层 → 组成解码器.
my_decoder = Decoder(my_decoder_layer, 6)
# 10. 跑一遍解码流程: 输入 → 6个解码器层 → 输出
result = my_decoder(position_y, encoder_output, source_mask, target_mask)
print(f'解码器的形状: {result.shape}') # [2, 4, 512]
# print(f'解码器对象: \n{my_decoder}')
# 11. 返回解码器处理后的结果.
return result
# todo 3. 测试代码.
if __name__ == '__main__':
use_decoder()
memory 在解码器层之间怎么用?
练习里的 memory 就是这里的 encoder_output:编码器已经算完的结果,也叫中间语义张量 C。形状是 [2, 4, 512]。练习代码里规范化写成 LayerNorm(layer.size),咱们文件里是 layer.d_model,都是 512。
循环里每一层都收到同一份,循环本身不改写它:
python
for layer in self.layers:
x = layer(x, encoder_output, source_mask, target_mask)
x 每过一层就换成这一层的输出。后一层看到的是前一层加工过的解码器特征,这条是解码器自己从下往上走的残差通路:掩码自注意力、交叉注意力、前馈,每块外面加一次 Add & Norm,这一层的输出再作为下一层的输入。
encoder_output 是横着分出去的。第 1 层到第 N 层用的是同一份,不是一层用完再传给下一层。进到每一层里面,它只进中间那块编码器-解码器注意力,当作 K 和 V。Q 仍是解码器这一路当时的 x。最下面的掩码自注意力和最上面的前馈都不拿它。
python
# 本层里,encoder_output 只出现在这一行
x = self.src_attn(x, encoder_output, encoder_output, source_mask)
所以 N 个解码器层公用这一份编码器结果。每一层都拿当前的解码状态,去对照同一份源句特征。
132. 输出部分_代码实现(掌握)
输出部分。 接在解码器后面,由线性层和 Softmax 两层组成。解码器交出来的是特征,这两层把它变成下一个词的概率。
线性层做指定维度的变换。上一步每个位置是 512 维,线性层把它映射到词表那么多维。测试里词表是 1000,形状就从 [2, 4, 512] 变成 [2, 4, 1000]。每一维是一个词的分数,还不是概率。
Softmax 加在最后一维上,把这 1000 个分数收成概率分布,同一个位置上全部词的概率加起来是 1。4 个词各有一份分布。模型按这个概率决定该位置输出哪个词。
python
"""
案例:
演示 Transformer的输出部分.
"""
# 导包
from dm07_decoder import *
# todo 1. 定义输出部分, 把解码器的特征 转成 最终预测结果(即: 词汇表中每个词的概率)
class Generator(nn.Module):
# 1. 初始化函数.
def __init__(self, d_model, vocab_size):
"""
输出部分的 初始化函数
:param d_model: 词向量维度, 例如: 512
:param vocab_size: 词汇表大小, 例如: 1000
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 定义全连接层, 封装输入维度(词向量512) → 输出维度(1000)
self.linear = nn.Linear(d_model, vocab_size)
# 2. 前向传播函数.
def forward(self, x):
# 1. 线性层处理: [2, 4, 512] → [2, 4, 1000]
x = self.linear(x)
# 2. log_softmax()函数: 将分数 转成 对数概率分布.
# dim=-1 表示最最后一维(词汇表维度)进行计算, 确保概率和为: 1
return F.log_softmax(x, dim=-1)
133. 输出部分_代码测试(理解)
python
"""
案例:
演示 Transformer的输出部分.
"""
# 导包
from dm07_decoder import *
# todo 1. 定义输出部分, 把解码器的特征 转成 最终预测结果(即: 词汇表中每个词的概率)
class Generator(nn.Module):
# 1. 初始化函数.
def __init__(self, d_model, vocab_size):
"""
输出部分的 初始化函数
:param d_model: 词向量维度, 例如: 512
:param vocab_size: 词汇表大小, 例如: 1000
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 定义全连接层, 封装输入维度(词向量512) → 输出维度(1000)
self.linear = nn.Linear(d_model, vocab_size)
# 2. 前向传播函数.
def forward(self, x):
# 1. 线性层处理: [2, 4, 512] → [2, 4, 1000]
x = self.linear(x)
# 2. log_softmax()函数: 将分数 转成 对数概率分布.
# dim=-1 表示最最后一维(词汇表维度)进行计算, 确保概率和为: 1
return F.log_softmax(x, dim=-1)
# todo 2. 测试输出部分.
def use_generator():
"""
获取解码器的特征 → 通过生成器转成目标词汇的概率分布 → 验证输出维度
:return:
"""
# 1. 获取解码器的输出结果.
result = use_decoder()
# 2. 初始化 输出生成器, 将512维的解码器输出, 转成1000维的概率分布.
generator = Generator(512, 1000)
# 3. 通过生成器, 获取概率分布.
output = generator(result)
# 4. 验证输出维度.
print(f'output 模型最终输出结果: {output.shape}, {output}') # [2, 4, 1000]
# 5. 验证概率和为1.
# 5.1 提取第1个样本, 第1个词的 对数概率
log_probs = output[0, 0] # 形状: torch.Size([1000]), 第1个样本, 第1个词的概率分布
# log_probs = output[1, 3] # 形状: torch.Size([1000]), 第2个样本, 第4个词的概率分布
# print(f'log_probs: {log_probs.shape}') # torch.Size([1000])
# 5.2 把对数概率 → 普通概率
probs = torch.exp(log_probs)
# print(f'probs: {probs}') # 具体的1000个词的概率分布
# 5.3 验证概率和为1
print(f'第1个词的概率和为: {torch.sum(probs)}') # 1.0
print(f'第1个词的概率和为: {probs.sum()}') # 1.0
# todo 3. 测试代码
if __name__ == '__main__':
use_generator()
134. 完整的Transformer架构搭建(掌握)
完整模型。 词嵌入、位置编码、编码器、解码器、输出部分都已经单独跑通。这一步把它们接成一条编码器-解码器。论文里编码器、解码器两侧的 N 都是 6。
左边是源句。词嵌入加上位置编码之后,叠 N 个编码器层。每一层从下往上是多头注意力和前馈,外面各一套 Add & Norm。出来的是源句特征,形状仍是 [2, 4, 512]。
右边是目标句,输入是右移过的输出。同样先做词嵌入和位置编码,再叠 N 个解码器层。每一层从下往上是掩码多头注意力、编码器-解码器注意力、前馈。中间那块的 K、V 来自左边编码器的输出,每一层公用同一份。再往上是线性层和 Softmax,变成词表上的概率。
源侧和目标侧各有一套词嵌入、位置编码。测试里词表都是 1000,维度都是 512。编码器自己的自注意力用 source_mask 挡源句里的 PAD。这份掩码还要交给解码器的交叉注意力。解码器最下面的掩码自注意力用 target_mask,挡住还没生成的词和目标句里的 PAD。
图上最上面写的是 Softmax。代码里的 Generator 用的是 log_softmax,交出来的是对数概率,形状 [2, 4, 1000]。exp 之后才是普通概率,同一个位置上 1000 个词的概率和为 1。
python
"""
案例:
演示Transformer整体模型架构.
"""
# 导包
from dm08_output import *
# todo 1. 定义整体的Transformer模型架构, 初始化: Transformer模型的主要组件.
class EncoderDecoder(nn.Module):
# 1. 初始化函数.
def __init__(self, source_embed, encoder, target_embed, decoder, generator):
"""
初始化Transformer框架的基本参数
:param source_embed: 编码器的输入嵌入层, 包含: 词向量 和 位置编码
:param encoder: 编码器模块
:param target_embed: 解码器的输入嵌入层, 包含: 词向量 和 位置编码
:param decoder: 解码器模块
:param generator: 输出层, 将解码器的输出 转换为 词汇表的概率分布
"""
# 1. 初始化父类成员.
super().__init__()
# 2. 定义参数, 记录上述的变量.
self.source_embed = source_embed
self.encoder = encoder
self.target_embed = target_embed
self.decoder = decoder
self.generator = generator
# 2. 前向传播
def forward(self, source_x, target_y, source_mask, target_mask):
"""
(编码解码器)前向传播函数, Transformer模型的前向传播过程, 即: 先编码, 后解码.
:param source_x: 编码器的输入, 例如: [batch_size, seq_len] → [2, 4]
:param target_y: 解码器的输入, 例如: [batch_size, seq_len] → [2, 4]
:param source_mask: padding mask机制, 防止填充的pad值影响注意力计算, 例如: [8, 4, 4]
:param target_mask: sentence mask机制, 防止未来的信息被提前利用.
:return: 模型预测的词汇表的概率分布, 即: [2, 4, 1000]
"""
# 1. 先得到编码器的输出结果.
encoder_result = self.encode(source_x, source_mask)
# 2. 再得到解码器的输出结果.
decoder_result = self.decode(target_y, encoder_result, source_mask, target_mask)
# 3. 将解码器的结果 进行输出层处理, 并输出.
output = self.generator(decoder_result)
# 4. 返回结果.
return output
# 3. 得到编码器的输出结果, 即:编码器的前向传播过程.
def encode(self, source_x, source_mask):
"""
得到编码器的输出结果, 即: 编码器前向传播过程.
:param source_x: 编码器的输入, [batch_size, seq_len] → [2, 4]
:param source_mask: 输入序列的掩码, [n_head, seq_len, seq_len] → [2, 4, 4], padding-mask: 防止填充的pad值影响注意力计算结果.
:return: encoder_output 编码器的输出 → [batch_size, seq_len, d_model] → [2, 4, 512]
"""
# 1. 对source_x进行词嵌入处理, 转成向量.
embed_x = self.source_embed(source_x)
# 2. 通过编码器处理嵌入后的数据.
return self.encoder(embed_x, source_mask)
# 4. 获取解码器的输出结果, 即:解码器的前向传播过程.
def decode(self, target_y, encoder_output, source_mask, target_mask):
"""
得到解码器的输出结果, 即: 解码器的前向传播过程.
:param target_y: 解码器的输入, 例如: [batch_size, seq_len] → [2, 4]
:param encoder_output: 编码器的输出结果, [batch_size, seq_len, d_model] → [2, 4, 512]
:param source_mask: 代表padding mask, 防止填充的pad值影响注意力计算结果.
:param target_mask: 代表sentence mask, 防止未来的信息被提前利用(防止偷看未来的词)
:return: 解码器的输出结果, [batch_size, seq_len, d_model] → [2, 4, 512]
"""
# 1. 对target_y进行词嵌入处理, 转成向量.
embed_y = self.target_embed(target_y)
# 2. 通过解码器处理嵌入后的数据.
return self.decoder(embed_y, encoder_output, source_mask, target_mask)
# todo 2. 测试Transformer模型架构.
def make_model():
pass
135. Transformer架构测试(理解)
python
# todo 2. 测试Transformer模型架构.
def make_model():
# 1. 定义深拷贝工具函数, 用于复制模块.
c = copy.deepcopy
# ------------------------------ 编码器层 ------------------------------
# 2. 编码器, 词嵌入层.
source_embed = Embedding(vocab_size=1000, d_model=512)
# 3. 编码器, 位置编码层.
source_position = PositionalEncoding(d_model=512, dropout=0.1)
# 4. 多头注意力机制 → 实例化.
self_attn = MultiHeadAttention(embed_dim=512, head=8)
# 5. 定义前馈全连接层.
ff = FeedForward(d_model=512, d_ff=2048)
# 6. 设置随机失活概率, 防止: 过拟合.
dropout_p = 0.2
# 7. 单个编码器层的实例化, 包括: 多头注意力机制, 前馈全连接层, 残差连接, 规范化层
my_encoder_layer = EncoderLayer(d_model=512, self_attn=self_attn, feed_forward=ff, dropout=dropout_p)
# 8. 编码器的初始化.
my_encoder = Encoder(my_encoder_layer, N=6)
# ------------------------------ 解码器层 ------------------------------
# 1. 解码器, 词嵌入层.
# target_embed = Embedding(vocab_size=1000, d_model=512) # 思路1: 手动写一遍.
# target_embed = copy.deepcopy(source_embed) # 思路2: 深拷贝.
target_embed = c(source_embed) # 思路3: 深拷贝 → 语法糖...
# 2. 解码器, 位置编码层.
target_position = c(source_position)
# 3. 解码器的自注意力机制, 处理: 目标序列内部关系.
self_attn1 = c(self_attn)
# 4. 解码器-编码器注意力机制: 处理: 目标序列与源序列之间的关系.
source_attn1 = c(self_attn)
# 5. 定义前馈全连接层.
ff1 = c(ff)
# 6. 单个解码器层的实例化, 包括: 多头注意力机制, 前馈全连接层, 残差连接, 规范化层
my_decoder_layer = DecoderLayer(d_model=512, self_attn=self_attn1, src_attn=source_attn1, feed_forward=ff1, dropout=dropout_p)
# 7. 解码器的初始化.
my_decoder = Decoder(my_decoder_layer, N=6)
# ------------------------------ 输出层 ------------------------------
# 1. 输出层, 实例化.
generator = Generator(d_model=512, vocab_size=1000)
# ------------------------------ 组装完整的Transformer模型 ------------------------------
# 编码器输入处理: 词嵌入 + 位置编码
# 解码器输入处理: 词嵌入 + 位置编码
my_transformer = EncoderDecoder(
nn.Sequential(source_embed, source_position), # 编码器输入处理链
my_encoder, # 编码器
nn.Sequential(target_embed, target_position), # 解码器输入处理链
my_decoder, # 解码器
generator # 输出层
)
# 打印模型结构
print(my_transformer)
# ------------------------------ Transformer模型的 前向测试 ------------------------------
# 1. 构建测试输入数据 → 源序列, 假设: 2个句子, 每个句子有4个单词(token)
source_x = torch.LongTensor([[1, 2, 3, 4], [5, 6, 7, 8]])
# 2. 构建目标输入数据 → 目标序列, 假设: 2个句子, 每个句子有4个单词(token)
target_y = torch.LongTensor([[3, 8, 6, 4], [9, 6, 2, 6]])
# 3. 初始化掩码(实际开发要根据序列长度 动态生成)
source_mask = torch.zeros(8, 4, 4)
target_mask = c(source_mask)
# 4. 执行前向传播.
print(' -.- ' * 10)
result = my_transformer(source_x, target_y, source_mask, target_mask)
print(f'result是Transformer的输出结果: {result}')
print(f'result的形状: {result.shape}') # [2, 4, 1000]
# todo 3. 测试代码
if __name__ == '__main__':
make_model()
EncoderDecoder 怎么实现?
__init__ 收 5 个已经建好的模块:source_embed、encoder、target_embed、decoder、generator。源侧嵌入里是词嵌入加位置编码,目标侧同样一套,生成器把解码结果变成词表上的概率。
类里三个方法:
forward(source_x, target_y, source_mask, target_mask)是主流程。先编码,再解码,最后过generator。源句、目标句都是[2, 4],返回[2, 4, 1000]。encode(source_x, source_mask)先走source_embed,再进编码器,得到[2, 4, 512]。decode(target_y, encoder_output, source_mask, target_mask)先走target_embed。练习里把encoder_output叫做memory,它是编码器的输出,当作交叉注意力的 K、V。解码结果仍是[2, 4, 512]。
make_model 怎么实现?
练习里的函数有 7 个参数:源词表大小、目标词表大小、堆叠层数 N、词向量维度 d_model、前馈内层维度 d_ff、头数、dropout。默认是 N=6、d_model=512、d_ff=2048、head=8、dropout=0.1。函数最后 return model。
咱们这份 make_model() 没有形参,这几个数直接写在函数里:源词表和目标词表都是 1000,编码器和解码器都叠 6 层,d_model=512,d_ff=2048,8 个头。位置编码的 dropout 是 0.1,子层连接上的 dropout 是 0.2。函数里打印模型并跑一遍前向。
词嵌入要不要深拷贝?
编码端这份不要拷。6 个编码器层查的是同一张词表,源句每个位置都走 source_embed 这一个模块。再 deepcopy 一份,就变成另一张表,各层对不上同一个嵌入。
要拷的是每一层自己的参数。注意力和前馈各层分开训:解码器的 self_attn1、source_attn1 都是从编码器的 self_attn 深拷贝出来的,ff1 = c(ff) 同理。位置编码的 pe 不训练,目标侧 target_position = c(source_position) 是为了让两套 dropout 各自独立。
咱们文件里 target_embed = c(source_embed),是让目标侧有自己的词表参数。练习里源、目标各 new 一个 Embeddings,编码端那一份不拷。有的层要深拷贝,有的层共用同一个。
复盘。
高内聚:词嵌入、编码器、解码器、生成器各自做一件事。低耦合:EncoderDecoder 只把这几块接上,forward 负责按顺序调用。回调是子层连接那一层,forward(x, sublayer) 把注意力或前馈当参数传进去,连接结构本身不写死是哪一种子层。
论文图和这份代码有对不齐的地方。图上输出是 Softmax,Generator 用的是 log_softmax。练习里 make_model 用参数并把模型返回,咱们的测试把数字写死,在函数里打印。按自己的代码把数据流走通即可。
136. Transformer架构组件介绍(理解)
ini
EncoderDecoder( # 自定义的Transformer架构, 编码解码模型.
(source_embed): Sequential( # (输入部分)任务处理链 -> 词嵌入层 + 位置编码
(0): Embedding(
(embed): Embedding(1000, 512)
)
(1): PositionalEncoding(
(dropout): Dropout(p=0.1, inplace=False)
)
)
(encoder): Encoder( # 编码器
(layers): ModuleList( # 编码器 = 6个编码器层
(0-5): 6 x EncoderLayer(
(self_attn): MultiHeadAttention( # 每个编码器层 = 2个子层, 这里是第1个子层, 即: 多头注意力子层
(linears): ModuleList(
(0-3): 4 x Linear(in_features=512, out_features=512, bias=True) # 多头注意力子层有 4个 全连接层.
)
(dropout): Dropout(p=0.1, inplace=False)
)
(feed_forward): FeedForward( # 第2个子层: 前馈全连接子层(就是两个全连接层, 升维, 降维, 强化特征)
(linear1): Linear(in_features=512, out_features=2048, bias=True)
(linear2): Linear(in_features=2048, out_features=512, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(sublayer): ModuleList( # 每个编码器层 = 2个子层
(0-1): 2 x SublayerConnection(
(norm): LayerNorm()
(dropout): Dropout(p=0.2, inplace=False)
)
)
)
)
(norm): LayerNorm()
)
(target_embed): Sequential(
(0): Embedding(
(embed): Embedding(1000, 512)
)
(1): PositionalEncoding(
(dropout): Dropout(p=0.1, inplace=False)
)
)
(decoder): Decoder( # 解码器
(layers): ModuleList(
(0-5): 6 x DecoderLayer( # 解码器 = 6个解码器层
(self_attn): MultiHeadAttention( # 第1个子层: 掩码多头注意力子层, 防止偷看未来词.
(linears): ModuleList(
(0-3): 4 x Linear(in_features=512, out_features=512, bias=True)
)
(dropout): Dropout(p=0.1, inplace=False)
)
(src_attn): MultiHeadAttention( # 第2个子层: 多头注意力子层, 算注意力权重的.
(linears): ModuleList(
(0-3): 4 x Linear(in_features=512, out_features=512, bias=True)
)
(dropout): Dropout(p=0.1, inplace=False)
)
(feed_forward): FeedForward( # 第3个子层: 前馈全连接子层
(linear1): Linear(in_features=512, out_features=2048, bias=True)
(linear2): Linear(in_features=2048, out_features=512, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(layers): ModuleList(
(0-2): 3 x SublayerConnection( # 每个解码器层 = 3个子层
(norm): LayerNorm()
(dropout): Dropout(p=0.2, inplace=False)
)
)
)
)
(norm): LayerNorm()
)
(generator): Generator( # 输出部分: 把词向量维度 转成 词汇表大小的概率分布, 最终就可以预测结果了
(linear): Linear(in_features=512, out_features=1000, bias=True)
)
)
137. Fasttext_简介及环境搭建(理解)
fastText。 Facebook AI Research(FAIR)出的自然语言处理开源工具包。常见任务有情感分析、文本分类、命名实体识别、机器翻译。
它主要做两件事。
- 文本分类。一堆新闻分成体育、金融、科技、娱乐。用户评论分成正面、负面、中性。
- 训练词向量。做法和 Word2Vec 同一路:CBOW、Skip-Gram。
优势是精度还在的时候,训练和预测都快。原因有四条。
- 模型结构简单。
- 类别特别多时用层次 Softmax,超多类别下更快。
- 负采样(negative sampling)。每次只更新一小部分权重,梯度下降时少算很多。
- 模型本身太简单,抓不住词序。用 n-gram 把词的局部片段提出来,补上这个缺口,精度会好一些。
安装。 词向量工具库有两种装法。
简洁版:
bash
pip install fasttext
源码安装是课程推荐的做法。以 Linux 为例,先切到虚拟环境所在目录,再克隆:
bash
git clone https://github.com/facebookresearch/fastText.git
cd fastText
sudo pip install .
装之前对一下环境。conda env list 看当前是不是要用的那个虚拟环境,课程示例在 base 下面。pwd 看自己在哪个路径安装。pip -V 看这个 pip 会装到哪。
验证:进 Python,能 import fasttext 就说明装上了。课程环境是 Python 3.8.12,Anaconda 自带的 GCC 7.5.0。
138. Fasttext_哈夫曼树介绍(理解)
FastText 的网络和 Word2Vec 的 CBOW 很像。CBOW 用上下文预测中间那个词,例如「我喜欢自然语言」里把中间的词挖掉再猜。FastText 吃进一段文本,预测的是这段文本的分类标签。
精度还在、训练和预测仍然快,主要靠三件事:模型结构简单,类别很多时用层次 Softmax(哈夫曼树),再加上负采样。
138.1 三层架构
| 层 | 做什么 |
|---|---|
| 输入层 | 文本拆开,结合 n-gram,再转成词向量。这些向量带 n-gram 特征。例如「我爱武汉」拆成我、爱、武汉、我爱、爱武汉 |
| 隐藏层 | 把所有 n-gram 向量求和再平均,得到一个代表整段文本的特征向量 |
| 输出层 | 用这个特征向量预测文档对应的 label |
输入记成 (x_1, x_2, \ldots, x_N),先汇总到 hidden,再送到 output。
138.2 层次 Softmax
类别特别多时,每个类别都单独算一遍传统 Softmax 会很慢。层次 Softmax 用哈夫曼树来算概率:从根走到目标叶子,沿途每次只做一次二分类,把这些概率乘起来。
138.3 哈夫曼树
哈夫曼树是带权路径长度最小的二叉树。带权路径长度(weighted path length,WPL)是每个叶子的权值乘上它到根的边数,再全部加起来。
拿叶子权值 3、5、7、9 举例。它们到根的步数分别是 3、3、2、1,WPL 是 (3 \times 3 + 5 \times 3 + 7 \times 2 + 9 \times 1 = 47)。权值大的叶子离根更近,总长度就小。WPL 最小的那棵二叉树就是哈夫曼树。
n 个权值 (w_1, w_2, \ldots, w_n) 造出 n 个叶子,步骤是:
- 把每个权值看成一棵只有根节点的树,n 棵树组成一片森林。
- 选出根节点权值最小的两棵,合并成一棵新树。它们分别做新树的左子树和右子树,新根的权值是两者之和。
- 从森林里删掉这两棵,把新树加回去。
- 重复第 2、3 步,直到森林里只剩一棵树。这棵就是哈夫曼树。
138.4 四个标签怎么建
四个标签在语料里的次数是 A 5 次、B 9 次、C 7 次、D 3 次。
| 第几次 | 选中的两棵 | 新根 | 合并之后森林里还剩 |
|---|---|---|---|
| 1 | D(3)和 A(5) | 8 | B(9)、C(7)、这棵 8 |
| 2 | C(7)和上面那棵 8 | 15 | B(9)、这棵 15 |
| 3 | B(9)和上面那棵 15 | 24 | 只剩这一棵 |
第三次合并之后的树是:
text
24
/ \
B 15
9 / \
C 8
7 / \
D A
3 5
B 离根 1 步,C 离根 2 步,D 和 A 离根 3 步。WPL 是 (9 \times 1 + 7 \times 2 + 3 \times 3 + 5 \times 3 = 47)。出现次数多的 B 挂得最近。
138.5 建完之后怎么训练
所有单词都挂在叶子上。规定左分支是 0,右分支是 1。从根走到某个叶子,沿途的 0 和 1 就是这个叶子的哈夫曼编码。D 的路径是右、右、左,编码是 110。
0 当成负类,1 当成正类。每个非叶子节点做一次二分类,正类概率用 sigmoid:
σ(XTθ)=1+e−XTθ1
负类概率是 (1 - \sigma(X^{T}\theta))。(\theta) 是这个非叶子节点上的参数,(X) 是隐藏层算出来的那份文本特征向量。
用输入 a 预测标签 D。从根到 D 有三次分支,三次概率乘在一起就是走到 D 的概率:
| 第几次 | 走到哪 | 编码 | 概率 |
|---|---|---|---|
| 1 | 根(权值 24)往右,参数 (\theta_1) | 1 | (P(1 \mid X, \theta_1) = \sigma(X^{T}\theta_1)) |
| 2 | 权值 15 的节点往右,参数 (\theta_2) | 1 | (P(1 \mid X, \theta_2) = \sigma(X^{T}\theta_2)) |
| 3 | 权值 8 的节点往左,到达 D,参数 (\theta_3) | 0 | (P(0 \mid X, \theta_3) = 1 - \sigma(X^{T}\theta_3)) |
三个概率相乘就是最终结果。用这个概率做极大似然,算出损失,让损失变小,从而更新 (\theta_1, \theta_2, \theta_3)。类别有多少,传统 Softmax 就要算多少次;层次 Softmax 只算从根走到这个叶子的那几步。
139. Fasttext_负采样介绍(理解)
一个训练样本过完网络,权重就会调整一次。Skip-Gram 训词向量时,词表要是有上万个词,Softmax 就要算上万个概率,每个概率都要反向传播去更新参数。计算量太大,训练会非常慢。
负采样每次只更新一小部分权重。传统做法像每次考试都把整本书复习一遍,一万个词的权重全动。负采样像只复习错题,再带上少量容易错的题:正样本一定更新,再抽几个该输出 0 的词一起更新,剩下的先不动。
139.1 hello 和 man
训练样本是输入词 hello、输出词 man。两个词都先做成 one-hot。词表大小 10000 时,输出层有 10000 个神经元。期望 man 那个神经元输出 1,其余 9999 个输出 0。
期望输出为 1 的 man 是正样本。那 9999 个期望输出为 0 的词是负样本(negative word)。
负采样从这些负样本里随机抽一小部分来更新权重,正样本 man 也要更新。小数据集抽 5 到 20 个负样本比较合适,大数据集抽 2 到 5 个。下面按抽 5 个来算。
隐藏层到输出层的权重矩阵是 (300 \times 10000),一共 300 万个权重。
| 每次要动的输出神经元 | 每次更新的权重 | |
|---|---|---|
| 不用负采样 | 10000 个词全更新 | (300 \times 10000 = 3000000) |
| 使用负采样 | 正样本 man,再加 5 个负样本,共 6 个 | (300 \times 6 = 1800) |
1800 相对 300 万,大约是 0.06% 的权重。剩下的 9994 个神经元这次先不更新。
139.2 优势
训练更快。损失只在抽中的那部分样本上算,计算更简单。
效果也会好一些。多放进一些负样本,相当于把真实场景里的噪声放进来,模型更稳。
139.3 说一说
说一说 fastText 的主要作用,以及它为什么快还能保持效果?
主要做两件事:文本分类,训练词向量。常见任务有情感分析、文本分类、命名实体识别、机器翻译。
快而且效果还在,靠这几条。模型结构简单。训词向量、类别特别多时用层次 Softmax,少算很多类别上的概率。负采样让每次训练只更新一小部分权重,梯度下降的计算量降下来。模型太简单,抓不住词序,再用 n-gram 把局部片段提出来,把这个缺口补上,精度会好一些。
说一说层次 Softmax 为什么比普通 Softmax 算概率分布要快?
普通 Softmax 算词 (w_2) 的概率,分母要把词表里每个词都加一遍:
p(w2)=ew1+ew2+ew3+⋯+ewnew2=∑i=1newiew2
层次 Softmax 用哈夫曼树。每个词的概率分布变成这个词从根走到叶子的路径概率乘积,沿途只做几次二分类。
说一说哈夫曼树的构建过程?
- 把权值 (w_1, w_2, \ldots, w_n) 看成 n 棵只有一个节点的树,组成一片森林。
- 选出根节点权值最小的两棵,合并成一棵新树,分别做新树的左子树和右子树。新根的权值是两者之和。
- 从森林里删掉这两棵,把新树加回去。
- 重复第 2、3 步,直到森林里只剩一棵树。这棵就是哈夫曼树。
说一说负采样的基本原理?
每次一个训练样本只更新一小部分权重,梯度下降时少算很多,训练更快。再补上一些负样本,用来模拟真实场景里的噪声,模型更稳。
139.4 单选题
1. FastText 主要用于哪些自然语言处理任务?
- A. 图像分类
- B. 语音识别
- C. 文本分类、词向量学习等
- D. 3D 建模
答案:C。文本分类和词向量训练。
2. 层次 Softmax(Hierarchical Softmax)是用于解决什么问题的技术?
- A. 提高模型的准确率
- B. 减少计算成本
- C. 加速模型的训练速度
- D. 增强模型的泛化能力
答案:C。提高模型训练速度。
3. 在层次 Softmax 中,树的结构是如何用于提高计算效率的?
- A. 通过减少模型参数
- B. 通过降低网络的深度
- C. 通过将标签分成多个子集
- D. 通过将标签组织成树形结构
答案:C。每个单词的概率分布转成这个单词路径上的概率乘积。
4. 负采样(Negative Sampling)是用于解决什么问题的技术?
- A. 提高模型的准确率
- B. 减少计算成本
- C. 增加模型的复杂度
- D. 增强模型的泛化能力
答案:B。主要用来减少成本。
5. 负采样中的负样本是如何生成的?
- A. 从目标词的上下文窗口中随机选取词作为负样本
- B. 从整个词汇表中按照概率分布选取词作为负样本
- C. 从目标词的上下文窗口中排除正样本词后,按照概率分布选取词作为负样本
- D. 从目标词的上下文窗口中选取与正样本词相似的词作为负样本
答案:C。负样本从目标词的上下文窗口里抽,先排除正样本,再按概率分布选取。
140. Fasttext_直接训练_代码实现(掌握)
140.1 文本分类
文本分类是把文档分到一个或多个类别里。文档可以是电子邮件、帖子、文本消息、产品评论。训练分类模型是有监督学习,数据上要有标签。
| 种类 | 一条文本能进几个类别 | 损失 | 例子 |
|---|---|---|---|
| 二分类 | 两个类别里选一个。要么是 A,要么是 B | 二元交叉熵 BCELoss |
一句评论是好评还是差评 |
| 单标签多分类 | 多个类别里只打一个标签。要么是 A,要么是 B,要么是 C,或者其它某一个 | softmax(),CrossEntropyLoss() |
输入一个人名,判断它来自哪个国家 |
| 多标签多分类 | 可以同时打上多个标签。是 A 也可以是 B,也可以是 C | 实际开发里拆成多个互相独立的二分类 | 一段讨论的话题可以同时是美食、体育新闻、游戏 |
140.2 案例要做什么
用 fastText 给烹饪相关的数据做文本分类。步骤是:
- 获取数据
- 划分训练集和验证集
- 训练模型
- 用模型预测并评估
- 模型调优
- 模型保存与重新加载
数据是 Facebook AI 实验室的演示集,放在虚拟机 /root/data/cooking 下面。下载和解压:
bash
wget https://dl.fbaipublicfiles.com/fasttext/data/cooking.stackexchange.tar.gz && tar xvzf cooking.stackexchange.tar.gz
140.3 数据长什么样
文件是 cooking.stackexchange.txt。一行一条,标签写在正文前面,一个标签一份 __label__。一条可以有多个标签,这是多标签多分类。格式是:
text
__label__AAA __label__BBB text1text1text1
前 10 条:
text
__label__sauce __label__cheese How much does potato starch affect a cheese sauce recipe?
__label__food-safety __label__acidity Dangerous pathogens capable of growing in acidic environments
__label__cast-iron __label__stove How do I cover up the white spots on my cast iron stove?
__label__restaurant Michelin Three Star Restaurant; but if the chef is not there
__label__knife-skills __label__dicing Without knife skills, how can I quickly and accurately dice vegetables?
__label__storage-method __label__equipment __label__bread What's the purpose of a bread box?
__label__baking __label__food-safety __label__substitutions __label__peanuts how to seperate peanut oil from roasted peanuts at home?
__label__chocolate American equivalent for British chocolate terms
__label__baking __label__oven __label__convection Fan bake vs bake
__label__sauce __label__storage-lifetime __label__acidity __label__mayonnaise Regulation and balancing of readymade packed mayonnaise and other sauces
140.4 划分训练集和验证集
bash
wc cooking.stackexchange.txt
wc 打出来三列,依次是多少行、多少单词、占用多少字节:
text
15404 169582 1401900 cooking.stackexchange.txt
一共 15404 行。前 12404 行做训练,最后 3000 行做验证。
bash
head -n 12404 cooking.stackexchange.txt > cooking.train
tail -n 3000 cooking.stackexchange.txt > cooking.valid
140.5 直接训练
dm01_basic 用 fasttext.train_supervised 做有监督训练。参数 input 是训练集路径,脚本里这份文件是 day10/data/cooking_train.txt。
python
import fasttext
model = fasttext.train_supervised(input="data/cooking/cooking.train")
训练结束时会打出一份日志。一次跑完的结果是:
text
Read 0M words
Number of words: 14543
Number of labels: 735
Progress: 100.0% words/sec/thread: 60162 lr: 0.000000 avg.loss: 10.056812 ETA: 0h 0m 0s
| 字段 | 含义 |
|---|---|
Read 0M words |
读进来的单词总量。这里不到 100 万,所以显示 0M |
Number of words: 14543 |
不重复的词有 14543 个 |
Number of labels: 735 |
标签一共 735 个 |
Progress |
训练进度。这行是训练完成时的信息,所以是 100% |
words/sec/thread |
每个线程每秒处理多少个词 |
lr |
当前学习率。训练完成时是 0 |
avg.loss |
训练过程的平均损失 |
ETA |
预计还要多久。已经训完,所以是 0 |
140.6 预测和评估
predict 返回一个元组。第一项是标签,第二项是对应概率。
python
model.predict("Which baking dish is best to bake a banana bread ?")
# (('__label__baking',), array([0.06550845]))
model.predict("Why not put knives in the dishwasher?")
# (('__label__food-safety',), array([0.07541209]))
香蕉面包这条预测成 baking,和常识一致,但概率只有大约 0.066。刀具能不能进洗碗机这条预测成 food-safety,和常识对不上。
dm01_basic 里另外两条的一次输出是:
text
result1的预测结果为: (('__label__food-safety',), array([0.02387223]))
result2的预测结果为: (('__label__baking',), array([0.02931042]))
How is mass egg-frying performed? 预测成 food-safety。Using liquid nitrogen for tenderizing octopus? 预测成 baking。
评估用 model.test,验证集是 3000 条。返回值三项依次是验证集样本数量、精度、召回率。
python
model.test("data/cooking/cooking.valid")
# (3000, 0.124, 0.0541)
精度和召回率都很低,后面要调优。脚本里这次直接训练写在注释上的结果是 (3000, 0.1456666666666667, 0.06299553128945654)。每次训练的数会有一点差别,三项的含义不变。
140.7 保存和重新加载
save_model 把模型写到指定路径,文件名是 model_cooking.bin。fasttext.load_model 再把它读回来。读回来之后,predict 的用法和训练完直接用一样。
python
model.save_model("data/model/model_cooking.bin")
model = fasttext.load_model("data/model/model_cooking.bin")
model.predict("Which baking dish is best to bake a banana bread ?", k=1, threshold=0.5)
# (('__label__baking', '__label__bananas', '__label__bread'), array([1.00000, 0.939923, 0.592677]))
k 是返回几个标签,threshold 是概率下限。
140.8 代码实现
python
"""
案例:
演示 Fasttext 实现 文本分类案例.
文本分类的几种场景:
二分类问题:
单标签多分类:
多标签多分类: 一般会拆解成 多个 二分类问题解决.
例如: 数据格式如下, 如何处理呢?
标签 特征
__label__soup __label__texture __label__standards What is the correct consistency of a cream soup?
思路:
转成 多个 二分类问题即可, 例如:
特征 标签
What is the correct consistency of a cream soup? __label__soup
What is the correct consistency of a cream soup? __label__texture
What is the correct consistency of a cream soup? __label__standards
"""
# 导包
import os
import fasttext
# todo 1. 直接训练.
def dm01_basic():
# 1. 获取训练模型(有监督训练: 有特征, 有标签) → 得到训练好的模型.
model = fasttext.train_supervised(os.path.join(os.path.dirname(__file__), 'data', 'cooking_train.txt'))
# 2. 使用模型进行预测.
# 预测第1条样本.
result1 = model.predict('How is mass egg-frying performed?')
print(f'result1的预测结果为: {result1}')
# 预测第2条样本
result2 = model.predict('Using liquid nitrogen for tenderizing octopus?')
print(f'result2的预测结果为: {result2}')
# 3. 测试模型(看看日志)
result3 = model.test(os.path.join(os.path.dirname(__file__), 'data', 'cooking_valid.txt'))
print(f'模型在测试集上的测试结果: {result3}') # [验证集样本数量, 精度(准确率), 召回率] → (3000, 0.1456666666666667, 0.06299553128945654)
# todo n. 测试代码
if __name__ == '__main__':
# 1. 测试: 直接训练.
dm01_basic()
140.9 接下来怎么调
直接训练的准确率很低,调优从这几方面入手:
- 数据:重新处理数据。
- 增加训练轮数。
- 调整学习率。
- 增加 n-gram 特征。
- 修改损失计算方式。
- 自动超参数调优。
- 实际生产里,多标签多分类怎么算损失。