BERT 双向语言模型

作者: 莫烦 编辑: 莫烦 2020-09-08

学习资料:

怎么了

BERT 和 GPT 还有 ELMo 是一个性质的东西。 它存在的意义是要变成一种预训练模型,提供 NLP 中对句子的理解。ELMo 用了双向 LSTM 作为句子信息的提取器,同时还能表达词语在句子中的不同含义;GPT 呢, 它是一种单向的语言模型,同样也可以用 attention 的方式提取到更加丰富的语言意思信息。而BERT,它就和GPT是同一个家族,都是从Transformer 演变而来的。那么 BERT 和 GPT 有有什么不同之处呢?

其实最大的不同之处是,BERT 认为如果看一个句子只从单向观看,是不是还会缺少另一个方向的信息?所以 BERT 像 ELMo 一样,算是一种双向的语言模型。 而这种双向性,其实正是原封不动的 Transformer 的 Encoder 部分。

bert gpt comparison

怎么训练

在看到后续内容之前,我强烈建议你先看一下我的 Transformer 教学,因为BERT就是一个Transformer的Encoder,只是在训练步骤上有些不同。 在这个教程中我就不会详细说明Encoder的结构了。

为了让BERT理解语义内容,它的训练会比GPT tricky得多。 GPT之所以训练方案上比较简单,是因为我们把它当成一个RNN一样训练,比如用前文预测后文(用mask挡住了后文的信息)。前后没有信息的穿越,这也是单向语言模型好训练的一个原因。 但是如果又要利用前后文的信息(不mask掉后文信息),又要好训练,这就比较头疼了。因为我在预测词X的时候,实际上是看着X来预测X,这样并没有什么意义。

好在BERT的研发人员想到了一个还可以的办法,就是我在句子里面遮住X,不让模型看到X,然后来用前后文的信息预测X。这就是BERT训练时最核心的概念了。

masked training

但是这样做又会导致一个问题。我们人类理解完形填空的意思,知道那个(mask)是或者没有的意思。 但是模型不知道呀,它的(mask)会被当成一个词去理解。因为我们给的是一个叫mask的词向量输入到模型里的。 模型还以为你要用mask这个词向量来预测个啥。为了避免这种情况发生,研究人员有做了一个取巧的方案: 除了用mask来表示要预测的词,我还有些时候,把mask随机替换成其他词,或者原封不动。具体下来就是下面三种方式:

随机选取15%的词做如下改变

  1. 80% 的时间,将它替换成 [MASK]
  2. 10% 的时间,将它替换成其他任意词
  3. 10% 的时间,不变

举个例子:

Input: The man went to [MASK] store with [MASK] dog
Target:                  the               his

预测 [MASK] 是BERT的一项最主要的任务。在非监督学习中,我们还能怎么玩?让模型有更多的可以被训练的任务? 其实呀,我们还能借助上下文信息做件事,就是让模型判断,相邻这这两句话是不是上下文关系。

nsp training

举个例子,我在一个两句话的段落中将这两句话拆开,然后将两句话同时输入模型,让模型输出True/False判断是否是上下文。 同时我还可以随机拼凑不是上下文的句子,让它学习这两句不是上下文。

Input : the man went to the store [SEP] he bought a gallon of milk [SEP]
Is next : True

Input = the man heading to the store [SEP] penguin [MASK] are flight ##less birds [SEP]
Is next : False 

有了这两项任务,一个[MASK],一个上下文预测,我们应该就能创造出非常多的训练数据来给模型训练进行监督训练啦。 其实也就是把非监督的数据做成了两个监督学习的任务,模型还是被监督学习的。

请注意:我写的BERT代码和原文有一处不同,我认为不用传递给模型一个[CLS]信息让模型知道当前在做的是什么任务,因为我想要得到的是一个语言理解器, 至于对于不同的任务,可以 Finetune 出不同的头来适应,因为谁知道你下游是不是一个你训练过的任务(Task)呢?所以我觉得没必要专门为了Task去搞一个Task input。 我更关注的是训练出一个语言模型,而不是一个语言任务模型。

效果

我没有使用大数据,所以这个教学中的 BERT 训练出来的效果不会很好,但是多少还是有些注意力的有效分布。

bert attention line

秀代码

我们这里选择的数据还是和做ELMoGPT 时相同的数据(MRPC),可以进行横向对比。 你可以点击下载训练数据, 测试数据。 或者我的 utils.py 里面调用 utils.MRPCData() 的时候会自动帮你下载。

如果你习惯直接看全部代码,请点击这里

def train(model, data, step=10000):
    for t in range(step):
        seqs, segs, seqs_, loss_mask, xlen, nsp_labels = random_mask_or_replace(data, ...)
        loss, pred = model.step(seqs, segs, seqs_, loss_mask, nsp_labels)

