Simple Lexicon代码剖析
Simple Lexicon的原理比较简单,至少不涉及模型架构方面的改写,只是将所有词的embedding融合到字符的embedding中(详情请看原论文,上一篇),其项目源码其实是用了Lattice LSTM的源代码,然后在此基础上改成了python3.6的版本,并且加上词向量融合部分的代码。
下面直接进行代码分析,在剖析代码的过程中联系论文来深入理解
data_initialization
首先在构建语料库这里,依然还是有
build_alphabet、build_gaz_file,这两个方法与Lattice LSTM是一致的;
有变化的是build_gaz_alphabet,新增了一个count参数(用于统计词频)
在Lattice LSTM中build_gaz_alphabet方法是建立gaz_alphabet,即找到了所有可能的词语(具体逻辑就是利用trie属性保存的词典,然后调用gaz.enumerateMatchList,这里面调用的又是trie.enumerateMatch方法)
在Simple Lexicon中这个方法内部也是先找到所有可能的词语(但是也会返回单个的字),其次是计算词频;
在Lattice LSTM中,enumerateMatch是这么实现的
1 | def enumerateMatch(self, word, space="_", backward=False): |
在Simple Lexicon中,enumerateMatch是这么实现的(改动点其实就是while循环的条件,从而达到单个字也可以返回的目的)
1 | def enumerateMatch(self, word, space="_", backward=False): # space=‘’ |
Simple Lexicon之所以要返回单个字就是为了也统计字的频率,当count参数为True时
1 | if count: |
entitys是一个句子匹配到的所有可能的词(包含这个句子的所有单个字符),统计词频时是先对entitys按照元素长度从长到短排序,将长的元素计数之后,然后再去entitys里面找剩下的有没有这个词的子集(包含本身),将其从entitys中删掉。这里为什么要这么做呢?
原论文中说:“请注意,如果w被与词典匹配的另一个子序列覆盖,则w的频率不会增加。这防止了较短单词的频率总是小于覆盖它的较长单词的频率的问题”。
我感觉作者应该是说反了,应该是防止较短单词的频率总是大于覆盖它的较长单词的频率的问题
这样统计好的词频就保存在了self.gaz_count中,其key是这个词在gaz_alphabet中的索引,value是出现的次数(在当前数据集如训练集、测试集等出现的频率)
generate_instance_with_gaz
read_instance_with_gaz:在Lattice LSTM中最终生成两个列表instance_texts和instance_ids,其分别又包含5个列表
instance_texts.append([words, biwords, chars, gazs, labels])
instance_ids.append([word_Ids, biword_Ids, char_Ids, gaz_Ids, label_Ids])
在Simple Lexicon中
instance_texts.append([words, biwords, chars, gazs, labels])
instance_Ids.append([word_Ids, biword_Ids, char_Ids, gaz_Ids, label_Ids, gazs, gazs_count, gaz_char_Id, layergazmasks, gazchar_masks, bert_text_ids])
即instance_ids中包含11个列表,多了6个列表,下面我们来分别剖析每个列表的含义,但是需要借助例子帮助理解,例如句子:[‘历’, ‘任’, ‘公’, ‘司’, ‘副’, ‘总’, ‘经’, ‘理’, ‘、’, ‘总’, ‘工’, ‘程’, ‘师’, ‘,’]
gazs:这里的gazs与Lattice LSTM不一样,这里的gazs虽然列表长度与句子长度一致,但是每个元素又是一个固定长度为4的列表,而且4个元素也分别为列表,分别表示句子当前位置的字符在所有的分词结果中属于B、M、E、S的情况(与论文相对应)。
gazs初始时(length=14):[[[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []], [[], [], [], []]]
第1个字“历”匹配的分词结果有两种:“历”和“历任”,所以针对“历”是单个字(Single),gazs的第一个元素应该是
[[], [], [], [1]]
针对“历任”(历处在Begin位置),所以gazs的第一个元素应该是:[[1], [], [], [1]],相应的对于“任”(End)字,即句子的第二个位置,应该是:[[], [], [1], []]
然后要注意的是,这里实际存放到gazs中并不是1和0,而是”历”、”历任”在gaz_alphabet中的索引,如”历任”的索引是219,”历”的索引是47,接下来还需要针对四个列表中空的列表补个0,即[[219], [0], [0], [47]],[[0], [0], [219], [0]]
虽然gazs的形式变了,但是gaz_Ids的格式与Lattice LSTM一致
到这还没结束,后面还有padding操作,还是这里的例子,gazs的第一个元素为:
[[219], [0], [0], [47]]
max_gazlist记录了当前句子的某个位置B、M、E、S集合中元素最多的数量(假设为3)
这里就需要将gazs中每个元素中的四个列表长度统一成3,即得到:
[[219, 0, 0], [0, 0, 0], [0, 0, 0], [47, 0, 0]]
在mask的过程中,gazmask记录了padding的索引,即1是padding的,0是真实的
[[0, 1, 1], [0, 1, 1], [0, 1, 1], [0, 1, 1]],然后将gazmask添加到layergazmasks中
gazs_count: 其shape与gazs完全一致,相应位置保存的是这些词的词频,如第一个元素为[[305], [], [], [1]],305表示”历任”出现的次数,1表示”历”出现的次数,第二个元素为[[], [], [305], []],305表示”历任”出现的次数。
同样的在gazs进行补0操作之后,也要在gazs_count中相应位置补1(表示1次)
接下来与gazs中的padding保持一致,给padding的元素记录的count=0,这里gazs_count第1个元素本来是:
[[305], [1], [1], [1]]
进行与之对应的padding操作后变成:
[[305, 0, 0], [1, 0, 0], [1, 0, 0], [1, 0, 0]]
gaz_char_Id: 其shape与gazs完全一致,相应位置保存的是这些词拆成单个字符之后,该字符在word_alphabet中的索引,例如”历任”可以拆成”历”和”任”在word_alphabet中的索引分别为28,58,那么第一个元素为
[[[28, 58]],[[0]],[[0]],[[28]]],即相当于使用[28,58]代替219,[28]代替315,因为历任是一个整体,而且可能还会有其他以”历”字开头的词,所以这里的char_id又多了一层列表,即[28, 58];之后同样的针对空列表补零([0])
接下来也是padding操作,max_gazcharlen记录了当前句子最长的候选词的长度(假设为4)
所以这里需要将gaz_char_Id的每个元素中的char_ids列表长度统一成4(padding操作),即得到:
1 | [ |
注意这里不仅将每个char_ids长度统一成了4,而且将每个元素中四个列表的长度统一为了3(与gazs和gazs_count一致)
这个例子中,gazs和gazs_count的size为:(14, 4, 3)
而gaz_char_Id的size为:(14, 4, 3, 4),最后多了一维
同理,在padding的时候,gazcharmask也保存了padding的索引,例如这里第1个元素padding之后得到的gazcharmask为:
1 | [ |
最后将gazcharmask添加到gazchar_masks中
layergazmasks: 其size与gazs和gazs_count一致,记录的是gazs_mask
gazchar_masks: 其size与gaz_char_Id一致,记录的是gazcharmask
bert_text_ids:利用bert tokenizer获取本条句子的id,在开始添加[CLS],在结尾添加[SEP]
1 | tokenizer = BertTokenizer.from_pretrained('bert-base-chinese', do_lower_case=True) |
这个例子中words = [‘历’, ‘任’, ‘公’, ‘司’, ‘副’, ‘总’, ‘经’, ‘理’, ‘、’, ‘总’, ‘工’, ‘程’, ‘师’, ‘,’]
有可能其实并没有使用bert_text_ids?
batchify_with_label
传入的参数是上面得到的instance_Ids,其最外围size为训练集样本数量,然后每一个元素则为一个size=11的列表,即包含了上面解析的11个元素;
在batchify_with_label内部,将一批batch_size大小的数据统一成相同的size的张量返回
分别使用以下变量名替换原来的11个元素
word_ids –> word_seq_tensor
biword_Ids –> biword_seq_tensor
char_Ids,这个丢弃了没有使用
gaz_Ids –> gazs 这个没做处理,返回的gazs是取得gaz_Ids(后续其实也并没有使用,这代码写的也是很离谱…)
label_Ids –> label_seq_tensor
gazs –> layer_gaz_tensor
gazs_count –> gaz_count_tensor
gaz_char_Id –> gaz_chars_tensor
layergazmasks –> gaz_mask_tensor
gazchar_masks –> gazchar_mask_tensor
bert_text_ids –> bert_seq_tensor
另外还返回了 word_seq_lengths(同一批次内每个样本长度),mask,bert_mask
1 | ...... |
mask和word_seq_tensor是一样的,即一开始统一成了(batch_size, max_seq_length),之后针对每个样本(for b, seqlen in enumerate(word_seq_lengths),补上真实的值
1 | mask[b, :seqlen] = torch.Tensor([1]*int(seqlen)) |
而layer_gaz_tensor、gaz_count_tensor、gaz_mask_tensor则一开始初始化为(batch_size, max_seq_length, 4, max_gaz_num)大小,然后针对每个样本再补上真实值。
1 | gaz_num = [len(layer_gazs[i][0][0]) for i in range(batch_size)] |
gaz_chars_tensor、gazchar_mask_tensor一开始初始化为(batch_size, max_seq_length, 4, max_gaz_num, max_gaz_len)大小,但是注意后者的初始值为1(即1是padding的索引,0是实际的,与gaz_mask_tensor一致)
1 | gaz_len = [len(gaz_chars[i][0][0][0]) for i in range(batch_size)] |
SeqModel
包含以下层或模块
两个embedding层(加载预训练的词向量)
1 | self.gaz_embedding = nn.Embedding(data.gaz_alphabet.size(), self.gaz_emb_dim) |
NERmodel(支持lstm,cnn和transformer三种结构)
1 | if self.model_type == 'lstm': |
dropout层、线性层和CRF模型(自行实现的CRF模型)
1 | self.drop = nn.Dropout(p=data.HP_dropout) |
NERmodel
1 | class NERmodel(nn.Module): |
NERmodel方面的实现也比较简单,lstm网络就是直接使用的pytorch内置的nn.LSTM。
模型前向计算
NERmodel中有三个主要方法:get_tags、neg_log_likelihood_loss、forward
1 | def neg_log_likelihood_loss(self, gaz_list, word_inputs, biword_inputs, word_seq_lengths, layer_gaz, gaz_count, gaz_chars, gaz_mask, gazchar_mask, mask, batch_label, batch_bert, bert_mask): |
在模型训练时,其实调用的是neg_log_likelihood_loss,因为需要计算损失,而模型在预测时调用的是forward;不过这两个方法内部都调用了get_tags,这个才是比较核心的方法。
下面主要介绍方法内部比较核心的部分
首先是将tensor传入embedding层和dropout层
1 | # word_inputs shape(batch_size, seq_length) |
其次是针对gaz_embeds_d中padding的部分置0,根据gaz_mask可以知道哪些位置是padding的
1 | # gaz_mask_input的shape是(batch_size, seq_length, 4, max_gaz_num) |
接下来要把gaz_embeds和words_input_d融合到一起,但是这两个张量的size不一样,首先需要将gaz_embeds转化成words_input_d一样的size,在转化之前原论文中考虑如何使用这些集合中的这些词向量的问题,在原论文中提出两种方式:
第1种,直接每个集合(这里的集合指的是B、M、E、S集合)内部计算平均值
1 | gaz_num = (gaz_mask_input == 0).sum(dim=-1, keepdim=True).float() # (b,l,4,1) |
论文中表示这种方式效果不理想,所以提出一个权重算法用于利用词信息,使用每个单词的频率作为其权重的指示,不过注意这里的词频是指某一集合中的词在统计数据集中的次数除以4个集合中所有词的次数,根据计算得到的结果再乘以4来决定这个词的embedding占用多少权重
1 | count_sum = torch.sum(gaz_count, dim=3, keepdim=True) # (b,l,4,gn) |
再然后只需要把gaz_embeds四个集合的tensor拼接起来,然后再拼接到word_input_d上
1 | gaz_embeds_cat = gaz_embeds.view(batch_size, seq_len, -1) # ( b,l,4*ge) |
最后就是传入lstm网络和最后一层线性分类曾,也就是lstm的输入张量size为(batch_size, seq_length, word_embedding_dim+4xgaz_embedding_dim)
1 | # feature_out_d shape(batch_size, seq_length, hidden_dimx2) |
上述内容是第一阶段对代码的剖析,当时仅想使用lstm结构的NERmodel,所以对transformer结构的NERmodel以及联合Bert的方法没有深入研究(而且当时我以为transformer的结构和BERT的预训练权重一定要联合使用,这是误解,后面会解释)。
项目实战
前期准备
在/data目录新建一个文件夹,存放你的数据集,数据集的格式与Lattice LSTM的数据集格式一模一样,BIO/BIOES均可,但是每行一个字符。
有几个预训练的词向量权重需要下载一下,下载地址也是与Lattice LSTM一样
1 | char_emb = "./data/gigaword_chn.all.a2b.uni.ite50.vec" |
以上预训练词向量的下载地址为:https://pan.baidu.com/s/1pLO6T9D
char_emb是单个字符的预训练词向量,大小为5.2MB
bichar_emb是二元字符的预训练词向量,大小为1.8G,这个是命名实体识别发展中的一种过渡方法,其实后续大家基本都不怎么使用了,效果提升有限,所以可以不使用,将bichar_emd置空即可
gaz_file是预训练的词语向量,大小为325.7MB
超参数
优化器:Adamax
学习率lr: 0.0015(不断衰减)
batch_size: 1
hidden_dim:300
num_layer: 4(这个参数只有当model_type=’transfomer’时有效)
lstm_layers: 1(这个参数只有当model_type=’lstm’时有效)
model_type:默认lstm(可以改为cnn、transformer)
use_biword: False
use_count: True,这个就是simple lexicon的核心思想参数,使用词频来设置BMES集合中的词的权重
use_bert: False
按照Simple Lexicon源代码的默认配置,仅使用一层的lstm网络(双向),输入的词向量维度为50+4x50,搭配hidden_dim=300,实际测试训练效果确实比Lattice LSTM好很多,而且因为参数量很小,速度也很快。
如果数据量比较多,想要批量化训练,也是可以的,可以增大batch_size的值,但是经过我实际测试需要修改一行代码,也就是crf.py中的这部分1
2
3
4for idx in range(len(back_points) - 2, -1, -1):
pointer = torch.gather(back_points[idx], 1, pointer.contiguous().view(batch_size, 1))
pointer = pointer.squeeze(1) # batch_size > 1时,新增的逻辑
decode_idx[idx] = pointer.data
但是Simple Lexicon有个缺点,当训练的数据量很大时,保存的”.dset”文件也会很大,会比较占内存。
结合BERT
一开始我以为simple lexicon结合BERT是指使用BERT的网络结构以及BERT的预训练权重,所以我同时将
model_type设置为transformer,use_bert设置为True。
关于use_bert=True部分的代码逻辑是这样的
模型定义部分1
2
3
4if self.use_bert:
self.bert_encoder = BertModel.from_pretrained('bert-base-chinese')
for p in self.bert_encoder.parameters():
p.requires_grad = False
直接使用transformer包里封装好的方法导入BERT,且使用的是预训练的中文BERT,这一步首次运行会先下载模型权重和配置。
前向计算部分1
2
3
4
5
6
7
8# cat bert feature
if self.use_bert:
seg_id = torch.zeros(bert_mask.size()).long().cuda()
outputs = self.bert_encoder(batch_bert, bert_mask, seg_id)
outputs = outputs[0][:, 1:-1, :]
word_input_cat = torch.cat([word_input_cat, outputs], dim=-1)
feature_out_d = self.NERmodel(word_input_cat)
在前向计算时,直接将输入的句子输入到bert_encoder中,得到的输出是768维的向量(outputs),而word_input_cat在上面介绍了是一个50+4x50=250维的待输入到NERmodel的向量,也就是说如果use_bert=True,这里是先把输入句子完整走了一遍Bert模型,再将得到的输出与原来的输入拼接在一起组成新的输入,最终再输入到NERmodel,而NERmodel无论是transformer还是lstm都是可以的。
我们详细看一下transformer类型的NERmodel的实现
1 | if self.model_type == 'transformer': |
NERmodel1
2
3
4
5
6# attention model
if self.model_type == 'transformer':
self.attention_model = AttentionModel(d_input=input_dim, d_model=hidden_dim, d_ff=2*hidden_dim, head=4, num_layer=num_layer, dropout=dropout)
for p in self.attention_model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
1 | class AttentionModel(nn.Module): |
首先这个transformer结构的NERmodel的层数为4(num_layer),注意力头的个数为4,d_ff的大小为600(2*hidden_dim),这明显与Base BERT的架构不一致,所以可以证实use_bert和transformer结构的NERmodel并非强关联;
其次这里d_input是1018(768+250),d_model=hidden_dim=300,正常的transformer模型只有d_input和d_ff,但是这里在forward中,先将输入过了一个线性层,即将输入的维度大小从d_input转为d_model,然后再输入到transformer模型中,不难想象这一操作可能会损失较多信息,实践也证明,use_bert+transformer模型的效果并不理想,远不如lstm。
当然不使用这一线性层也是不行的,因为输入的维度1018太过尴尬,很难设置注意力头的个数(要保证input_dim可以整除head_nums),但是可以考虑将hidden_dim设置为1000,即通过这一线性层将1018转为1000,丢失的信息就比较少了,但是实际测试效果依然不理想。按照我的经验,使用transformer的效果一直不如lstm,除了使用与bert一致的结构。
既然这样,可以使用use_bert+lstm的配置,因为输入是1018维,我设置了对照组,hidden_dim=300的默认值以及hidden_dim=1000的对比值,实验结果表明hidden_dim=1000的效果明显更好。
另外当hidden_dim=300,use_bert=True+lstm比use_bert=False+lstm略好一丢丢,并不明显。
另外use_bert=True时,最终得到的模型权重文件是比较大的,等于base_bert的权重大小+NERmodel的大小,但是虽然参数量比较大,实际上base_bert这部分参数在训练时是不会改变的(Non-trainable params),只有NERmodel部分才是会改变的参数(trainable params)。