当前位置:   article > 正文

NLP预训练模型2 -- BERT详解和源码分析_bert 预训练代码

bert 预训练代码

1 模型结构

论文信息:2018年10月,谷歌,NAACL
论文地址 https://arxiv.org/pdf/1810.04805.pdf
模型和代码地址 https://github.com/google-research/bert

BERT自18年10月问世以来,就引起了NLP业界的广泛关注。毫不夸张的说,BERT基本上是近几年来NLP业界意义最大的一个创新,其意义主要包括

大幅提高了GLUE任务SOTA performance(+7.7%),使得NLP真正可以应用到各生产环境中,大大推进了NLP在工业界的落地
预训练模型从大量人类优质语料中学习知识,并经过了充分的训练,从而使得下游具体任务可以很轻松的完成fine-tune。大大降低了下游任务所需的样本数据和计算算力,使得NLP更加平民化,推动了在工业界的落地。
pretrain fine-tune两阶段已基本成为NLP业界新的范式,引领了一大波pretrain预训练模型的落地。
Transformer架构更加深入人心,attention机制基本取代了RNN。有了Transformer后,模型层面创新对NLP任务推动作用比较有限,可以将精力更多的放在数据和任务层面上了。
BERT全称为“Bidirectional Encoder Representations from Transformers”。它是一个基于Transformer结构的双向编码器。其结构可以简单理解为Transformer的encoder部分。如下图所示

最左边即为BERT,它是真正意义上的双向语言模型。双向对于语义表征的作用不言而喻,能够更加完整的利用上下文学习到语句信息。GPT是基于auto regression的单向语言模型,无法利用下文学习当前语义。ELMO虽然看起来像双向,但其实是一个从左到右的lstm和一个从右到左的lstm单独训练然后拼接而成,本质上并不是双向。

BERT主要分为三层,embedding层、encoder层、prediction层。

1.1 embedding层

embedding层如下所示

包括三部分

1、token embeddings。和Transformer的token embedding基本相同,也是通过自训练embedding_lookup查找表方式。token做了word piece。

2、position embedding。对字的位置进行编码,和Transformer不同,bert采用了自训练embedding_lookup方式,而不是三角函数encoding
3、segment embedding。bert采用了两句话拼接的方式构建训练语料,利用自训练embedding_lookup方式得到。
1.2 encoder层
encoder层则和Transformer encoder基本相同,详见之前一篇文章 NLP预训练模型1 – transformer详解和源码分析。

1.3 prediction层
prediction层则采用线性全连接并softmax归一化,下游任务基本上对prediction做改造即可。在不同的下游任务使用中,可以把bert理解为一个特征抽取encoder,根据下游任务灵活使用。下面分别是BERT应用的四个场景

1、语句对分类,如语句相似度任务,语句蕴含判断等
2、单语句分类,如情感分类/3、QA任务,如阅读理解,将question和document构建为语句对,输出start和end的位置即可
4、序列标注,如NER,从每个位置得到类别即可。


 

2 源码分析
我们大致了解了BERT模型结构,下面我们从源码角度进行分析,从而加深理解。分析的源码为基于PyTorch的HuggingFace Transformer。git地址 https://github.com/huggingface/transformers。bert源码放在src/transformers/modeling_bert.py中,入口类为BertModel。

2.1 入口和总体架构
使用bert进行下游任务fine-tune时,我们通常先构造一个BertModel,然后由它从输入语句中提取特征,得到输出。我们先来看看构造方法

  1. class BertModel(BertPreTrainedModel):
  2. """
  3. 模型入口,可以作为一个encoder
  4. """
  5. def __init__(self, config):
  6. super().__init__(config)
  7. self.config = config
  8. # 1 embedding向量输入层
  9. self.embeddings = BertEmbeddings(config)
  10. # 2 encoder编码层
  11. self.encoder = BertEncoder(config)
  12. # 3 pooler输出层,CLS位置输出
  13. self.pooler = BertPooler(config)
  14. # 从pretrain model加载初始化参数,多头剪枝等
  15. self.init_weights()
  16. def get_input_embeddings(self):
  17. # 获取embedding层的word_embedding,
  18. # 不要直接用它作为固定的词向量,需要在下游任务中fine-tune
  19. # 如果想直接使用固定的词向量,比如在LSTM网络中,则不如直接使用预训练词向量
  20. return self.embeddings.word_embeddings
  21. def set_input_embeddings(self, value):
  22. # 利用别的数据来初始化word_embeddings,正常情况下,我们使用bert预训练模型中的即可,不需要重设
  23. self.embeddings.word_embeddings = value

