如果一个比赛或者一个项目可以使用预训练模型,那么好了,bert预训练模型一定是你最好的选择。对于初入深度学习坑的朋友们直接上手tensorflow版的(fine tune)bert可能不太友好,keras会让你发现使用bert预训练模型就像搭乐高积木一样简单。本文主要介绍bert在文本分类和主体抽取中的应用。
在开始之前要先pip安装一下keras版的bert,然后可以下载官方版的预训练模型,根据官方介绍,这份权重是用中文维基百科为语料进行训练的。
import json
import numpy as np
import pandas as pd
from random import choice
from keras_bert import load_trained_model_from_checkpoint, Tokenizer
import re, os
import codecs
maxlen = 100
config_path = '../chinese_L-12_H-768_A-12/bert_config.json'
checkpoint_path = '../chinese_L-12_H-768_A-12/bert_model.ckpt'
dict_path = '../chinese_L-12_H-768_A-12/vocab.txt'
token_dict = {}
with codecs.open(dict_path, 'r', 'utf8') as reader:
for line in reader:
token = line.strip()
token_dict[token] = len(token_dict)
class OurTokenizer(Tokenizer):
def _tokenize(self, text):
R = []
for c in text:
if c in self._token_dict:
R.append(c)
elif self._is_space(c):
R.append('[unused1]') # space类用未经训练的[unused1]表示
else:
R.append('[UNK]') # 剩余的字符是[UNK]
return R
tokenizer = OurTokenizer(token_dict)
这一部分是引入bert的Tokenizer,并对其进行进行重写。
这里简单解释一下 Tokenizer 的输出结果。首先,默认情况下,分词后句子首位会分别加上 [CLS] 和 [SEP] 标记,其中 [CLS] 位置对应的输出向量是能代表整句的句向量(反正 Bert 是这样设计的),而 [SEP] 则是句间的分隔符,其余部分则是单字输出(对于中文来说)。
本来 Tokenizer 有自己的 _tokenize 方法,我这里重写了这个方法,是要保证 tokenize 之后的结果,跟原来的字符串长度等长(如果算上两个标记,那么就是等长再加 2)。 Tokenizer 自带的 _tokenize 会自动去掉空格,然后有些字符会粘在一块输出,导致 tokenize 之后的列表不等于原来字符串的长度了,这样如果做序列标注的任务会很麻烦。
而为了避免这种麻烦,还是自己重写一遍好了。主要就是用 [unused1] 来表示空格类字符,而其余的不在列表的字符用 [UNK] 表示,其中 [unused*] 这些标记是未经训练的(随即初始化),是 Bert 预留出来用来增量添加词汇的标记,所以我们可以用它们来指代任何新字符。
neg = pd.read_excel('neg.xls', header=None)
pos = pd.read_excel('pos.xls', header=None)
data = []
for d in neg[0]:
data.append((d, 0))
for d in pos[0]:
data.append((d, 1))
# 按照9:1的比例划分训练集和验证集
random_order = list(range(len(data)))
np.random.shuffle(random_order)
train_data = [data[j] for i, j in enumerate(random_order) if i % 10 != 0]
valid_data = [data[j] for i, j in enumerate(random_order) if i % 10 == 0]
def seq_padding(X, padding=0):
L = [len(x) for x in X]
ML = max(L)
return np.array([
np.concatenate([x, [padding] * (ML - len(x))]) if len(x) < ML else x for x in X
])
class data_generator:
def __init__(self, data, batch_size=32):
self.data = data
self.batch_size = batch_size
self.steps = len(self.data) // self.batch_size
if len(self.data) % self.batch_size != 0:
self.steps += 1
def __len__(self):
return self.steps
def __iter__(self):
while True:
idxs = list(range(len(self.data)))
np.random.shuffle(idxs)
X1, X2, Y = [], [], []
for i in idxs:
d = self.data[i]
text = d[0][:maxlen]
x1, x2 = tokenizer.encode(first=text)
y = d[1]
X1.append(x1)
X2.append(x2)
Y.append([y])
if len(X1) == self.batch_size or i == idxs[-1]:
X1 = seq_padding(X1)
X2 = seq_padding(X2)
Y = seq_padding(Y)
yield [X1, X2], Y
[X1, X2, Y] = [], [], []
这部分就是数据的处理,了解keras的朋友应该都不陌生,划分训练集和测试集以及一种省内存的数据的读入方式。data_generator只是一种为了节约内存的一种方式可以随便写
from keras.layers import *
from keras.models import Model
import keras.backend as K
from keras.optimizers import Adam
bert_model = load_trained_model_from_checkpoint(config_path, checkpoint_path, seq_len=None)
for l in bert_model.layers:
l.trainable = True
x1_in = Input(shape=(None,))
x2_in = Input(shape=(None,))
x = bert_model([x1_in, x2_in])
x = Lambda(lambda x: x[:, 0])(x)
p = Dense(1, activation='sigmoid')(x)#根据分类种类自行调节,也可以多加一些层数
model = Model([x1_in, x2_in], p)
model.compile(
loss='binary_crossentropy',
optimizer=Adam(1e-5), # 用足够小的学习率
metrics=['accuracy']
)
model.summary()
train_D = data_generator(train_data)
valid_D = data_generator(valid_data)
model.fit_generator(
train_D.__iter__(),
steps_per_epoch=len(train_D),
epochs=5,
validation_data=valid_D.__iter__(),
validation_steps=len(valid_D)
)
本次试验选择的数据集选择的是书评分析,一对具体的样本样例如下:
'作者完全是以一个过来的自认为是成功者的角度去写这个问题,感觉很不客观。虽然不是很喜欢,但是,有不少的观点还是可取的。', 0
'我推荐我公司一副总,他看了以后也说好书,现在都没还给我,他甚至不相信此书为一人所写,因为出版行业有时书的策划协作是团队操作的。', 1
模型最终的训练过程如下:
对比以下是其他一些方法的训练结果,我们发现bert做迁移学习的方法对其他方法进行了降纬打击。迭代3次就有了测试集96%的成果,远远高于其他方法。
这次选的例子是选用的CCKS 2019 面向金融领域的事件主体抽取的例子,取一个样本如下:
(' 在搜索引擎输入.尚赫”就可以发现多篇.尚赫涉嫌传销”.虚假宣传”.跨区经营”等各类媒体公开报道天津尚赫保健品有限公司违规经营的报道', '涉嫌欺诈', '尚赫')
可以理解为阅读理解问题,第一部分为文本,第二部分为问题,第三部分为答案,针对这样的搞? 这是一个典型的双输入问题,这里的实体类型只有有限个,直接 Embedding 也行,只不过我使用一种更能体现 Bert 的简单粗暴和强悍的方案:直接用连接符将两个输入连接成一个句子,然后就变成单输入了!
比如上述示例样本处理成:
输入:“___涉嫌欺诈___在搜索引擎输入.尚赫”就可以发现多篇.尚赫涉嫌传销”.虚假宣传”.跨区经营”等各类媒体公开报道天津尚赫保健品有限公司违规经营的报道”
输出:“尚赫”
然后就变成了普通的单输入抽取问题了。说到这个,这个模型的代码也就没有什么好说的了,就简单几行,
bert_model = load_trained_model_from_checkpoint(config_path, checkpoint_path, seq_len=None)
for l in bert_model.layers:
l.trainable = True
x1_in = Input(shape=(None,)) # 待识别句子输入
x2_in = Input(shape=(None,)) # 待识别句子输入
s1_in = Input(shape=(None,)) # 实体左边界(标签)
s2_in = Input(shape=(None,)) # 实体右边界(标签)
x1, x2, s1, s2 = x1_in, x2_in, s1_in, s2_in
x_mask = Lambda(lambda x: K.cast(K.greater(K.expand_dims(x, 2), 0), 'float32'))(x1)
x = bert_model([x1, x2])
ps1 = Dense(1, use_bias=False)(x)
ps1 = Dense(1, use_bias=False)(ps1)
ps1 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps1, x_mask])
ps2 = Dense(1, use_bias=False)(x)
ps2 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps2, x_mask])
model = Model([x1_in, x2_in], [ps1, ps2])
train_model = Model([x1_in, x2_in, s1_in, s2_in], [ps1, ps2])
loss1 = K.mean(K.categorical_crossentropy(s1_in, ps1, from_logits=True))
ps2 -= (1 - K.cumsum(s1, 1)) * 1e10
loss2 = K.mean(K.categorical_crossentropy(s2_in, ps2, from_logits=True))
loss = loss1 + loss2
train_model.add_loss(loss)
我们在训练的时候,由于是双输出所以损失函数这边要自己写一下,这里是star和end的损失是相加的;
整个项目的整体流程如下,所需要的数据集到官网下载
import json
from tqdm import tqdm
import os, re
import numpy as np
import pandas as pd
from keras_bert import load_trained_model_from_checkpoint, Tokenizer
import codecs
#一些套路性质的代码
mode = 0
maxlen = 250
learning_rate = 3e-5
min_learning_rate = 6e-6
config_path = '../chinese_wwm/bert_config.json'
checkpoint_path = '../chinese_wwm/bert_model.ckpt'
dict_path = '../chinese_wwm/vocab.txt'
token_dict = {}
with codecs.open(dict_path, 'r', 'utf8') as reader:
for line in reader:
token = line.strip()
token_dict[token] = len(token_dict)
class OurTokenizer(Tokenizer):
def _tokenize(self, text):
R = []
for c in text:
if c in self._token_dict:
R.append(c)
elif self._is_space(c):
R.append('[unused1]') # space类用未经训练的[unused1]表示
else:
R.append('[UNK]') # 剩余的字符是[UNK]
return R
tokenizer = OurTokenizer(token_dict)
# 对数据集的一些处理,方便加载和使用
D = pd.read_csv('../ccks2019_event_entity_extract/event_type_entity_extract_train.csv', encoding='utf-8', header=None)
D = D[D[2] != u'其他']
classes = set(D[2].unique())
train_data = []
for t,c,n in zip(D[1], D[2], D[3]):
train_data.append((t, c, n))
D = pd.read_csv('../ccks2019_event_entity_extract/event_type_entity_extract_eval.csv', encoding='utf-8', header=None)
test_data = []
for id,t,c in zip(D[0], D[1], D[2]):
test_data.append((id, t, c))
#统计出现在训练集的一些特殊字符
additional_chars = set()
for d in train_data:
additional_chars.update(re.findall(u'[^\u4e00-\u9fa5a-zA-Z0-9\*]', d[2]))
additional_chars.remove(u',')
上面这一部分主要是一些前期的数据加载和一些特殊词的统计;下面一部分主要是一些基本函数的定义。
# 对数据进行padding
def seq_padding(X, padding=0):
L = [len(x) for x in X]
ML = max(L)
return np.array([
np.concatenate([x, [padding] * (ML - len(x))]) if len(x) < ML else x for x in X
])
def list_find(list1, list2):
"""在list1中寻找子串list2,如果找到,返回第一个下标;
如果找不到,返回-1。
"""
n_list2 = len(list2)
for i in range(len(list1)):
if list1[i: i+n_list2] == list2:
return i
return -1
# 构建传入模型的生成器,包括训练集和训练集的标签。这里训练集的数据是问题和文本的合并,
#而标签这是star和end两个标注向量(并与输入的文本句子对齐)。
class data_generator:
def __init__(self, data, batch_size=12):
self.data = data
self.batch_size = batch_size
self.steps = len(self.data) // self.batch_size
if len(self.data) % self.batch_size != 0:
self.steps += 1
def __len__(self):
return self.steps
def __iter__(self):
while True:
idxs = list(range(len(self.data)))
np.random.shuffle(idxs)
X1, X2, S1, S2 = [], [], [], []
for i in idxs:
d = self.data[i]
text, c = d[0][:maxlen], d[1]
text = u'___%s___%s' % (c, text)
tokens = tokenizer.tokenize(text)
e = d[2]
e_tokens = tokenizer.tokenize(e)[1:-1]
s1, s2 = np.zeros(len(tokens)), np.zeros(len(tokens))
start = list_find(tokens, e_tokens)
if start != -1:
end = start + len(e_tokens) - 1
s1[start] = 1
s2[end] = 1
x1, x2 = tokenizer.encode(first=text)
X1.append(x1)
X2.append(x2)
S1.append(s1)
S2.append(s2)
if len(X1) == self.batch_size or i == idxs[-1]:
X1 = seq_padding(X1)
X2 = seq_padding(X2)
S1 = seq_padding(S1)
S2 = seq_padding(S2)
yield [X1, X2, S1, S2], None
X1, X2, S1, S2 = [], [], [], []
def softmax(x):
x = x - np.max(x)
x = np.exp(x)
return x / np.sum(x)
# 这一部分是核心的规则,实现了由概率空间到文本提取的映射,具体规则详见代码。
def extract_entity(text_in, c_in):
if c_in not in classes:
return 'NaN'
text_in = u'___%s___%s' % (c_in, text_in)
text_in = text_in[:510]
_tokens = tokenizer.tokenize(text_in)
_x1, _x2 = tokenizer.encode(first=text_in)
_x1, _x2 = np.array([_x1]), np.array([_x2])
_ps1, _ps2 = model.predict([_x1, _x2])
_ps1, _ps2 = softmax(_ps1[0]), softmax(_ps2[0])
for i, _t in enumerate(_tokens):
if len(_t) == 1 and re.findall(u'[^\u4e00-\u9fa5a-zA-Z0-9\*]', _t) and _t not in additional_chars:
_ps1[i] -= 10
start = _ps1.argmax()
for end in range(start, len(_tokens)):
_t = _tokens[end]
if len(_t) == 1 and re.findall(u'[^\u4e00-\u9fa5a-zA-Z0-9\*]', _t) and _t not in additional_chars:
break
end = _ps2[start:end+1].argmax() + start
a = text_in[start-1: end]
return a
# 这一部分是自定义的损失函数
class Evaluate(Callback):
def __init__(self,i):
self.ACC = []
self.best = 0.
self.passed = 0
self.i = i
def on_batch_begin(self, batch, logs=None):
"""
第一个epoch用来warmup,第二个epoch把学习率降到最低
"""
if self.passed < self.params['steps']:
lr = (self.passed + 1.) / self.params['steps'] * learning_rate
K.set_value(self.model.optimizer.lr, lr)
self.passed += 1
elif self.params['steps'] <= self.passed < self.params['steps'] * 2:
lr = (2 - (self.passed + 1.) / self.params['steps']) * (learning_rate - min_learning_rate)
lr += min_learning_rate
K.set_value(self.model.optimizer.lr, lr)
self.passed += 1
def on_epoch_end(self, epoch, logs=None):
acc = self.evaluate()
self.ACC.append(acc)
if acc > self.best:
self.best = acc
train_model.save_weights('best_trainmodel_{}.weights'.format(self.i))
model.save_weights('best_model_{}.weights'.format(self.i))
print ('acc: %.4f, best acc: %.4f\n' % (acc, self.best))
def evaluate(self):
"""
这个是一个简单的测评函数,统计命中实体的准确率
"""
A = 1e-10
F = open('dev_pred.json', 'wb')
for d in tqdm(iter(dev_data)):
R = extract_entity(d[0], d[1])
if R == d[2]:
A += 1
s = ', '.join(d + (R,))
F.write(s.encode('utf-8') + b'\n')
F.close()
print(A / len(dev_data))
return A / len(dev_data)
下面这一部分代码主要是做10倍的交叉验证,并生成一个test结果
from sklearn.model_selection import KFold
from keras.callbacks import EarlyStopping,ModelCheckpoint
# if __name__ == '__main__':
kf= KFold(n_splits=10)
for i,( tr, va) in enumerate(kf.split(train_data)):
K.clear_session()
bert_model = load_trained_model_from_checkpoint(config_path, checkpoint_path, seq_len=None)
for l in bert_model.layers:
l.trainable = True
x1_in = Input(shape=(None,)) # 待识别句子输入
x2_in = Input(shape=(None,)) # 待识别句子输入
s1_in = Input(shape=(None,)) # 实体左边界(标签)
s2_in = Input(shape=(None,)) # 实体右边界(标签)
x1, x2, s1, s2 = x1_in, x2_in, s1_in, s2_in
x_mask = Lambda(lambda x: K.cast(K.greater(K.expand_dims(x, 2), 0), 'float32'))(x1)
x = bert_model([x1, x2])
ps1 = Dense(1, use_bias=False)(x)
ps1 = Dense(1, use_bias=False)(ps1)
ps1 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps1, x_mask])
ps2 = Dense(1, use_bias=False)(x)
ps2 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps2, x_mask])
model = Model([x1_in, x2_in], [ps1, ps2])
train_model = Model([x1_in, x2_in, s1_in, s2_in], [ps1, ps2])
loss1 = K.mean(K.categorical_crossentropy(s1_in, ps1, from_logits=True))
ps2 -= (1 - K.cumsum(s1, 1)) * 1e10
loss2 = K.mean(K.categorical_crossentropy(s2_in, ps2, from_logits=True))
loss = loss1 + loss2
train_model.add_loss(loss)
train_model.compile(optimizer=Adam(learning_rate))
evaluator = Evaluate(i)
train_D = data_generator([train_data[j] for j in tr])
# train_D = data_generator(train_data)
dev_data = [train_data[j] for j in va]
train_model.fit_generator(train_D.__iter__(),
steps_per_epoch=len(train_D),
epochs=2,
callbacks=[evaluator]
)
model.load_weights('best_model_{}.weights'.format(i))
F = open('result{}.txt'.format(i), 'wb')
for d in tqdm(iter(test_data)):
s = u'"%s","%s"\n' % (d[0], extract_entity(d[1], d[2]))
s = s.encode('utf-8')
F.write(s)
F.close()