深入探索LLM架构,使用pytorch实现Transformer

深入探究架构并利用从RNN到Transformer的NLP模型构建实际应用

本系列文章

1. LLM大模型架构专栏|| 从NLP基础谈起
2.LLM大模型架构专栏|| 自然语言处理(NLP)之建模
3. LLM大模型架构之词嵌入(Part1)
4. LLM大模型架构之词嵌入(Part2)
5. LLM大模型架构之词嵌入(Part3)
6. LLM架构从基础到精通之循环神经网络(RNN)
7. LLM架构从基础到精通之LSTM
8. LLM架构从基础到精通之门控循环单元(GRUs)
9. 20000字的注意力机制讲解,全网最全
10. 深入探究编码器 - 解码器架构:从RNN到Transformer的自然语言处理模型
11. 2w8000字深度解析从RNN到Transformer:构建NLP应用的架构演进之路
欢迎关注公众号 柏企科技圈柏企阅文 如果您有任何问题或建议,欢迎在评论区留言交流!

1. 引言

2017年,谷歌研究团队发表了一篇名为《Attention Is All You Need》的论文,提出了Transformer架构,这在机器学习领域,尤其是深度学习和自然语言处理领域,是一次范式转变。

Transformer凭借其并行处理能力,使得模型更加高效且可扩展,在大规模数据集上进行训练也变得更加容易。它在多个自然语言处理任务中表现出色,比如情感分析和文本生成任务。

这篇论文中提出的架构为后续的模型,如GPT和BERT奠定了基础。除了自然语言处理领域,Transformer架构还应用于其他领域,如音频处理和计算机视觉。你可以在笔记本《音频数据:音乐流派分类》中看到Transformer在音频分类中的应用。

尽管借助🤗Transformers库,你可以轻松使用不同类型的Transformer,但通过从零开始构建模型来理解其真正的工作原理至关重要。

2. Transformer

Transformer在训练和推理过程中的工作方式略有不同。

我们先来看看训练期间的数据流动过程。训练数据由两部分组成:

  • 源序列或输入序列(例如,在翻译问题中,英文的“You are welcome” )
  • 目标序列(例如,西班牙语的“De nada” )

Transformer的目标是通过使用输入序列和目标序列,学习如何输出目标序列。
Transformer对数据的处理过程如下:

  1. 输入序列被转换为嵌入向量(并添加位置编码),然后输入到编码器中。
  2. 编码器栈对其进行处理,并生成输入序列的编码表示。
  3. 目标序列在开头添加一个句子起始标记,转换为嵌入向量(并添加位置编码),然后输入到解码器中。
  4. 解码器栈结合编码器栈的编码表示对其进行处理,生成目标序列的编码表示。
  5. 输出层将其转换为单词概率,最终得到输出序列。

Transformer的损失函数会将这个输出序列与训练数据中的目标序列进行比较。这个损失用于生成梯度,在反向传播过程中训练Transformer。

3. 从零开始构建Transformer

Transformer架构有两个主要模块:编码器和解码器。让我们进一步了解一下它们。

编码器
它具有多头注意力机制和一个全连接的前馈网络。在这两个子层周围还有残差连接,并且每个子层的输出都进行了层归一化。模型中的所有子层和嵌入层生成的输出维度均为$d_{model}=512$。

解码器
解码器的结构与之类似,但它插入了第三个子层,该子层对编码器块的输出执行多头注意力操作。此外,解码器块中的自注意力子层进行了修改,以避免当前位置关注后续位置。这种掩码操作确保了位置$i$的预测仅依赖于位置小于$i$的已知输出。

编码器和解码器块都重复$N$次。在原始论文中,他们定义$N = 6$,在本文中我们也将定义类似的值。

我们将探索Transformer架构及其所有组件。我们将使用PyTorch构建所有必要的结构和模块。

让我们从导入所有必要的库开始。

import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader, random_split
from torch.utils.tensorboard import SummaryWriter
import math
from datasets import load_dataset
from tokenizers import Tokenizer
from tokenizers.models import WordLevel
from tokenizers.trainers import WordLevelTrainer
from tokenizers.pre_tokenizers import Whitespace
from pathlib import Path
from typing import Any
from tqdm import tqdm
import warnings

3.1 输入嵌入

观察上面的Transformer架构图,我们可以看到嵌入是两个模块的第一步。

下面的InputEmbeddings类负责将输入文本转换为$d_{model}$维度的数值向量。为了防止输入嵌入变得极小,我们将其乘以$\sqrt{d_{model}}$进行归一化。

在下图中,我们可以看到嵌入是如何创建的。首先,有一个句子被分割成标记(稍后我们将探讨什么是标记)。然后,标记ID(识别编号)被转换为嵌入向量,即高维向量。

class InputEmbeddings(nn.Module):
    def __init__(self, d_model: int, vocab_size: int):
        super().__init__()
        self.d_model = d_model
        self.vocab_size = vocab_size
        self.embedding = nn.Embedding(vocab_size, d_model)

    def forward(self, x):
        return self.embedding(x) * math.sqrt(self.d_model)

3.2 位置编码

在原始论文中,作者在编码器和解码器块的底部将位置编码添加到输入嵌入中,这样模型就可以获得关于序列中标记相对或绝对位置的信息。位置编码与嵌入具有相同的维度$d_{model}$,以便两个向量可以相加,从而将词嵌入中的语义内容与位置编码中的位置信息相结合。

