前面两篇分别梳理了下BERT的原理和BERT的训练,接着前面的内容,梳理下BERT是如何在下游任务上运用的。
原理就是上面这个图了。四种任务,实际上从他的训练模型的代码和我之前的NER的代码就已经对(b)(d)两个部分进行了梳理。那接下来我首先梳理下BERT是如何在SQUAD2.0阅读理解这个任务上使用的。
Part2:SQUAD项目实战
关于阅读理解部分,我会专门做个专题梳理,这里只针对bert进行讲解。
一.数据下载与模型准备
这里bert开源项目上就有,自行下载,然后根据说明配置一下目录就行。注:模型我下载的是BERT-Large, Uncased,readme以及说的很清楚了,cased的版本是考虑了字母大小写的,适合NER任务。
模型参数:
二.数据处理
之前看过QA-net的代码,SQUAD部分的数据处理还比较熟悉,这里还是简单的说明,拿SQUAD2.0训练数据为例:
训练集中包含了442段话题,然后每个话题有一个title,对于每个话题下都有几十段的paragraph,每个paragraph是一个json字典文件,包含了context和至少一组问答对:
注:
这里的text就是答案,answer_start:是指答案在文章开始的地方,根据这两个算出answer_start,answer_end,然后在到文章中去验证是不是有这个答案,这是数据清洗的部分,这里就大致过一下。
最后将数据处理成逐条的形式,每条数据内容如下:
注意:1.SQUAD1.0和SQUAD2.0任务的区别就是后者的数据更多,并且加入了无法回答的问题数据。
2.代码中的tokenization是一个字符处理和数据清洗的工具,这里补充说明下,主要有BasicTokenizer和WordpieceTokenizer两个类,先使用前者,主要是分离标点,转换大小写,和判断中文字符;后者的作用是对部分字符切片,避免OOV过多的问题,这样做的好处在这里举两个例子:
Question: What year was John Smith born?
Context: The leader was John Smith (1895-1943).
Answer: 1895
像这种问题(1895-1943).是连接在一起的,没有空格,需要把这个部分分开,因此要用到WordpieceTokenizer部分。
三.代码讲解
代码部分的讲解我选择pytorch的版本,其实都一样,我主要比较了下两个版本的实验结果是不是一样的,因为自己在做调试的时候感觉pytorch的版本梳理起来稍微方便点,后续我还会提到GPU的使用问题。
关于bert的部分我就不再说明,我们从输出的部分开始看,这里要明确建模任务,我们显然是要找出答案在原文中的位置,也就是start和end两个类别在原文中每个位置上的概率。搞清楚了这个,我们来看bert的最后一层输出就是[bz,max_length,dim],对于不同任务我们需要接不同的全连接层,对于这个任务只需要接一个这样的线性层就行:
当然如果你想在“更厉害”的网络上嵌入bert做微调也是可以的,后续我会找个例子实现一下。
接下来主要说一下GPU的使用问题。
在这个问题上,假如你的手头GPU的计算资源不够,你可能会感觉寸步难行,动不动就报OFM的错误,我大概计算了一下,如果要用32batch size任何trick不加,你至少需要4块TITAN吧,也就是40G的显存,必将large model就是1G+,再乘上batch,大概就需要这么大,那么怎么办?官方代码给了解决方案,我们来看一下可行性:
分布式训练也就是第三点我就不说了,主要是还没用过,不是很会,剩余的四个我们来看一下:
1.Gradient Accumulation:所谓梯度累计,就是假如你设置的32batch size,太大了,跑不通,那么就将batch size除以Gradient Accumulation系数,如果是2,那么batch size就变成了16,但是在反向传播的时候,会将mean_loss也除以2,然后进行累计,直到2次后在进行梯度清零,就相当于将32size分成两个16,但是是进行的连续的反向转播,如果看不懂就看看代码:
这样的做法并不能提速,只是为了跑32batch_size的数据分两次,然后累积清空梯度,
2.Multi-GPU
这里主要就是model = torch.nn.DataParallel(model).cuda()官方代码里不是这么写的,上来就检测你所有的显卡,然后把空闲的都用了,这里建议自己设定卡的id比较好,把代码稍稍修改一下,原理就是首先将模型加载到主 GPU 上,然后再将模型复制到各个指定的从 GPU 中,然后将输入数据按 batch 维度进行划分,具体来说就是每个 GPU 分配到的数据 batch 数量是总输入数据的 batch 除以指定 GPU 个数。每个 GPU 将针对各自的输入数据独立进行 forward 计算,最后将各个 GPU 的 loss 进行求和,再用反向传播更新单个 GPU 上的模型参数,再将更新后的模型参数复制到剩余指定的 GPU 中,这样就完成了一次迭代计算。
3.Optimize on
关于Adam优化,这里有份教程讲的很清楚,这里大致是说Adam优化需要优化的有初始化参数向量、一阶矩向量、二阶矩向量:
这样一来,第一块GPU就要存储3倍模型的大小,这样就限制了batch_size的大小,所以我们将这部分参数存在CPU上:
接下来在训练的过程中:set_optimizer_params_grad和copy_optimizer_params_to_model是两个过程,当要开始训练时set_optimizer_params_grad将需要训练的参数从模型中取出param_opti.grad.data.copy_(param_model.grad.data),然后optimizer.step(),完成后再将新的参数送回模型param_model.data.copy_(param_opti.data)。
4.16-bits training
这部分代码也是基于CPU优化上完成的,训练过程中,每一层的权重存储成FP32数据类型(Mater-Weights),每次训练时都会将FP32的权重降精度至FP16( a master copy),前向推理和后向梯度都使用FP16进行计算,更新时将FP16的梯度累加到FP32的Mater-Weight上,样例图如下,
混合精度训练可以解决权重更新量很小的问题,但无法解决梯度本身很小的问题。在一些网络中(比如SSD),梯度大部分都在FP16的表示范围之外,因此需要将梯度平移到FP16的表示范围内 。所以用到了loss_scale参数,一种简单高效的方法是直接在前向时就将loss乘以scale,这样在后向传导时所有的梯度都会被乘以相同的scale。权重更新时需要将移位后的梯度除以scale后,再更新到权重上。
上面就是我对pytorch代码的在squad任务上的解读,pytorch版本的代码只适合1.0任务,2.0任务我后续会进行修改,
上面是我的参数设置,下面来看下实验结果:
这个结果比官方给的差一点点,毕竟不是最佳参数,在GPU的使用上也有限制。再贴这个结果的时候实验大概跑了三天吧,现在pytorch版本又出了在fp16中自动调节loss_scale的代码,下面是新的实验结果,新的代码里面用了APEX的包,训练速度有很大的提升,缩短了好几倍,结果仍旧差不多:
(上面两个实验都是在一块泰坦上搞定的)