构造方法主要做三件事

1、读取配置config,它可以是一个BertConfig对象,包括vocab_size, num_attention_heads, num_hidden_layers 等重要参数,我们一般把它们放在配置文件 bert_config.json中构造

2、embedding、encoder、pooler三个对象,对应到我们上面说的embedding层、encoder层、prediction三层。这三个对象也是我们要分析的主要对象
3、利用pretrain model初始化weights,进行多头剪枝prune_heads等。
从输入语句提取特征,并得到输出,代码如下

  1. def forward(
  2. self,
  3. input_ids=None,
  4. attention_mask=None,
  5. token_type_ids=None,
  6. position_ids=None,
  7. head_mask=None,
  8. inputs_embeds=None,
  9. encoder_hidden_states=None,
  10. encoder_attention_mask=None,
  11. ):
  12. # 省略一段输入预处理代码,主要为
  13. # 1. input_ids和inputs_embeds处理,支持这两种输入token,但不能同时二者都指定
  14. # 2. 如果attention_mask为空,则默认构建为全1矩阵
  15. # 3. 如果token_type没指定,默认0。它表示语句A或语句B,只能取0或者1。这是由预训练模型决定的。
  16. # 4. 如果被用作decoder,则处理encoder_attention_mask
  17. # 5. 处理head_mask,可以利用它进行多头剪枝
  18. ......
  19. # 1 embedding层,包括word_embedding, position_embedding, token_type_embedding三个
  20. embedding_output = self.embeddings(
  21. input_ids=input_ids, position_ids=position_ids, token_type_ids=token_type_ids, inputs_embeds=inputs_embeds
  22. )
  23. # 2 encoder层,得到每个位置的编码、所有中间隐层、所有中间attention分布
  24. encoder_outputs = self.encoder(
  25. embedding_output,
  26. attention_mask=extended_attention_mask,
  27. head_mask=head_mask,
  28. encoder_hidden_states=encoder_hidden_states,
  29. encoder_attention_mask=encoder_extended_attention_mask,
  30. )
  31. sequence_output = encoder_outputs[0]
  32. # 3 CLS位置编码向量
  33. pooled_output = self.pooler(sequence_output)
  34. # 返回每个位置编码、CLS位置编码、所有中间隐层、所有中间attention分布等。
  35. # sequence_output, pooled_output, (hidden_states), (attentions)。
  36. # (hidden_states), (attentions)需要config中设置相关配置,否则默认不保存
  37. outputs = (sequence_output, pooled_output,) + encoder_outputs[
  38. 1:
  39. ] # add hidden_states and attentions if they are here
  40. return outputs

由上可见,从输入语句中抽取特征,得到输出主要包括三步

1、embedding层,对input_ids、position_ids、token_type_ids进行embedding,它们都是采用embedding_lookup查表得到
2、encoder层,embedding后的结果,经过多层Transformer encoder,得到输出。每一层encoder结构基本相同,均包括multi-head self-attention和feed-forward,并经过layer-norm和残差连接
3、pooler层,对CLS位置进行线性全连接,将它作为整个sequence的输出。
最终返回4个结果

1、sequence_output:每个位置的编码输出,每个位置对应一个向量
2、pooled_output: CLS位置编码输出,经过了一层Linear和activation。一般用CLS来代表整个语句
3、hidden_states:所有中间层的隐层,这个需要在config中打开,才会保存下来
4、attentions: 所有中间层的attention分布,这个也需要在config中打开,才会保存。

2.2 embedding层