在下面的PositionalEncoding类中,我们将创建一个维度为(seq_len, d_model)的位置编码矩阵pe。首先,我们用0填充它。然后,对位置编码矩阵的偶数索引应用正弦函数,对奇数索引应用余弦函数。

我们应用正弦和余弦函数是因为这使得模型能够根据序列中其他单词的位置来确定一个单词的位置。因为对于任何固定的偏移量$k$,$PE_{pos + k}$可以表示为$PE_{pos}$的线性函数。这是由于正弦和余弦函数的性质,输入的偏移会导致输出产生可预测的变化。

class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int, seq_len: int, dropout: float) -> None:
        super().__init__()
        self.d_model = d_model
        self.seq_len = seq_len
        self.dropout = nn.Dropout(dropout)
        pe = torch.zeros(seq_len, d_model)
        position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)
        self.register_buffer('pe', pe)

    def forward(self,x):
        x = x + (self.pe[:, :x.shape[1], :]).requires_grad_(False)
        return self.dropout(x)

3.3 层归一化

观察编码器和解码器块时,我们会看到几个名为“Add & Norm”的归一化层。

下面的LayerNormalization类对输入数据执行层归一化操作。在其前向传播过程中,我们计算输入数据的均值和标准差。然后,通过减去均值并除以标准差加上一个小的数(称为epsilon,以避免除以零)来对输入数据进行归一化。这个过程会得到一个均值为0、标准差为1的归一化输出。

然后,我们用一个可学习的参数alpha对归一化输出进行缩放,并加上一个可学习的参数bias。训练过程负责调整这些参数。最终结果是一个经过层归一化的张量,这确保了网络中各层输入的尺度一致。

class LayerNormalization(nn.Module):
    def __init__(self, eps: float = 10**-6) -> None:
        super().__init__()
        self.eps = eps
        self.alpha = nn.Parameter(torch.ones(1))
        self.bias = nn.Parameter(torch.zeros(1))

    def forward(self, x):
        mean = x.mean(dim=-1, keepdim=True)
        std = x.std(dim=-1, keepdim=True)
        return self.alpha * (x - mean) / (std + self.eps) + self.bias

3.4 前馈网络

在全连接前馈网络中,我们应用两个线性变换,并在中间使用ReLU激活函数。我们可以用数学公式表示这个操作:

$FFN(x)=max(0,xW_1 + b_1)W_2 + b_2$

$W_1$和$W_2$是权重,$b_1$和$b_2$是两个线性变换的偏置。

在下面的FeedForwardBlock类中,我们将定义两个线性变换(self.linear_1self.linear_2)以及内层维度d_ff。输入数据首先经过self.linear_1变换,将其维度从$d_{model}$增加到$d_{ff}$。这个操作的输出经过ReLU激活函数,引入非线性,使网络能够学习更复杂的模式,然后应用self.dropout层以减轻过拟合。最后一个操作是对经过dropout修改的张量进行self.linear_2变换,将其转换回原始的$d_{model}$维度。

class FeedForwardBlock(nn.Module):
    def __init__(self, d_model: int, d_ff: int, dropout: float) -> None:
        super().__init__()
        self.linear_1 = nn.Linear(d_model, d_ff)
        self.dropout = nn.Dropout(dropout)
        self.linear_2 = nn.Linear(d_ff, d_model)

    def forward(self, x):
        return self.linear_2(self.dropout(torch.relu(self.linear_1(x))))

3.5 多头注意力机制

多头注意力机制是Transformer中最关键的组件。它负责帮助模型理解数据中的复杂关系和模式。

下图展示了多头注意力机制的工作原理。图中没有包括批量维度,因为它仅说明了单个句子的处理过程。

多头注意力块接收输入数据,并将其分割为查询(queries)、键(keys)和值(values),并组织成矩阵$Q$、$K$和$V$。每个矩阵包含输入的不同方面,并且它们与输入具有相同的维度。

然后,我们通过各自的权重矩阵$WQ$、$WK$和$W^V$对每个矩阵进行线性变换。这些变换将产生新的矩阵$Q'$、$K'$和$V'$,它们将被分割成对应不同头$h$的较小矩阵,使模型能够并行地关注来自不同表示子空间的信息。这种分割为每个头创建了多组查询、键和值。

最后,我们将每个头连接成一个$H$矩阵,然后通过另一个权重矩阵$W_o$进行变换,以产生多头注意力输出,即一个保持输入维度的矩阵$MH - A$。

class MultiHeadAttentionBlock(nn.Module):
    def __init__(self, d_model: int, h: int, dropout: float) -> None:
        super().__init__()
        self.d_model = d_model
        self.h = h
        assert d_model % h == 0, 'd_model is not divisible by h'
        self.d_k = d_model // h
        self.w_q = nn.Linear(d_model, d_model)
        self.w_k = nn.Linear(d_model, d_model)
        self.w_v = nn.Linear(d_model, d_model)
        self.w_o = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)

    @staticmethod
    def attention(query, key, value, mask, dropout: nn.Dropout):
        d_k = query.shape[-1]
        attention_scores = (query @ key.transpose(-2,-1)) / math.sqrt(d_k)
        if mask is not None:
            attention_scores.masked_fill_(mask == 0, -1e9)
        attention_scores = attention_scores.softmax(dim=-1)
        if dropout is not None:
            attention_scores = dropout(attention_scores)
        return (attention_scores @ value), attention_scores

    def forward(self, q, k, v, mask):
        query = self.w_q(q)
        key = self.w_k(k)
        value = self.w_v(v)
        query = query.view(query.shape[0], query.shape[1], self.h, self.d_k).transpose(1,2)
        key = key.view(key.shape[0], key.shape[1], self.h, self.d_k).transpose(1,2)
        value = value.view(value.shape[0], value.shape[1], self.h, self.d_k).transpose(1,2)
        x, self.attention_scores = MultiHeadAttentionBlock.attention(query, key, value, mask, self.dropout)
        x = x.transpose(1, 2).contiguous().view(x.shape[0], -1, self.h * self.d_k)
        return self.w_o(x)

