zoukankan      html  css  js  c++  java
  • 原来你是这样的BERT,i了i了! —— 超详细BERT介绍(一)BERT主模型的结构及其组件

    原来你是这样的BERT,i了i了! —— 超详细BERT介绍(一)BERT主模型的结构及其组件

    BERTBidirectional Encoder Representations from Transformers)是谷歌在2018年10月推出的深度语言表示模型。

    一经推出便席卷整个NLP领域,带来了革命性的进步。
    从此,无数英雄好汉竞相投身于这场追剧(芝麻街)运动。
    只听得这边G家110亿,那边M家又1750亿,真是好不热闹!

    然而大家真的了解BERT的具体构造,以及使用细节吗?
    本文就带大家来细品一下。


    前言

    本系列文章分成三篇介绍BERT,本文主要介绍BERT主模型(BertModel)的结构及其组件相关知识,另有两篇分别介绍BERT预训练相关和如何将BERT应用到不同的下游任务

    文章中的一些缩写:NLP(natural language processing)自然语言处理;CV(computer vision)计算机视觉;DL(deep learning)深度学习;NLP&DL 自然语言处理和深度学习的交叉领域;CV&DL 计算机视觉和深度学习的交叉领域。

    文章公式中的向量均为行向量,矩阵或张量的形状均按照PyTorch的方式描述。
    向量、矩阵或张量后的括号表示其形状。

    本系列文章的代码均是基于transformers库(v2.11.0)的代码(基于Python语言、PyTorch框架)。
    为便于理解,简化了原代码中不必要的部分,并保持主要功能等价。
    在代码最开始的地方,需要导入以下包:

    代码
    from math import inf, sqrt
    import torch as tc
    from torch import nn
    from torch.nn import functional as F
    from transformers import PreTrainedModel
    

    阅读本系列文章需要一些背景知识,包括Word2VecLSTMTransformer-BaseELMoGPT等,由于本文不想过于冗长(其实是懒),以及相信来看本文的读者们也都是冲着BERT来的,所以这部分内容还请读者们自行学习。
    本文假设读者们均已有相关背景知识。


    目录


    1、主模型

    BERT的主模型是BERT中最重要组件,BERT通过预训练(pre-training),具体来说,就是在主模型后再接个专门的模块计算预训练的损失(loss),预训练后就得到了主模型的参数(parameter),当应用到下游任务时,就在主模型后接个跟下游任务配套的模块,然后主模型赋上预训练的参数,下游任务模块随机初始化,然后微调(fine-tuning)就可以了(注意:微调的时候,主模型和下游任务模块两部分的参数一般都要调整,也可以冻结一部分,调整另一部分)。

    主模型由三部分构成:嵌入层编码器池化层
    如图:

    其中

    • 输入:一个个小批(mini-batch),小批里是batch_size个序列(句子或句子对),每个序列由若干个离散编码向量组成。
    • 嵌入层:将输入的序列转换成连续分布式表示(distributed representation),即词嵌入(word embedding)或词向量(word vector)。
    • 编码器:对每个序列进行非线性表示。
    • 池化层:取出[CLS]标记(token)的表示(representation)作为整个序列的表示。
    • 输出:编码器最后一层输出的表示(序列中每个标记的表示)和池化层输出的表示(序列整体的表示)。

    下面具体介绍这些部分。


    1.1、输入

    一般来说,输入BERT的可以是一句话:

    I'm repairing immortals.
    

    也可以是两句话:

    I'm repairing immortals. ||| Me too.
    

    其中|||是分隔两个句子的分隔符。

    BERT先用专门的标记器(tokenizer)来标记(tokenize)序列,双句标记后如下(单句类似):

    I ' m repair ##ing immortal ##s . ||| Me too .
    

    标记器其实就是先对句子进行基于规则的标记化(tokenization),这一步可以把'm以及句号.等分割开,再进行子词分割(subword segmentation),示例中带##的就是被子词分割开的部分。
    子词分割有很多好处,比如压缩词汇表、表示未登录词(out of vocabulary words, OOV words)、表示单词内部结构信息等,以后有时间专门写一篇介绍这个。

    数据集中的句子长度不一定相等,BERT采用固定输入序列(长则截断,短则填充)的方式来解决这个问题。
    首先需要设定一个seq_length超参数(hyperparameter),然后判断整个序列长度是否超出,如果超出:单句截掉最后超出的部分,双句则先删掉较长的那句话的末尾标记,如果两句话长度相等,则轮流删掉两句话末尾的标记,直到总长度达到要求(即等长的两句话删掉的标记数量尽量相等);如果序列长度过小,则在句子最后添加[PAD]标记,使长度达到要求。

    然后在序列最开始添加[CLS]标记,以及在每句话末尾添加[SEP]标记。
    单句话添加一个[CLS]和一个[SEP],双句话添加一个[CLS]和两个[SEP]
    [CLS]标记对应的表示作为整个序列的表示,[SEP]标记是专门用来分隔句子的。
    注意:处理长度时需要考虑添加的[CLS][SEP]标记,使得最终总的长度=seq_length[PAD]标记在整个序列的最末尾。

    例如seq_length=12,则单句变为:

    [CLS] I ' m repair ##ing immortal ##s . [SEP] [PAD] [PAD]
    

    如果seq_length=10,则双句变为:

    [CLS] I ' m repair [SEP] Me too . [SEP]
    

    分割完后,每一个空格分割的子字符串(substring)都看成一个标记(token),标记器通过查表将这些标记映射成整数编码。
    单句如下:

    [101, 146, 112, 182, 6949, 1158, 15642, 1116, 119, 102, 0, 0]
    

    最后整个序列由四种类型的编码向量表示,单句如下:

    标记编码:[101, 146, 112, 182, 6949, 1158, 15642, 1116, 119, 102, 0, 0]
    位置编码:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
    句子位置编码:[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    注意力掩码:[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
    

    其中,标记编码就是上面的序列中每个标记转成编码后得到的向量;位置编码记录每个标记的位置;句子位置编码记录每个标记属于哪句话,0是第一句话,1是第二句话(注意:[CLS]标记对应的是0);注意力掩码记录某个标记是否是填充的,1表示非填充,0表示填充。

    双句如下:

    标记编码:[101, 146, 112, 182, 6949, 102, 2508, 1315, 119, 102]
    位置编码:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    句子位置编码:[0, 0, 0, 0, 0, 0, 1, 1, 1, 1]
    注意力掩码:[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    

    上面的是英文的情况,中文的话BERT直接用汉字级别表示,即

    我在修仙( ̄︶ ̄)↗
    

    这样的句子分割成

    我 在 修 仙 (  ̄ ︶  ̄ ) ↗
    

    然后每个汉字(包括中文标点)看成一个标记,应用上述操作即可。


    1.2、嵌入层

    嵌入层的作用是将序列的离散编码表示转换成连续分布式表示。
    离散编码只能表示A和B相等或不等,但是如果将其表示成连续分布式表示(即连续的N维空间向量),就可以计算(A)(B)之间的相似度或距离了,从而表达更多信息。
    这个是词嵌入或词向量的知识,可以参考Word2Vec相关内容,本文不再赘述了。

    嵌入层包含三种组件:嵌入变换(embedding)、层标准化(layer normalization)、随机失活(dropout)。
    如图:


    1.2.1、嵌入变换

    嵌入变换实际上就是一个线性变换(linear transformation)。
    传统上,离散标记往往表示成一个独热码(one-hot)向量,也叫标准基向量,即一个长度为(V)的向量,其中只有一位为(1),其他都为(0)
    在NLP&DL领域,(V)一般是词汇表的大小。
    但是这种向量往往维数很高(词汇表往往比较大)而且很稀疏(每个向量只有一位不为(0)),不好处理。
    所以可以通过一个线性变换将这个向量转换成低维稠密的向量。

    假设(v)(V))是标记(t)的独热码向量,(W)(V imes H))是一个(V)(H)列的矩阵,则(t)的嵌入(e)为:

    [e = v W ]

    实际上(W)中每一行都可以看成一个词嵌入,而这个矩阵乘就是把(v)中等于(1)的那个位置对应的(W)中的词嵌入取出来。
    在工程实践中,由于独热码向量比较占内存,而且矩阵乘效率也不高,所以往往用一个整数编码来代替独热码向量,然后直接用查表的方式取出对应的词嵌入。

    所以假设(n)(t)的编码,一般是在词汇表中的编号,那么上面的公式就可以改成:

    [e = W_{n} ]

    其中下标表示取出对应的行。

    那么一个标记化后的序列就可以表示成一个编码向量。
    假设序列(T)的编码向量为(s)(L)),(L)为序列的长度,即(T)中有(L)个标记。
    如果词嵌入长度为(H),那么经过嵌入变换,得到(T)的隐状态(hidden state)(h)(L imes H))。


    1.2.2、层标准化

    层标准化类似于批标准化(batch normalization),可以加速模型训练,但其实现方式和批标准化不一样,层标准化是沿着词嵌入(通道)维进行标准化的,不需要在训练时存储统计量来估计整体数据集的均值和方差,训练(training)和评估(evaluation)或推理(inference)阶段的操作是相同的。
    另外批标准化对小批大小有限制,而层标准化则没有限制。

    假设输入的一个词嵌入为(e = [x_0, x_1, ..., x_{H-1}])(x_k)(e)(k = 0, 1, ..., (H-1)) 维的分量,(H)是词嵌入长度。
    那么层标准化就是

    [y_{k} = frac{x_{k}-mu}{sigma} * alpha_k + eta_k ]

    其中,(y_{k})是输出,(mu)(sigma^2)分别是均值和方差:

    [ mu = frac{1}{H} sum_{k=0}^{H-1} x_{k} \ sigma^2 = frac{1}{H} sum_{k=0}^{H-1} (x_{k}-mu)^2 \ ]

    (alpha_k)(eta_k)是学习得到的参数,用于防止模型表示能力退化。

    注意:(mu)(sigma^2)是针对每个样本每个位置的词嵌入分别计算的,而(alpha_k)(eta_k)对所有的词嵌入都是共用的;(sigma^2)的计算没有使用贝塞尔校正(Bessel's correction)。


    1.2.3、随机失活

    随机失活是DL领域非常著名且常用的正则化(regularization)方法(然而被谷歌注册专利了),用来防止模型过拟合(overfitting)。

    具体来说,先设置一个超参数(P in [0, 1]),表示按照概率(P)随机将值置(0)
    然后假设词嵌入中某一维分量是(x),按照均匀随机分布产生一个随机数(r in [0, 1]),然后输出值(y)为:

    [ y = left{ egin{aligned} & frac{x}{1-P} &, & r > P \ & 0 &, & r le P \ end{aligned} ight. ]

    由于按照概率(P)(0),相当于输出值的期望变成原来的((1-P))倍,所以再对输出值除以((1-P)),就可以保持期望不变。

    以上操作针对训练阶段,在评估阶段,输出值等于输入值:

    [y = x ]


    嵌入层代码如下:

    代码
    # BERT之嵌入层
    class BertEmb(nn.Module):
    	def __init__(self, config):
    		super().__init__()
    		# 标记嵌入,padding_idx=0:编码为0的嵌入始终为零向量
    		self.tok_emb = nn.Embedding(config.vocab_size, config.hidden_size, padding_idx=0)
    		# 位置嵌入
    		self.pos_emb = nn.Embedding(config.max_position_embeddings, config.hidden_size)
    		# 句子位置嵌入
    		self.sent_pos_emb = nn.Embedding(config.type_vocab_size, config.hidden_size)
    
    		# 层标准化
    		self.layer_norm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
    		# 随机失活
    		self.dropout = nn.Dropout(config.hidden_dropout_prob)
    
    	def forward(self,
    			tok_ids,  # 标记编码(batch_size * seq_length)
    			pos_ids=None,  # 位置编码(batch_size * seq_length)
    			sent_pos_ids=None,  # 句子位置编码(batch_size * seq_length)
    	):
    		device = tok_ids.device  # 设备(CPU或CUDA)
    		shape = tok_ids.shape  # 形状(batch_size * seq_length)
    		seq_length = shape[1]
    
    		# 默认:[0, 1, ..., seq_length-1]
    		if pos_ids is None:
    			pos_ids = tc.arange(seq_length, dtype=tc.int64, device=device)
    			pos_ids = pos_ids.unsqueeze(0).expand(shape)
    		# 默认:[0, 0, ..., 0],即所有标记都属于第一个句子
    		if sent_pos_ids is None:
    			sent_pos_ids = tc.zeros(shape, dtype=tc.int64, device=device)
    
    		# 三种嵌入(batch_size * seq_length * hidden_size)
    		tok_embs = self.tok_emb(tok_ids)
    		pos_embs = self.pos_emb(pos_ids)
    		sent_pos_embs = self.sent_pos_emb(sent_pos_ids)
    
    		# 三种嵌入相加
    		embs = tok_embs + pos_embs + sent_pos_embs
    		# 层标准化嵌入
    		embs = self.layer_norm(embs)
    		# 随机失活嵌入
    		embs = self.dropout(embs)
    		return embs  # 嵌入(batch_size * seq_length * hidden_size)
    

    其中,
    config是BERT的配置文件对象,里面记录了各种预先设定的超参数;
    vocab_size是词汇表大小;
    hidden_size是词嵌入长度,默认是768(bert-base-*)或1024(bert-large-*);
    max_position_embeddings是允许的最大标记位置,默认是512;
    type_vocab_size是允许的最大句子位置,即最多能输入的句子数量,默认是2;
    layer_norm_eps是一个>0并很接近0的小数(epsilon),用来防止计算时发生除0等异常操作;
    hidden_dropout_prob是随机失活概率,默认是0.1;
    batch_size是小批的大小,即一个小批里的样本个数;
    seq_length是输入的编码向量的长度。


    1.3、编码器

    编码器的作用是对嵌入层输出的隐状态进行非线性表示,提取出其中的特征(feature),它是由num_hidden_layers个结构相同(超参数相同)但参数不同(不共享参数)的隐藏层串连构成的。
    如图:


    1.3.1、隐藏层

    隐藏层包括线性变换、激活函数(activation function)、多头自注意力(multi-head self-attention)、跳跃连接(skip connection),以及上面介绍过的层标准化和随机失活。
    如图:

    其中,激活函数默认是GELU,线性变换均是逐位置线性变换,即对不同样本不同位置的词嵌入应用相同的线性变换(类似于CV&DL领域的(1 imes 1)卷积)。


    1.3.1.1、线性变换

    线性变换在CV&DL领域也叫全连接层(fully connected layer),即

    [y = x W^T + b ]

    其中,(x)(A))是输入向量,(y)(B))是输出向量,(W)(B imes A))是权重(weight)矩阵,(b)(B))是偏置(bias)向量;(W)(b)是学习得到的参数。

    另外,严格来说,当(b = vec 0)时,上式为线性变换;当(b e vec 0)时,上式为仿射变换(affine transformation)。
    但是在DL中,人们往往并不那么抠字眼,对于这两种变换,一般都简单地称为线性变换。


    1.3.1.2、激活函数

    激活函数在DL中非常关键!
    因为如果要提高一个神经网络(neural network)的表示能力,往往需要加深网络的深度。
    然而如果只叠加多个线性变换的话,这等价于一个线性变换(大家可以推推看)!
    所以只有在线性变换后接一个非线性变换(nonlinear transformation),即激活函数,才能逐渐加深网络并提高表示能力。

    激活函数有很多,常见的包括sigmoidtanhsoftmaxReLUGELUSwishMish等。
    本文只讲和BERT相关的激活函数:tanh、softmax、GELU。


    1.3.1.2.1、tanh

    激活函数的一个功能是调整输入值的取值范围。
    tanh即双曲正切函数,可以将((-infty, +infty))的数映射到((-1, 1)),并且严格单调。
    函数图像如图:

    tanh在NLP&DL领域用得比较多。


    1.3.1.2.2、softmax

    softmax顾名思义,它可以对输入的一组数值根据其大小给出每个数值的概率,数值越大,概率越高,且概率求和为(1)

    假设输入(x_k)(k = 0, 1, ..., (N-1)),则输出值(y_k)为:

    [y_k = frac{exp(x_k)}{sum_{i=0}^{N-1} exp(x_i)} ]

    实际上,对于任意一个对数几率(logit)(x in (-infty, +infty))(x)越大,表示某个事件发生的可能性越大,softmax可以将其转化为概率,即将取值范围映射到((0, 1))


    1.3.1.2.3、GELU

    GELUGaussian Error Linear Units)是2016年6月提出的一个激活函数。
    GELU相比ReLU曲线更为光滑,允许梯度更好地传播。
    GELU的想法类似于随机失活,随机失活是按照0-1分布,又叫两点分布,也叫伯努利分布(Bernoulli distribution),随机通过输入值;而GELU则是将这个概率分布改成正态分布(Normal distribution),也叫高斯分布(Gaussian distribution),然后输出期望。

    假设输入值是(x),输出值是(y),那么GELU就是:

    [y = x P(X le x) ]

    其中,(X sim mathcal{N}(0, 1))(P)为概率。

    GELU的函数图像如图:

    其中蓝线为ReLU函数图像,橙线为GELU函数图像。


    1.3.1.3、多头自注意力

    多头自注意力是Transformer的一大特色。
    多头自注意力的名字可以分成三个词:多头、自、注意力:

    • 注意力:是DL领域近年来最重要的创新之一!可以使模型以不同的方式对待不同的输入(即分配不同的权重),而无视空间(即输入向量排成线形、面形、树形、图形等拓扑结构)的形状、大小、距离。
    • 自:是在普通的注意力基础上修改而来的,可以表示输入与自身的依赖关系。
    • 多头:是对注意力中涉及的向量分别拆分计算,从而提高表示能力。

    对于一般的多头注意力,假设计算(x)(H))对(y_i)(H)),(i = 0, 1, ..., (L-1)),的多头注意力,则首先计算(q)(H)、(k_i)(H)、(v_i)(H):

    [ q = x W_q^T + b_q \ k_i = y_i W_k^T + b_k \ v_i = y_i W_v^T + b_v \ ]

    其中,(W_z)(H imes H))和(b_z)(H))分别为权重矩阵和偏置向量,(z in { q, k, v })
    然后将这三种向量等长度拆分成(S)个向量,称为头向量:

    [ q_j = [q_0; q_1; ...; q_{S-1}] \ k_{ij} = [k_{i0}; k_{i1}; ...; k_{i, S-1}] \ v_{ij} = [v_{i0}; v_{i1}; ...; v_{i, S-1}] \ ]

    上式中的分号为串连操作,即把多个向量拼接起来组成一个更长的向量。
    其中,每个头向量长度都为(D),且(S imes D = H)

    然后计算(q_j)(k_{ij})的注意力分数(s_{ij})

    [s_{ij} = frac{q_j k_{ij}^T}{sqrt{D}} ]

    之后可以添加注意力掩码(也可以不加),即令(s_{mj} = -infty)(m)是需要添加掩码的位置。
    然后通过softmax计算注意力概率(p_{ij})

    [p_{ij} = frac{exp(s_{ij})}{sum_{t=0}^{L-1} exp(s_{tj})} ]

    之后对注意力概率进行随机失活:

    [hat{p}_{ij} = dropout(p_{ij}) ]

    再之后计算输出向量(r_j)(D)):

    [r_j = sum_{i=0}^{L-1} hat{p}_{ij} v_{ij} ]

    最终的输出向量是把每一头的输出向量串连起来:

    [r = [r_0; r_1; ...; r_{S-1}] ]

    其中(r)(H))为最终的输出向量。

    如果令(x = y_n)(n in { 0, 1, ..., L-1 }),即(x)(y_i)中的某一个向量,那么多头注意力就变为多头自注意力。

    代码如下:

    代码
    # BERT之多头自注意力
    class BertMultiHeadSelfAtt(nn.Module):
    	def __init__(self, config):
    		super().__init__()
    		# 注意力头数
    		self.num_heads = config.num_attention_heads
    		# 注意力头向量长度
    		self.head_size = config.hidden_size // config.num_attention_heads
    
    		self.query = nn.Linear(config.hidden_size, config.hidden_size)
    		self.key = nn.Linear(config.hidden_size, config.hidden_size)
    		self.value = nn.Linear(config.hidden_size, config.hidden_size)
    
    		self.dropout = nn.Dropout(config.attention_probs_dropout_prob)
    
    	# 输入(batch_size * seq_length * hidden_size)
    	# 输出(batch_size * num_heads * seq_length * head_size)
    	def shape(self, x):
    		shape = (*x.shape[:2], self.num_heads, self.head_size)
    		return x.view(*shape).transpose(1, 2)
    	# 输入(batch_size * num_heads * seq_length * head_size)
    	# 输出(batch_size * seq_length * hidden_size)
    	def unshape(self, x):
    		x = x.transpose(1, 2).contiguous()
    		return x.view(*x.shape[:2], -1)
    
    	def forward(self,
    			inputs,  # 输入(batch_size * seq_length * hidden_size)
    			att_masks=None,  # 注意力掩码(batch_size * seq_length * hidden_size)
    	):
    		mixed_querys = self.query(inputs)
    		mixed_keys = self.key(inputs)
    		mixed_values = self.value(inputs)
    
    		querys = self.shape(mixed_querys)
    		keys = self.shape(mixed_keys)
    		values = self.shape(mixed_values)
    
    		# 注意力分数(batch_size * num_heads * seq_length * seq_length)
    		att_scores = querys.matmul(keys.transpose(2, 3))
    		# 缩放注意力分数
    		att_scores = att_scores / sqrt(self.head_size)
    		# 添加注意力掩码
    		if att_masks is not None:
    			att_scores = att_scores + att_masks
    
    		# 注意力概率(batch_size * num_heads * seq_length * seq_length)
    		att_probs = att_scores.softmax(dim=-1)
    		# 随机失活注意力概率
    		att_probs = self.dropout(att_probs)
    
    		# 输出(batch_size * num_heads * seq_length * head_size)
    		outputs = att_probs.matmul(values)
    		outputs = self.unshape(outputs)
    		return outputs  # 输出(batch_size * seq_length * hidden_size)
    

    其中,
    num_attention_heads是注意力头数,默认是12(bert-base-*)或16(bert-large-*);
    attention_probs_dropout_prob是注意力概率的随机失活概率,默认是0.1。


    1.3.1.4、跳跃连接

    跳跃连接也是DL领域近年来最重要的创新之一!
    跳跃连接也叫残差连接(residual connection)。
    一般来说,传统的神经网络往往是一层接一层串连而成,前一层输出作为后一层输入。
    而跳跃连接则是某一层的输出,跳过若干层,直接输入某个更深的层。
    例如BERT的每个隐藏层中有两个跳跃连接。

    跳跃连接的作用是防止神经网络梯度消失或梯度爆炸,使损失曲面(loss surface)更平滑,从而使模型更容易训练,使神经网络可以设置得更深。

    按我个人的理解,一般来说,线性变换是最能保持输入信息的,而非线性变换则往往会损失一部分信息,但是为了网络的表示能力不得不线性变换与非线性变换多次堆叠,这样网络深层接收到的信息与最初输入的信息比可能已经面目全非,而跳跃连接则可以让输入信息原汁原味地传播得更深。


    隐藏层代码如下:

    代码
    # BERT之隐藏层
    class BertLayer(nn.Module):
    	# noinspection PyUnresolvedReferences
    	def __init__(self, config):
    		super().__init__()
    		# 多头自注意力
    		self.multi_head_self_att = BertMultiHeadSelfAtt(config)
    
    		self.linear = nn.Linear(config.hidden_size, config.hidden_size)
    		self.dropout = nn.Dropout(config.hidden_dropout_prob)
    		self.layer_norm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
    
    		# 升维线性变换
    		self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size)
    		# 激活函数,默认:GELU
    		self.act_fct = F.gelu
    
    		# 降维线性变换,使向量大小保持不变
    		self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size)
    		self.dropout_1 = nn.Dropout(config.hidden_dropout_prob)
    		self.layer_norm_1 = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
    	def forward(self,
    			inputs,  # 输入(batch_size * seq_length * hidden_size)
    			att_masks=None,  # 注意力掩码(batch_size * seq_length * hidden_size)
    	):
    		outputs = self.multi_head_self_att(inputs, att_masks=att_masks)
    		outputs = self.linear(outputs)
    		outputs = self.dropout(outputs)
    		att_outputs = self.layer_norm(outputs + inputs)  # 跳跃连接
    
    		outputs = self.linear_1(att_outputs)
    		outputs = self.act_fct(outputs)
    
    		outputs = self.linear_2(outputs)
    		outputs = self.dropout_1(outputs)
    		outputs = self.layer_norm_1(outputs + att_outputs)  # 跳跃连接
    		return outputs  # 输出(batch_size * seq_length * hidden_size)
    

    其中,
    intermediate_size是中间一个升维线性变换升维后的长度,默认是3072(bert-base-*)或4096(bert-large-*)。


    编码器代码如下:

    代码
    # BERT之编码器
    class BertEnc(nn.Module):
    	def __init__(self, config):
    		super().__init__()
    		# num_hidden_layers个隐藏层
    		self.layers = nn.ModuleList([BertLayer(config)
    			for _ in range(config.num_hidden_layers)])
    	# noinspection PyTypeChecker
    	def forward(self,
    			inputs,  # 输入(batch_size * seq_length * hidden_size)
    			att_masks=None,  # 注意力掩码(batch_size * seq_length)
    	):
    		# 调整注意力掩码的值和形状
    		if att_masks is not None:
    			device = inputs.device  # 设备(CPU或CUDA)
    			dtype = inputs.dtype  # 数据类型(float16、float32或float64)
    			shape = att_masks.shape  # 形状(batch_size * seq_length)
    			t = tc.zeros(shape, dtype=dtype, device=device)
    			t[att_masks<=0] = -inf  # exp(-inf) = 0
    			t = t[:, None, None, :]
    			att_masks = t
    
    		outputs = inputs
    		for layer in self.layers:
    			outputs = layer(outputs, att_masks=att_masks)
    		return outputs  # 输出(batch_size * seq_length * hidden_size)
    

    其中,
    num_hidden_layers是隐藏层数量,默认是12(bert-base-*)或24(bert-large-*)。


    1.4、池化层

    池化层是将[CLS]标记对应的表示取出来,并做一定的变换,作为整个序列的表示并返回,以及原封不动地返回所有的标记表示。
    如图:

    其中,激活函数默认是tanh。

    池化层代码如下:

    代码
    # BERT之池化层
    class BertPool(nn.Module):
    	def __init__(self, config):
    		super().__init__()
    		self.linear = nn.Linear(config.hidden_size, config.hidden_size)
    		self.act_fct = F.tanh
    	def forward(self,
    			inputs,  # 输入(batch_size * seq_length * hidden_size)
    	):
    		# 取[CLS]标记的表示
    		outputs = inputs[:, 0]
    		outputs = self.linear(outputs)
    		outputs = self.act_fct(outputs)
    		return outputs  # 输出(batch_size * hidden_size)
    

    1.5、输出

    主模型最后输出所有的标记表示和整体的序列表示,分别用于针对每个标记的预测任务和针对整个序列的预测任务。


    主模型代码如下:

    代码
    # BERT之预训练模型抽象基类
    class BertPreTrainedModel(PreTrainedModel):
    	from transformers import BertConfig
    	from transformers import BERT_PRETRAINED_MODEL_ARCHIVE_MAP
    	from transformers import load_tf_weights_in_bert
    
    	config_class = BertConfig
    	pretrained_model_archive_map = BERT_PRETRAINED_MODEL_ARCHIVE_MAP
    	load_tf_weights = load_tf_weights_in_bert
    	base_model_prefix = 'bert'
    
    	# 注意力头剪枝
    	def _prune_heads(self, heads_to_prune):
    		pass
    	# 参数初始化
    	def _init_weights(self, module):
    		config = self.config
    		f = lambda x: x is not None and x.requires_grad
    		if isinstance(module, nn.Embedding):
    			if f(module.weight):
    				# 正态分布随机初始化
    				module.weight.data.normal_(mean=0.0, std=config.initializer_range)
    		elif isinstance(module, nn.Linear):
    			if f(module.weight):
    				# 正态分布随机初始化
    				module.weight.data.normal_(mean=0.0, std=config.initializer_range)
    			if f(module.bias):
    				# 初始为0
    				module.bias.data.zero_()
    		elif isinstance(module, nn.LayerNorm):
    			if f(module.weight):
    				# 初始为1
    				module.weight.data.fill_(1.0)
    			if f(module.bias):
    				# 初始为0
    				module.bias.data.zero_()
    # BERT之主模型
    class BertModel(BertPreTrainedModel):
    	def __init__(self, config):
    		super().__init__(config)
    		self.config = config
    		# 嵌入层
    		self.emb = BertEmb(config)
    		# 编码器
    		self.enc = BertEnc(config)
    		# 池化层
    		self.pool = BertPool(config)
    		# 参数初始化
    		self.init_weights()
    
    	# noinspection PyUnresolvedReferences
    	def get_input_embeddings(self):
    		return self.emb.tok_emb
    	def set_input_embeddings(self, embs):
    		self.emb.tok_emb = embs
    
    	def forward(self,
    			tok_ids,  # 标记编码(batch_size * seq_length)
    			pos_ids=None,  # 位置编码(batch_size * seq_length)
    			sent_pos_ids=None,  # 句子位置编码(batch_size * seq_length)
    			att_masks=None,  # 注意力掩码(batch_size * seq_length)
    	):
    		outputs = self.emb(tok_ids, pos_ids=pos_ids, sent_pos_ids=sent_pos_ids)
    		outputs = self.enc(outputs, att_masks=att_masks)
    		pooled_outputs = self.pool(outputs)
    		return (
    			outputs,  # 输出(batch_size * seq_length * hidden_size)
    			pooled_outputs,  # 池化输出(batch_size * hidden_size)
    		)
    

    其中,
    BertPreTrainedModel是预训练模型抽象基类,用于完成一些初始化工作。


    后记

    本文详细地介绍了BERT主模型的结构及其组件,了解它的构造以及代码实现对于理解以及应用BERT有非常大的帮助。
    后续两篇文章会分别介绍BERT预训练下游任务相关。

    从BERT主模型的结构中,我们可以发现,BERT抛弃了RNN架构,而只用注意力机制来抽取长距离依赖(这个其实是Transformer架构的特点)。
    由于注意力可以并行计算,而RNN必须串行计算,这就使得模型计算效率大大提升,于是BERT这类模型也能够堆得很深。
    BERT为了能够同时做单句和双句的序列和标记的预测任务,设计了[CLS][SEP]等特殊标记分别作为序列表示以及标记不同的句子边界,整体采用了桶状的模型结构,即输入时隐状态的形状与输出时隐状态的形状相等(只是在每个隐藏层有升维与降维操作,整体上词嵌入长度保持不变)。
    由于注意力机制对距离不敏感,所以BERT额外添加了位置特征。


  • 相关阅读:
    python之matplotlib库中pyplot的基本使用(python数据分析之绘制图形)
    小球称重问题~通过三次称重找出十二个小球质量不一样的小球,并判断小球轻重
    python爬虫—爬取英文名以及正则表达式的介绍
    Python爬取酷狗飙升榜前十首(100)首,写入CSV文件
    Requests库主要方法解析以及Requests库入门需要掌握的框架
    彻底理解Java中的21种锁!
    JavaIO流常见面试题
    Linux常用命令
    语言学习网
    类加载器的命名空间
  • 原文地址:https://www.cnblogs.com/wangzb96/p/bert_model.html
Copyright © 2011-2022 走看看