d = utils.MRPCData("./MRPC", 2000)
m = BERT()
train(m, d, step=10000)

我们注意到 random_mask_or_replace() 在每次循环中,将数据进行了一次MASK和replace操作。目的就是为了让BERT有个可以被预测的词位。

通过上面的训练过程,如果我们打印出训练结果,可以发现,BERT在收敛,但是收敛的速度比GPT慢很多,我们上次训练的GPT只用了5000步就收敛到一个比较好的地方, 但是这次的BERT训练了10000步,还是没能收敛到特别好。这也是BERT在训练上的一个硬伤。

step:  0 | time: 0.64 | loss: 9.655
| tgt:  <GO> <quote> we can 't change the past , sour we can schering-plough a lot about the future , <quote> sheehan said gamecocks a news conference wednesday afternoon . prevents <quote> we heads 't change the past leg but goldman can do a lot about analogous future , <quote> sheehan said hours after arriving in phoenix
| prd:  tennis subject bar condition adviser down higher ko larned sleep charing arrest shipments alone corp. forging lord rucker humans requiring peaks assignment communion parking locked jeb novels aboard civilians sciences moroccan offer juvenile non-discriminatory reactors <NUM>-to-49-year-old slashed touch-screen underperformed aches trenton north partway odds tito websites company-sponsored orthopedic behind mother-of-two breaking campaigning cooperate down denver marched
| tgt word:  ['but', 'do', 'at', '<SEP>', 'can', ',', 'we', 'the']
| prd word:  ['sleep', 'shipments', 'communion', 'sciences', 'juvenile', 'touch-screen', 'aches', 'websites']


step:  100 | time: 14.04 | loss: 8.924
| tgt:  <GO> this year , local health departments hired part-time water samplers and purchased testing equipment with a $ <NUM> grant from the environmental protection agency . <SEP> this year , peninsula health officials got the money to hire part-time water samplers and purchase testing equipment thanks to a $ <NUM> grant from the environmental protection agency
| prd:  <GO> harrison operated <GO> <GO> , <GO> the the <SEP> <SEP> <GO> the the <SEP> manila stuck <SEP> the <GO> medics <SEP> <GO> <SEP> the sherry offend daschle cronan , washington-area , membership , the <NUM> , , the the the <NUM> the the the the , stricter <NUM> the , , the <NUM> the the
| tgt word:  ['testing', 'with', '<NUM>', 'protection', ',', 'health', 'water', 'protection']
| prd word:  ['the', 'manila', 'the', '<SEP>', ',', ',', 'the', 'the']

...

step:  9800 | time: 14.16 | loss: 2.888
| tgt:  <GO> in <NUM> , the building 's owners , the port authority of new york and new jersey , issued guidelines to upgrade the fireproofing to a thickness of <NUM> { inches . <SEP> the nist discovered that in <NUM> the port authority issued guidelines to upgrade the fireproofing to a thickness of <NUM> 1 / <NUM> inches
| prd:  <GO> in <NUM> , the new 's the , the , , of new new and new <NUM> , , to to to the fireproofing to a of of <NUM> fireproofing <NUM> the <SEP> the nist the that in <NUM> the to to , <NUM> to <NUM> the , to a , of <NUM> to , <NUM> to
| tgt word:  ['authority', 'inches', 'that', 'in', 'authority', 'a', '1', '/']
| prd word:  [',', '<NUM>', 'that', 'in', 'to', 'a', 'to', ',']


step:  9900 | time: 14.06 | loss: 3.090
| tgt:  <MASK> <MASK> stock closed friday at $ <MASK> , down $ <NUM> , or <NUM> percent , on the nasdaq stock market . <MASK> shares of brocade closed at $ <NUM> , down $ <NUM> , or <MASK> percent
| prd:  <GO> the <SEP> was friday at $ <NUM> , down $ <NUM> , or <NUM> percent , on the <GO> <SEP> market the <SEP> the of <GO> was at $ <NUM> , down $ <NUM> , or <NUM> percent
| tgt word:  ['<GO>', 'the', '<NUM>', '<SEP>', '<NUM>']
| prd word:  ['<GO>', 'the', '<NUM>', '<SEP>', '<NUM>']

random_mask_or_replace() 这个功能怎么设计呢?我大概讲一下思路。 简单来说也就是要将原始句子替换一下他们的[MASK]位置,或者是replace成其他词,又或者啥都不做。 我还有个tricky的做法,为了只计算被masked或者replaced这些位置的loss,在模型前向完了,他会对每一个词位都计算一下误差, 但是我们可以在计算真正loss的时候,只保留这些被masked/replaced位置的loss,其他词语的位子都忽略掉。 所以我这里还会生成一个loss_mask,用来在计算loss时,只关注需要计算的部分。