3.6 残差连接

观察Transformer的架构,我们可以看到每个子层,包括自注意力和前馈块,在将输出传递到“Add & Norm”层之前,都会将其输出添加到输入中。这种方法在“Add & Norm”层中将输出与原始输入进行整合。这个过程被称为跳跃连接,它通过为反向传播过程中的梯度提供一条捷径,使Transformer能够更有效地训练深度网络。

下面的ResidualConnection类负责这个过程。

class ResidualConnection(nn.Module):
    def __init__(self, dropout: float) -> None:
        super().__init__()
        self.dropout = nn.Dropout(dropout)
        self.norm = LayerNormalization()

    def forward(self, x, sublayer):
        return x + self.dropout(sublayer(self.norm(x)))

3.7 编码器

我们现在来构建编码器。我们创建EncoderBlock类,它由多头注意力层和前馈层以及残差连接组成。

在原始论文中,编码器块重复六次。我们创建Encoder类,将多个EncoderBlock组装在一起。在通过所有块处理输入后,我们还添加了层归一化作为最后一步。

class EncoderBlock(nn.Module):
    def __init__(self, self_attention_block: MultiHeadAttentionBlock, feed_forward_block: FeedForwardBlock, dropout: float) -> None:
        super().__init__()
        self.self_attention_block = self_attention_block
        self.feed_forward_block = feed_forward_block
        self.residual_connections = nn.ModuleList([ResidualConnection(dropout) for _ in range(2)])

    def forward(self, x, src_mask):
        x = self.residual_connections[0](x, lambda x: self.self_attention_block(x, x, x, src_mask))
        x = self.residual_connections[1](x, self.feed_forward_block)
        return x


class Encoder(nn.Module):
    def __init__(self, layers: nn.ModuleList) -> None:
        super().__init__()
        self.layers = layers
        self.norm = LayerNormalization()

    def forward(self, x, mask):
        for layer in self.layers:
            x = layer(x, mask)
        return self.norm(x)

3.8 解码器

类似地,解码器也由几个解码器块组成,在原始论文中,这些块重复六次。主要区别在于,它有一个额外的子层,该子层执行带有交叉注意力组件的多头注意力操作。这个子层使用编码器的输出作为键和值,同时将解码器的输入作为查询。

对于输出嵌入,我们可以使用与编码器相同的InputEmbeddings类。你还会注意到,自注意力子层是经过掩码处理的,这限制了模型访问序列中的未来元素。

我们将首先构建DecoderBlock类,然后构建Decoder类,该类将多个DecoderBlock组装在一起。

class DecoderBlock(nn.Module):
    def __init__(self,  self_attention_block: MultiHeadAttentionBlock, cross_attention_block: MultiHeadAttentionBlock, feed_forward_block: FeedForwardBlock, dropout: float) -> None:
        super().__init__()
        self.self_attention_block = self_attention_block
        self.cross_attention_block = cross_attention_block
        self.feed_forward_block = feed_forward_block
        self.residual_connections = nn.ModuleList([ResidualConnection(dropout) for _ in range(3)])

    def forward(self, x, encoder_output, src_mask, tgt_mask):
        x = self.residual_connections[0](x, lambda x: self.self_attention_block(x, x, x, tgt_mask))
        x = self.residual_connections[1](x, lambda x: self.cross_attention_block(x, encoder_output, encoder_output, src_mask))
        x = self.residual_connections[2](x, self.feed_forward_block)
        return x


class Decoder(nn.Module):
    def __init__(self, layers: nn.ModuleList) -> None:
        super().__init__()
        self.layers = layers
        self.norm = LayerNormalization()

    def forward(self, x, encoder_output, src_mask, tgt_mask):
        for layer in self.layers:
            x = layer(x, encoder_output, src_mask, tgt_mask)
        return self.norm(x)

在解码器的图示中可以看到,在经过一组解码器块处理后,我们对输出应用一个线性层和一个Softmax函数来得到概率。下面的ProjectionLayer类负责将模型的输出转换为词汇表上的概率分布,我们从可能的词汇表中选择每个输出标记。

class ProjectionLayer(nn.Module):
    def __init__(self, d_model: int, vocab_size: int) -> None:
        super().__init__()
        self.proj = nn.Linear(d_model, vocab_size)

    def forward(self, x):
        return torch.log_softmax(self.proj(x), dim = -1)

3.9 构建Transformer

我们终于准备好了Transformer架构的所有组件。现在,我们可以将它们组合在一起构建Transformer了。

在下面的Transformer类中,我们将把模型架构的所有组件整合到一起。

