用keras优雅的使用bert

1.概述

如果一个比赛或者一个项目可以使用预训练模型,那么好了,bert预训练模型一定是你最好的选择。对于初入深度学习坑的朋友们直接上手tensorflow版的(fine tune)bert可能不太友好,keras会让你发现使用bert预训练模型就像搭乐高积木一样简单。本文主要介绍bert在文本分类和主体抽取中的应用。
在开始之前要先pip安装一下keras版的bert,然后可以下载官方版的预训练模型,根据官方介绍,这份权重是用中文维基百科为语料进行训练的。

2.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%的成果,远远高于其他方法。
在这里插入图片描述

3.bert做事件主体抽取

这次选的例子是选用的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()
发布了25 篇原创文章 · 获赞 41 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_32796253/article/details/98844242