BERT系列: tinyBERT 介绍与代码训练。
tinyBert的训练过程
前情提要 :
上一篇文章讲述了BERT的全流程,但我们要做的是复现tinyBERT。BERT是一个大家族,里面有BERT-Tiny,BERT-Base,BERT-large等等。 他们的主要区别仅仅是结构不一样, 但是我们今天复现的tinyBERT是和他们不一样的,他的BERT在后面。 这就决定了它不只是结构不同,训练方式也是不同的。
结构差异 :
为了介绍结构的差异,我们先来读一个BERT的设置文档BERT config,一个config便可以决定一个BERT的结构。
{ "hidden_size": 384, #决定token被编码的长度,即特征长度 "intermediate_size": 1536, # MLP第一次映射的长度,这里特征长度乘以4 "max_position_embeddings": 512, # 最大输入长度。 "model_type": "tiny_bert", "num_attention_heads": 12, # 注意力头个数 "num_hidden_layers": 4, # 堆叠多少层 "vocab_size": 30522 # 训练词典个数,与训练语料有关 }
{ "hidden_size": 768, "intermediate_size": 3072, "max_position_embeddings": 512, "model_type": "bert", "num_attention_heads": 12, "num_hidden_layers": 12, "vocab_size": 30522 }
上是tinyBert 下是bert-base。我主要把bert家族改变时主要改变的属性拿出来。我们可以看到他们结构上的不同之处。参数的具体意思,可以看前文得到。 这样tinyBERT自然会参数减少很多。 为了性能保持不变,tinyBERT并不像BERT家族那样在无标注上预训练,而是采取了蒸馏学习的方式进行训练。通过对BERT-base的蒸馏,得到了很好的性能。 (可以百度学习一下知识蒸馏)。
蒸馏学习
BERT系列都是通过MLM和SEQ任务进行学习的。而tinyBERT则是蒸馏。蒸馏可以看作让参数的分布尽量靠近的意思。 很多网络是在网络的输出端蒸馏,希望整个网络的参数都学习到teacher模型。而tinyBERT则是在模型中间进行多次蒸馏。而且在整个训练过程也进行多次蒸馏。
前向过程中的多次蒸馏
tinyBERT,对于n层的teacher bert,设计了一个mapping function :n = g ( m ), 将student bert的第m层映射为原来的teacher的第n层。其实这个映射函数非常简单,就是n = k*g。k就是多少层当作tinyBERT的一层。当k=0时,对应的就是embedding layer。我们可以通过下图理解。图中仅为示例,tinyBERT每层的输出都去蒸馏学习Teacher net三层的输出,就是“一层顶三层”。
实际上的BERT-base有12层, 对于4层的tinyBERT,正好是三层对一层。 对于蒸馏学习,我们需要根据两个模型的参数或者输出来计算loss,更新模型。从上图中,我们可以看到一共有四种loss,下面分别介绍。
Embedding-layer distillation
这个是对embedding 矩阵的蒸馏loss,说是矩阵,其实是计算两个模型embedding输出的MSEloss。 而因为Student的embedding层的特征维度和Teacher是不一样的,因此要乘上一个转换的映射矩阵,此矩阵在训练时学习。
Hidden states based distillation and Attention based distillation
前文我们提到,BERTlayer每一层包含两部分,一个是自注意力层,一个是MLP(也就是两层全连接)。 hidden states层的蒸馏就是指的全连接层的层间蒸馏,而attention自然指的是注意力层注意力分数矩阵,也就是q和k算出来的那个值 的蒸馏学习。
Prediction-layer distillation
这个蒸馏,属于是回归本质了。是对最终输出层输出结果的 softmax进行蒸馏学习。
T是蒸馏学习中的温度系数。对输出蒸馏时,采用的是带温度系数的交叉熵loss。
训练过程中的多次蒸馏
tinyBERT 并不是像其他蒸馏那样,直接根据成品的Teacher model在分类时蒸馏,而是采用了模仿teacher训练过程的方法。他的蒸馏分两步:General Distillation 与 Task-specific Distillation. 前者是模仿BERT在大规模语料库进行预训练,后者则是在特定的任务上进行分类训练。值得注意的是,在预训练蒸馏阶段,使用的Teacher 模型是仅仅经过预训练未微调的BERT,而在特定任务分类蒸馏训练阶段,使用的Teacher 模型是在特定任务上经过微调的BERT。
到这里,我们已经完成了tinyBERT的多阶段,多层次蒸馏,得到了性能很好,又很快的tinyBERT模型。oumeideta!
上代码
下载基于torch的代码,让我们看看具体的训练过程吧!
Pretrained-Language-Model/TinyBERT at master · huawei-noah/Pretrained-Language-Model · GitHub
https://huggingface.co/bert-base-uncased/tree/main
事前准备
如果你想跟我一样跑通代码,你需要下载一下内容
1, BERT-base模型
这个就是teacher模型啦。bert-base-uncased at main
2,wiki数据集。
关于WIKI下载
我下载的这个:
然后使用WikiExtractor提取和整理数据集中的文本,使用步骤如下
- pip install wikiextractor
- python -m wikiextractor.WikiExtractor -o 【目标文件】-b 【大小】 【源文件】
注意这个 大小 他是够多少就形成一个文件,也就是如果写了1M 就会形成很多个1M的输出文件 我用的就是1M
我用的是AA里的00号文件,我们只是跑示例而已。
glue 数据集
https://github.com/nyu-mll/GLUE-baselines
可以只下载一个任务,比如QNLI。 自行参照readme 更改任务名即可。
代码阅读
预训练蒸馏。
--pregenerated_data data --teacher_model /home/model/bert --do_lower_case --train_batch_size 4 --output_dir model --student_model config
代码文件是general_distill.py
在pycharm 运行编辑配置中 输入上面的参数 。 大部分都是位置参数。自己训练的时候需要按自己的文件夹调整。 注意tiny_bert的config文件 在开头参数里,把bert的config改几个参数就行。
我们主要看的是模型的训练,所以数据,设置什么的都先不看了。 所以直接到这一步。
这是取数据,可以看到取了四个数据。可以在这里介绍一下,一句文本,会有一个tokenizer的东西对他做处理,生成3个东西。
input_ids : 代码里给每个词都编了号 ,这个就是那个号
input_mask: 输入中标记哪些词模型需要考虑,哪些不需要考虑。比如,输入长度不够时会被pad,这些pad的mask就为0,不需要考虑。
segment_ids: 标记属于哪个句子
其余的两个则是预训练任务需要的。
lm_labels_ids: 被遮盖的词的编号
is_next:输出的两个句子是不是上下文。
student_atts, student_reps = student_model(input_ids, segment_ids, input_mask)
tinyBERT的前向。我们可以看看student模型的样子。
class TinyBertForPreTraining(BertPreTrainedModel):
def __init__(self, config, fit_size=768):
super(TinyBertForPreTraining, self).__init__(config)
self.bert = BertModel(config)
self.cls = BertPreTrainingHeads(
config, self.bert.embeddings.word_embeddings.weight)
self.fit_dense = nn.Linear(config.hidden_size, fit_size)
self.apply(self.init_bert_weights)
def forward(self, input_ids, token_type_ids=None,
attention_mask=None, masked_lm_labels=None,
next_sentence_label=None, labels=None):
sequence_output, att_output, pooled_output = self.bert(
input_ids, token_type_ids, attention_mask)
tmp = []
for s_id, sequence_layer in enumerate(sequence_output):
tmp.append(self.fit_dense(sequence_layer))
sequence_output = tmp
return att_output, sequence_output
很简单的写法。BertModel 会根据你的设置返回一个BERT模型。他的输出是可以调控的。这里输出了三个东西。
sequence output 包含了每一层bertlayer的输出
att_output 包含了每一层的注意力分数。
pooled_output 最后一层的输出的pool值。可以取meanpool 也可以取cls。
而fit_dense就是前文所提到的,因为两个模型的特征维度不同,需要使用的转换矩阵。
cls等会再用到。(好吧 也没看到用在哪里)
teacher_reps, teacher_atts, _ = teacher_model(input_ids, segment_ids, input_mask)
得到teacher的注意力分数和输出。
teacher_reps = [teacher_rep.detach() for teacher_rep in teacher_reps] # speedup 1.5x
teacher_atts = [teacher_att.detach() for teacher_att in teacher_atts]
因为是不更新teacher模型的,所以这里要把teacher模型的输出从张量图上取下来。
new_teacher_reps = [teacher_reps[i * layers_per_block] for i in range(student_layer_num + 1)]
new_student_reps = student_reps
可以看到 层数对应的计算。 取对应层的最后一层的输出。也就是第3,6,9,12层的输出。
for student_att, teacher_att in zip(student_atts, new_teacher_atts):
student_att = torch.where(student_att <= -1e2, torch.zeros_like(student_att).to(device),
student_att)
teacher_att = torch.where(teacher_att <= -1e2, torch.zeros_like(teacher_att).to(device),
teacher_att)
att_loss += loss_mse(student_att, teacher_att)
计算注意力分数的loss。
new_teacher_reps = [teacher_reps[i * layers_per_block] for i in range(student_layer_num + 1)]
new_student_reps = student_reps
for student_rep, teacher_rep in zip(new_student_reps, new_teacher_reps):
rep_loss += loss_mse(student_rep, teacher_rep)
你会发现 BERT的输出有13个,其中第一个是embedding的输出。 上面这个代码段,会保留0,3,6,9,12层的输出。这个loss计算 可以得到 L_hide 和L_embd。在预训练阶段 没有pred的loss。 只需要这三个loss进行回传更新模型。
任务蒸馏
在特定任务上进行蒸馏。代码是 task_distill 参数是下面的,我用的时QNLI数据集。去掉了数据增广,因为数据增广只是提前处理,而不是在训练时增广。 记得将前面预训练阶段得到的tinybert的模型保存文件名字改为: pytorch_model.bin.。torch只认这个。
--teacher_model /home/model/bert --student_model /home/reapper/tinybert/model --data_dir /home/dataset/glue/QNLI --task_name qnli --output_dir model/qnli --do_lower_case --learning_rate 3e-5 --num_train_epochs 3 --eval_step 100 --max_seq_length 128 --train_batch_size 32 --pred_distill
数据部分我就不说了,GLUE的数据都是有现成的读的方法。介绍一下QNLI,QNLI的一条数据是有两句话,然后判断两句话是蕴含关系还是非蕴含关系,也就是二分类任务。
我们直接上模型。
if not args.do_eval:
teacher_model = TinyBertForSequenceClassification.from_pretrained(args.teacher_model, num_labels=num_labels)
teacher_model.to(device)
student_model = TinyBertForSequenceClassification.from_pretrained(args.student_model, num_labels=num_labels)
student_model.to(device)
我们发现他是用同一个模型读入的,前面提过了,只要config不同,结果就不同。
输入数据和上面很像。多了个seq_lengths,表示文本本身词的个数(padding前)。
student_logits, student_atts, student_reps = student_model(input_ids, segment_ids, input_mask,
is_student=True)
with torch.no_grad():
teacher_logits, teacher_atts, teacher_reps = teacher_model(input_ids, segment_ids, input_mask)
与前文一样,执行模型前向,teacher不计算梯度。不同的是,输出多了一个logits,熟悉的人都知道,这个就是分类头的输出,我们进模型内部看一下。
class TinyBertForSequenceClassification(BertPreTrainedModel):
def __init__(self, config, num_labels, fit_size=768):
super(TinyBertForSequenceClassification, self).__init__(config)
self.num_labels = num_labels
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, num_labels)
self.fit_dense = nn.Linear(config.hidden_size, fit_size)
self.apply(self.init_bert_weights)
def forward(self, input_ids, token_type_ids=None, attention_mask=None,
labels=None, is_student=False):
sequence_output, att_output, pooled_output = self.bert(input_ids, token_type_ids, attention_mask,
output_all_encoded_layers=True, output_att=True)
logits = self.classifier(torch.relu(pooled_output))
tmp = []
if is_student:
for s_id, sequence_layer in enumerate(sequence_output):
tmp.append(self.fit_dense(sequence_layer))
sequence_output = tmp
return logits, att_output, sequence_output
看到 出了bert外,还有一个分类头,一个用于蒸馏的线性映射fit_dense。没了,就这么简简单单。从bert得到pool的特征后,直接relu分类就完事了。
teacher_layer_num = len(teacher_atts)
student_layer_num = len(student_atts)
assert teacher_layer_num % student_layer_num == 0
layers_per_block = int(teacher_layer_num / student_layer_num)
new_teacher_atts = [teacher_atts[i * layers_per_block + layers_per_block - 1]
for i in range(student_layer_num)]
for student_att, teacher_att in zip(student_atts, new_teacher_atts):
student_att = torch.where(student_att <= -1e2, torch.zeros_like(student_att).to(device),
student_att)
teacher_att = torch.where(teacher_att <= -1e2, torch.zeros_like(teacher_att).to(device),
teacher_att)
tmp_loss = loss_mse(student_att, teacher_att)
att_loss += tmp_loss
att_loss: 与预训练时一致。
new_teacher_reps = [teacher_reps[i * layers_per_block] for i in range(student_layer_num + 1)]
new_student_reps = student_reps
for student_rep, teacher_rep in zip(new_student_reps, new_teacher_reps):
tmp_loss = loss_mse(student_rep, teacher_rep)
rep_loss += tmp_loss
hideloss 和 embdloss 与预训练一致。
if not args.pred_distill:
else:
if output_mode == "classification":
cls_loss = soft_cross_entropy(student_logits / args.temperature,
teacher_logits / args.temperature)
elif output_mode == "regression":
loss_mse = MSELoss()
cls_loss = loss_mse(student_logits.view(-1), label_ids.view(-1))
loss = cls_loss
pred loss 我们可以看到,他是有开关的,也就是可以选择不训练。 他这里是 如果是蒸馏前面的层,这里就不用蒸馏分类层了。 相当于bert输出已经蒸馏过了。 如果不蒸馏前面的层,就蒸馏分类层,。
我们现在是分类模式,所以loss是用softCE计算的,如果你看了模型蒸馏,你肯定知道这个东西。其实就是本来对于分类任务,他的target都是one-hot形式的:【0,1】 这样的,现在变成了【0.1,0.9】这样的。 这也是蒸馏的原理之一。 温度系数这里是1.
至此,我们得到了所有的四个loss 回传即可完成模型训练。
事后
至此,我们完成了tinyBERT的训练,得到了一个快速版的BERT。
代码我读起来很快,主要因为我对bert和transformer已经有了很深的了解。 如果对BERT不了解,这个博客也是很好的机会,你可以慢慢调试,一步一步的看BERT内部是怎么构成的,向量在里面是如何传递的。
这篇博文大部分内容来自个人经验和论文,如果有什么错误,请联系我,谢谢。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)