下面我们分别对embedding层,encoder层和pooler层进行分析。先来看embedding层。

  1. class BertEmbeddings(nn.Module):
  2. """Construct the embeddings from word, position and token_type embeddings.
  3. """
  4. def __init__(self, config):
  5. super().__init__()
  6. # word_embedding, position_embedding, token_type_embedding均采用自训练方式
  7. # max_position_embeddings决定了最大语句长度,如512。超过则截断,不足则padding
  8. # token_type_embedding决定了最大语句种类,一般为2,只能A句或者B句两种。
  9. self.word_embeddings = nn.Embedding(config.vocab_size, config.hidden_size, padding_idx=config.pad_token_id)
  10. self.position_embeddings = nn.Embedding(config.max_position_embeddings, config.hidden_size)
  11. self.token_type_embeddings = nn.Embedding(config.type_vocab_size, config.hidden_size)
  12. # layerNorm归一化,和dropout。layerNorm对归一化做一个线性连接,故有训练参数
  13. self.LayerNorm = BertLayerNorm(config.hidden_size, eps=config.layer_norm_eps)
  14. self.dropout = nn.Dropout(config.hidden_dropout_prob)
  15. def forward(self, input_ids=None, token_type_ids=None, position_ids=None, inputs_embeds=None):
  16. # 获取input_shape, [batch, seq_length]
  17. if input_ids is not None:
  18. input_shape = input_ids.size()
  19. else:
  20. input_shape = inputs_embeds.size()[:-1]
  21. seq_length = input_shape[1]
  22. device = input_ids.device if input_ids is not None else inputs_embeds.device
  23. if position_ids is None:
  24. # position_ids默认按照字的顺利进行编码,不足补0
  25. position_ids = torch.arange(seq_length, dtype=torch.long, device=device)
  26. position_ids = position_ids.unsqueeze(0).expand(input_shape)
  27. if token_type_ids is None:
  28. # token_type_ids默认全0,也就是都为语句A
  29. token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=device)
  30. # 通过embedding_lookup查表,将ids向量化
  31. if inputs_embeds is None:
  32. inputs_embeds = self.word_embeddings(input_ids)
  33. position_embeddings = self.position_embeddings(position_ids)
  34. token_type_embeddings = self.token_type_embeddings(token_type_ids)
  35. # 最终embedding为三者直接相加,不做加权。因为权值完全可以包含在embedding本身训练参数中
  36. embeddings = inputs_embeds + position_embeddings + token_type_embeddings
  37. # 归一化和dropout后,得到最终输入向量
  38. embeddings = self.LayerNorm(embeddings)
  39. embeddings = self.dropout(embeddings)
  40. return embeddings

 

主要步骤:

1、从三个embedding表中,通过id查找到对应向量。三个embedding表为word_embeddings,position_embeddings,token_type_embeddings。均是在train阶段训练得到。
2、三个embedding向量直接相加,得到总embedding。注意此处没有加权,因为权值可以被包含在各自embedding中
3、对总embedding进行归一化和dropout