class Transformer(nn.Module):
    def __init__(self, encoder: Encoder, decoder: Decoder, src_embed: InputEmbeddings, tgt_embed: InputEmbeddings, src_pos: PositionalEncoding, tgt_pos: PositionalEncoding, projection_layer: ProjectionLayer) -> None:
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.src_embed = src_embed
        self.tgt_embed = tgt_embed
        self.src_pos = src_pos
        self.tgt_pos = tgt_pos
        self.projection_layer = projection_layer

    def encode(self, src, src_mask):
        src = self.src_embed(src)
        src = self.src_pos(src)
        return self.encoder(src, src_mask)

    def decode(self, encoder_output, src_mask, tgt, tgt_mask):
        tgt = self.tgt_embed(tgt)
        tgt = self.tgt_pos(tgt)
        return self.decoder(tgt, encoder_output, src_mask, tgt_mask)

    def project(self, x):
        return self.projection_layer(x)

架构终于完成了。现在,我们定义一个名为build_transformer的函数,在这个函数中,我们定义参数以及构建一个用于机器翻译任务的完整可用的Transformer模型所需的一切。

我们将设置与原始论文《Attention Is All You Need》中相同的参数,其中 $d_{model}=512$,$N = 6$,$h = 8$,丢弃率$P_{drop}=0.1$,$d_{ff}=2048$。

def build_transformer(src_vocab_size: int, tgt_vocab_size: int, src_seq_len: int, tgt_seq_len: int, d_model: int = 512, N: int = 6, h: int = 8, dropout: float = 0.1, d_ff: int = 2048) -> Transformer:
    src_embed = InputEmbeddings(d_model, src_vocab_size)
    tgt_embed = InputEmbeddings(d_model, tgt_vocab_size)
    src_pos = PositionalEncoding(d_model, src_seq_len, dropout)
    tgt_pos = PositionalEncoding(d_model, tgt_seq_len, dropout)
    encoder_blocks = []
    for _ in range(N):
        encoder_self_attention_block = MultiHeadAttentionBlock(d_model, h, dropout)
        feed_forward_block = FeedForwardBlock(d_model, d_ff, dropout)
        encoder_block = EncoderBlock(encoder_self_attention_block, feed_forward_block, dropout)
        encoder_blocks.append(encoder_block)
    decoder_blocks = []
    for _ in range(N):
        decoder_self_attention_block = MultiHeadAttentionBlock(d_model, h, dropout)
        decoder_cross_attention_block = MultiHeadAttentionBlock(d_model, h, dropout)
        feed_forward_block = FeedForwardBlock(d_model, d_ff, dropout)
        decoder_block = DecoderBlock(decoder_self_attention_block, decoder_cross_attention_block, feed_forward_block, dropout)
        decoder_blocks.append(decoder_block)
    encoder = Encoder(nn.ModuleList(encoder_blocks))
    decoder = Decoder(nn.ModuleList(decoder_blocks))
    projection_layer = ProjectionLayer(d_model, tgt_vocab_size)
    transformer = Transformer(encoder, decoder, src_embed, tgt_embed, src_pos, tgt_pos, projection_layer)
    for p in transformer.parameters():
        if p.dim() > 1:
            nn.init.xavier_uniform_(p)
    return transformer

现在,模型已经准备好进行训练了!

3.9.1 分词器

分词是我们Transformer模型至关重要的预处理步骤。在这一步中,我们将原始文本转换为模型能够处理的数字格式。

有几种分词策略。我们将使用单词级分词,将句子中的每个单词转换为一个标记。

在对句子进行分词后,我们根据训练分词器时训练语料库中创建的词汇表,将每个标记映射到一个唯一的整数ID。每个整数代表词汇表中的一个特定单词。

除了训练语料库中的单词,Transformer还使用特殊标记来实现特定目的。以下是我们马上要定义的一些特殊标记:

  • [UNK]:这个标记用于识别序列中的未知单词。
  • [PAD]:填充标记,用于确保批次中的所有序列具有相同的长度,因此我们用这个标记填充较短的句子。在训练过程中,我们使用注意力掩码来 “告诉” 模型忽略填充标记,因为它们对任务没有实际意义。
  • [SOS]:这是一个用于表示句子开始的标记。
  • [EOS]:这是一个用于表示句子结束的标记。

在下面的build_tokenizer函数中,我们确保有一个可用的分词器来训练模型。它会检查是否存在现有的分词器,如果不存在,则训练一个新的分词器。

def build_tokenizer(config, ds, lang):
    tokenizer_path = Path(config['tokenizer_file'].format(lang))
    if not Path.exists(tokenizer_path):
        tokenizer = Tokenizer(WordLevel(unk_token = '[UNK]'))
        tokenizer.pre_tokenizer = Whitespace()
        trainer = WordLevelTrainer(special_tokens = ["[UNK]", "[PAD]",
        "[SOS]", "[EOS]"], min_frequency = 2)
        tokenizer.train_from_iterator(get_all_sentences(ds, lang), trainer = trainer)
        tokenizer.save(str(tokenizer_path))
    else:
        tokenizer = Tokenizer.from_file(str(tokenizer_path))
    return tokenizer

3.9.2 加载数据集

对于这个任务,我们将使用🤗Hugging Face上提供的OpusBooks数据集。这个数据集包含两个特征:idtranslationtranslation特征包含不同语言的句子对,如西班牙语和葡萄牙语、英语和法语等等。

