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 BERT
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.py
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-strategies
在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效果真的好嘛?这篇文章告诉你答案
这篇文章也提到了相应的介绍。
所以,说老实话,我觉得[cls]加不加没啥太大区别(从keras的mlm的官方的实现来看也可以看到,作者从头训练了一个MLM的bert model,压根就没有把[cls]这个special token加到词表里。)。无论是预训练mlm任务还是句子分类这样的下游任务,完全可以舍弃cls,只不过huggingface以及后续不少开源的model的语料训练的时候大家都模仿了最早的bert往里面加[cls],而预训练的模型的输入本身已经将[cls]+句子,这样的输入形式学习到模型的权重中去了(导致[cls]这个token在训练的过程中也被编码了某些知识
Jiang Lynn:解构BERT:从一亿参数中获取六种pattern
可以参考这篇,[cls]还是学习到了一些东西的),
如果你后续finetune的时候不这么做,[cls]+句子A和直接使用句子A分别作为输入来finetune下游的文本分类任务之类的,可能舍弃了[cls]就浪费了一部分信息,效果和保持原始输入形式的效果就会有一些差别。
最最后说一下[sep]:
其实前面提到的[sep]和[cls]的意义几乎差不多,就是预训练的时候一部分的知识被编码到这两个token对应的权重里了,sep用来区分句子,因为bert中有个nsp的任务:

这个其实去看下huggingface的bertmodel的源代码:
就可以知道,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,那么就完全不需要加。