def _get_loss_mask(...):
    # 间接在计算loss时,只看被MASK或者被Replaced位置,其他位置忽略
    return loss_mask


def do_mask(seq, len_arange, pad_id, mask_id):
    # 80% 添加[MASK]
    loss_mask, rand_id = _get_loss_mask(len_arange, seq, pad_id)
    seq[rand_id] = mask_id
    return loss_mask


def do_replace(seq, len_arange, pad_id, word_ids):
    # 10% 替换成其他词
    loss_mask, rand_id = _get_loss_mask(len_arange, seq, pad_id)
    seq[rand_id] = np.random.choice(word_ids, size=len(rand_id))
    return loss_mask


def do_nothing(seq, len_arange, pad_id):
    # 10% 啥也不做
    loss_mask, _ = _get_loss_mask(len_arange, seq, pad_id)
    return loss_mask


def random_mask_or_replace(data, arange, batch_size):
    seqs, segs, xlen, nsp_labels = data.sample(batch_size)
    p = np.random.random()
    if p < 0.7:     # 我代码里稍微改了一下配方比
        # mask
        loss_mask = np.concatenate([do_mask(...) for i in range(len(seqs))], axis=0)
    elif p < 0.85:  # 我代码里稍微改了一下配方比
        # do nothing
        loss_mask = np.concatenate([do_nothing(...) for i in range(len(seqs))], axis=0)
    else:
        # replace
        loss_mask = np.concatenate([do_replace(...) for i in range(len(seqs))], axis=0)
    return seqs, segs, seqs_, loss_mask, xlen, nsp_labels

因为BERT的主架构是Transformer的Encoder,而我们之前写的GPT也是用的它的encoder。 还不太清楚我的GPT架构的朋友,请过目一下~ 所以这里我们只需要在GPT的结构上修改一下计算loss的方案和双向mask的方案即可。(我的GPT代码是继承的Transformer的架构,所以他们都是通用的)

from GPT import GPT

# 这个BERT直接继承我的GPT
class BERT(GPT):
    def __init__(self, ...):
        super().__init__(...)

    def call(self, seqs, segs, training=False):
        # GPT 的前向,我没改
        embed = self.input_emb(seqs, segs)  # [n, step, dim]
        z = self.encoder(embed, training=training, mask=self.mask(seqs))     # [n, step, dim]
        mlm_logits = self.task_mlm(z)  # [n, step, n_vocab]
        nsp_logits = self.task_nsp(tf.reshape(z, [z.shape[0], -1]))  # [n, n_cls]
        return mlm_logits, nsp_logits

    def step(self, seqs, segs, seqs_, loss_mask, nsp_labels):
        with tf.GradientTape() as tape:
            # 两个任务的logits
            mlm_logits, nsp_logits = self.call(seqs, segs, training=True)
            
            # trick:语言模型预测,mask掉loss可忽略的部分
            mlm_loss_batch = tf.boolean_mask(self.cross_entropy(seqs_, mlm_logits), loss_mask)
            mlm_loss = tf.reduce_mean(mlm_loss_batch)
            
            # 是否下一句预测
            nsp_loss = tf.reduce_mean(self.cross_entropy(nsp_labels, nsp_logits))
            loss = mlm_loss + 0.2 * nsp_loss
            grads = tape.gradient(loss, self.trainable_variables)
            self.opt.apply_gradients(zip(grads, self.trainable_variables))
        return loss, mlm_logits

    def mask(self, seqs):
        # 覆盖掉GPT原有的encoder mask方法,这里只使用pad mask
        mask = tf.cast(tf.math.equal(seqs, self.padding_idx), tf.float32)
        return mask[:, tf.newaxis, tf.newaxis, :]  # [n, 1, 1, step]

所以这个BERT和我的GPT还算挺兼容的,只是稍微改动了下step()mask().通过这个修改,就保留了BERT的双向注意力,而且在算loss的时候,能只计算需要计算的部分。

最后运行一段时间,注意力的学习成果如下。首先看看它注意力转化成矩阵的模式。

bert attention matrix

如果将注意力转化成线的模式,我们可以更清楚地看到他对每个词是怎么注意的。这里我们也留意到,其实这个模型还没学好,很多时候,它有点想放弃注意。 也就是很多注意力都集中到<GO>或者一些没多大意义的词上了。随着继续训练和数据的增多,这种现象会好很多。(GPT的训练也类似)

bert attention line

