在职前端Leader学习/转行 AI Agent -DAY78

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 多出来两件事:

  1. 能用分布式 GPU 并行训练。RNN 要一个词接一个词算,Transformer 一次把一句话里的词都看完,训练效率更高。
  2. 句子变长以后,隔得远的词之间的语义关系抓得更好。

基于 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⁡ ( pos10000 2i/ dmodel ) PE(pos,\ 2i) = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right) PE(pos, 2i)=sin(100002i/dmodelpos)
PE(pos, 2i+1)=cos⁡ ( pos10000 2i/ dmodel ) PE(pos,\ 2i+1) = \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right) 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) 越大,这一维的周期越长

每个输入元素的嵌入向量都加上自己的位置信息。训练时,模型从这些向量里学元素之间的相对位置关系。

  1. 记住词的顺序。周期函数给每个位置一张独特的标签,模型能区分「我爱你」和「你爱我」。
  2. 计算高效。新位置靠线性组合推出来,三角函数不用重复算,省算力。句子越长,省得越多。
  3. 适应任意长度。句子多长都能算出编码,泛化能力强。
  4. 模型处理语言时更聪明,也更灵活。顺序记得住,新位置推得快,长短句都能接。
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 ( Q⋅KT dk ) ⋅VAttention(Q, K, V) = Softmax\left(\frac{Q \cdot K^{T}}{\sqrt{d_k}}\right) \cdot 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 多头注意力机制
  1. 线性变换。 Q、K、V 各自过一个线性层,相当于切丝、切片、切段。形状还是 [2, 4, 512],这一步是在准备分头。
  2. view 切分。 512 维切成 8 个头,每头 64 维,形状变成 [2, 4, 8, 64]。再把「头」这一维换到前面,得到 [2, 8, 4, 64]。h 就是头数。每个头单独做后面的注意力。
  3. attention。 每个头调用前面的缩放点积注意力,提取这一头的特征。8 个锅分开煮,番茄、麻辣、清汤各看各的。
  4. Concat。 把头拼回去,煮好后装盘,形状回到 [2, 4, 512]。
  5. 再过一个线性层。 相当于淋汁,把拼好的结果映射成最终要用的形状,这里仍是 [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 是同一份位置编码结果。

flowchart TD subgraph src[&#34;输入&#34;] direction TB input[&#34;INPUT<br/>[2, 4]&#34;] embedded[&#34;EMBEDDED<br/>[2, 4, 512]&#34;] position[&#34;POSITION<br/>sin(pos / 10000^(2i/d_model))<br/>cos(pos / 10000^(2i/d_model))<br/>[2, 4, 512]&#34;] input -->|&#34;向量化<br/>nn.Embedding&#34;| embedded embedded -->|&#34;可 dropout<br/>nn.Dropout&#34;| position end subgraph mha[&#34;多头注意力&#34;] direction TB q[&#34;Q<br/>[2, 4, 512]&#34;] k[&#34;K<br/>[2, 4, 512]&#34;] v[&#34;V<br/>[2, 4, 512]&#34;] proj[&#34;nn.Linear<br/>Linear + 转为多头<br/>[2, 4, 512] → [2, 4, 8, 64] → [2, 8, 4, 64]&#34;] subgraph selfattn[&#34;自注意力&#34;] direction LR mask[&#34;可 MASK<br/>masked_fill&#34;] score[&#34;Q · K^T / √d_k<br/>[2, 8, 4, 64] → [2, 8, 4, 4]&#34;] temp[&#34;TEMP<br/>F.softmax<br/>[2, 8, 4, 4]&#34;] resAttn[&#34;RES<br/>TEMP @ V<br/>[2, 8, 4, 64]&#34;] mask --> score --> temp temp -->|&#34;可 dropout<br/>nn.Dropout&#34;| resAttn end merged[&#34;MERGED<br/>多头注意力合并<br/>[2, 8, 4, 64] → [2, 4, 8, 64] → [2, 4, 512]&#34;] resOut[&#34;RES<br/>nn.Linear<br/>[2, 4, 512]&#34;] q --> proj k --> proj v --> proj proj -->|&#34;Q、K&#34;| score proj -->|&#34;V&#34;| resAttn resAttn --> merged merged -->|&#34;Linear&#34;| resOut end position -->|&#34;输入<br/>可 dropout nn.Dropout<br/>Q = K = V&#34;| q position --> k position --> v classDef tensor fill:#f4a261,stroke:#c46b2a,color:#1a1a1a classDef layer fill:#3d7ea6,stroke:#1f4e79,color:#ffffff classDef act fill:#7dce7d,stroke:#3d8b3d,color:#1a1a1a classDef op fill:#e6e0d4,stroke:#5c564c,color:#1a1a1a class input,embedded,position,q,k,v,resAttn,merged,resOut tensor class proj layer class mask,score op class temp act
节点 形状 解释
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 为什么需要它

  1. 稳住梯度。 层数一多,梯度容易变得极小,也容易变得极大。前者是梯度消失,后者是梯度爆炸。先把输入收到均值 0、标准差 1 附近,梯度往回传时幅度更稳。
  2. 加快收敛。 每层的数值尺度接近,参数更新更顺,训练更快走到能用的结果。

122.2 计算方式

按位置算,每个词自己的全部特征维一起算。测试里一个词是 512 维,所以均值和方差都在这 512 维上求。两句话、每句 4 个词,就是 8 个位置各算各的。

  1. 求出这个位置 512 维的均值 (\mu) 和方差 (\sigma^2)。
  2. 做线性变换,把这 512 维收成均值 0、方差 1。标准差就是 (\sqrt{\sigma^2}),方差为 1 时标准差也是 1。
  3. 再缩放、再平移。缩放因子和平移因子是学出来的参数,每个特征维各有一个。模型可以自己决定归一化之后还要不要把某些维再拉开。

\\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)。

flowchart LR x[&#34;x&#34;] -->|&#34;W · x + b&#34;| s1[&#34;s1&#34;] s1 -->|&#34;减去该列均值 μ<br/>除以 √(σ² + ε)&#34;| s2[&#34;s2&#34;] s2 -->|&#34;γ · s2 + β&#34;| s3[&#34;s3&#34;]

写成一个式子:

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)

flowchart LR x[&#34;x<br/>[2, 4, 512]&#34;] --> sub[&#34;Sublayer<br/>多头注意力或前馈&#34;] sub --> x2[&#34;x2<br/>[2, 4, 512]&#34;] x --> add[&#34;x + x2&#34;] x2 --> add add --> norm[&#34;Norm<br/>[2, 4, 512]&#34;]

编码器一层里这个结构出现两次:下面一次包住多头注意力,上面一次包住前馈。解码器一层里出现三次,掩码多头注意力、多头注意力、前馈各包一次。旁路都是把进子层前的 (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

括号从里往外算,顺序是:

  1. sublayer(x):子层处理,多头注意力或前馈。
  2. self.norm(...):层规范化。
  3. self.dropout(...):随机失活。
  4. + 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:子层算完,规范化,失活,再加上原来的输入。

flowchart BT x[&#34;x<br/>[2, 4, 512]&#34;] --> attn[&#34;Multi-Head Attention<br/>Add & Norm&#34;] attn --> ff[&#34;Feed Forward<br/>Add & Norm&#34;] ff --> y[&#34;输出<br/>[2, 4, 512]&#34;]

进来是 [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 次,后一层的输入是前一层的输出。

flowchart BT x[&#34;词嵌入 + 位置编码<br/>[2, 4, 512]&#34;] --> l1[&#34;编码器层 1&#34;] l1 --> l2[&#34;编码器层 2&#34;] l2 --> ln[&#34;编码器层 N&#34;] ln --> y[&#34;编码器输出<br/>[2, 4, 512]&#34;]

每一层进来和出去都是 [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,和编码器层用的是同一套残差加规范化。

flowchart BT x[&#34;解码器输入<br/>[2, 4, 512]&#34;] --> m1[&#34;Masked Multi-Head Attention<br/>Add & Norm&#34;] m1 --> m2[&#34;Multi-Head Attention<br/>Add & Norm&#34;] enc[&#34;编码器输出<br/>作为 K、V&#34;] --> m2 m2 --> ff[&#34;Feed Forward<br/>Add & Norm&#34;] ff --> y[&#34;输出<br/>[2, 4, 512]&#34;]
  1. 掩码多头注意力。 看解码器自己已经有的词。当前位置只能用左边已经出现的词,掩码挡住右边还没生成的词。Q、K、V 都来自解码器这一路。
  2. 多头注意力。 Q 来自上一块的输出,K、V 来自编码器的输出。生成目标词时,对照源句里已经提取好的特征。
  3. 前馈全连接。 每个位置再单独加工一次,和编码器层最上面那块相同。

进来是 [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。

flowchart BT x[&#34;目标侧 词嵌入 + 位置编码<br/>上一次的预测<br/>[2, 4, 512]&#34;] --> l1[&#34;解码器层 1&#34;] l1 --> l2[&#34;解码器层 2&#34;] l2 --> ln[&#34;解码器层 N&#34;] enc[&#34;编码器输出<br/>[2, 4, 512]&#34;] --> l1 enc --> l2 enc --> ln ln --> y[&#34;最终特征表示<br/>[2, 4, 512]&#34;]

作用有两步。先根据编码器的结果,再加上已经预测出来的词,对下一个可能出现的值做特征表示。叠完 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 两层组成。解码器交出来的是特征,这两层把它变成下一个词的概率。

flowchart BT x[&#34;解码器输出<br/>[2, 4, 512]&#34;] --> linear[&#34;Linear<br/>512 → 词表大小&#34;] linear --> sm[&#34;Softmax<br/>最后一维归一化&#34;] sm --> y[&#34;Output Probabilities<br/>[2, 4, vocab_size]&#34;]

线性层做指定维度的变换。上一步每个位置是 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,变成词表上的概率。

flowchart BT src[&#34;源句索引<br/>[2, 4]&#34;] --> src_emb[&#34;Input Embedding + 位置编码<br/>[2, 4, 512]&#34;] src_emb --> enc[&#34;编码器 × N<br/>多头注意力 + 前馈&#34;] enc --> mem[&#34;编码器输出<br/>[2, 4, 512]&#34;] tgt[&#34;目标句索引,右移<br/>[2, 4]&#34;] --> tgt_emb[&#34;Output Embedding + 位置编码<br/>[2, 4, 512]&#34;] tgt_emb --> dec[&#34;解码器 × N<br/>掩码自注意力 + 交叉注意力 + 前馈&#34;] mem --> dec dec --> gen[&#34;Linear + Softmax&#34;] gen --> prob[&#34;输出概率<br/>[2, 4, 1000]&#34;]

源侧和目标侧各有一套词嵌入、位置编码。测试里词表都是 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。

优势是精度还在的时候,训练和预测都快。原因有四条。

  1. 模型结构简单。
  2. 类别特别多时用层次 Softmax,超多类别下更快。
  3. 负采样(negative sampling)。每次只更新一小部分权重,梯度下降时少算很多。
  4. 模型本身太简单,抓不住词序。用 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 个叶子,步骤是:

  1. 把每个权值看成一棵只有根节点的树,n 棵树组成一片森林。
  2. 选出根节点权值最小的两棵,合并成一棵新树。它们分别做新树的左子树和右子树,新根的权值是两者之和。
  3. 从森林里删掉这两棵,把新树加回去。
  4. 重复第 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θ)= 11+e−XTθ \sigma(X^{T}\theta) = \frac{1}{1 + e^{-X^{T}\theta}} σ(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)= ew2ew1+ew2+ew3+⋯+ewn = ew2 ∑i=1n ewi p(w_2) = \frac{e^{w_2}}{e^{w_1} + e^{w_2} + e^{w_3} + \cdots + e^{w_n}} = \frac{e^{w_2}}{\sum_{i=1}^{n} e^{w_i}} p(w2)=ew1+ew2+ew3+⋯+ewnew2=∑i=1newiew2

层次 Softmax 用哈夫曼树。每个词的概率分布变成这个词从根走到叶子的路径概率乘积,沿途只做几次二分类。

说一说哈夫曼树的构建过程?

  1. 把权值 (w_1, w_2, \ldots, w_n) 看成 n 棵只有一个节点的树,组成一片森林。
  2. 选出根节点权值最小的两棵,合并成一棵新树,分别做新树的左子树和右子树。新根的权值是两者之和。
  3. 从森林里删掉这两棵,把新树加回去。
  4. 重复第 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 给烹饪相关的数据做文本分类。步骤是:

  1. 获取数据
  2. 划分训练集和验证集
  3. 训练模型
  4. 用模型预测并评估
  5. 模型调优
  6. 模型保存与重新加载

数据是 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 接下来怎么调

直接训练的准确率很低,调优从这几方面入手:

  1. 数据:重新处理数据。
  2. 增加训练轮数。
  3. 调整学习率。
  4. 增加 n-gram 特征。
  5. 修改损失计算方式。
  6. 自动超参数调优。
  7. 实际生产里,多标签多分类怎么算损失。
相关推荐
代码猎人1 小时前
前端怎么封装工具类
前端
deli0071 小时前
PDF 合并别再用在线工具:12 份文件浏览器里合完,实测不上传
前端
shuoshuohaohao1 小时前
SpringBoot3-web开发-静态资源-内容协商
前端
kiros_wang1 小时前
RDB关系数据库实战:增删改查、事务、分页、版本迁移
前端
用户1733598075371 小时前
纯前端 PDF 双面打印避坑指南:翻页顺序错了整个白印?
前端·javascript·vue.js
庖丁解牛1 小时前
第四课:循环,让电脑重复做事(4)
前端
数据掘金1 小时前
用户路径分析的会话切分与节点聚合实现
前端
代码猎人1 小时前
前端怎么搭建项目基础架构
前端
lichenyang4531 小时前
Promise 基础与进阶:状态规则、链式错误处理和异步执行顺序
前端