多Transformer的双向编码器表示法
Bidirectional Encoder Representations from Transformers,即Bert;
第3章 Bert实战
学习如何使用预训练的BERT模型:
- 如何使用预训练的BERT模型作为特征提取器;
- 探究Hugging Face的Transformers库,学习如何使用Transformers库从预训练的BERT模型中提取嵌入;
- 如何从BERT的所有编码器层提取嵌入;
- 如何为下游任务微调预训练的BERT模型,文本分类、情感分析;
- 如何将预训练的BERT模型应用于自然语言推理任务、问答任务以及命名实体识别等任务;
预训练的BERT模型
从头开始预训练BERT模型是很费力的,因此可以下载预训练的BERT模型并直接使用(从Github仓库直接下载);
L表示编码器层数,H表示隐藏神经元的数量(特征大小),BERT-base的L=12,H=768;
预训练模型可以使用不区分大小写(BERT-uncased)的格式和区分大小写(BERT-cased)的格式;
- 不区分大小写时,所有标记都转化为小写;
- 区分大小写是,标记大小写不变,直接用于训练;
不区分大小写的模型是最常用的模型,但对一些特定任务如命名实体识别,则必须保留大小写,就需要使用区分大小写模型;
预训练模型的应用场景:
- 作为特征提取器,提取嵌入;
- 针对文本分类任务、问答等下游任务对BERT模型进行微调;
使用预训练BERT模型作为特征提取词嵌入
示例句子 ->
标记句子 ->
送入模型 ->
返回每个标记的词级嵌入 以及 句级特征;
构建分类数据集,作正反向观点的二分类:
- 每一个句子都有对应的标签,1表示正面,0表示负面;
训练分类器做情感分类:
- 通过模型或算法对文本进行向量化,可以使用预训练的BERT模型对数据集的句子进行向量化(这说的就是提嵌入);
标记句子:
- 在句子开始添加
[CLS]
标记,在结尾添加[SEP]
,为统一所有句子的标记长度(假设是512),那么不足512的会使用标记[PAD]
来重复填充; - 为了让模型理解标记
[PAD]
只是用于匹配长度,而不是实际标记的一部分,需要引入注意力掩码,将所有位置的注意力掩码值设为1,再将标记[PAD]
的位置设为0; - 最后将所有标记映射为一个唯一的标记ID,
[CLS]
标记对应的ID为101;标记[PAD]
的仍为0;
接下来把 token_ids和 attention_mask一起输入预训练的BERT模型,并获得每个标记的特征向量;
最终输出的 R[CLS]
就是标记[CLS]
的嵌入,它可以代表整个句子的总特征;如果使用的是BERT-base模型配置,那么每个标记的特征向量大小为768;
采用类似的方法,就可计算出训练集所有句子的特征向量,一旦有了训练集所有句子的特征,就可以把这些特征作为输入,训练一个分类器;
值的注意的是,使用
[CLS]
标记的特征代表整个句子的特征并不总是一个好主意;要获得一个句子的特征,最好基于所有标记的特征进行平均或者汇聚;
Hugging Face的Transformers库
Hugging Face是一个致力于通过自然语言将AI技术大众化的组织,它提供了开源的Transformers库对一些自然语言处理任务和自然语言理解(NLU)任务非常有效;Transformers库包含了百余种语言的数千个预训练模型,而且还可以与Pytroch和TF兼容;
安装命令:pip install Transformers==3.5.1
python
!pip install Transformers==4.27.4 --ignore-installed PyYAML
# 问题:cannot import name 'is_tokenizers_available' from 'transformers.utils'
# 参考:https://discuss.huggingface.co/t/how-to-resolve-the-hugging-face-error-importerror-cannot-import-name-is-tokenizers-available-from-transformers-utils/23957/2
python
# 示意代码 仅做指示
from transformers import BertModel, BertTokenizer
import torch
def get_tokens_and_attention_mask(tokens_a):
# 标记更新
tokens = []
# 上下句掩码(当前示例任务 没什么用)
segment_ids = []
tokens.append("[CLS]")
segment_ids.append(0)
for token in tokens_a:
tokens.append(token)
segment_ids.append(0)
if tokens[-1] != "[SEP]":
tokens.append("[SEP]")
segment_ids.append(0)
# 将标记转换为它们的标记ID
token_ids = tokenizer.convert_tokens_to_ids(tokens)
# 输入的注意力掩码
attention_mask = [1] * len(token_ids)
while len(token_ids) < max_seq_length:
token_ids.append(0)
attention_mask.append(0) # 这里加的0 实际对应的就是标记 [PAD],这里补0,实际就不用处理添加[PAD]标记的逻辑
segment_ids.append(0)
assert len(token_ids) == max_seq_length
return token_ids, attention_mask
# 下载并加载预训练模型
model = BertModel.from_pretrained("bert-tiny-uncased")
# 下载并加载用于预训练模型的词元分析器
tokenizer = BertTokenizer.from_pretrained("bert-tiny-uncased")
sentence = "I am Good"
# 分词并获取标记
tokens_a = tokenizer.tokenize(sentence)
token_ids, attention_mask = get_tokens_and_attention_mask(tokens_a)
python
# 转为张量
token_ids = torch.tensor(token_ids).unsqueeze(0)
attention_mask = torch.tensor(attention_mask).unsqueeze(0)
# 送入模型
hidden_rep, cls_head = model(token_ids, attention_mask=attention_mask)
hidden_rep:
- hidden_rep 包含了所有标记的嵌入(特征),我们使用的是bert-tiny,假设max_seq_length=10,那么hidden_rep的形状就是
torch.size([1, 10, 128])
; [1, 10, 128]
分别对应[batch_size, sequence_length, hidden_size]
;隐藏层的大小等于特征向量大小;- 第1个标记
[CLS]
的特征:hidden_rep[0][0]
- 第2个标记
I
的特征:hidden_rep[0][1]
- 第1个标记
cls_head:
- 它包含了
[CLS]
标记的特征,shape为torch.size([1, 128])
;
可以用cls_head作为句子的整句特征;
从BERT的所有编码器层中提取嵌入
前面介绍的是如何从预训练的BERT模型的顶层编码器提取嵌入,此外,也可以考虑从所有的编码器层获得嵌入;
使用h0表示输入嵌入层,h1则表示第一个编码器层(第一个隐藏层),研究人员使用预训练的BERT-base模型的不同层编码器的嵌入作为特征,应用在命名实体识别任务,所得的F1分数(调和均值)发现,将最后4个编码器的嵌入 连接起来可以得到最高的F1,这说明可以使用其他层所提取的嵌入,而不必只用顶层编码器的嵌入;
python
# 示意代码 仅做指示
from transformers import BertModel, BertTokenizer
import torch
# output_hidden_states 可以控制输出所有编码器的嵌入
model = BertModel.from_pretrained("bert-base-uncased", output_hidden_states = True)
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
sentence = "I am Good"
tokens_a = tokenizer.tokenize(sentence)
token_ids, attention_mask = get_tokens_and_attention_mask(tokens_a)
# 转为张量
token_ids = torch.tensor(token_ids).unsqueeze(0)
attention_mask = torch.tensor(attention_mask).unsqueeze(0)
# 送入模型
last_hidden_state, pooler_output, hidden_states = model(token_ids, attention_mask=attention_mask)
- last_hidden_state: 包含从最后编码器获得所有标记的特征,shape=
[1, 10 ,768]
对应[batch_size, sequence_length, hidden_size]
; - pooler_output: 表示来自最后的编码器的
[CLS]
标记的特征,它被一个线性激活函数和tanh激活函数进一步处理,shape=[1, 768]
,可被用作句子的特征; - hidden_states:包含从所有编码器获得的所有标记的特征,这是一个包含了13个值的元组,包含了从输入层h0到最后的编码器层h2的特征;
hidden_states[0]
:输入嵌入层h0获得的所有标记的特征;hidden_states[12]
:最后一个编码器层h12获得的所有标记的特征,shape=[1, 10 ,768]
;
通过对返回值的解析,就可以获得所有编码器层的标记嵌入;
为下游任务微调预训练BERT模型
到目前为止,我们已经学会了如何使用BERT模型,再看如何针对下游任务进行微调;
在提取句子的嵌入
R[CLS]
后,可以将其送入一个分类器并训练其进行分类;类似的在微调过程中,也可以这样做(对R[CLS]
使用softmax激活函数的前馈网络层)
微调的两种调整权重的方式:
- 与分类器层一起更新预训练的BERT模型参数;
- 仅更新分类器层的权重,可以冻结预训练的BERT模型权重(这类似于使用预训练的BERT模型作为特征提取器的情况),也可以直接使用预训练的BERT模型作为特征提取器;
安装必要的库:pip install nlp
python
# 示意代码 仅做指示(当时实际用的是pytorch_pretrained_bert的库,也是hugging face的)
# from pytorch_pretrained_bert.tokenization import BertTokenizer
# from pytorch_pretrained_bert.modeling import BertForSequenceClassification
# from pytorch_pretrained_bert.optimization import BertAdam
from transformers import BertForSequenceClassification, BertTokenizerFast, Trainer, TrainingArguments
from nlp import load_dataset
from torch
import numpy as np
# 使用nlp库加载并下载数据集
dataset = dataset.train_test_split(test_size=0.3)
# {
# "test": Dataset(text, label),
# "train": Dataset(text, label)
# }
train_set = dataset["train"]
test_set = dataset["test"]
model = BertForSequenceClassification.from_pretrained("bert-base-uncased")
# 下载并加载用于预训练模型的词元分析器,注意这里使用BertTokenizerFast类
tokenizer = BertTokenizerFast.from_pretrained("bert-tiny-uncased")
# 这里词元分析器会帮我们完成get_tokens_and_attention_mask函数的功能
tokenizer("I am Good")
# 返回
# {
# 'input_ids':[101, 1000, 1001, 1002, 102],
# 'token_type_ids':[0, 0, 0, 0, 0],
# 'attention_mask': [1, 1, 1, 1, 1]
# }
# 词元分析器,可以输入任意数量的句子,并动态地进行补长和填充,只需将padding=True, max_length=10
# tokenizer(["I am Good","I am boy"], padding=True, max_length=10)
# 返回(输入两个句子)
# {
# 'input_ids':[[], []],
# 'token_type_ids':[[], []],
# 'attention_mask': [[], []]
# }
# 使用词元分析器预处理数据集
# 可以定义一个名为 preprocess的函数来处理数据集
def preprocess(data):
return tokenizer(data["text"], padding=True, truncation=True) # 截断
train_set.set_format("torch", columns=["input_ids", "attention_mask", "label"])
test_set.set_format("torch", columns=["input_ids", "attention_mask", "label"])
python
# 训练模型
batch_size = 8 # 批量大小
epochs = 2 # 迭代次数
warmup_steps = 500 # 预热步骤
weight_decay = 0.01 # 权重衰减
# 设置训练参数
training_args = TrainingArguments(
output_dir = "./results",
num_train_epochs = epochs,
per_device_train_batch_size = batch_size,
per_device_eval_batch_size = batch_size,
warmip_steps = warmup_steps,
weight_decay = weight_decay,
evaluate_during_training = True,
logging_dir = "./logs"
)
trainer = Trainer(
model = model,
args = training_args,
train_dataset = train_set,
eval_dataset = test_set
)
# 开始模型训练
trainer.train()
# 训练结束后 评估模型
trainer.evaluate()
# {'epoch': 1.0, 'eval_loss': 0.68}
# {'epoch': 2.0, 'eval_loss': 0.50}
# ...
自然语言推理任务的BERT微调
在该任务中,在确定的"前提"下,推定假设是"真"、"假"还是"未定的";即模型的目标是:确定一个句子对(前提-假设对)是真、是假、还是中性;
- 对句子进行标记:第一句开头加
[CLS]
标记,每句结尾添加[SEP]
标记; - 送入预训练模型 得到
[CLS]
标记的特征(即整个句子对的特征); - 将
R[CLS]
送入分类器;
问答任务的BERT微调
问答任务重,对一个问题,模型会返回一个答案,目标是让模型返回正确答案;
虽然这个任务可以使用生成式的任务模型,但这里是基于微调BERT实现的,思路不同;
BERT模型输入是一个问题和一个段落,这个段落需要是一个含有答案的段落,BERT必须从该段落中提取答案;
要通过微调BERT模型来完成这项任务,模型必须要了解给定段落中包含答案的文本段的起始索引和结束索引;要找到这两个索引,模型应该返回"该段落中每个标记是答案的起始标记和结束标记的概率";
这里引入两个向量:
- 起始向量S;
- 结束向量E;
两个向量的值,将通过训练获得;
为了计算这个概率:
- 对于每个标记i,计算标记特征Ri和起始向量S之间的点积,然后将softmax函数应用于点积,得到概率;
- 接下来选择其中具有最高概率的标记,并将其索引值作为起始索引;
- 结束索引的计算方式类似;这样就可以使用起始索引和结束索引选择包含答案的文本段了;
python
# 微调BERT模型用于问答任务
from transformers import BertForQuestionAnswering, BertTokenizer
# 下载模型
model = BertForQuestionAnswering.from_pretrained("bert-large-uncased-whole-word-masking-fine-tuned-squad") # 该模型基于斯坦福问答数据集(SQyAD)微调而得
# 下载并加载词元分析器
tokenizer = BertTokenizer.from_pretrained("bert-large-uncased-whole-word-masking-fine-tuned-squad")
question = "A"
paragraph = "AAABBBBBBAAAA"
question = "[CLS]" + question + "[SEP]"
paragraph = paragraph + "[SEP]"
# 转为 input_ids
question_tokens = tokenizer.tokenize(question)
paragraph_tokens = tokenizer.tokenize(paragraph)
# 设置segment_ids
segment_ids = [0] * len(question_tokens)
segment_ids = segment_ids + [1] * len(paragraph_tokens)
# 转张量
input_ids = torch.tensor([input_ids])
segment_ids = torch.tensor([segment_ids])
start_scores, end_scores = model(input_ids, token_type_ids = segment_ids)
start_index = torch.argmax(start_scores)
end_index = torch.argmax(end_scores)
# 答案
' '.join(tokens[start_index: end_index + 1])
关于问答任务BERT微调的一些疑问?
- 问题1:起始向量和结束向量 是怎么来的?
R[CLS]
和R[SEP]
是否就是这两个向量?- 问题2:上面的示例代码段,很明显并不是一个微调,而是一个应用,虽然是应用一个已经微调好的模型,但如何训练却未讲明;
可以展开思考(待查明)
对执行命名实体识别任务的BERT模型微调
任务目标是将命名实体划分到预设的类别中,如某一句子种出现了人名和地名(还有其他词)的词汇,能将相应词汇进行准确归类;
- 对句子进行标记
- 送入预训练BERT模型,获得每个标记特征
- 在将这些标记特征送入一个分类器(使用softmax激活函数的前馈网络层)
- 最后分类器返回每个命名实体对应的类别
好吧,这里说的依旧很简略,但至少上我们知道了大致的实现逻辑;
此前分类任务的实践代码梳理
todo