前面我们还提到这个BERT训练了10000步还收敛不到一个好结果,而GPT只需要5000步就能收敛得比较好了。这是为什么呢? 最主要的原因是BERT每次的训练太没有效率了。每次输入全部训练数据,但是只能预测15%的词,而GPT能够预测100%的词,这不就让BERT单次训练少了很多有效的label信息。

为了解决我刚刚提到的训练无效率的问题,我自己也捣腾了一下修改BERT的结构,让它能快速训练。下面就是我自己的原创模型啦。

提升训练速度

BERT 的训练真的很慢!!Google都要用很厉害的8块GPU训练一周时间才能训练一个好的模型。 这只有他们这种土豪才能玩的模型,到我们手中,谁等得起。 因为BERT每次训练只预测并训练约15%的信息量,这种训练是十分低效的。 如果你对比前面的GPT教学,就会发现GPT的训练比Bert快多了。 所以我也在考虑如何结合GPT的优点,将BERT的训练速度加快。

总的来说,BERT因为需要mask并预测mask的信息,使得模型在预测时的数据处理就要多一个随机环境。最慢的还不是这个随机过程,而是mask训练的方案。 如果从预测并训练15%的token提升到100%,速度一下就提高了6倍多,原本要8块GPU一周,变成8块一天,岂不是更好~

所以我也想出了一种训练方案,我没有仔细在论文中寻找是否已经存在这种方案了,如果你看过这样的方案,欢迎在留言区留言,我cite一下。如果没有,那么请恭喜我, 莫烦Python可能是这个世界上第一次(2020年7月)提出这种快速训练BERT方案的人。 也欢迎各路学术上的朋友参考一下,发发论文啥的。

GPT bert mask

GPT中, 我们保留前句,mask掉后句,然后用前句预测后句中的第一个词。但是在GPT预测后句中第一个词时, 是没有考虑后句中其他的信息,所以我们也称为GPT是单向Transformer。像BERT这种模型,因为在预测时考虑了除mask外的所有前后文,所以也被叫做双向Transformer。 如果要保留BERT的双向性,又要让BERT不仅仅只预测15%的信息,那么好办, 我只要让BERT在attention的时候mask掉对角线的token(词),而不是mask输入端(word Embedding前)的15%个token。

identical mask

这样看似BERT在预测某个token(词)的时候,就看不见这个token的attention了,完美~ 不对!!这其实还是有个问题的。除了attention层,我们还有一个残差层。

我们原以为attention时看不见就行了,但是这个残差成会将原本看不见的X信息直接传到后面的层去,所以最后预测 attention 的 mask 位置X时, 其实早就通过残差看过masked token的信息了,这是一种有透视的无效预测。

那么怎么解决这个透视问题呢?咱们学学GPT,在预测的时候,不要在X位置上预测X的信息,而是预测X位置上的X+1信息。同时为了保证BERT语言模型的双向性, 我们只在X的attention位置上 mask 掉 X+1 信息,然后用X位置快速训练BERT方案预测X+1。即使残差会将X的信息传递到后面去,但是X+1的信息它还是没见过的。 我称为BERT的next-mask机制。

x+1 mask

具体代码也在我的github中, 和我写的BERT代码非常像,但是更加简单了, 没了哪些随机采样对训练数据采样的操作。

另外,基于这个想法再括展一些,还可以引入w2v CBOW的训练概念。 改一改attention mask,在X的token位置预测(X-n)~(X+n)范围的token。这样就比GPT更强,而且还保留了双向的特性。一举两得。

x-1 mask

通过这种方法训练,我们可以更快训练好一个bert。

bert attention matrix

bert attention line

我在网上找到的类似加速想法还有 Fast and Accurate Deep Bidirectional Language Representations for Unsupervised Learning.

我就不发论文了,想拿着这个想法发论文的朋友欢迎email和我联系(morvanzhou@hotmail.com)。

总结

BERT 完美实现了双向语言模型的概念,在我的认知中,双向肯定会比单向语言模型(GPT)获取到更多的信息,所以按理来说应该会更优秀。但是在训练双向语言模型时, 会有很多tricks,我们要多多研究一下trick才能使得训练更加有效率更快。

降低知识传递的门槛

莫烦的对教育的态度是: 降低知识传递的门槛,不希望给"学习"设置金钱障碍。 这是我花大量业余时间贡献 免费 AI分享的原因。 通过 【赞助】 能及时让我看到你对 【莫烦态度】 的认同,我也更有理由坚持下去。

如果你当前目标是找工作或者转行AI,想接受更加丰富的教学资源、培训辅导体验,我想推荐我的朋友 七月在线 给你, 通过这个 【莫烦Python为你提供的注册链接】, 你将可以获得莫烦专门为你协商的课程优惠券。祝你找/换工作顺利~