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
,es
unaffable
拆分为 ["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 |