NLP/Bert 源码解读 2
在上一篇文章中 ,我们简单 了解了 transformers 的设计,从宏观上对整个结构有了了解。
今天,我们继续深入了解一下,在 Hugging Face 的 transformers 中,和 Bert 有关的类,都有哪些,
Bert 的组成
首先,从整体来看,Bert 的组成包括如下几个部分:
- 基本模型和配置
- BertModel
- BertConfig
- Tokenizer
- BertTokenizer
- BasicTokenizer
- WordpieceTokenizer
- BertTokenizerFast
- 预训练模型
- BertForPreTraining
- BertForMaskedLM
- BertForNextSentencePrediction
- 针对特定任务进行 fine-tune 的模型,在
BertModel的基础上添加了针对特定任务的网络层- BertForQuestionAnswering:问答系统
- BertForTokenClassification:对每个 token 做分类
- BertForMultipleChoice:对本文做多选任务
- BertForSequenceClassification:文本分类
而关于训练好的模型的选择,常用的有如下这些(下面这些模型,没有在特定任务上进行 fine-tune):
- bert-base-chinese:基于中文
- bert-base-uncased:基于英语,不区分大小写
- bert-base-cased:基于英语,区分大小写
- bert-base-german-cased:基于德语,区分大小写
- bert-base-multilingual-uncased:多语言,不区分大小写
- bert-base-multilingual-cased:多语言,区分大小写
- bert-large-cased:基于英语,更加大型的 Bert,区分大小写
- bert-large-uncased:基于英语,更加大型的 Bert,不区分大小写
其中用的较多的是:
bert-base-uncased。更多模型的选择,你可以查看 https://huggingface.co/。
下面从基本模型和配置开始讲起。
基本模型和配置
BertModel
BertModel是最基本的 Bert 模型,是很多模型的重要组件,由于代码较多,我们会在下一篇文章详细讲解BertModel的代码。这里先简单介绍一下。
BertModel 是最基本的 Bert 模型,不包括全连接层,没有对特定任务进行 fine-tune,输出是 hidden-states。
BertModel 可以作为编码器(encoder);也可以作为解码器(decoder),作为解码器时,在 self-attention 层之间会增加一个 cross-attention 层。
你可以通过BertConfig的参数 is_decoder=True ,来指定 BertModel 作为解码器(decoder)。
- 当作为编码器(encoder)时,输入参数至少有 1 个:
input_ids或input_embeds。input_ids是经过 tokenization 后的序列,形状是(batch_size, sequence_length)。- 而
input_embeds是从 tokenization 后的序列转换为词向量的矩阵,形状是(batch_size, sequence_length, hidden_size)。
- 当作为解码器(decoder)时,输入是
encoder_hidden_states,来自于编码器(encoder)的输出。
构造方法
构造函数如下:
1 | def __init__(self, config): |
可以看到包含了 BertEmbeddings,BertEncoder 和 BertPooler 。
forward() 方法
forward() 方法参数如下:
- input_ids:输入序列,形状是
(batch_size, sequence_length)。 - attention_mask:表示编码器输入序列对应的 mask,避免在 padding 的 token 上计算 attention,形状是
(batch_size, sequence_length)。0 表示屏蔽(mask)的 token,1 表示没有屏蔽(mask)的 token。 - token_type_ids:序列对应的 token_type_ids,形状是
(batch_size, sequence_length)。 - position_ids:表示每个序列中每个 token 的位置,形状是
(batch_size, sequence_length),每个元素的大小范围是:[0, config.max_position_embeddings - 1]。为什么需要表示位置的向量?因为在 transformers 中,是不考虑 token 顺序的(在 RNN 中,是考虑顺序的)。如果你想要提供句子中 token 的顺序信息,可以传入这个参数。 - head_mask:形状是
(num_heads,)或者(num_layers, num_heads)。0 表示屏蔽(mask)的一组注意力(attention head),1 表示没有屏蔽(mask)的一组注意力(attention head)。 - inputs_embeds:输入序列的矩阵,可以替代
input_ids。形状是(batch_size, sequence_length, hidden_size)。将input_ids的元素转换为向量后,输入到模型。当你想自己控制这个转换过程的时候,在模型外面转换好,使用inputs_embeds传给模型。 - encoder_hidden_states:当
BertConfig的is_decoder=True,指定BertModel作为解码器,那么encoder_hidden_states就是输入的参数,表示解码器的输出。形状是(batch_size, sequence_length, hidden_size)。 - output_attentions:是否返回 attention,默认为 None。
- encoder_attention_mask:表示解吗器输入序列对应的 mask,和
attention_mask的作用类似。不过attention_mask会作用在编码器中,而encoder_attention_mask会作用在解码器中。
返回值有 4 个:
last_hidden_state:最后一层编码器或者解码器的输出。形状是
(batch_size, sequence_length, hidden_size)。pooler_output:第一个 token(
[CLS])对应的最后一层 hidden_state,经过一个线性层和 Tanh 激活函数,得到的结果。形状是(batch_size, hidden_size)。其中,线性层的权重是next sentence prediction (NSP)(分类) 任务中训练得到的。但根据论文,这个输出值不能很好地表示整个句子,通常更好的做法是:对一个序列中的所有 token 的最后一层 hidden_state 进行平均或者池化。
hidden_states:当
output_hidden_states=True或者config.output_hidden_states=True时,会返回这个值。是一个 tuple,包括两个元素,形状都是(batch_size, sequence_length, hidden_size)。- 第 1 个元素是 1 个 tuple,包含每层的 hidden_state。
- 第 2 个元素是 最后 1 层的 hidden_state。
attentions:当
output_attentions=True或者config.output_attentions=True时,会返回这个值。表示经过 Softmax 后的 attention,可以用于计算多头注意力的平均值。形状是(batch_size, num_heads, sequence_length, sequence_length)。
BertConfig
BertConfig 是 BertModel 的配置类,用于配置一些超参数。继承自 PretrainedConfig 。
参数举例如下:
- vocab_size:词汇表大小,默认为 30522
- hidden_size:encoder 层和 pooler 层输出的向量维度,默认为 768
- num_hidden_layers:encoder 的隐藏层数量,默认为 12
- num_attention_heads:每个 encoder 中 attention 层的 head 数,默认为 12
默认加载的是类似于 bert-base-uncased 模型的结构。
关于更多参数,你可以查看https://huggingface.co/transformers/model_doc/bert.html?highlight=bertforsequenceclassification#bertconfig
Tokenizer
Tokenizer 的作用就是对文本进行预处理,存储了每个模型的词汇表,并且对文本进行预处理和编码,如把文本转换为数字,对文本进行 padding,添加 mask 等操作。
下面,我们会讲解和 Bert 有关的每个 Tokenizer。
如果你不想看下面关于每个 tokenizer 的内容,你可以先看 Tokenizer 的最佳实践。
Tokenizer 的最佳实践
首先,我们的序列经过 tokenization,一般需要得到 3 个参数:input_ids, token_type_ids, attention_mask,这 3 个参数是模型必须的。
Tokenizer 提供了一种简便的用法,让我们可以一次性获得这 3 个变量。
例子如下:
1 | from transformers import BertTokenizer, BertForSequenceClassification |
直接调用tokenizer(text),返回的 inputs 就是一个 dict,包括 input_ids, token_type_ids, attention_mask。在下面的代码示例中,我们都是使用这种方法来进行 tokenization。
现在我们讲解 tokenizer(text) 的内部机制。
首先你需要了解 BertTokenizer 的继承关系,如下所示:
- PreTrainedTokenizerBase
- PreTrainedTokenizer
- BertTokenizer
- PreTrainedTokenizer
BertTokenizer 继承于 PreTrainedTokenizer, PreTrainedTokenizer 继承于 PreTrainedTokenizerBase。
我们调用tokenizer(text) ,会进入到 PreTrainedTokenizerBase 的 __call__() 方法。
在这个方法里,会判断传入的数据是一个序列,还是一个 batch_size 的多个序列:
- 如果是一个序列,会调用
encode_plus()方法。 - 如果是一个 batch_size 的多个序列,会调用
batch_encode_plus()方法。
而这两个方法是在 PreTrainedTokenizer 中实现的。
换言之,调用tokenizer(),和调用 encode_plus() 方法(或者 batch_encode_plus() 方法), 是一样的。
encode_plus() 方法
当你的数据是一个序列时,使用这个方法。
下面来了解一下 encode_plus() 方法的部分参数。
text:第 1 个序列(第一句话)
text_pair:第 2 个序列(第二句话)
add_special_tokens:是否在序列前添加 [CLS],结尾添加 [SEP]
max_length:序列的最大长度
truncation_strategy:当传入
max_length时,这个参数才生效,表示截断策略- 'longest_first':直接把两个序列拼接起来,把后面超出 max_length 的部分截断。适用于输入两个文本的情况,处理后两个文本的序列长度之和是
max_length - 'only_first':仅截断第 1 个序列
- 'only_second':仅截断第 2 个序列
- 'do_not_truncate':不截断,如果输入序列大于
max_length, 则会报错
- 'longest_first':直接把两个序列拼接起来,把后面超出 max_length 的部分截断。适用于输入两个文本的情况,处理后两个文本的序列长度之和是
return_tensors:返回的张量类型
- 'tf':返回 PyTorch 的 Tensor(需要装好 PyTorch)
- 'pt':返回 Tensorflow 的 Tensor(需要装好 Tensorflow)
encode_plus() 方法默认会返回一个 dict,包括 input_ids, token_type_ids, attention_mask。
如果你只需要 input_ids,可以调用encode()方法,encode() 方法的代码很简洁,实际上就是用了 encode_plus() 方法,但是只选择返回 input_ids。代码如下所示。
1 | def encode( |
batch_encode_plus() 方法
当你的数据是一个 batch_size 的形式时,使用这个方法。
batch_encode_plus() 方法的参数,大部分和 encode_plus() 方法一样,除了输入的序列。
- batch_text_or_text_pairs:表示多个序列的 list
- 如果只有一个 list,那么直接传入 list 即可。
- 如果有 2 个 list:
text, text_pair。第 1 个 listtext表示第 1 个序列,第 2 个 listtext_pair表示第 2 个序列。你可以传入list(zip(text, text_pair))。
- add_special_tokens:是否在序列前添加 [CLS],结尾添加 [SEP]
- max_length:序列的最大长度
- truncation_strategy:当传入
max_length时,这个参数才生效,表示截断策略 - 'longest_first':直接把两个序列拼接起来,把后面超出 max_length 的部分截断。适用于输入两个文本的情况,处理后两个文本的序列长度之和是
max_length - 'only_first':仅截断第 1 个序列
- 'only_second':仅截断第 2 个序列
- 'do_not_truncate':不截断,如果输入序列大于
max_length, 则会报错
- 'longest_first':直接把两个序列拼接起来,把后面超出 max_length 的部分截断。适用于输入两个文本的情况,处理后两个文本的序列长度之和是
- return_tensors:返回的张量类型
- 'tf':返回 PyTorch 的 Tensor(需要装好 PyTorch)
- 'pt':返回 Tensorflow 的 Tensor(需要装好 Tensorflow)
小结
既然,我只需要调用tokenizer() 或者 batch_encode_plus() 方法,就能够得到想要的输出。那么还有必要了解下面关于其他 tokenizer 的内容吗?
还是有必要的。
因为库中提供的 tokenizer 的方法,不一定能够满足你的需要。如果这些定义好的方法,不能满足你的文本预处理需求,那么你就需要自定义 tokenizer。而自定义 tokenizer,就需要了解 tokenizer 的一些原理机制。
在后面的文章中,我会提供一个实战案例,使用自定义 tokenizer,来完成文本预处理。
BertTokenizer
BertTokenizer 是针对 Bert 模型,基于 WordPiece 的 tokenizer,继承自 PreTrainedTokenizer 。
参数
参数如下:
- vocab_file:包含词汇表的文件路径
- do_lower_case:是否转换为小写,默认为 True
- do_basic_tokenize:在使用
WordpieceTokenizer之前,是否先使用BasicTokenizer,默认为 True - never_split:当
do_basic_tokenize=True时才生效。一个列表,列表里面出现的词不会进行 tokenization - tokenize_chinese_chars:是否对中文的字前后添加空格,默认为 True。当对日文进行 tokenization 时,这个值要设置为 False
关于更多参数,你可以查看 https://huggingface.co/transformers/model_doc/bert.html?highlight=bertforsequenceclassification#berttokenizer。
方法
BertTokenizer 内部,有3 个比较重要的方法:
- _tokenize()
- create_token_type_ids_from_sequences()
- build_inputs_with_special_tokens()
最重要的是 _tokenize()方法,在_tokenize()方法里调用了 BasicTokenizer 和 WordpieceTokenizer,下面会详细讲解 BasicTokenizer 和 WordpieceTokenizer。
_tokenize()
_tokenize() 方法的流程如下:
- 如果
do_basic_tokenize为 True,那么先使用BasicTokenizer,得到的词之后。- 如果词在
never_split里,那么就不使用WordpieceTokenizer。 - 如果词不在
never_split里,再使用WordpieceTokenizer分割。
- 如果词在
- 如果
do_basic_tokenize为 False,只使用WordpieceTokenizer。
代码如下:
1 | def _tokenize(self, text): |
create_token_type_ids_from_sequences()
作用是创建句子的 mask 序列,用于 sequence-pair classification 任务,也称为 Next sentence prediction (NSP)。
输入两个句子,输出句子的 token type ids。类似下面,0 对应位置的单词表示第一个句子,1 对应位置的单词表示第二个句子。
1 | 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 |
参数如下:
- token_ids_0:第 1 个序列
- token_ids_1:第 2 个序列
create_token_type_ids_from_sequences() 方法的流程如下:
如果
token_ids_1为空,那么输入是 1 个序列,输出是全为 0 的token type ids向量(表示一个序列)。如果
token_ids_1不为空,那么输入是 2 个序列,输出是两个序列的token type ids向量。
代码如下:
1 | def create_token_type_ids_from_sequences( |
build_inputs_with_special_tokens()
作用是:在序列开头添加 [CLS],在序列末尾添加 [SEP]。
参数如下:
- token_ids_0:第 1 个序列
- token_ids_1:第 2 个序列
build_inputs_with_special_tokens()` 方法的流程如下:
如果
token_ids_1为空,那么输入是 1 个序列X,输出是[CLS] X [SEP]。如果
token_ids_1不为空,那么输入是 2 个序列A和B,输出是[CLS] A [SEP] B [SEP]。
代码如下:
1 | def build_inputs_with_special_tokens(self, token_ids_0, token_ids_1=None): |
BertTokenizerFast
针对 Bert 模型,基于 WordPiece 的 tokenizer,继承自 PreTrainedTokenizer 。
和类似,不同之处是:没有 _tokenize() 方法,只有 create_token_type_ids_from_sequences() 方法和 build_inputs_with_special_tokens() 方法。
下面来讲解 BasicTokenizer 和 WordpieceTokenizer。
BasicTokenizer
BasicTokenizer 的作用是:根据空格分割词汇和符号。
参数
- do_lower_case:是否将字母转换为小写,默认为 True
- never_split:一个列表,列表里面出现的词不会进行 tokenization
- tokenize_chinese_chars:是否对中文的字前后添加空格,默认为 True
方法
最重要的是 tokenize() 方法,流程如下:
- 当
tokenize_chinese_chars=True,会首先在中文的每个字前后增加空格。 - 调用
whitespace_tokenize(),通过空格分割单词、符号,以及中文的字 - 然后调用
_run_strip_accents(),从单词中去除重音字母 - 最后再调用
whitespace_tokenize()分割单词、符号,以及中文的字,返回结果
代码如下:
1 | def tokenize(self, text, never_split=None): |
WordpieceTokenizer
WordpieceTokenizer 的作用是:对每个单词进行 WordPiece tokenization。
WordPiece 的字面理解,就是把 word 拆成 piece(一片),主要使用双字节编码(BPE,Byte-Pair Encoding)的方式实现。
BPE 的过程可以理解为把一个单词拆分为更小的粒度,使得我们的词汇表更加精简,并且寓意更加清晰。
比如 loved,loving,loves 这三个单词。本身的语义都是“爱”的意思,只是后缀不一样,如果我们以单词为单位,那它们就是不一样的词。而在英语中,意思相近不同后缀的词非常的多,就会使得词表变的很大,训练速度变慢,训练的效果也不是太好。
BPE 算法通过训练,能够把一个单词拆分成更小的单元,称为 token。
例如:
loved,loving,loves这三个单词拆分为 ["lov", "##ed", "##ing", "##es"],表示lov,ed,ing,esunaffable拆分为 ["un", "##aff", "##able"],表示un,aff,able
这样可以把词的本身的意思和时态分开,有效的减少了词汇表的数量。
参数
- vocab:WordPiece tokenization 的词汇表
- unk_token:当一个字没有出现在词汇表中,用这个 unk_token 表示,比如用 "[UNK]" 表示未知字符
- max_input_chars_per_word:每个单词最大的字符数,默认100。如果单词的字母数量超过这个长度,就不进行 WordPiece tokenization,而是用 unk_token 表示这个单词
方法
tokenize() 方法流程如下:
- 首先判断每个单词的长度,如果单词的字母数量超过 max_input_chars_per_word,就不进行 WordPiece tokenization,而是用 unk_token 表示这个单词
- 然后使用贪婪最长匹配优先算法,找出每一个 piece
- 最后把 piece 添加到输出的列表中
代码如下:
1 | def tokenize(self, text): |
针对特定任务进行 fine-tune 的模型
下面这 4 个模型,都是在 BertModel的基础上,添加了线性层,进行分类或者回归。
你可以认为这 4 个模型,在 BertModel的基础上套了一层壳,以适应特定的任务。
BertForSequenceClassification
BertForSequenceClassification 是一个用于文本分类任务的模型。
构造方法
构造函数如下:
1 | def __init__(self, config): |
可以看到,BertForSequenceClassification 是在 BertModel 的基础上,添加了一个 Dropout 层和线性层。
forward() 方法
forward() 方法的参数,在 BertModel的基础上,添加了一个 labels 参数,表示标签。
- labels:标签值,形状是
(batch_size,)。如果config.num_labels == 1,那么会计算回归损失(均方误差);如果config.num_labels > 1,那么会计算分类损失(交叉熵)。
返回值有 4 个。其中,只有 logits 是确定会返回的,其他 3 个不一定有返回。
- loss:损失函数的值。只有传入
labels参数时,才会计算 loss 返回。如果config.num_labels == 1,那么会计算回归损失(均方误差);如果config.num_labels > 1,那么会计算分类损失(交叉熵)。 - logits:返回 Softmax 前一层的输出,形状是
(batch_size, config.num_labels)。 - hidden_states:当
output_hidden_states=True或者config.output_hidden_states=True时,会返回这个值。是一个 tuple,包括两个元素,形状都是(batch_size, sequence_length, hidden_size)。- 第 1 个元素是 1 个 tuple,包含每层的 hidden_state。
- 第 2 个元素是 最后 1 层的 hidden_state。
- attentions:当
output_attentions=True或者config.output_attentions=True时,会返回这个值。表示经过 Softmax 后的 attention,可以用于计算多头注意力的平均值。形状是(batch_size, num_heads, sequence_length, sequence_length)。
forward() 方法的流程如下:
- 首先调用
BertModel,得到输出。 - 取出其中的
pooled_output,表示第一个 [CLS] 对应的 hidden state,经过 Dropout 层、线性层,得到 logits - 如果有 label,那么计算 loss
- 返回
(loss), logits, (hidden_states), (attentions)。其中,只有 logits 是确定会返回的,其他 3 个不一定有返回。
代码如下:
1 | def forward( |
例子
1 | from transformers import BertTokenizer, BertForSequenceClassification |
BertForTokenClassification
BertForTokenClassification 是一个用于对每个 token 进行分类的模型。
构造方法
它的构造函数,和 BertForSequenceClassification 一样,在 BertModel 的基础上,添加了一个 Dropout 层和线性层。
forward() 方法
forward() 方法的参数,在 BertModel的基础上,添加了一个 labels 参数,表示标签。
- labels:表示每个 token 的标签,形状是
(batch_size, sequence_length)。注意这里 labels 的形状和BertForSequenceClassification的 labels 形状不一样。
forward() 方法的流程,和 BertForSequenceClassification 的 forward() 方法基本一样 ,不同的是:
在
BertForSequenceClassification中,使用的是outputs[1]的输出,也就是表示第一个 [CLS] 对应的 hidden state,形状是(batch_size, hidden_size);而在
BertForTokenClassification中,使用的是outputs[0]的输出,也就是最后一层所有 token 的 hidden state。形状是(batch_size, sequence_length, hidden_size)。
另外,由于是 token 级别的分类,因此计算 loss 时,使用了 attention_mask 来跳过那些不需要计算的位置(一般是指 padding 的位置)。
代码如下:
1 | def forward( |
例子
1 | from transformers import BertTokenizer, BertForTokenClassification |
BertForQuestionAnswering
BertForQuestionAnswering 是基于文本提取的问答系统。
输入是一段文本和问题,输出是 2 个整数,表示答案在文本中开始位置和结束位置。
构造方法
构造函数如下:
1 | class BertForQuestionAnswering(BertPreTrainedModel): |
在 BertModel 的基础上,添加了一个线性层对每个 token 做 2 分类,没有 Dropout 层。
forward() 方法
forward() 方法的参数,在 BertModel的基础上,添加了 2 个参数。
- start_positions:答案在文本中的开始位置。如果位置超过文本长度范围,那么不计算 loss。
- end_positions:答案在文本中的结束位置。如果位置超过文本长度范围,那么不计算 loss。
有 5 个返回值:
- loss:损失函数的值。只有传入
start_positions参数和end_positions时,才会计算 loss 返回。 - start_scores:答案在文本中开始位置对应的分数(没有经过 Softmax)。
- end_scores:答案在文本中结束位置对应的分数(没有经过 Softmax)。
- hidden_states:当
output_hidden_states=True或者config.output_hidden_states=True时,会返回这个值。是一个 tuple,包括两个元素,形状都是(batch_size, sequence_length, hidden_size)。- 第 1 个元素是 1 个 tuple,包含每层的 hidden_state。
- 第 2 个元素是 最后 1 层的 hidden_state。
- attentions:当
output_attentions=True或者config.output_attentions=True时,会返回这个值。表示经过 Softmax 后的 attention,可以用于计算多头注意力的平均值。形状是(batch_size, num_heads, sequence_length, sequence_length)。
forward() 方法的流程是:
- 首先调用
BertModel,得到输出。 - 取出其中的
last_hidden_state,也就是最后一层所有 token 的 hidden state。 - 把
last_hidden_state,每个 token 做 2 分类,得到logits,形状为(batch_size, sequence_length, 2)。 - 使用
logits.split(1, dim=-1),划分为start_logits,end_logits,形状均为(batch_size, sequence_length, 1)。start_logits表示开始位置,end_logits表示结束位置。 - 如果
start_positions和end_positions都不为空,那么分别计算start_logits和end_logits的 loss,取平均返回。 - 返回
(loss), start_logits, end_logits, (hidden_states), (attentions)。其中,只有start_logits和end_logits是确定会返回的,其他 3 个不一定有返回。
代码如下:
1 | def forward( |
例子
1 | from transformers import BertTokenizer, BertForQuestionAnswering |