我首先尝试将句子从英语翻译成葡萄牙语(我的母语),但这个语言对只有1400个示例,所以在当前模型配置下,结果并不理想。然后我尝试使用英语 - 法语语言对,因为它有更多的示例(12.7万个),但在当前配置下训练时间太长。最后,我选择在英语 - 意大利语语言对上训练模型,这也是在“用PyTorch从头开始编码Transformer,完整解释、训练和推理”视频中使用的语言对,因为它在性能和训练时间之间达到了很好的平衡。

我们首先定义get_all_sentences函数,用于遍历数据集并根据定义的语言对提取句子(我们稍后会进行定义)。

def get_all_sentences(ds, lang):
    for pair in ds:
        yield pair['translation'][lang]

定义get_ds函数来加载并准备用于训练和验证的数据集。在这个函数中,我们构建或加载分词器,分割数据集,并创建DataLoader,以便模型能够成功地按批次遍历数据集。这些函数的结果是源语言和目标语言的分词器以及DataLoader对象。

def get_ds(config):
    ds_raw = load_dataset('opus_books', f'{config["lang_src"]}-{config["lang_tgt"]}', split = 'train')
    tokenizer_src = build_tokenizer(config, ds_raw, config['lang_src'])
    tokenizer_tgt = build_tokenizer(config, ds_raw, config['lang_tgt'])
    train_ds_size = int(0.9 * len(ds_raw))
    val_ds_size = len(ds_raw) - train_ds_size
    train_ds_raw, val_ds_raw = random_split(ds_raw, [train_ds_size, val_ds_size])
    train_ds = BilingualDataset(train_ds_raw, tokenizer_src, tokenizer_tgt, config['lang_src'], config['lang_tgt'], config['seq_len'])
    val_ds = BilingualDataset(val_ds_raw, tokenizer_src, tokenizer_tgt, config['lang_src'], config['lang_tgt'], config['seq_len'])
    max_len_src = 0
    max_len_tgt = 0
    for pair in ds_raw:
        src_ids = tokenizer_src.encode(pair['translation'][config['lang_src']]).ids
        tgt_ids = tokenizer_src.encode(pair['translation'][config['lang_tgt']]).ids
        max_len_src = max(max_len_src, len(src_ids))
        max_len_tgt = max(max_len_tgt, len(tgt_ids))
    print(f'Max length of source sentence: {max_len_src}')
    print(f'Max length of target sentence: {max_len_tgt}')
    train_dataloader = DataLoader(train_ds, batch_size = config['batch_size'], shuffle = True)
    val_dataloader = DataLoader(val_ds, batch_size = 1, shuffle = True)
    return train_dataloader, val_dataloader, tokenizer_src, tokenizer_tgt

我们定义casual_mask函数,为解码器的注意力机制创建一个掩码。这个掩码可以防止模型获取序列中未来元素的信息。

我们首先创建一个填充为1的正方形网格,通过size参数确定网格大小。然后,我们将主对角线上方的所有数字改为0,使对角线一侧的所有数字变为0,而其余数字保持为1。然后,函数将所有这些值取反,将1变为0,0变为1。这个过程对于预测序列中未来标记的模型至关重要。

def casual_mask(size):
    mask = torch.triu(torch.ones(1, size, size), diagonal = 1).type(torch.int)
    return mask == 0

BilingualDataset类通过对数据集中目标语言和源语言的文本进行分词,并添加所有必要的特殊标记来处理这些文本。这个类还确保两种语言的句子都在最大序列长度内,并对所有必要的句子进行填充。

