3 Implementing minBERT
3.1 bert的细节
Tokenization (tokenizer.py)
BERT模型在进行任何额外处理之前,首先将句子输入转换为tokens。具体来说,BERT模型使用一种名为WordPiece的分词器,将句子拆分成更小的词片(word pieces)。BERT预定义了一套包含30,000个不同词片的集合。这些词片随后被转换为id,以便在BERT模型的其余部分中使用。除了将每个句子分割成它的构成词片tokens之外,之前未见过的词片(即,不是原始30,000词片集合中的部分)将被标记为[UNK] token。
为了确保所有输入句子具有相同的长度,每个输入句子都会被填充到给定的最大长度(512)并使用[PAD] token。最后,对于许多下游任务,BERT的第一个token的隐藏状态通常用作整个句子的嵌入表示。为了适应这一点,每个输入句子的token表示前会添加一个[CLS] token。在作业的第一部分中,处理这个token的隐藏状态。
最后,BERT使用[SEP] token在两个输入句子之间引入一个人工的分隔。这种分隔token对BERT的预训练任务——下一个句子预测至关重要,但对情感分析任务而言,大部分无关紧要。可学习的token嵌入将单个输入id映射到向量表示中,供后续使用。更具体地说,给定一些输入的词片索引 w1, ..., wk ∈ N,嵌入层执行一个嵌入查找,将这些索引转换为token嵌入 v1, ..., vk ∈ RD。此外,可学习的分段嵌入用于区分不同类型的句子。在这个项目中,只考虑单独的句子,并不进行下一个句子预测的任务,因此分段嵌入在提供的代码库中是以占位符的形式实现的。这表示在当前的代码实现中,分段嵌入不是活跃使用的,而是预留位置以便未来可能的使用或修改。
Embedding Layer (bert.BertModel.embed)
最后,位置嵌入用于编码输入中不同单词的位置。与token嵌入一样,位置嵌入是参数化的嵌入,它们为给定BERT输入中的每一个位置(共512个位置)学习得到。这意味着每个位置都有一个独特的向量表示,这有助于模型理解词语在句子中的相对或绝对位置,从而在处理语言时考虑到词序和结构的影响。
BERT Transformer Layer (bert.BertLayer)
BERT的一个Transformer层包含以下几个部分:
-
多头注意力(Multi-head Attention):这是Transformer架构的核心部分。它包含多个注意力机制,每个机制关注输入序列中不同的信息子空间。这个层通过考虑序列中其他元素(单词或token)的信息,来调整每个元素(单词或token)的表示。
-
加法与规范化层(Add & Norm):在多头注意力之后,会有一个加法和规范化层,带有残差连接。这个层的作用是将注意力层的输出与输入相加(即残差连接),然后进行规范化处理,这有助于缓解深层网络训练中的梯度消失问题。
-
前馈层(Feed Forward):这是一个全连接的神经网络,它对每个位置的token进行进一步的变换,通常包括两个线性变换和一个非线性激活函数。
-
另一个加法与规范化层(Add & Norm):前馈层后面紧跟着又一个加法和规范化层,同样包含残差连接,以确保每一层的输出都加上输入再进行规范化。
每一个BERT的编码器层都依次通过这些子层。残差连接允许层直接访问前面层的输出,有助于信息在网络中的流动,防止在深层网络中发生梯度消失问题。规范化则负责在网络的每个层保持数据的均值和方差,以保证训练的稳定性。整个Transformer层的设计目标是高效地处理序列数据,并允许模型捕捉到复杂的单词间依赖关系。
Multi-head attention (bert.BertSelfAttention.attention)
Dropout
Dropout是一种常用的正则化技术,它通过在训练过程中随机将网络中的子集单位暂时舍弃来减少模型的过拟合。这意味着在每次训练迭代中,每个神经元有一个概率pdrop不会对下一层产生任何影响,也就是说,它的激活值暂时被设置为0。这样做可以模拟一个更稀疏的网络,并迫使网络学习到更鲁棒的特征,因为它不能依赖于任何一个特征的存在。
在BERT模型中,dropout被应用在以下两个地方:
-
子层输出上:在每个子层(如多头注意力层、前馈层等)的输出被加到输入(残差连接)并规范化之前,先应用dropout。这有助于防止在这些子层中学到的特征过于依赖于训练集的特定样本,提高模型在看不见的数据上的泛化能力。
-
嵌入和位置编码的总和上:在将token嵌入、分段嵌入和位置嵌入相加以形成最终的输入嵌入表示之前,BERT也对这些嵌入的总和应用了dropout。这样可以减少模型对于输入嵌入的过拟合,并增加其泛化性。
BERT模型使用的dropout概率是pdrop = 0.1,这意味着在训练过程中,每个元素有10%的概率被丢弃。
这些信息可以在BERT的原始论文中找到,论文的作者通过大量实验确定了这个dropout概率,旨在在防止过拟合和保持网络容量之间找到平衡。这个策略是BERT训练策略中的一个关键部分,对于实现其出色的性能至关重要
BERT output (bert.BertModel.forward)
-
嵌入层(Embedding Layer):这层的目的是将输入的词汇(通常是词片)转换为稠密的向量表示。这些表示通过学习在训练数据中的模式,捕捉到单词的语义和语法属性。嵌入层由两部分组成:
- 词嵌入(Word Embedder):将每个词汇映射到一个预先定义的向量空间,使得词汇的语义被编码成实值向量。这有助于模型理解单词的意义和单词之间的关系。
- 位置嵌入(Position Embedder):由于Transformer架构本身不具有捕捉序列中位置信息的能力,位置嵌入是必需的,它为序列中每个词的位置提供信息,使模型能够理解词语在句子中的顺序和结构。
-
BERT编码器层(BERT Encoder Layers):这些层是BERT的核心,它们负责处理嵌入层的输出并生成高层次的特征表示。每个编码器层都会对输入数据进行复杂的转换,包括自注意力和前馈网络,以捕捉输入序列内部的复杂依赖关系。这些层堆叠在一起(BERT base模型中有12层),使模型能够捕获更深层次的语言规律和模式。
在堆叠的编码器层中,每一层都对前一层的输出进行进一步的抽象和提炼。这种深度和层次化的处理使BERT能够处理非常复杂的语言现象,并且提高其在多种语言任务中的适用性和性能。
BERT模型提供两个输出是为了满足不同的自然语言处理(NLP)任务需求:
-
last_hidden_state: 这个输出提供了输入序列每个token的上下文化表示。由于BERT能够理解单词的双向上下文(即考虑前面和后面的单词),所以这些表示捕获了相对复杂的语义信息,使得它们非常适合进行词性标注、实体识别、问答和其他需要词级别信息的任务。基本上,任何需要理解序列中每个元素上下文的任务都可以利用这些表示。
-
pooler_output: 这个输出则是对输入序列的整体语义进行编码的表示,它专门来自于第一个token(即[CLS] token)。这种表示方式在设计上是用于任务如句子或文档级的分类,例如情感分析,其中模型需要捕获整个输入序列的整体意义。[CLS] token经过整个Transformer模型的处理,最终携带了整个序列的综合信息。
为什么要设计这样两个输出的主要原因是BERT旨在成为一个通用的语言表示模型,适用于广泛的NLP任务,而不同的任务可能需要不同层次的语义表示。last_hidden_state 提供了细粒度的信息,而 pooler_output 则提供了粗粒度的信息。通过这两个输出,BERT可以更灵活地应用于不同的下游任务,而无需对模型架构进行重大修改
Training BERT
图中展示的是BERT模型训练过程的两个主要阶段:预训练和微调。
-
预训练(Pre-training):这个阶段的目标是在大量未标记的数据上训练BERT模型,以学习语言的深层次特征。预训练任务包括两个无监督的任务:
- 掩码语言模型(Masked Language Model, MLM):这个任务随机地从输入句子中掩盖(或隐藏)一些token(如图中的T1, ..., TN),模型的目标是预测这些被掩盖的token。这种方法迫使模型学习到更深层次的语言理解能力,因为它必须使用上下文来猜测隐藏的单词。
- 下一句预测(Next Sentence Prediction, NSP):在这个任务中,模型被给予一对句子,并必须预测第二句是否在原始文档中紧随第一句之后。这帮助模型学习理解句子间的关系,这对于很多下游任务,比如问答和自然语言推理,都是非常重要的。
-
微调(Fine-tuning):在预训练之后,BERT模型会针对特定的任务进行微调。这包括在有标记的数据上训练,比如进行情感分析、命名实体识别(NER)、或者问答任务(如SQuAD)。在微调阶段,通常会在BERT的输出层添加一些任务特定的层,并在下游任务的数据集上继续训练模型。
预训练和微调的设计允许BERT模型先在大量文本上学习通用的语言表示,然后迅速适应特定的NLP任务。这种策略使BERT能够在多种NLP任务中取得突破性的性能,是其设计中的一个重要创新点。
Masked Language Modeling
在BERT的预训练过程中,掩码语言模型(Masked Language Modeling, MLM)是用来训练模型从数据中提取深层次双向表示的关键方法。在MLM任务中,训练过程会随机遮蔽一定百分比(原始论文中是15%)的词片(word piece)tokens,并尝试预测它们。具体来说,对应于被掩码tokens的最后隐藏向量被输入到词汇表上的一个输出softmax层,并被用来预测原始的token。
为了避免预训练和后续微调阶段出现不匹配的情况,训练过程中“掩码”的tokens并不总是被[MASK] token所替代。相反,训练数据生成器随机选择15%的token位置进行预测,然后在这些选定的tokens中,有80%被替换为[MASK],10%被替换为一个随机token,另外10%保持不变。这种做法有几个目的:
-
提高泛化能力:通过这种方式,模型不仅仅学习去预测[MASK] token,它还学习到即使在输入中存在噪声时(如随机的token)或者没有任何改变时,也能对token进行正确的预测。
-
避免模型偏差:这种策略防止了模型只专注于[MASK]位置的学习,这是因为在真实的微调任务中,输入序列不会有[MASK]标记。通过这种方法,模型在预训练时就已经适应了实际任务的环境。
-
改善上下文理解:允许BERT学习如何利用整个句子的上下文信息,而不仅仅是在掩码位置进行预测。
近年来的论文进一步探讨了MLM对模型性能的影响,并进行了一些改进,比如XLNet提出了置换语言模型(Permutation Language Modeling),可以捕捉更长范围的上下文依赖,或者ELECTRA使用了一种不同的方法,用生成的tokens取代一些原始tokens,并训练模型去区分哪些是真实的,哪些是被替换的,这提供了更有效率的预训练方法。这些创新都是在BERT的原始MLM概念上进行的扩展和改进。
Next Sentence Prediction
在BERT的预训练过程中,下一句预测(Next Sentence Prediction, NSP)任务是设计来帮助BERT模型理解两个句子之间的关系。这个任务对于理解句子间的逻辑连接、因果关系以及它们在文档中的位置关系非常关键,这些能力对于问答、自然语言推理和摘要等下游任务至关重要。
具体来说,BERT模型的NSP任务按以下方式进行:
- 正样本:模型有50%的概率被展示一个句子及其紧随的下一个句子,这形成了一对连续的句子,也就是正样本。
- 负样本:另外50%的概率,模型被展示一个句子和一个随机选择的第二个句子,这构成了不相关的句子对,也就是负样本。
然后BERT模型需要预测第二个句子是否是第一个句子的下文。通过这种方式,BERT不仅学习了句子内的单词如何结合在一起,还学习了句子之间如何关联。
尽管NSP在BERT的原始论文中被提出并使用,但后续的研究开始质疑这一任务对于模型性能提升的贡献。例如,RoBERTa,一个对BERT模型进行改进的后续研究,发现去除NSP并不会降低模型在下游任务中的性能。相反,他们发现,仅使用MLM任务并用更大的数据集和更长时间的训练,模型性能可以得到提升。
RoBERTa,全称为“A Robustly Optimized BERT Pretraining Approach”,是Facebook AI于2019年提出的一种改进的BERT模型。RoBERTa在多个NLP任务上显示出了比原始BERT更优越的性能。以下是它的主要改进点:
-
更长时间、更大批量的训练:RoBERTa通过使用更大的批量和更长时间的训练,以及更大的数据集,来进一步优化BERT的训练过程。
-
去除NSP任务:如前所述,RoBERTa在其预训练过程中去除了下一句预测(NSP)任务,研究者们发现这一步骤对于模型的性能并不是必要的。
-
动态掩码生成:RoBERTa对训练数据进行动态掩码,即掩码是在数据加载时生成的,而不是像BERT一样预先固定的。这使得模型在训练过程中见到更多不同的掩码模式,从而提高了模型的泛化能力。
-
更大的字节级BPE:RoBERTa使用了一个更大的字节级字节对编码(Byte-Pair Encoding, BPE)词汇表,这有助于模型更好地处理不同的数据集和更多的语言现象。
-
全词掩蔽(Full-Word Masking):在BERT中,掩码是随机应用于token上的,可能会导致一个词中的一部分被掩盖,而其余部分没有。RoBERTa改进了这一点,通过掩蔽整个单词来保证掩码的一致性。
-
更大的数据集:RoBERTa在预训练过程中使用了更大和更广泛的数据集,包括多个不同来源的文本数据,从而让模型学习到更加丰富的语言特征。
-
超参数优化:RoBERTa对BERT的一些超参数进行了细微调整,如学习率和注意力权重的剪切,这些调整有助于提升模型的性能。
3.2 Code To Be Implemented: Multi-head Self-attention and the TransformerLayer
class BertSelfAttention(nn.Module):
这部分是完成bert.BertSelfAttention的attention部分,这部分是整个多头注意力的流程,做这部分之前,要理解init部分和transform部分
这个transform部分是将输入的隐藏状态(来自之前层的输出)通过线性变换,然后将其分割成多个“头”,以便并行处理不同的表示子空间。
bs, seq_len = x.shape[:2]
这一行是用于提取输入张量 x
的前两个维度
前两个部分为attention部分准备了矩阵的形状,代码的提示部分提供了以下信息:
计算注意力分数
应用注意力掩码:在对分数进行归一化之前,需要使用一个注意力掩码。这个掩码的目的是在计算softmax之前屏蔽掉填充的token,以防它们对最终结果产生影响
归一化分数
计算加权值:使用归一化后的注意力分数与value
矩阵进行矩阵乘法。这一步骤将每个value
根据其相关性权重进行加权,产生最终的注意力输出。
重塑输出
def attention(self, key, query, value, attention_mask):
# Each attention is calculated following eq. (1) of https://arxiv.org/pdf/1706.03762.pdf.
# Attention scores are calculated by multiplying the key and query to obtain
# a score matrix S of size [bs, num_attention_heads, seq_len, seq_len].
# S[*, i, j, k] represents the (unnormalized) attention score between the j-th and k-th
# token, given by i-th attention head.
# Before normalizing the scores, use the attention mask to mask out the padding token scores.
# Note that the attention mask distinguishes between non-padding tokens (with a value of 0)
# and padding tokens (with a value of a large negative number).
# Make sure to:
# - Normalize the scores with softmax.
# - Multiply the attention scores with the value to get back weighted values.
# - Before returning, concatenate multi-heads to recover the original shape:
# [bs, seq_len, num_attention_heads * attention_head_size = hidden_size].
### TODO
#multiply key and query matrices, divide by sqrt d_k (self.attention_head_size)
score_matrix = torch.matmul(query, torch.transpose(key, -1, -2))
score_matrix = score_matrix / math.sqrt(self.attention_head_size)
bs, num_attention_heads, seq_len, seq_len = score_matrix.size()
#Mask all pad tokens to have big neg val; everything else has 0 by default
score_matrix = score_matrix.masked_fill_(attention_mask[:, :, :seq_len, :seq_len] != 0, -1e10)
#Nonlinear activation function applied to what we have so far
score_matrix = F.softmax(score_matrix, dim=-1)
#Apply dropout before applying my score_matrix of weight to my values
score_matrix = self.dropout(score_matrix)
#applying my score_matrix of weight to my values
out = torch.matmul(score_matrix, value)
#Reshaping
out = out.transpose(1, 2).contiguous()
out = out.view(bs, seq_len, self.all_head_size)
return out
整理一下这三个函数的矩阵的变化过程:
bertselfattention的最后一个函数是forward,用来整合前面定义的组件和操作流程,将它们串联起来形成完整的层的前向传播过程。先是生成 key、value、query 的,然后调用 attention
函数来计算多头注意力的输出
class BertLayer(nn.Module):
def __init__(self, config):
多头注意力后->线性化加层归一化->前向反馈->线性化加层归一化
线性化和层归一化用公式理解:
add_norm
def add_norm(self, input, output, dense_layer, dropout, ln_layer):
# 使用密集层对输出进行变换。
transformed = dense_layer(output)
# 对变换后的输出应用 Dropout。
dropped = dropout(transformed)
# 将输入(残差连接)加到经过 Dropout 的输出上。
residual = input + dropped
# 对加和后的结果应用层归一化。
normalized = ln_layer(residual)
return normalized
这里,apply_dense
是当前层的输出,经过了某个线性变换。接着,dropout
函数被应用到这个变换后的输出上。最后,经过dropout处理的输出(apply_dropout
)与这个层的原始输入(input
)相加,从而形成残差连接。通过这个相加操作,原始输入被“跳过”了当前的网络层,并且被直接传递到后面的层归一化和最终输出中。
残差连接的优势包括:
- 缓解梯度消失:在反向传播时,残差连接允许梯度直接流过,增强了梯度的传播,这对于训练深度网络非常重要。
- 提升学习速率:由于改善了梯度流动,模型通常能够使用更高的学习率,从而加快训练速度。
- 增加网络容量:残差连接使得网络可以通过添加更多层来增加其容量,而不会导致训练困难。
- 更好的信息流动:输入信息可以在不被破坏或丢失的情况下通过网络流动,有助于保持信息的完整性。
因此,残差连接通过为每一层提供直接的“信息高速通道”,增加了深层网络的训练稳定性和效率。
forward
这部分的编写要安装init函数的逻辑,分为四个部分,参数和变量都是init里出现过的,逻辑也在之前分析过
def forward(self, hidden_states, attention_mask):
"""
hidden_states: either from the embedding layer (first BERT layer) or from the previous BERT layer
as shown in the left of Figure 1 of https://arxiv.org/pdf/1706.03762.pdf.
Each block consists of:
1. A multi-head attention layer (BertSelfAttention).
2. An add-norm operation that takes the input and output of the multi-head attention layer.
3. A feed forward layer.
4. An add-norm operation that takes the input and output of the feed forward layer.
"""
# 第1步:多头注意力层
attention_output = self.self_attention(hidden_states, attention_mask)
# 第2步:注意力层后的加法规范化操作
attention_output = self.add_norm(hidden_states, attention_output,
self.attention_dense, self.attention_dropout,
self.attention_layer_norm)
# 第3步:前馈网络
feed_forward_output = self.interm_dense(attention_output)
feed_forward_output = F.gelu(feed_forward_output) # 应用GELU激活函数
# 第4步:前馈网络后的加法规范化操作
output = self.add_norm(attention_output, feed_forward_output,
self.out_dense, self.out_dropout,
self.out_layer_norm)
return output
class BertModel(BertPreTrainedModel):
def __init__(self, config):
位置嵌入是Transformer架构中的关键组件,因为它们提供了模型处理序列数据时必需的顺序信息。由于Transformer的自注意力机制本身并不处理输入数据的顺序,位置嵌入确保了模型能够考虑到词汇在句子中的相对位置。position_ids
的使用确保了每个输入位置都可以从位置嵌入矩阵中获得正确的嵌入向量。
代码行 position_ids = torch.arange(config.max_position_embeddings).unsqueeze(0)
只包含一行,它实现了几个操作:
-
torch.arange(config.max_position_embeddings)
:这个函数生成一个包含从0到config.max_position_embeddings - 1
的整数的一维张量。这里的max_position_embeddings
参数通常表示模型可以处理的输入序列的最大长度。 -
.unsqueeze(0)
:这个操作在张量的第0维(即最前面)增加一个新的维度。原来的一维张量变为了二维张量,形状由[max_position_embeddings]
变为[1, max_position_embeddings]
。
这样处理的目的是为了便于将位置ID张量与其他需要二维批次大小的张量一起使用,比如在后续将这些位置ID用于获取位置嵌入时,可以直接与输入的批次大小进行广播(broadcast)。这种方法在处理变长输入时尤为重要,因为它允许模型根据每个输入的实际长度动态调整位置信息
这部分是对BertLayer的应用
def embed(self, input_ids):
def embed(self, input_ids):
input_shape = input_ids.size()
seq_length = input_shape[1]
# Get word embedding from self.word_embedding into input_embeds.
inputs_embeds = None
### TODO
raise NotImplementedError
# Use pos_ids to get position embedding from self.pos_embedding into pos_embeds.
pos_ids = self.position_ids[:, :seq_length]
pos_embeds = None
### TODO
raise NotImplementedError
# Get token type ids. Since we are not considering token type, this embedding is
# just a placeholder.
tk_type_ids = torch.zeros(input_shape, dtype=torch.long, device=input_ids.device)
tk_type_embeds = self.tk_type_embedding(tk_type_ids)
# Add three embeddings together; then apply embed_layer_norm and dropout and return.
### TODO
raise NotImplementedError
这里也是根据init部分的逻辑编写,
-
[:, :seq_length]
:这部分是一个切片操作,用于从position_ids
张量中选择需要的部分。:
表示选择所有行(在这种情况下,由于position_ids
可能只有一行,这代表选择这唯一的一行)。:seq_length
表示从每行中选择从开始到seq_length
的元素。这里seq_length
是输入序列的实际长度,因此这个操作确保只获取当前序列长度所需的位置ID。
这种操作的结果是得到一个形状为 [1, seq_length]
的张量,其中包含了从第0个位置到第 seq_length-1
个位置的位置ID,适用于当前输入序列的长度。
def embed(self, input_ids):
input_shape = input_ids.size() # 获取输入的维度
seq_length = input_shape[1] # 序列长度
# 从self.word_embedding获取每个输入标记ID的词嵌入
inputs_embeds = self.word_embedding(input_ids)
# 使用pos_ids从self.pos_embedding获取位置嵌入
pos_ids = self.position_ids[:, :seq_length]
pos_embeds = self.pos_embedding(pos_ids)
# 获取标记类型ID。由于这里不考虑标记类型,因此这个嵌入只是一个占位符。
tk_type_ids = torch.zeros(input_shape, dtype=torch.long, device=input_ids.device)
tk_type_embeds = self.tk_type_embedding(tk_type_ids)
# 将三种嵌入相加;然后应用层归一化和dropout,并返回结果。
embeddings = inputs_embeds + pos_embeds + tk_type_embeds
embeddings = self.embed_layer_norm(embeddings)
embeddings = self.embed_dropout(embeddings)
return embeddings
位置嵌入层的分析:
-
[:, :seq_length]
:这部分是一个切片操作,用于从position_ids
张量中选择需要的部分。:
表示选择所有行(在这种情况下,由于position_ids
只有一行,这代表选择这唯一的一行)。:seq_length
表示从每行中选择从开始到seq_length
的元素。这里seq_length
是输入序列的实际长度,因此这个操作确保只获取当前序列长度所需的位置ID。
这种操作的结果是得到一个形状为 [1, seq_length]
的张量,其中包含了从第0个位置到第 seq_length-1
个位置的位置ID,适用于当前输入序列的长度。
标记类型嵌入层的分析:
总的来说,这部分就是对init部分的应用。
def encode(self, hidden_states, attention_mask):
def encode(self, hidden_states, attention_mask):
"""
hidden_states: the output from the embedding layer [batch_size, seq_len, hidden_size]
attention_mask: [batch_size, seq_len]
"""
# Get the extended attention mask for self-attention.
# Returns extended_attention_mask of size [batch_size, 1, 1, seq_len].
# Distinguishes between non-padding tokens (with a value of 0) and padding tokens
# (with a value of a large negative number).
extended_attention_mask: torch.Tensor = get_extended_attention_mask(attention_mask, self.dtype)
# Pass the hidden states through the encoder layers.
for i, layer_module in enumerate(self.bert_layers):
# Feed the encoding from the last bert_layer to the next.
hidden_states = layer_module(hidden_states, extended_attention_mask)
return hidden_states
-
输入:
hidden_states
: 这些是来自嵌入层的输出,结合了词嵌入、位置嵌入和标记类型嵌入。hidden_states
的形状为[batch_size, seq_len, hidden_size]
,表示批次大小、序列长度和隐藏层大小。attention_mask
: 一个形状为[batch_size, seq_len]
的张量,用于指示哪些位置是有效的输入标记,哪些是填充标记。这对于自注意力机制的正确执行至关重要,因为它需要区分有效数据和填充数据。
-
扩展注意力掩码:
extended_attention_mask
: 使用函数get_extended_attention_mask
将简单的attention_mask
转换为一个更适合自注意力使用的形式。转换后的掩码形状为[batch_size, 1, 1, seq_len]
。这种形式的掩码可以直接应用于自注意力机制,区分非填充标记(值为0)和填充标记(值为一个很大的负数),确保在注意力计算中忽略填充标记。
-
通过编码器层处理:
- 这一步骤中,输入的隐藏状态
hidden_states
依次通过BERT模型中的每一层编码器。每一层编码器通常包括一个多头自注意力机制和一个前馈网络。 - 对于每一层,都将当前层的输出作为下一层的输入,同时使用扩展的注意力掩码来确保注意力机制正确地应用于非填充区域。
- 这一步骤中,输入的隐藏状态
每次循环中,layer_module
(即一个 BertLayer
)接收当前的 hidden_states
和 extended_attention_mask
,并输出更新后的 hidden_states
给下一层。这样,数据在模型的每一层中被逐步处理,增加了数据的上下文信息,提高了模型对输入数据的理解深度。
这种层级的设计是BERT以及其他基于Transformer的模型极其强大的原因之一
4 返回最终的隐藏状态
经过所有编码器层之后,最终的hidden_states
包含了经过多层处理后的序列信息,这些信息已经充分融合了上下文关系和输入数据的特征。
def forward(self, input_ids, attention_mask):
def forward(self, input_ids, attention_mask):
"""
input_ids: [batch_size, seq_len], seq_len is the max length of the batch
attention_mask: same size as input_ids, 1 represents non-padding tokens, 0 represents padding tokens
"""
# Get the embedding for each input token.
embedding_output = self.embed(input_ids=input_ids)
# Feed to a transformer (a stack of BertLayers).
sequence_output = self.encode(embedding_output, attention_mask=attention_mask)
# Get cls token hidden state.
first_tk = sequence_output[:, 0]
first_tk = self.pooler_dense(first_tk)
first_tk = self.pooler_af(first_tk)
return {'last_hidden_state': sequence_output, 'pooler_output': first_tk}
1. 输入处理
- 参数:
input_ids
: 形状为[batch_size, seq_len]
的张量,包含了每个输入序列的标记ID。这里的seq_len
是批次中最大的序列长度。attention_mask
: 与input_ids
相同大小的张量,其中1
表示非填充标记,0
表示填充标记。这个掩码帮助模型区分哪些标记是有效的,哪些是为了序列对齐而添加的填充。
2. 获取嵌入向量
embedding_output = self.embed(input_ids=input_ids)
: 这一步调用embed
函数,将每个输入标记ID转换为对应的嵌入向量。这些嵌入包括词嵌入、位置嵌入和(可能的)标记类型嵌入。
3. 通过Transformer编码
sequence_output = self.encode(embedding_output, attention_mask=attention_mask)
: 接下来,嵌入向量被送入encode
函数,该函数通过一系列的BERT层(BertLayer
)处理嵌入向量。这些层使用多头注意力机制处理输入,根据attention_mask
忽略填充标记。
4. 处理[CLS]标记
- 提取[CLS]标记的隐藏状态:
first_tk = sequence_output[:, 0]
: 从编码后的输出中提取第一个标记的隐藏状态,即[CLS]标记的状态。在BERT中,[CLS]标记经常用于分类任务。
- 通过额外层处理:
first_tk = self.pooler_dense(first_tk)
: 使用一个全连接层进一步处理[CLS]标记的隐藏状态。first_tk = self.pooler_af(first_tk)
: 应用激活函数(通常是双曲正切函数Tanh),使输出更适合用作分类任务的特征。
5. 输出结果
- 返回一个字典,包含两个关键的输出:
last_hidden_state
: 这是从最后一个BERT层输出的所有标记的隐藏状态,可用于标记级任务,如命名实体识别或问答。pooler_output
: 这是处理过的[CLS]标记的最终状态,常用于分类任务
sequence_output[:, 0]
从序列输出张量中获取批次中所有序列的第一个元素。这个元素对应于[CLS]标记的隐藏状态。
测试结果: