bert中的special token到底是怎么发挥作用的(待续)

  • 2021 年 4 月 6 日
  • AI

bert中的special token有 [cls],[sep],[unk],[pad],[mask];


首先是[pad],

这个很简单了,就是占位符,和程序设计有关,和lstm中做padding一样,tf或者torch的bert之类的预训练model的接口api只能接受长度相同的input,所以用[pad]让所有短句都能够对齐,长句就直接做截断,[pad]这个符号只是一种约定的用法,看文档:

>>> batch_sentences = ["Hello I'm a single sentence",
...                    "And another sentence",
...                    "And the very very last one"]

>>> batch = tokenizer(batch_sentences, padding=True, truncation=True, return_tensors="pt")
>>> print(batch)
{'input_ids': tensor([[ 101, 8667,  146,  112,  182,  170, 1423, 5650,  102],
                      [ 101, 1262, 1330, 5650,  102,    0,    0,    0,    0],
                      [ 101, 1262, 1103, 1304, 1304, 1314, 1141,  102,    0]]),
 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0],
                           [0, 0, 0, 0, 0, 0, 0, 0, 0],
                           [0, 0, 0, 0, 0, 0, 0, 0, 0]]),
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 0, 0, 0, 0],
                           [1, 1, 1, 1, 1, 1, 1, 1, 0]])}

这里padding的部分的index都是0,即在embedding矩阵中对应第0行,因为pad在所难免,所以input输入的时候,你总得在embedding矩阵里加一行embedding vector专门用来代表[pad],否则程序报错,但是这个embedding因为被mask后续不会参与到权重更新,所以其实把bert原始的token embedding矩阵中这一行对应的权重改成任意的向量都没什么区别;


然后是[UNK],

这个更多是为了预测服务的,如果为了input的句子里有embedding矩阵没有的token,分token的时候,这类token都会变成unk,同样 bert的embedding矩阵里也有一行embedding向量专门用来表示UNK,不过具体不知道bert pretrained model里这个UNK是怎么设计的,简单的方法就是直接取所有embedding vector的均值作为代替;


然后是[mask],

[mask]主要是为了MLM任务,同样Bert的embedding向量中也有专门存放[mask]这个token的embedding向量,设计的目的也是为了保证程序正常运行,如果embedding layer里没有[mask]则程序报错,

但是如果bertmodel的下游没有设置MLM任务之类的和[mask]相关的预训练任务对应的layer,把token替换成[mask]啥屁用都没有,你就真的把这个词变成[mask]了。

而所谓的预训练任务对应的layer,可以看看keras官方文档,重头撸到尾:

Keras documentation: End-to-end Masked Language Modeling with BERTkeras.io图标

    mlm_output = layers.Dense(config.VOCAB_SIZE, name="mlm_cls", activation="softmax")(
        encoder_output
    )

这就是mlm任务对应的任务头(layer),就是一个vocab_size大小的超级多分类,这里没法用word2vec中用的负采样因为mask可能有多个,也就是输出可能是[0,0,0,1,0,1,0,….]这样的向量输出。

可以看到[mask]除了对input进行替换之外,全程打酱油,最终是根据输出来确定了[mask]的作用,注意,bert中针对input的的mask机制仅仅针对padding部分进行mask,对[mask]是不做mask操作的

所以,当我们把某个token mask之后,如果这个句子中没有被mask的token都和被mask的token不相同的话,那么这个被mask 的token的word embedding不会得到任何训练,因为压根也没有往前传。


最后是最让人纠结的[cls]和[sep]

[cls],huggingface的berttokenize默认是给句子配一个[cls]和一个[seq],分别在句首和句尾,我看了很多百度知乎和谷歌上的说法:

CLS:special classification embedding,用于分类的向量,会聚集所有的分类信息
SEP:输入是QA或2个句子时,需添加SEP标记以示区别

基本都是这么解释的。

这种解释压根难以make sence。

首先,如果我们的预训练任务或者是下游的应用的输入都是单个句子,[sep]根本就没用,不用加到分token后的句子里

先来理解一下[cls],

先看下原文的解释:

The first token of every sequence is always a special classification token ([CLS]). The final hidden state corresponding to this token is used as the aggregate sequence representation for classification tasks.

讲了和没讲差不多。。。

然后我又看了一下google bert的源代码,其中对这部分做了解释:

//github.com/google-research/bert/blob/eedf5716ce1268e56f0a50264a88cafad334ac61/run_classifier.pygithub.com

For classification tasks, the first vector (corresponding to [CLS]) is used as the “sentence vector”. Note that this only makes sense because the entire model is fine-tuned.

翻译过来就是,你要用[cls]去做下游句子分类任务的fine-tune才有意义,反正最后根据下游任务finetune之后,bert的参数就会发生一定的变化使得[cls]处得到的hidden state包含句子的表征信息,换句话说,你用别的token的last hidden state来做finetune也没问题。。。。。wtm

//github.com/hanxiao/bert-as-service#q-what-are-the-available-pooling-strategiesgithub.com

在bert as service中也提到了这个问题以及作者的回答:

Q: Why not use the hidden state of the first token as default strategy, i.e. the [CLS]?
A: Because a pre-trained model is not fine-tuned on any downstream tasks yet. In this case, the hidden state of [CLS] is not a good sentence representation. If later you fine-tune the model, you may use [CLS] as well.

后续看完sentence-bert之后,发现确实cls这么做效果没有直观的方法——对所有的input的token的last hidden state做pooling效果来的好。

Elesdspline:BERT中CLS效果真的好嘛?这篇文章告诉你答案zhuanlan.zhihu.com图标

这篇文章也提到了相应的介绍。

所以,说老实话,我觉得[cls]加不加没啥太大区别(从keras的mlm的官方的实现来看也可以看到,作者从头训练了一个MLM的bert model,压根就没有把[cls]这个special token加到词表里。)。无论是预训练mlm任务还是句子分类这样的下游任务,完全可以舍弃cls,只不过huggingface以及后续不少开源的model的语料训练的时候大家都模仿了最早的bert往里面加[cls],而预训练的模型的输入本身已经将[cls]+句子,这样的输入形式学习到模型的权重中去了(导致[cls]这个token在训练的过程中也被编码了某些知识

Jiang Lynn:解构BERT:从一亿参数中获取六种patternzhuanlan.zhihu.com图标

可以参考这篇,[cls]还是学习到了一些东西的

如果你后续finetune的时候不这么做,[cls]+句子A和直接使用句子A分别作为输入来finetune下游的文本分类任务之类的,可能舍弃了[cls]就浪费了一部分信息,效果和保持原始输入形式的效果就会有一些差别。


最最后说一下[sep]:

其实前面提到的[sep]和[cls]的意义几乎差不多,就是预训练的时候一部分的知识被编码到这两个token对应的权重里了,sep用来区分句子,因为bert中有个nsp的任务:

这个其实去看下huggingface的bertmodel的源代码:

//github.com/huggingface/transformers/blob/1c06240e1b3477728129bb58e7b6c7734bb5074e/src/transformers/models/bert/modeling_bert.pygithub.com

就可以知道,embedding部分压根就没有关于[sep]的额外处理,就单独对pad的index进行了定义,也就是[sep]也是当作一个字符放进去了。google上有不少train from scratch的代码,比如这段:

    def __getitem__(self, idx):
        t1,t2 = self.get_sentence(idx)
        t1_random, t1_label,_ = self.random_word(t1) 
        t2_random, t2_label,_ = self.random_word(t1) 

        t1 = [self.vocab['[CLS]']] + t1_random + [self.vocab['[SEP]']]
        t2 = t2_random + [self.vocab['[SEP]']]
        t1_label = [self.vocab['[PAD]']] + t1_label + [self.vocab['[PAD]']]
        t2_label = t2_label + [self.vocab['[PAD]']] 

        segment_label = ([0 for _ in range(len(t1))] + [1 for _ in range(len(t2))])[:self.seq_len]
        bert_input = (t1 + t2)[:self.seq_len]

        padding = [self.vocab['[PAD]'] for _ in range(self.seq_len - len(bert_input))]
        attention_mask = len(bert_input) * [1] + len(padding) * [0]
        

        bert_input.extend(padding)
        
        bert_label = t1_label
        padding = [self.vocab['[PAD]'] for _ in range(self.seq_len - len(bert_label))]
        bert_label.extend(padding)# , segment_label.extend(padding)
        
        attention_mask = np.array(attention_mask)
        bert_input = np.array(bert_input)
        segment_label = np.array(segment_label)
        bert_label = np.array(bert_label)
        #is_next_label = np.array(is_next_label)
        output = {"input_ids": bert_input,
                  "token_type_ids": segment_label, 
                  'attention_mask': attention_mask,
                  "bert_label": bert_label}#, is_next_label
        return output

是关于nsp任务准备的,可以发现,句子的先后顺序,也就是segment embedding对应的

segment_label:

segment_label = ([0 for _ in range(len(t1))] + [1 for _ in range(len(t2))])[:self.seq_len]

和[sep]没有半毛钱关系。


总结:不是因为special token有特殊意义我们才要刻意地使用special token,而是因为开源地预训练模型是在这样地输入形式上训练地,所以我们使用地时候也要尊崇其语料地形式对新语料进行改造,换句话说,如果你有能力自己预训练一个新的model,special token,例如[pad],[mask],[unk]想换成什么都行,而[cls],[seq]不加都可以,或者某些开源的预训练模型里也没有[cls],[seq]之类的奇怪的token,那么就完全不需要加。