姓名:TRY
学号:
专业:计算机科学与技术
时间:2020/11/27
本次任务是使用**$BiLSTM+CRF$来实现中文分词模型,并在SIGHAN Microsoft Research数据集上进行中文分词的训练和测试**。
中文分词是中文文本处理的一个基础步骤,也是中文人机自然语言交互的基础模块。不同于英文的是,中文句子中没有词的界限,因此在进行中文自然语言处理时,通常需要先进行分词,分词效果将直接影响词性、句法树等模块的效果。当然分词只是一个工具,场景不同,要求也不同。在人机自然语言交互中,成熟的中文分词算法能够达到更好的自然语言处理效果,帮助计算机理解复杂的中文语言。
而在本次实验中,中文分词实际上就是序列标注问题,即通过**$BiLSTM+CRF$模型对数据集中的每一个字打上B,M,E,S
的标签,并通过合理有效的标签组合来实现分词。其中,B
** for "begin",表示词组的开头的字;M
for "middle",表示词组的中间的字;E
for "end",表示词组最后的字;S
for "single",表示单独成词的字。例如:
结果 | |
---|---|
原句子 | 我周末去中山大学玩 |
分词结果 | 我/周末/去/中山大学/玩 |
标签结果 | S BE S BMME S |
训练和测试基于下面的3个数据集:
即通过多次迭代后得到的model模型对测试集msr_test.utf8
进行预测,并与真实测试集msr_test_gold.utf8
的结果进行比较,计算测试集所有句子F1分数的平均值。
-
$BiLSTM$ 是双向的$LSTM$,融合了两组学习方向相反(一个按句子顺序,一个按句子逆序)的LSTM层,能够在理论上实现当前词即包含历史信息、又包含未来信息,更有利于对当前词进行标注。$BiLSTM$在时间上的展开图如下所示 : -
若输入句子由120个词组成,每个词由300维的词向量表示,则模型对应的输入是(120,300),经过$BiLSTM$后隐层向量变为T1(120,100),其中100为模型中$BiLSTM$的输出维度。如果不使用CRF层,则可以在模型最后加上一个全连接层用于分类。设分词任务的目标标签为B(Begin)、M(Middle)、E(End)、S(Single),则模型最终输出维度为(120,4)的向量。对于每个词对应的4个浮点值,分别表示对应B\M\E\S的概率,最后取概率大的标签作为预测label。通过大量的已标注数据和模型不断迭代优化,这种方式能够学习出不错的分词模型。
-
然而,虽然依赖于神经网络强大的非线性拟合能力,理论上已经能学习出不错的模型。但是,上述模型只考虑了标签上的上下文信息。对于序列标注任务来说,当前位置的标签$L_t$与前一个位置$L_{t-1}$、后一个位置$L_{t+1}$都有潜在的关系。因此,不能保证标签每次都是预测正确的,会出现标签偏置的问题。例如: “我/S 喜/B 欢/E 你/S”被标注为“我/S 喜/B 欢/B 你/S”,由分词的标注规则可知,B标签后只能接M和E,出现了标签偏置问题。
-
对于上述“标签偏置“问题,NLP领域的学者引入了CRF层,增加一些约束规则,降低标签偏置的概率。
- 当我们设计标签时,如用
B,M,E,S
的 4 个标签来做字标注法的分词,目标输出序列本身会带有一些上下文关联,比如S
后面就不能接M
和E
,第一个词应该是以B
或者O
开头等等。$BiLSTM$的逐标签softmax
并没有考虑这种输出层面的上下文关联,所以它意味着把这些关联放到了编码层面,希望模型能自己学到这些内容,但有时候会“强模型所难”(如下图)。
- CRF层将输出层面的关联分离出来,对$BiLSTM $最后预测的标签添加一些约束,来保证预测的标签是合法的。在训练数据训练过程中,这些约束可以通过CRF层自动学习到。
-
完整的$BiLSTM+CRF$的网络结构图如下:
-
输入层: 比如句子
x
有n
个字。首先将n
个字做one-hot得到稀疏向量,再通过look-up table得到n个单词的d维稠密词向量。在实验中,可直接通过查找预训练好的中文字向量集来得到稠密字向量。因此,经过输入层后,每一个字都由word embedding来构成的向量。 -
**双向LSTM层:*共有$n s$个LSTM模型, 用来迭代训练中文分词,得到$n$个$m$维向量,在设置dropout之后 通过一个全连接层(线性层$y=w*x+b$) 降维到$k$维向量(
$k$ 也就是标注集的标签数)其中$p_{ij}$ 都视作将字$x_i$ 分类到第j
个标签的打分值。 -
CRF层:给句子添加起始位和结束位可以构建一个$(k+2)*(k+2)$ 的状态转移矩阵,输出的是句子
x
中每个单元的标签(B\M\E\S)。
-
以下讲解各部分的核心代码,详细见代码注释。
-
**读文件
readfile()
函数:**实现从utf8文件中读取内容,并获得整个数据集的字列表char
和标签列表label
。- 其中,调用了get_char函数和get_label函数,获得每行的char和label。
def read_file(file): char, content, label = [], [], [] maxlen = 0 for i in range(len(file)): # 记得加range!! line = file.loc[i,0] # 用loc来访问dataframe line = line.strip('\n') #去掉换行符 line = line.strip(' ') #去掉开头和结尾的空格 char_list = get_char(line) #获得字列表 label_list = get_label(line) # 获得标签列表 maxlen = max(maxlen, len(char_list)) if len(char_list)!=len(label_list): continue # 由于数据集本身问题,要删掉有问题的样本(在训练集中有26个样本;测试集中无) char.extend(char_list) #每一个单元是1个字 content.append(char_list) # 每一个单元是一行里面的各个字(分好) label.append(label_list) #每一个单元是一行里面打好标签的结果(含标点) return char, content, label, maxlen #word是单列表,content和label是双层列表 # 将句子转换成字序列 def get_char(sentence): char_list = [] sentence = ''.join(sentence.split(' ')) #去掉空格 for i in sentence: char_list.append(i) return char_list #将句子转成BMES序列 def get_label(sentence): result = [] word_list = sentence.split(' ') #两个空格来分隔一个词 for i in range(len(word_list)): if len(word_list[i]) == 1: result.append('S') elif len(word_list[i]) == 2: result.append('B') result.append('E') else: temp = len(word_list[i]) - 2 result.append('B') result.extend('M'*temp) result.append('E') return result
-
加工数据函数
process_data()
,主要涉及对char
序列和label
序列进行padding操作。首先,构建vocab2idx
字典,形成字到序号的映射;并对整个数据集的char_list
和label_list
中每一个句子的char
和label
进行序号的映射,根据最大长度MAXLEN
进行padding。最后,再对标签列表进行归一化操作。- padding调用
pad_sequences()
函数进行实现,具体操作为:大于MAXLEN
的进行截断,小于MAXLEN
的进行padding。且padding是默认的left_padding。 char
列表的默认padding值为0,label
列表的默认padding值为-1。分别表示<PAD>
和‘E’
。- 归一化操作具体调用
to_categorical()
函数实现。
# process data: padding def process_data(char_list, label_list, vocab, chunk_tags, MAXLEN): vocab2idx = {char: idx for idx, char in enumerate(vocab)} # get every char of every word, map to idx in vocab, set to <UNK> if not in vocab x = [[vocab2idx.get(char, 1) for char in s] for s in char_list] # map label to idx y_chunk = [[chunk_tags.index(label) for label in s] for s in label_list] # padding of x, default is 0(symbolizes <PAD>). padding includes:over->cutoff, less->padding. default: left_padding x = pad_sequences(x, maxlen=MAXLEN, value=0) # padding of y_chunk y_chunk = pad_sequences(y_chunk, maxlen=MAXLEN, value=-1) # one_hot: y_chunk = to_categorical(y_chunk, len(chunk_tags)) return x, y_chunk
- padding调用
-
**加载数据
load_data()
函数:**调用read_file()
函数,得到训练集和测试集的字符列表和标签列表,且字符列表有单列表形式的train_char
,也有双层列表形式的train_content
。然后再利用train_char
和test_char
构建的词表vocab
来对数据集与测试集进行加工,调用process_data()
函数完成。- 其中,
train_char
是用来和test_char
一起形成整个数据集的词表(用来除重使用的)。 - 并且得到词表
vocab
之后,需要加上两个特殊词<PAD>
和<UNK>
,形成完整的词表。 - 在处理测试集的时候,需要得到测试集的最大句子长度
MAXLEN
, 来作为加工数据中padding的长度依据。- 实际上,可以取其他的长度作为
MAXLEN
。但由于本次实验要求输出测试集的预测结果,因此取测试集的最大句子长度作为MAXLEN
。
- 实际上,可以取其他的长度作为
- **注意:**实际上,一开始我只将训练集的词表作为了整个数据集的词表,因此引入了
<PAD>
和<UNK>
两个符号,其中<UNK>
表示在测试集中存在但在训练集中不存在的字。但在本次实验的后序调参过程中,我发现将测试集和训练集所有的字一起加起来作为整个数据集的词表的效果更好,因此对此处的load_data
进行了修改,因此实际上<UNK>
在此处已经没有作用了。
def load_data(): chunk_tags = ['S','B','M','E'] train_char, train_content, train_label, _ = read_file(train_set) test_char, test_content, test_label, maxlen = read_file(test_set) vocab = list(set(train_char + test_char)) # 合并,构成大词表 special_chars = ['<PAD>', '<UNK>'] #特殊词表示:PAD表示padding,UNK表示词表中没有 vocab = special_chars + vocab # save initial config data with open(SAVE_PATH, 'wb') as f: pickle.dump((train_char, chunk_tags), f) # process data: padding print('maxlen is %d' % maxlen) train_x, train_y = process_data(train_content, train_label, vocab, chunk_tags, maxlen) test_x, test_y = process_data(test_content, test_label, vocab, chunk_tags, maxlen) return train_x, train_y, test_x, test_y, vocab, chunk_tags, maxlen, test_content
- 其中,
-
**读取预训练的词向量集:**这里,调用了genism库中的
KeyedVectors.load_word2vec_format
函数对词向量集进行读取。word2vec_model_path = 'sgns.context.word-character.char1-1.bz2' #词向量位置 word2vec_model = KeyedVectors.load_word2vec_format(word2vec_model_path, binary=False, unicode_errors='ignore')
-
构造整个词表的大词向量矩阵:这里需要构建一个字对词向量的大矩阵,用于后面的embedding层。且构建的顺序就是每一个字在vocab中的顺序。
- 如果这个字不在词向量列表中,则将其赋值为全0.
def make_embeddings_matrix(word2vec_model, vocab): char2vec_dict = {} # 字对词向量 vocab2idx = {char: idx for idx, char in enumerate(vocab)} for char, vector in zip(word2vec_model.vocab, word2vec_model.vectors): char2vec_dict[char] = vector embeddings_matrix = np.zeros((len(vocab), EMBED_DIM))# form huge matrix for i in tqdm(range(2, len(vocab))): char = vocab[i] if char in char2vec_dict.keys(): # 如果char在词向量列表中,更新权重;否则,赋值为全0(默认) char_vector = char2vec_dict[char] embeddings_matrix[i] = char_vector return embeddings_matrix
-
构建$BiLSTM + CRF$模型:调用
keras.models
和keras.layers
中的各个函数进行实现。其中,包括的层有:Input输入层,Masking屏蔽层,Embedding嵌入层(包含加载预训练词向量的操作),Bi-LSTM层,Dropout层(防止过拟合),TimeDistributed全连接层,CRF层。并调用了model.summary()
函数和model.compile()
函数,前者输出model的各项参数信息;后者compile模型,参数可指定目标函数类型,如adam
,SGD
,SGDM
,RMSprop
等等。train_x, train_y, test_x, test_y, vocab, chunk_tags, maxlen, test_content = load_data() embeddings_matrix = make_embeddings_matrix(word2vec_model, vocab) # input layer inputs = Input(shape=(maxlen, ), dtype='int32') # masking layer 屏蔽层 x = Masking(mask_value=0)(inputs) # embedding layer: map the word to it's weights(with embedding-matrix) x = Embedding(len(vocab), EMBED_DIM, weights=[embeddings_matrix], input_length=maxlen, trainable=True)(x) # Bi-LSTM layer x = Bidirectional(LSTM(BiRNN_UNITS // 2, return_sequences=True))(x) # Dropout: 正则化,防止过拟合.argument means percentage x = Dropout(0.5)(x) # 一维展开,全连接 x = TimeDistributed(Dense(len(chunk_tags)))(x) # output layer outputs = CRF(len(chunk_tags))(x) # model model = Model(inputs=inputs, outputs=outputs) # print arguments of each layer model.summary() # target_function: includes optimizer, function_type, metrics SGD = keras.optimizers.SGD(lr=0.01, momentum=0.0, decay=0.0, nesterov=False) SGDM = keras.optimizers.SGD(lr=0.01, momentum=0.9, decay=0.0, nesterov=False) RMSprop = keras.optimizers.RMSprop(lr=0.001, rho=0.9, epsilon=1e-06) model.compile(optimizer=RMSprop, loss=crf_loss, metrics=[crf_viterbi_accuracy])
-
训练函数:调用fit函数是实现训练。
# train model.fit(train_x, train_y, batch_size=BATCH_SIZE, epochs=EPOCHS, verbose=1, validation_split=0.1) score = model.evaluate(test_x, test_y, batch_size=BATCH_SIZE) print(score) model.save_weights('model.h5')
-
测试函数:调用predict函数实现预测,并对预测出来的各标签的概率取最大值,得到各个字对应的标签。
# test model.load_weights('model.h5') test_predict = model.predict(test_x) test_predict = [[np.argmax(char) for char in sample] for sample in test_predict] # get the max label_id test_predict_tag = [[chunk_tags[i] for i in sample ]for sample in test_predict] # get the label of predic test_gold = [[np.argmax(char) for char in sample] for sample in test_y] # get the label_id test_gold_tag = [[chunk_tags[i] for i in sample] for sample in test_gold] # get the label of real
-
计算测试集的F1函数:计算每个句子的F1值,并对所有的F1值求均值。
- **注意:**由于本次实验F1的定义为
$$
F1=\frac{2precisionrecall}{precision+recall}\
precision=分对词数/预测分词结果的总词数\
recall=分对词数/正确分词结果的总词数
$$
因此,不可以使用
sklearn.metrics
库中的f1_score
函数进行计算。因为f1_score
函数是对每一个label进行统计的,而本次实验是要对每一个分好的词进行统计,两者要求不同。(实际上,我也对f1_score进行了计算。发现f1_score的macro版本计算出来的值和我自己设计的函数计算的值相近,大概差1个百分点。)
_, test_content, _, _ = read_file(test_set) f_sum = 0 # 各个句子的f值之和 for i in range(len(test_predict_tag)): correct_word_num = 0 # 分对词数 predict_word_num = 0 # 预测分词结果的总词数 gold_word_num = 0 # 实际分词结果总词数 predict_sample = test_predict_tag[i] gold_sample = test_gold_tag[i] s_len = len(test_content[i]) # the real length of the sentence flag = False # true: inside a word; false: outside a word for j in range(len(predict_sample) - s_len, len(predict_sample)): if gold_sample[j] == 'S' or gold_sample[j] == 'E' or j == len(predict_sample) - 1: # update gold_word_num gold_word_num += 1 if predict_sample[j] == 'S' or predict_sample[j] == 'E' or j == len(predict_sample) - 1: # update predict_word_num predict_word_num += 1 if gold_sample[j] != predict_sample[j]: flag = False continue elif gold_sample[j] == predict_sample[j] and (gold_sample[j] == 'S' or (gold_sample[j] == 'E' and flag is True)): correct_word_num += 1 flag = False elif gold_sample[j] == predict_sample[j] and gold_sample[j] == 'B': flag = True # inside the word: start precision = float(correct_word_num) /float( predict_word_num) recall = float(correct_word_num) / float(gold_word_num) if precision == 0 and recall == 0: f1 = 0 else: f1 = 2 * precision * recall / (precision + recall) f_sum += f1 print(f_sum / len(test_predict_tag))
- **注意:**由于本次实验F1的定义为
$$
F1=\frac{2precisionrecall}{precision+recall}\
precision=分对词数/预测分词结果的总词数\
recall=分对词数/正确分词结果的总词数
$$
因此,不可以使用
本次实验使用$keras$深度学习框架进行编写,具体步骤如下:
- 安装$keras$ 2.3.1版本和$tensorflow$ 2.2版本,并安装$keras_\ contrib$库和$gensim$库。
-
$keras$ 和$tensorflow$版本一定要对应且不能过高!否则会出现很多奇奇怪怪的报错。 -
$keras_\ contrib$ 库是$keras$的一个扩展库,包含了封装好的CRF层。 -
$gensim$ 用于读取预训练好的词向量。
-
- 定义超参量
BiRNN_UNITS
,BATCH_SIZE
,EMBED_DIM
,EPOCHS
等。 - 利用$pandas$库,读取训练集和测试集的utf8文件。
- 利用$gensim$库,读取预训练好的词向量文件。
- 调用
load_data()
函数,读取训练集和测试集的字列表和标签列表,并进行padding处理和one-hot处理,实现“数据预处理”。 - 根据预训练的词向量,构建大的词向量矩阵,下标对应
vocab
的顺序,实现“词嵌入”。 - 构建Bi-LSTM+CRF的model,对训练集进行迭代训练。
- 利用上步得到的model,对测试集进行预测,并计算F1值。
- 重复上述过程调参,获得最佳参数。
本次实验的调参变量有:是否对词向量进行训练train=true/false,BATCH_SIZE =16/32/64,优化器=adam
, SGD
, SGDM
, RMSprop
.
且由于$keras$训练速度较慢,使用cuda或者纯cpu下都需要近半小时完成一次迭代,所以每种参数组合只迭代了5次进行比较。
-
在Bi-LSTM+CRF的模型中,构建Embedding层的代码如下,其中,trainable表示是否训练词向量参数。
# embedding layer: map the word to it's weights(with embedding-matrix) x = Embedding(len(vocab), EMBED_DIM, weights=[embeddings_matrix], input_length=maxlen, trainable=True)(x)
-
trainable=False表示不对预训练的词向量进行训练,而trainable=True表示对预训练的词向量参数进行后续的训练更新。发现当不对词向量参数进行训练时,模型参数如下:
训练参数:非训练参数=1:5。
-
当优化器为
adam
,BATCH_SIZE=64的情况下,结果如下:可以看出,train=True的效果明显优于train=False的效果,即训练词向量的效果要优于不训练的效果。
-
在训练的代码部分,调用了
model.fit
函数,可以成批训练数据,参数为batch_size。model.fit(train_x, train_y, batch_size=BATCH_SIZE, epochs=1, verbose=1, validation_split=0.1)
-
因此,我对批数据的大小
BATCH_SIZE
进行了调参,分别取16,32,64进行了实验。当优化器为'adam
'或者'RMSprop
',train=true时,结果如下:**结论:**在这两个优化器中,共同规律是随着
BATCH_SIZE
的大小的增加,F1_score
的大小在逐渐减小,因此,BATCH_SIZE=16的效果最好。
-
在代码中,优化器实际是作为“目标函数”:
SGD = keras.optimizers.SGD(lr=0.01, momentum=0.0, decay=0.0, nesterov=False) SGDM = keras.optimizers.SGD(lr=0.01, momentum=0.9, decay=0.0, nesterov=False) RMSprop = keras.optimizers.RMSprop(lr=0.001, rho=0.9, epsilon=1e-06) # model.compile(optimizer=RMSprop, loss=crf_loss, metrics=[crf_viterbi_accuracy]) # model.compile(optimizer=SGD, loss=crf_loss, metrics=[crf_viterbi_accuracy]) # model.compile(optimizer=SGDM, loss=crf_loss, metrics=[crf_viterbi_accuracy]) model.compile(optimizer='adam', loss=crf_loss, metrics=[crf_viterbi_accuracy])
-
因此,我对优化器进行了调参,分别取了'
adam
','SGD
',‘SGDM
’,‘RMSprop
’进行实验。当BATCH_SIZE=64,train=true时,结果如下:**结论:**可以发现,
SGD
优化器在本次实验中表现非常不好,SGDM
表现一般,RMSprop
和Adam
的表现非常好(红色线和绿色线重合)。 -
经过查询资料,发现:
SGD
是随机梯度下降优化器,SGDM
是SGD
的动量版本;RMSprop
通常是训练循环神经网络RNN的不错选择,且建议使用优化器的默认参数。Adam
本质上是RMSprop
与动量 momentum 的结合。
因此,本次实验使用$keras$框架时的最佳参数为:
参数 | 最佳取值 |
---|---|
BATCH_SIZE | 16 |
Optimizer | Adam |
BiRNN_UNITS | 200(=100+100) |
Epoch | 5 |
F1_score | 0.9302 |
此时,F1的值为0.9302,分词结果实例如下:
句子编号 | 类别 | 结果 |
---|---|---|
3 | 正确分词 | 海运 业 雄踞 全球 之 首 , 按 吨位 计 占 世界 总数 的 17% 。 |
实际分词 | 海运业 雄踞 全球 之 首 , 按 吨 位计 占 世界 总数 的 17% 。 | |
6 | 正确分词 | 十几年 来 , 改革开放 的 中国 经济 高速 发展 , 远东 在 崛起 。 |
实际分词 | 十几年 来 , 改革开放 的 中国 经济 高速 发展 , 远东 在 崛起 。 | |
21 | 正确分词 | 近几年来 , 兼 从事 社会工作 及 社会保障 问题 研究 。 |
实际分词 | 近几年来 , 兼 从事 社会 工作 及 社会保障 问题 研究 。 |
结果保存为文件:msr_test_predict.txt
和msr_test_predict.utf8
两种格式。
在本次实验中,加分项为:使用预训练词向量,模型超参数组合,选择不同的目标函数等。(详见第五部分的实验结果)