bert代码
原始链接 作者看的特别详细,是我楷模。
BERT Tokenization 分词模型(BertTokenizer)
BERT Model 本体模型(BertModel)
- BertEmbeddings
- BertEncoder
- BertLayer
- BertAttention
- BertIntermediate
- BertOutput
- BertPooler
- BertLayer
BERT Tokenization 分词模型(BertTokenizer)
BertTokenizer
是基于BasicTokenizer
和WordPieceTokenizer
的分词器:
-
BasicTokenizer负责处理的第一步——按标点、空格等分割句子,并处理是否统一小写,以及清理非法字符。
- 对于中文字符,通过预处理(加空格)来按字分割;
- 同时可以通过never_split指定对某些词不进行分割;
- 这一步是可选的(默认执行)。
-
WordPieceTokenizer在词的基础上,进一步将词分解为子词(subword)
- subword 介于 char 和 word 之间,既在一定程度保留了词的含义,又能够照顾到英文中单复数、时态导致的词表爆炸和未登录词的 OOV(Out-Of-Vocabulary)问题,将词根与时态词缀等分割出来,从而减小词表,也降低了训练难度;
- tokenizer = “token”+ “##izer”
BertTokenizer 有常用方法如下:
- from_pretrained:从包含词表文件(vocab.txt)的目录中初始化一个分词器;
- tokenize:将文本(词或者句子)分解为子词列表;
- convert_tokens_to_ids:将子词列表转化为子词对应下标的列表;
- convert_ids_to_tokens :与上一个相反;
- convert_tokens_to_string:将 subword 列表按“##”拼接回词或者句子;
- encode:对于单个句子输入,分解词并加入特殊词形成“[CLS], x, [SEP]”的结构并转换为词表对应下标的列表;对于两个句子输入(多个句子只取前两个),分解词并加入特殊词形成“[CLS], x1, [SEP], x2, [SEP]”的结构并转换为下标列表;
- decode:可以将 encode 方法的输出变为完整句子。
使用
2-Model-BertModel
本体使用:默认当作encoder;可以当作decoder:obj:is_decoder设置为obj:
True。seq2seq 模型:设置两个地方:obj:
True; 和obj:
encoder_hidden_states(
add_cross_attention` )
BertModel 主要为 transformer encoder 结构,包含三个部分:
- embeddings,即BertEmbeddings类的实体,根据单词符号获取对应的向量表示;
- encoder,即BertEncoder类的实体;
- pooler,即BertPooler类的实体,这一部分是可选的。
Bert本体输入参数:
Bert embedding:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GvKzk7S4-1629644216648)(C:\Users\朱朱\AppData\Roaming\Typora\typora-user-images\image-20210822221202679.png)]
- word_embeddings,上文中 subword 对应的嵌入。
- token_type_embeddings,用于表示当前词所在的句子,辅助区别句子与 padding、句子对间的差异。
- position_embeddings,句子中每个词的位置嵌入,用于区别词的顺序。和 transformer 论文中的设计不同,这一块是训练出来的,而不是通过 Sinusoidal 函数计算得到的固定嵌入。一般认为这种实现不利于拓展性(难以直接迁移到更长的句子中)。
三个 embedding 不带权重相加,并通过一层 LayerNorm+dropout 后输出,其大小为(batch_size, sequence_length, hidden_size)。
插入:为什么用layernorm,不用batch norm?
-
layer normalization 有助于得到一个球体空间中符合0均值1方差高斯分布的 embedding, batch normalization不具备这个功能。
解释:NLP任务真正学习的开端是从"embedding"开始的,而embedding 是学习得来的,emmbedding并不存在一个客观的分布,相反我们需要考虑的是:我们希望得到一个符合什么样分布的embedding?
很好理解,通过layer normalization得到的embedding是 以坐标原点为中心,1为标准差,越往外越稀疏的球体空间中。
-
layer normalization可以对transformer学习过程中由于多词条embedding累加可能带来的“尺度”问题施加约束,相当于对表达每个词一词多义的空间施加了约束,有效降低模型方差。batch normalization也不具备这个功能。
bert词向量:每个词有一片相对独立的小空间,通过在这个小空间中产生一个小的偏移来达到表示一词多义的效果,可以表示为下面这个公式:
E i = α E i + β E j Ei = αEi + βEj Ei=αEi+βEj
transformer每一层都做了这件事,也就是在不断调整每个词在空间中的位置。具体的Ei看成当前词,Ej可以看成所有环境词的加权和。 这个过程会有一个潜在风险:Ej是不可控的,环境词向量和环境词的数量对Ej都有影响,这就可能造成偏移量会很大,相加后的“尺度”也有可能发生较大变化,尤其是transformer的维度较高,每个维度一个很小的变化也可能引起很大的“尺度”变化。然后,再考虑下layer normalization,你会发现,当Ej较小时候,layer normalizaiton基本不起作用,但是当Ej较大时,layer normalization 可以将相加后得到的向量拉回到原来向量的附近,让它不至于跑太远,显然 batch normalizaiton 起不到这样的作用。
BertEncoder
包含多层 BertLayer,这一块本身没有特别需要说明的地方,不过有一个细节值得参考:利用 gradient checkpointing 技术以降低训练时的显存占用。
gradient checkpointing 即梯度检查点,通过减少保存的计算图节点压缩模型占用空间,但是在计算梯度的时候需要重新计算没有存储的值,参考论文《Training Deep Nets with Sublinear Memory Cost》。
BertAttention
class BertAttention(nn.Module):
def __init__(self, config):
super().__init__()
self.self = BertSelfAttention(config)
#self 多头注意力
self.output = BertSelfOutput(config)
# output 实现attention后的全连接+dropout+residual+LayerNorm
self.pruned_heads = set()
def prune_heads(self, heads):
"""
find_pruneable_heads_and_indices是定位需要剪掉的 head,以及需 要保留的维度下标 index;
prune_linear_layer则负责将 Wk/Wq/Wv 权重矩阵(连同 bias)中 按照 index 保留没有被剪枝的维度后转移到新的矩阵。 接下来就到重头戏 ——Self-Attention 的具体实现。
"""
if len(heads) == 0:
return
heads, index = find_pruneable_heads_and_indices(
heads, self.self.num_attention_heads, self.self.attention_head_size, self.pruned_heads
)
# Prune linear layers
self.self.query = prune_linear_layer(self.self.query, index)
self.self.key = prune_linear_layer(self.self.key, index)
self.self.value = prune_linear_layer(self.self.value, index)
self.output.dense = prune_linear_layer(self.output.dense, index, dim=1)
# Update hyper params and store pruned heads
self.self.num_attention_heads = self.self.num_attention_heads - len(heads)
self.self.all_head_size = self.self.attention_head_size * self.self.num_attention_heads
self.pruned_heads = self.pruned_heads.union(heads)
BertSelfAttention【核心区域】
首先回顾一下 multi-head self-attention 的基本公式:
M
H
A
(
Q
,
K
,
V
)
=
C
o
n
c
a
t
(
h
e
a
d
1
,
.
.
.
,
h
e
a
d
h
)
W
O
MHA(Q, K, V) = Concat(head_1, ..., head_h)W^O
MHA(Q,K,V)=Concat(head1,...,headh)WO
h
e
a
d
i
=
S
D
P
A
(
Q
W
i
Q
,
K
W
i
K
,
V
W
i
V
)
head_i = SDPA(QW_i^Q, KW_i^K, VW_i^V)
headi=SDPA(QWiQ,KWiK,VWiV)
S
D
P
A
(
Q
,
K
,
V
)
=
s
o
f
t
m
a
x
(
Q
K
T
(
d
k
)
)
V
SDPA(Q, K, V) = softmax(\frac{QK^T}{\sqrt(d_k)})V
SDPA(Q,K,V)=softmax((dk)QKT)V
1. positional_embedding_type:
- absolute:默认值,这部分就不用处理;
- relative_key:对 key_layer 作处理,将其与这里的positional_embedding和 key 矩阵相乘作为 key 相关的位置编码;
- relative_key_query:对 key 和 value 都进行相乘以作为位置编码。
2. attention_scores = attention_scores + attention_mask是在做什么?难道不应该是乘 mask 吗?
- 因为这里的 attention_mask 已经【被动过手脚】,将原本为 1 的部分变为 0,而原本为 0 的部分(即 padding)变为一个较大的负数,这样相加就得到了一个较大的负值:
- 至于为什么要用【一个较大的负数】?因为这样一来经过 softmax 操作以后这一项就会变成接近 0 的小数。
class BertModel(BertPreTrainedModel):
# bertmodel继承BertPreTrainedModel 的方式
3. 注意力头,
众所周知是并行计算的,所以上面的 query、key、value 三个权重是唯一的——这并不是所有 heads 共享了权重,而是“拼接”起来了。
BertSelfOutput
又出现了 LayerNorm 和 Dropout 的组合,先 Dropout,进行残差连接后再进行 LayerNorm。至于为什么要做残差连接,最直接的目的就是降低网络层数过深带来的训练难度,对原始输入更加敏感~
BertIntermediate
Attention 后面还有一个全连接+激活的操作,全连接做了一个扩展,以 bert-base 为例,扩展维度为 3072,是原始维度 768 的 4 倍之多;
bertoutput
全连接+dropout+layernorm ,一个残差连接residual connect。
bertpooler
这一层只是简单地取出了句子的第一个token,即[CLS]
对应的向量,然后过一个全连接层和一个激活函数后输出:(这一部分是可选的,因为pooling有很多不同的操作)