class BilingualDataset(Dataset):
    def __init__(self, ds, tokenizer_src, tokenizer_tgt, src_lang, tgt_lang, seq_len) -> None:
        super().__init__()
        self.seq_len = seq_len
        self.ds = ds
        self.tokenizer_src = tokenizer_src
        self.tokenizer_tgt = tokenizer_tgt
        self.src_lang = src_lang
        self.tgt_lang = tgt_lang
        self.sos_token = torch.tensor([tokenizer_tgt.token_to_id("[SOS]")], dtype=torch.int64)
        self.eos_token = torch.tensor([tokenizer_tgt.token_to_id("[EOS]")], dtype=torch.int64)
        self.pad_token = torch.tensor([tokenizer_tgt.token_to_id("[PAD]")], dtype=torch.int64)

    def __len__(self):
        return len(self.ds)

    def __getitem__(self, index: Any) -> Any:
        src_target_pair = self.ds[index]
        src_text = src_target_pair['translation'][self.src_lang]
        tgt_text = src_target_pair['translation'][self.tgt_lang]
        enc_input_tokens = self.tokenizer_src.encode(src_text).ids
        dec_input_tokens = self.tokenizer_tgt.encode(tgt_text).ids
        enc_num_padding_tokens = self.seq_len - len(enc_input_tokens) - 2
        dec_num_padding_tokens = self.seq_len - len(dec_input_tokens) - 1
        if enc_num_padding_tokens < 0 or dec_num_padding_tokens < 0:
            raise ValueError('Sentence is too long')
        encoder_input = torch.cat(
            self.sos_token,
            torch.tensor(enc_input_tokens, dtype = torch.int64),
            self.eos_token,
            torch.tensor([self.pad_token] * enc_num_padding_tokens, dtype = torch.int64)
        decoder_input = torch.cat(
            self.sos_token,
            torch.tensor(dec_input_tokens, dtype = torch.int64),
            torch.tensor([self.pad_token] * dec_num_padding_tokens, dtype = torch.int64)
        label = torch.cat(
            torch.tensor(dec_input_tokens, dtype = torch.int64),
            self.eos_token,
            torch.tensor([self.pad_token] * dec_num_padding_tokens, dtype = torch.int64)
        assert encoder_input.size(0) == self.seq_len
        assert decoder_input.size(0) == self.seq_len
        assert label.size(0) == self.seq_len
        return {
            'encoder_input': encoder_input,
            'decoder_input': decoder_input,
            'encoder_mask': (encoder_input != self.pad_token).unsqueeze(0).unsqueeze(0).int(),
            'decoder_mask': (decoder_input != self.pad_token).unsqueeze(0).unsqueeze(0).int() & casual_mask(decoder_input.size(0)),
            'label': label,
            'src_text': src_text,
            'tgt_text': tgt_text

3.9.3 验证循环

我们现在将为验证循环创建两个函数。验证循环对于评估模型在翻译训练过程中未见过的数据中的句子的性能至关重要。

我们将定义两个函数。第一个函数greedy_decode,通过获取最可能的下一个标记来给出模型的输出。第二个函数run_validation,负责运行验证过程,在这个过程中,我们解码模型的输出,并将其与目标句子的参考文本进行比较。

def greedy_decode(model, source, source_mask, tokenizer_src, tokenizer_tgt, max_len, device):
    sos_idx = tokenizer_tgt.token_to_id('[SOS]')
    eos_idx = tokenizer_tgt.token_to_id('[EOS]')
    encoder_output = model.encode(source, source_mask)
    decoder_input = torch.empty(1,1).fill_(sos_idx).type_as(source).to(device)
    while True:
        if decoder_input.size(1) == max_len:
            break
        decoder_mask = casual_mask(decoder_input.size(1)).type_as(source_mask).to(device)
        out = model.decode(encoder_output, source_mask, decoder_input, decoder_mask)
        prob = model.project(out[:, -1])
        _, next_word = torch.max(prob, dim=1)
        decoder_input = torch.cat([decoder_input, torch.empty(1,1). type_as(source).fill_(next_word.item()).to(device)], dim=1)
        if next_word == eos_idx:
            break
    return decoder_input.squeeze(0)


def run_validation(model, validation_ds, tokenizer_src, tokenizer_tgt, max_len, device, print_msg, global_state, writer, num_examples=2):
    model.eval()
    count = 0
    console_width = 80
    with torch.no_grad():
        for batch in validation_ds:
            count += 1
            encoder_input = batch['encoder_input'].to(device)
            encoder_mask = batch['encoder_mask'].to(device)
            assert encoder_input.size(0) ==  1, 'Batch size must be 1 for validation.'
            model_out = greedy_decode(model, encoder_input, encoder_mask, tokenizer_src, tokenizer_tgt, max_len, device)
            source_text = batch['src_text'][0]
            target_text = batch['tgt_text'][0]
            model_out_text = tokenizer_tgt.decode(model_out.detach().cpu().numpy())
            print_msg('-'*console_width)
            print_msg(f'SOURCE: {source_text}')
            print_msg(f'TARGET: {target_text}')
            print_msg(f'PREDICTED: {model_out_text}')
            if count == num_examples:
                break

3.9.4 训练循环

我们准备在OpusBook数据集上训练我们的Transformer模型,用于英语到意大利语的翻译任务。

我们首先定义get_model函数,通过调用我们之前定义的build_transformer函数来加载模型。这个函数使用config字典来设置一些参数。

def get_model(config, vocab_src_len, vocab_tgt_len):
    model = build_transformer(vocab_src_len, vocab_tgt_len, config['seq_len'], config['seq_len'], config['d_model'])
    return model

在这个笔记本中,我多次提到了config字典。现在,是时候创建它了。

在下面的代码单元中,我们将定义两个函数来配置我们的模型和训练过程。

get_config函数中,我们定义了训练过程的关键参数。batch_size表示一次迭代中使用的训练示例数量,num_epochs表示整个数据集在Transformer中进行前向和后向传递的次数,lr表示优化器的学习率等等。我们最终还将定义OpusBook数据集中的语言对,'lang_src': 'en'表示选择英语作为源语言,'lang_tgt': 'it'表示选择意大利语作为目标语言。

get_weights_file_path函数构建了用于保存或加载特定epoch的模型权重的文件路径。

def get_config():
    return{
        'batch_size': 8,
        'num_epochs': 20,
        'lr': 10**-4,
        'seq_len': 350,
        'd_model': 512,
        'lang_src': 'en',
        'lang_tgt': 'it',
        'model_folder': 'weights',
        'model_basename': 'tmodel_',
        'preload': None,
        'tokenizer_file': 'tokenizer_{0}.json',
        'experiment_name': 'runs/tmodel'


def get_weights_file_path(config, epoch: str):
    model_folder = config['model_folder']
    model_basename = config['model_basename']
    model_filename = f"{model_basename}{epoch}.pt"
    return str(Path('.')/ model_folder/ model_filename)

我们最终定义最后一个函数train_model,它将config参数作为输入。

在这个函数中,我们将为训练设置好一切。我们会将模型及其必要组件加载到GPU上以加快训练速度,设置Adam优化器,并配置交叉熵损失函数,用于计算模型输出的翻译结果与数据集中参考翻译之间的差异。

这个函数包含了遍历训练批次、进行反向传播和计算梯度所需的所有循环。我们还将用它来运行验证函数,并保存模型的当前状态。

def train_model(config):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    print(f"Using device {device}")
    Path(config['model_folder']).mkdir(parents=True, exist_ok=True)
    train_dataloader, val_dataloader, tokenizer_src, tokenizer_tgt = get_ds(config)
    model = get_model(config,tokenizer_src.get_vocab_size(), tokenizer_tgt.get_vocab_size()).to(device)
    writer = SummaryWriter(config['experiment_name'])
    optimizer = torch.optim.Adam(model.parameters(), lr=config['lr'], eps = 1e-9)
    initial_epoch = 0
    global_step = 0
    if config['preload']:
        model_filename = get_weights_file_path(config, config['preload'])
        print(f'Preloading model {model_filename}')
        state = torch.load(model_filename)
        initial_epoch = state['epoch'] + 1
        optimizer.load_state_dict(state['optimizer_state_dict'])
        global_step = state['global_step']
    loss_fn = nn.CrossEntropyLoss(ignore_index = tokenizer_src.token_to_id('[PAD]'), label_smoothing = 0.1).to(device)
    for epoch in range(initial_epoch, config['num_epochs']):
        batch_iterator = tqdm(train_dataloader, desc = f'Processing epoch {epoch:02d}')
        for batch in batch_iterator:
            model.train()
            encoder_input = batch['encoder_input'].to(device)
            decoder_input = batch['decoder_input'].to(device)
            encoder_mask = batch['encoder_mask'].to(device)
            decoder_mask = batch['decoder_mask'].to(device)
            encoder_output = model.encode(encoder_input, encoder_mask)
            decoder_output = model.decode(encoder_output, encoder_mask, decoder_input, decoder_mask)
            proj_output = model.project(decoder_output)
            label = batch['label'].to(device)
            loss = loss_fn(proj_output.view(-1, tokenizer_tgt.get_vocab_size()), label.view(-1))
            batch_iterator.set_postfix({f"loss": f"{loss.item():6.3f}"})
            writer.add_scalar('train loss', loss.item(), global_step)
            writer.flush()
            loss.backward()
            optimizer.step()
            optimizer.zero_grad()
            global_step += 1
            run_validation(model, val_dataloader, tokenizer_src, tokenizer_tgt, config['seq_len'], device, lambda msg: batch_iterator.write(msg), global_step, writer)
            model_filename = get_weights_file_path(config, f'{epoch:02d}')
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'global_step': global_step
            }, model_filename)

我们现在可以训练模型了!

if __name__ == '__main__':
    warnings.filterwarnings('ignore')
    config = get_config()
    train_model(config)
Using device cuda
Downloading builder script:
6.08k/? [00:00<00:00, 391kB/s]
Downloading metadata:
161k/? [00:00<00:00, 11.0MB/s]
Downloading and preparing dataset opus_books/en-it (download: 3.14 MiB, generated: 8.58 MiB, post-processed: Unknown size, total: 11.72 MiB) to /root/.cache/huggingface/datasets/opus_books/en-it/1.0.0/e8f950a4f32dc39b7f9088908216cd2d7e21ac35f893d04d39eb594746af2daf...
Downloading data: 100%
3.30M/3.30M [00:00<00:00, 10.6MB/s]
Dataset opus_books downloaded and prepared to /root/.cache/huggingface/datasets/opus_books/en-it/1.0.0/e8f950a4f32dc39b7f9088908216cd2d7e21ac35f893d04d39eb594746af2daf. Subsequent calls will reuse this data.
Max length of source sentence: 309
Max length of target sentence: 274

最后一个epoch的性能最佳,损失值为2.094。进一步增加训练的epoch数量,以及微调一些参数,可能会带来更理想的结果。

4. 损失函数

Transformer训练中损失函数的选择取决于具体任务。以下是一些常用的损失函数:

4.1 交叉熵损失

用途:这是分类任务中应用最广泛的损失函数,包括自然语言处理中的序列到序列任务(如翻译)。
工作原理:交叉熵损失衡量预测概率分布与真实分布(独热编码的目标)之间的差异。目标是最小化这种差异,促使模型为正确的类别分配更高的概率。
数学公式
$$H(p,q)=-\sum_{x}p(x)\log(q(x))$$
其中$p$是真实分布,$q$是预测分布。

适用场景:适用于下一令牌预测(语言建模)和序列到序列任务(翻译)。

4.1.1 标签平滑

  • 作用:防止模型预测过于自信。
  • 原理:将小概率分布在所有类别上,避免模型对某一类别过度自信。

4.2 标记级损失与序列级损失

  • 标记级损失:在翻译等任务中,损失通常在每个标记步骤计算,粒度非常细。
  • 序列级损失:对于摘要等特定任务,考虑序列级指标(如BLEU分数),对整个预测序列计算单个损失值可能更有益。

5. 结论

本文深入探讨了从头开始构建和训练Transformer模型的复杂细节,详细拆解了过程中的每个关键组件和步骤。

在“训练Transformer”部分,我们探究了一般的训练过程,概述了优化技术、数据要求和计算方面的考虑因素。

“从头构建Transformer”部分是逐块构建Transformer模型的全面指南。我们首先研究输入嵌入及其在为Transformer表示数据中的作用。随后介绍位置编码,它是一种将顺序和上下文注入标记序列的机制。层归一化确保了训练过程的稳定性并加速了收敛。前馈网络和多头注意力层作为两个核心构建块,我们详细描述了它们在建模单词之间复杂关系中的贡献。我们还强调了残差连接在缓解梯度消失问题、促进更深层次架构方面的重要性。

然后,我们将重点转移到编码器和解码器架构,解释它们如何协同工作来处理输入并生成输出序列。在“构建Transformer”部分,我们讨论了实际要素,如分词器、加载数据集、验证循环和训练循环,深入分析了实际实现中的挑战和解决方案。

最后一部分“损失函数”解释了用于训练和微调模型的关键评估指标。详细讨论了交叉熵损失,并对标记级损失和序列级损失进行了比较,强调了它们对模型性能的影响。

6. 测试你的知识!

  1. Transformer中的自注意力机制如何实现长距离依赖建模,其计算挑战是什么?
    自注意力计算序列中所有标记之间的成对关系,使模型能够捕捉长距离依赖,而不受循环神经网络(RNN)中固定上下文窗口的限制。然而,这导致了与输入长度成二次方的时间复杂度,对于长序列来说计算成本很高。
  2. 解释查询、键和值矩阵在Transformer的多头注意力机制中是如何工作的。
    在多头注意力中,输入序列中的每个标记被转换为三个向量:查询(Q)、键(K)和值(V)。注意力分数通过计算查询和键向量的点积、缩放并经过softmax函数得到。然后,这些分数对值向量进行加权,以产生输出表示,从而捕获上下文信息。
  3. 不同类型的位置编码(正弦编码与学习嵌入)对Transformer性能有何影响?
    正弦位置编码引入了基于位置的确定性函数,使模型能够外推到更长的序列。学习嵌入提供了灵活性,可能更有效地捕捉特定位置的模式,但如果没有适当的正则化,在训练数据之外的泛化能力可能较差。
  4. Transformer如何处理可变长度序列,这种能力会带来哪些挑战?
    Transformer通过使用填充标记确保批次内输入维度一致,并应用注意力掩码在计算时忽略填充部分,以此来处理可变长度序列。挑战包括填充带来的计算开销增加,以及由于批次不平衡可能导致的效率降低。
  5. 描述在Transformer解码器的上下文中,交叉注意力与自注意力有何不同。
    交叉注意力用于解码器中,以整合来自编码器的编码表示,使解码器能够关注输入序列的相关部分。相比之下,解码器中的自注意力仅关注解码器自身的输入(先前的输出),以确保上下文和自回归预测能力。
  6. 可以应用哪些优化技术来降低Transformer中自注意力机制的计算复杂度?
    优化技术包括稀疏注意力机制、局部敏感哈希(LSH)注意力、线性化注意力(如Linformer和Performer)以及像Reformer这样的内存高效实现。这些方法旨在近似或限制注意力计算,以降低计算成本和内存使用。
  7. 解释残差连接如何影响深度Transformer架构中的梯度流动和模型收敛。
    残差连接通过在反向传播期间为梯度提供直接回流路径,防止了梯度消失问题。这有助于更稳定的训练和更快的收敛,特别是在深度架构中,降低了梯度在多层中衰减的风险。
  8. 为什么在一些Transformer架构中,层归一化在子层之前应用(预归一化),它与后归一化的Transformer有何不同?
    预归一化Transformer在每个子层操作(如注意力或前馈)之前应用层归一化,有助于稳定训练并改善收敛,尤其适用于更深的模型。后归一化则在子层之后应用,对于非常深的架构可能会带来优化挑战。
  9. 自定义分词器如何影响Transformer在特定领域任务上的性能,使用子词分词与基于单词的分词各有哪些权衡?
    针对特定领域词汇定制的分词器可确保对罕见或技术术语有更好的表示,从而提高模型性能。子词分词在处理罕见词和词汇表外术语时具有更好的泛化能力,但可能导致输入序列更长;而基于单词的分词在遇到未见过的单词时可能会遇到困难。
  10. 在训练Transformer时,使用序列级损失与标记级损失在下游任务(如语言生成或翻译)的性能方面有何不同?
    序列级损失相对于真实值优化整个预测输出序列,鼓励生成连贯且上下文准确的序列,适用于文本生成等任务。标记级损失关注每个标记的准确性,这可能导致局部最优但全局不一致的输出,在需要一致性的任务中可能导致次优性能。

1. LLM大模型架构专栏|| 从NLP基础谈起
2.LLM大模型架构专栏|| 自然语言处理(NLP)之建模
3. LLM大模型架构之词嵌入(Part1)
4. LLM大模型架构之词嵌入(Part2)
5. LLM大模型架构之词嵌入(Part3)
6. LLM架构从基础到精通之循环神经网络(RNN)
7. LLM架构从基础到精通之LSTM
8. LLM架构从基础到精通之门控循环单元(GRUs)
9. 20000字的注意力机制讲解,全网最全
10. 深入探究编码器 - 解码器架构:从RNN到Transformer的自然语言处理模型
11. 2w8000字深度解析从RNN到Transformer:构建NLP应用的架构演进之路
欢迎关注公众号 柏企科技圈柏企阅文 如果您有任何问题或建议,欢迎在评论区留言交流!

本文由mdnice多平台发布

posted @   图南CBQ  阅读(37)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示