2.3 encoder层

  1. class BertEncoder(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. self.output_attentions = config.output_attentions
  5. self.output_hidden_states = config.output_hidden_states
  6. # 每层结构相同,都是 BertLayer
  7. self.layer = nn.ModuleList([BertLayer(config) for _ in range(config.num_hidden_layers)])
  8. def forward(
  9. self,
  10. hidden_states,
  11. attention_mask=None,
  12. head_mask=None,
  13. encoder_hidden_states=None,
  14. encoder_attention_mask=None,
  15. ):
  16. all_hidden_states = ()
  17. all_attentions = ()
  18. # 遍历所有layer。bert中每个layer结构相同
  19. for i, layer_module in enumerate(self.layer):
  20. # 保存每层hidden_state, 默认不保存
  21. if self.output_hidden_states:
  22. all_hidden_states = all_hidden_states + (hidden_states,)
  23. # 执行每层self-attention和feed-forward计算。得到隐层输出
  24. layer_outputs = layer_module(
  25. hidden_states, attention_mask, head_mask[i], encoder_hidden_states, encoder_attention_mask
  26. )
  27. hidden_states = layer_outputs[0]
  28. # 保存每层attention分布,默认不保存
  29. if self.output_attentions:
  30. all_attentions = all_attentions + (layer_outputs[1],)
  31. # 保存最后一层
  32. if self.output_hidden_states:
  33. all_hidden_states = all_hidden_states + (hidden_states,)
  34. outputs = (hidden_states,)
  35. if self.output_hidden_states:
  36. outputs = outputs + (all_hidden_states,)
  37. if self.output_attentions:
  38. outputs = outputs + (all_attentions,)
  39. return outputs # last-layer hidden state, (all hidden states), (all attentions)

 encoder由多个结构相同的子层BertLayer组成,遍历所有的子层,执行每层的self-attention和feed-forward计算,并保存每层的hidden_state和attention分布。下面先看子层BertLayer结构。

2.3.1 BertLayer子层

  1. class BertLayer(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. # 1 multi-head self attention层
  5. self.attention = BertAttention(config)
  6. self.is_decoder = config.is_decoder
  7. if self.is_decoder:
  8. # 2 对于decoder,cross-attention和self-attention共用一个函数。他们仅仅q k v的来源不同而已
  9. self.crossattention = BertAttention(config)
  10. # 3 两层feed-forward全连接,然后残差并layerNorm输出
  11. self.intermediate = BertIntermediate(config)
  12. self.output = BertOutput(config)
  13. def forward(
  14. self,
  15. hidden_states,
  16. attention_mask=None,
  17. head_mask=None,
  18. encoder_hidden_states=None,
  19. encoder_attention_mask=None,
  20. ):
  21. # 1 self-attention, 支持attention_mask 和 head_mask
  22. self_attention_outputs = self.attention(hidden_states, attention_mask, head_mask)
  23. # hidden state隐层输出
  24. attention_output = self_attention_outputs[0]
  25. # attention分布
  26. outputs = self_attention_outputs[1:] # add self attentions if we output attention weights
  27. # 2 decoder的话,self-attention结束后,还需要做一层soft-attention。将encoder信息和decoder信息产生交互
  28. if self.is_decoder and encoder_hidden_states is not None:
  29. cross_attention_outputs = self.crossattention(
  30. attention_output, attention_mask, head_mask, encoder_hidden_states, encoder_attention_mask
  31. )
  32. attention_output = cross_attention_outputs[0]
  33. outputs = outputs + cross_attention_outputs[1:]
  34. # 3 feed-forward 和 layerNorm归一化
  35. intermediate_output = self.intermediate(attention_output)
  36. layer_output = self.output(intermediate_output, attention_output)
  37. # 输出hidden_state隐层和attention分布
  38. outputs = (layer_output,) + outputs
  39. return outputs

 

主要包括三步

1、multi-head self-attention, 支持attention_mask 和 head_mask

2、如果将bert用作decoder的话,self-attention结束后,还需要做一层cross-attention。将encoder信息和decoder信息产生交互
3、feed-forward全连接 和 layerNorm归一化。
主要操作有BertAttention,BertIntermediate和BertOutput,分别来看看它们的实现

2.3.2 BertAttention注意力计算

  1. class BertAttention(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. # self-attention
  5. self.self = BertSelfAttention(config)
  6. # add + layerNorm
  7. self.output = BertSelfOutput(config)
  8. # 多头剪枝
  9. self.pruned_heads = set()
  10. def prune_heads(self, heads):
  11. # 对每层多头进行裁剪,是一种直接对权重矩阵剪枝的方式,效果还是比较明显的。
  12. # 总体方法为:利用attention mask,需要prune的head,其mask为1。保留的head则mask为0
  13. # 可以参见论文 "Are Sixteen Heads Really Better than One"
  14. if len(heads) == 0:
  15. return
  16. # mask为全1矩阵,[num_heads, head_size]
  17. mask = torch.ones(self.self.num_attention_heads, self.self.attention_head_size)
  18. heads = set(heads) - self.pruned_heads # 去掉要剪枝的head
  19. for head in heads:
  20. # 需要保留head对应的mask设置为0,需要prune的则维持1
  21. head = head - sum(1 if h < head else 0 for h in self.pruned_heads)
  22. mask[head] = 0
  23. mask = mask.view(-1).contiguous().eq(1)
  24. index = torch.arange(len(mask))[mask].long()
  25. # q,k,v和全连接上,加入mask
  26. self.self.query = prune_linear_layer(self.self.query, index)
  27. self.self.key = prune_linear_layer(self.self.key, index)
  28. self.self.value = prune_linear_layer(self.self.value, index)
  29. self.output.dense = prune_linear_layer(self.output.dense, index, dim=1)
  30. # Update hyper params and store pruned heads
  31. self.self.num_attention_heads = self.self.num_attention_heads - len(heads)
  32. self.self.all_head_size = self.self.attention_head_size * self.self.num_attention_heads
  33. self.pruned_heads = self.pruned_heads.union(heads)
  34. def forward(
  35. self,
  36. hidden_states,
  37. attention_mask=None,
  38. head_mask=None,
  39. encoder_hidden_states=None,
  40. encoder_attention_mask=None,
  41. ):
  42. # self-attention计算
  43. self_outputs = self.self(
  44. hidden_states, attention_mask, head_mask, encoder_hidden_states, encoder_attention_mask
  45. )
  46. # 残差连接和归一化
  47. attention_output = self.output(self_outputs[0], hidden_states)
  48. # 输出归一化后隐层,和attention概率分布
  49. outputs = (attention_output,) + self_outputs[1:] # add attentions if we output them
  50. return outputs

 BertAttention主要包括两步,self-attention计算和归一化残差连接。这两步和Transformer基本相同,我们就不分析了,可以详细看 NLP预训练模型1 – transformer详解和源码分析。简略代码分析如下

  1. class BertSelfAttention(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. if config.hidden_size % config.num_attention_heads != 0 and not hasattr(config, "embedding_size"):
  5. raise ValueError(
  6. "The hidden size (%d) is not a multiple of the number of attention "
  7. "heads (%d)" % (config.hidden_size, config.num_attention_heads)
  8. )
  9. self.output_attentions = config.output_attentions
  10. # 每个头的隐层大小,等于总隐层大小除以多头数目。故增加多头,每个头的size下降,总隐层size不变
  11. self.num_attention_heads = config.num_attention_heads
  12. self.attention_head_size = int(config.hidden_size / config.num_attention_heads)
  13. self.all_head_size = self.num_attention_heads * self.attention_head_size
  14. # q,k,v矩阵 [hidden_size, all_head_size], 比如[768, 768]
  15. self.query = nn.Linear(config.hidden_size, self.all_head_size)
  16. self.key = nn.Linear(config.hidden_size, self.all_head_size)
  17. self.value = nn.Linear(config.hidden_size, self.all_head_size)
  18. self.dropout = nn.Dropout(config.attention_probs_dropout_prob)
  19. def transpose_for_scores(self, x):
  20. new_x_shape = x.size()[:-1] + (self.num_attention_heads, self.attention_head_size)
  21. x = x.view(*new_x_shape)
  22. return x.permute(0, 2, 1, 3)
  23. def forward(
  24. self,
  25. hidden_states,
  26. attention_mask=None,
  27. head_mask=None,
  28. encoder_hidden_states=None,
  29. encoder_attention_mask=None,
  30. ):
  31. # 多头query向量 [hidden_size, seq_len]
  32. mixed_query_layer = self.query(hidden_states)
  33. # 多头key和value向量,注意soft-attention和self-attention的区别
  34. if encoder_hidden_states is not None:
  35. # soft-attention,k和v来自encoder,而q来自decoder
  36. mixed_key_layer = self.key(encoder_hidden_states)
  37. mixed_value_layer = self.value(encoder_hidden_states)
  38. attention_mask = encoder_attention_mask # attention_mask, 比如遮挡预测字后面的字,防止未来数据穿越
  39. else:
  40. # self-attention,q k v 都来自encoder自己
  41. mixed_key_layer = self.key(hidden_states)
  42. mixed_value_layer = self.value(hidden_states)
  43. # q k v转置
  44. query_layer = self.transpose_for_scores(mixed_query_layer)
  45. key_layer = self.transpose_for_scores(mixed_key_layer)
  46. value_layer = self.transpose_for_scores(mixed_value_layer)
  47. # attention计算。softmax(mask(q * k / sqrt(dk))) * v
  48. # 1 q * k计算两向量相关性。除根号dk,对向量长度做归一化,防止方差过大
  49. attention_scores = torch.matmul(query_layer, key_layer.transpose(-1, -2))
  50. attention_scores = attention_scores / math.sqrt(self.attention_head_size)
  51. # 2 attention_mask,[seq_len, seq_len], 代表两不同位置间是否做attention。
  52. # decoder的attention_mask为一个上三角矩阵,防止未来信息穿越
  53. # encoder也要使用attention_mask, padding位置与其他所有位置为0
  54. # 计算层面,mask中0实际为一个绝对值很大负数,使得softmax时趋近0. 1则实际为0
  55. if attention_mask is not None:
  56. attention_scores = attention_scores + attention_mask
  57. # 3 softmax 归一化,dropout
  58. attention_probs = nn.Softmax(dim=-1)(attention_scores)
  59. attention_probs = self.dropout(attention_probs)
  60. # 4 head mask,直接将一个head剪枝掉
  61. if head_mask is not None:
  62. attention_probs = attention_probs * head_mask
  63. # 5 value矩阵加权求和,attention_probs可看做一个权重矩阵,得到每个位置的attention后向量
  64. context_layer = torch.matmul(attention_probs, value_layer)
  65. context_layer = context_layer.permute(0, 2, 1, 3).contiguous()
  66. new_context_layer_shape = context_layer.size()[:-2] + (self.all_head_size,)
  67. context_layer = context_layer.view(*new_context_layer_shape)
  68. # 最终输出attention后隐层和attention分布矩阵。attention矩阵表示了不同位置间两两相关关系,甚至比隐层更重要
  69. outputs = (context_layer, attention_probs) if self.output_attentions else (context_layer,)
  70. return outputs
  1. class BertSelfOutput(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. # 线性连接 -> layerNorm -> dropout
  5. self.dense = nn.Linear(config.hidden_size, config.hidden_size)
  6. self.LayerNorm = BertLayerNorm(config.hidden_size, eps=config.layer_norm_eps)
  7. self.dropout = nn.Dropout(config.hidden_dropout_prob)
  8. def forward(self, hidden_states, input_tensor):
  9. # 线性连接
  10. hidden_states = self.dense(hidden_states)
  11. # dropout
  12. hidden_states = self.dropout(hidden_states)
  13. # 残差连接,并做layerNorm。从而保证self-attention和feed-forward模块的输入均是经过归一化的
  14. # layerNorm中包含训练参数w和b
  15. hidden_states = self.LayerNorm(hidden_states + input_tensor)
  16. return hidden_states

2.3.3 BertIntermediate全连接

  1. class BertIntermediate(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. self.dense = nn.Linear(config.hidden_size, config.intermediate_size)
  5. if isinstance(config.hidden_act, str):
  6. self.intermediate_act_fn = ACT2FN[config.hidden_act]
  7. else:
  8. self.intermediate_act_fn = config.hidden_act
  9. def forward(self, hidden_states):
  10. # 全连接,[hidden_size, intermediate_size]
  11. hidden_states = self.dense(hidden_states)
  12. # 非线性激活,如glue,relu。bert默认使用glue
  13. hidden_states = self.intermediate_act_fn(hidden_states)
  14. return hidden_states

feed-forward这一步比较简单,主要就是全连接和非线性激活。

2.3.4 BertOutput输出

  1. class BertOutput(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. self.dense = nn.Linear(config.intermediate_size, config.hidden_size)
  5. self.LayerNorm = BertLayerNorm(config.hidden_size, eps=config.layer_norm_eps)
  6. self.dropout = nn.Dropout(config.hidden_dropout_prob)
  7. def forward(self, hidden_states, input_tensor):
  8. # 全连接, [intermediate_size, hidden_size]
  9. hidden_states = self.dense(hidden_states)
  10. # dropout
  11. hidden_states = self.dropout(hidden_states)
  12. # add + layerNorm
  13. hidden_states = self.LayerNorm(hidden_states + input_tensor)
  14. return hidden_states

输出层也比较简单,经过一层全连接、dropout、layerNorm归一化和残差连接,即可得到输出隐层。

2.4 pooler层输出

pooler层对CLS位置向量,进行全连接和tanh激活,从而得到输出向量。CLS位置向量一般用来代表整个sequence。

  1. class BertPooler(nn.Module):
  2. def __init__(self, config):
  3. super().__init__()
  4. self.dense = nn.Linear(config.hidden_size, config.hidden_size)
  5. self.activation = nn.Tanh()
  6. def forward(self, hidden_states):
  7. # CLS位置输出
  8. first_token_tensor = hidden_states[:, 0]
  9. # 全连接 + tanh激活 [768, 768]
  10. pooled_output = self.dense(first_token_tensor)
  11. pooled_output = self.activation(pooled_output)
  12. return pooled_output

3 实验和分析
3.1 预训练任务

BERT预训练任务包括两部分,

MLM,masked language model,学习token间信息。类似于完形填空,对15%的经过word piece后的token,进行处理。其中80%设置为[MASK],10%随机替换为其他token,10%不变。利用双向上下文信息,来预测这些位置的词语。
NSP,next sequence prediction,学习语句间信息。一个二分类问题,给定两句话,判断seqB是否为seqA的下一句。构造的正负样本为 1:1。
3.1.1 消融分析
两部分的loss直接相加(没有加权),构成了multi-task learning。两部分均有比较重要的作用,消融分析如下

1、去掉NSP,而只使用MLM后,不利于sequence级别信息的学习,故performance下降了一些,但不算很多
2、不使用双向MLM,而改成和GPT类似的LTR(left to right LM)后,performance下降很多,故可见双向语言模型的重要性。因为从两个方向学习到的信息才是完整的,语义表达更准确。

3.1.2 双向MLM和单向LTR对比

如图所示,双向MLM模型基本是吊打单向LTR的。

3.1.3 不同的mask策略
bert随机选择15%的token进行predict,为了缓解mask导致的pretrain和fine-tune两阶段不一致问题,这些token又被分为三类

1、80% token被mask
2、10% token保持不变
3、10% token随机替换为其他token
这个比例文章也做了充分实验,结果如下

3.2 输入预处理 tokenize

对输入语句的处理,主要包括

  1. 经过word piece,中文的话不需要处理
  2. 每句话末尾添加一个[SEP]标志,并在整个语句最前面添加[CLS]。例子如下
[CLS] my dog is cute [SEP] he likes play ##ing [SEP]

3、语句超过max_seq_len则截断,否则补齐[PAD]

4、利用vocab词典,将token转变为id。此处值得4、注意的是,如果我们在具体下游任务中fine-tune时,有词语没有包括在vocab中,则可以直接添加到[unused]位置处。vocab中添加了大量的[unused]占位符

3.3 语料数据
采用了两部分,包含800M词语的BooksCorpus,和包含2,500M词语的英语Wikipedia。openAI GPT仅采用了BooksCorpus语料。后续的Roberta等优化模型,大幅扩充了训练语料,从而提升了模型performance。

3.4 耗时分析
预训练十分耗时,batch-size=256, 1M个step,33亿原始语料情况下,bert-base在4块TPU训练了4天,bert-large则需要16块TPU训练4天。

一定要训练100万个step吗?文中指出,对于MNLI任务,100万个step比50万,提高了1%的ACC。另外文中也指出基于双向语言模型的MLM比仅单向的LTR要更耗时,但performance更高。

fine-tune则相对轻松很多,文章中做的所有任务,如glue SQuAD SWAG,在一块TPU上一个小时就可以全部fine-tune完成。即使在GPU上,也仅需要几个小时而已。

提升bert预训练、fine-tune和inference的速度一直以来都是一个比较大的话题。英伟达使用1472个V100 GPU,实现了53分钟训练完bert-large,一次推理仅需2.2ms。

3.5 实验结果
glue任务上,相比当时的SOTA,也就是openAI GPT,平均score大幅提高了7个点。各项子任务也都得到了提升。CoLA任务甚至提高了15个点。这也是当时引起巨大反响的一个主要原因。具体如下

SQuAD1.1和SQuAD2.0任务上,也是有大幅度的提升。如下为SQuAD2.0上结果

3.6 超参分析

采用不同的hidden size(隐层大小)、number of layers(子层数目)、number of attention heads(多头个数),可以得到不同大小的模型。performance也会有一定的变化。如下

基本可以认为模型越大,performance越高。常用的base和large模型超参如下

bert-base: (L=12, H=768, A=12, Total Parameters=110M)
bert-large: (L=24, H=1024, A=16, Total Parameters=340M)
3.6.2 fine-tune超参选择
fine-tune的超参可以和pretrain时差不多,下面几个超参均可以得到不错的下游任务结果。

Batch size: 16, 32
Learning rate (Adam): 5e-5, 3e-5, 2e-5
Number of epochs: 2, 3, 4

可见bert超参的泛化能力很强。特别是当下游任务训练数据较多时(10万量级),超参变得不敏感。

3.7 feature-based和fine-tune
下游任务中可以采用两种方法来使用bert,feature-based和fine-tune。二者的区别在于,feature-based方法中,bert的Variable不会参与到训练。而fine-tune则会利用下游任务数据,来调整bert参数。fine-tune方法的效果会好一些,但它需要有监督数据。feature-based方法效果差一些,但不需要监督数据(如bert-as-service提取句向量)。二者的performance对比如下


4 总结
BERT的推出意义重大,引领了NLP领域的一股风潮,大大加速了NLP在各工业界的落地。但它也有很多缺点,如pretrain fine-tune两阶段不一致,中文字mask方式过于简单粗暴,语料仍可丰富,预训练速度过慢等问题。后续诸多模型,如XLNet、ERNIE、SpanBERT、Roberta、T5、distillBERT、TinyBERT、Electra对它进行了优化和改进,我们后续再详细分析。
 

 

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/天景科技苑/article/detail/908585
推荐阅读
相关标签
  

闽ICP备14008679号