1 前言

BERT模型的使用可以分为两种形式:第一种使用方法直接将语句序列输入BERT模型获取特征表示,BERT模型一共提供十二层不同的特征向量输出,随层数的递进,特征表示从专于词义表示到专于语义表示而有所区别,此时BERT模型相当于静态的word2vector模型,仅用于特征表示,关于如何获取BERT预训练模型及如何使用第一种方法,可以参考我的博客。

第二种则是更为常用的将BERT模型作为深度学习网络的一部分继续训练,以达到个性化需求适应的目的,此时BERT模型相当于为深度学习网络层中预设了一个较优的初始参数值,有利于模型训练在优化目标损失值时有一个较好的初始点,这有利于优化算法能够更快更精确地寻找到可行域中的最优解。

本文主要就如何使用keras_bert库来实现第二种方法,以及当中可能存在的问题做阐述

2 keras_bert 库使用示例

keras_bert库是利用keras框架封装BERT模型训练使用的python包,源码来自于https://github.com/CyberZHG/keras-bert,可以使用pip直接安装👇

pip install keras_bert

官方给出的keras_bert使用示例是这样的👇

#! -*- coding:utf-8 -*-

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 = '../bert/chinese_L-12_H-768_A-12/bert_config.json'
checkpoint_path = '../bert/chinese_L-12_H-768_A-12/bert_model.ckpt'
dict_path = '../bert/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)

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 = 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 = 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] = [], [], []


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)
)

上面这段代码的模型构建的核心部分如下所示👇

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()

 我们首先需要导入bert_model,两个参数config_pathcheckpoint_path,在下载好BERT模型后是显而易见的👇

paddlenlp bert模型 bert模型怎么用_自定义

paddlenlp bert模型 bert模型怎么用_nlp_02

然后我们看到bert_model的是需要两个输入x1_inx2_in,这两个输入怎么获得?事实上这两个输入就是第一种使用BERT模型方法需要输入的参数,即下面这段代码中text2input函数返回的前两个值(这段代码即为BERT模型的第一种使用方法示例)👇

# -*- coding: UTF-8 -*-
# Author: 囚生
# 调用BERT模型的工具函数

import os
import tensorflow as tf

from bert import modeling,tokenization

def text2input(text,tokenizer,											 # 接收三个参数: 超参数, 文本, 分词器
	maxlen=100,															 # 文本token最大数
	return_tensor=True,													 # 是否返回tensor类型的结果: 否则返回list类型
):																		 # 将文本转化为BERT输入
	tokens = tokenizer.tokenize(text)									 # 分词器分词
	if len(tokens)>maxlen-2: tokens = tokens[:maxlen-2]					 # 注意tokens的数量不能超过maxlen-2, 因为头尾还需要加句首与分句标志
	tokens_bert = ["[CLS]"]										 # 存放token的列表: 置入句首标志
	token_type_ids = [0]												 # 标识token属句类别的列表: 置入句首标志的标识
	for token in tokens:												 # 添加token与对应标识
		tokens_bert.append(token)										 # 添加token
		token_type_ids.append(0)										 # 这个表示一般用0,1,2,...表示是第几句话, 该函数一般只接收一个句子, 因此都是0
	tokens_bert.append("[SEP]")											 # 置入分句标志
	token_type_ids.append(0)											 # 置入分句标志的标识
	input_ids = tokenizer.convert_tokens_to_ids(tokens_bert)			 # 将tokens转化为input_ids
	input_mask = [1]*len(input_ids)										 # 设置蒙布
	while len(input_ids)<maxlen:										 # 对input_ids,input_mask,token_type_ids进行padding
		input_ids.append(0)												 # 零填充
		input_mask.append(0)											 # 零填充
		token_type_ids.append(0)										 # 零填充
	if return_tensor: 													 # 若返回tensor类型
		input_ids = tf.convert_to_tensor([input_ids],dtype=tf.int32,name="input_ids")
		input_mask = tf.convert_to_tensor([input_mask],dtype=tf.int32,name="input_mask")
		token_type_ids = tf.convert_to_tensor([token_type_ids],dtype=tf.int32,name="token_type_ids")
	return input_ids,input_mask,token_type_ids							 # 返回BERT输入的三个参数

def load_model(input_ids,input_mask,token_type_ids,cpath,mpath,
):																		 # 模型载入
	config = modeling.BertConfig.from_json_file(cpath)					 # 载入配置文件
	config_session = tf.ConfigProto()									 # 创建对象配置session运行参数
	config_session.gpu_options.allow_growth = True						 # 动态申请显存
	with tf.Session(config=config_session).as_default() as session:
		model = modeling.BertModel(										 # 载入模型
			config=config,												 # BERT配置信息
			is_training=True,											 # 训练模式
			input_ids=input_ids,										 # 输入参数: 输入token的索引
			input_mask=input_mask,										 # 输入参数: 蒙布
			token_type_ids=token_type_ids,								 # 输入参数: 
			use_one_hot_embeddings=False,								 # 不使用one-hot编码
		)
		saver = tf.train.Saver()										 # 训练保存器
		session.run(tf.global_variables_initializer())					 # 先初始化, 再加载参数,否则会把BERT的参数重新初始化
		saver.restore(session,mpath)									 # 保存模型到ckpt文件
		
	sequence_output = model.get_sequence_output()						 # 获取每个token的输出: shape(batch_size,sequence_length,embedding_size)
	pooled_output = model.get_pooled_output()							 # 获取每个分句的输出: shape(batch_size,embedding_size)
	layers = model.all_encoder_layers									 # 获取所有层的输出: shape(batch_size,sequence_length,embedding_size)
	embedding_output = model.get_embedding_output()
	embedding_table = model.get_embedding_table()

	'''
	with tf.Session() as session:
		session.run(tf.global_variables_initializer())

		sequence_output = session.run(sequence_output)
		pooled_output = session.run(pooled_output)
		embedding_output = session.run(embedding_output)
		embedding_table = session.run(embedding_table)

		print("sequence_output: {}".format(sequence_output.shape))		 # (1,32,768)
		print("pooled_output: {}".format(pooled_output.shape))			 # (1,768)
		print("embedding_output: {}".format(embedding_output.shape))	 # (1,32,768)
		print("embedding_table: {}".format(embedding_table.shape))		 # (28996,768)
		
		for layer in layers:
			print(layer.shape)											 # (1,32,768)
	'''
	
	return layers,embedding_output,pooled_output,embedding_table

if __name__ == "__main__":
	# 以下4个路径变量请根据自己的实际情况修改
	root = "otherdata/model/bert_cased_L-12_H-768_A-12"
	vpath = os.path.join(root,"vocab.txt")								 # 词汇表文件
	cpath = os.path.join(root,"bert_config.json")
	mpath = os.path.join(root,"bert_model.ckpt")						 # 这个文件其实不存在, 但是就得这么写, 我也不知道为什么
	
	tokenizer = tokenization.FullTokenizer(vpath)						 # 这个老版本中可能是CharTokenizer类, 目前源码中不存在该类了
	text = "This will , if not already , cause problems as there is very limited space for us ."
	input_ids,input_mask,token_type_ids = text2input(hp,text,tokenizer,32)
	load_model(input_ids,input_mask,token_type_ids,cpath,mpath)

知悉了keras_bert 的输入变量的格式基本上问题就迎刃而解了。事实上仅一个bert_model 模型就有10亿以上的参数数量,如果你将bert_model 的输出结果继续添加到复杂的神经网络中,模型参数数量是极为可怕,基本上只有几G内存或显存的个人计算机连一个epoch 都跑不动,代码是很难在本地调试的,因此笔者将就keras_bert可能发生的问题做一个汇总。

3 keras_bert 库使用问题填坑

这部分笔者限于水平与经验,举几个初次使用keras_bert常常会遇到的问题做解释。

3.1 使用已导出的带有BERT模型的模型时发生未知层错误:Unknown Layers

事实上如果在模型网络层中存在自定义的层,如Attention层、Capsule层时,模型导出后再次导入就会出现未知层错误。此时在加载模型时需要将自定义的layer类作为custom_objects的参数以字典形式传入👇

model = load_model("model_weight.h5", custom_objects={'Attention':Attention,'Capsule':Capsule})

当然我们并不能简单得知BERT模型中到底使用了多少自定义层,因此需要使用get_custom_objects返回所有自定义的Layer信息,如下所示👇

from keras_bert import get_custom_objects

model = load_model("model_weight.h5",custom_objects=get_custom_objects())

这里说一个小插曲,因为如果不将model导出,直接将训练后的model用于预测是可以使用的,笔者一开始不知道如何解决这个问题就想,索性不使用model.save("model_weight.h5")来导出模型,直接用pickle库把model以二进制数据导出:pickle.dump(model,open("model_weight.p","wb")),之后再以二进制数据输入pickle.load(open("model_weight.p","rb"))就完事了,结果仍然会报同样的错误。而且笔者发现这两种导出模型的方法获得的本地文件大小竟然是出奇的一致,看来model.save()函数就是基本上以二进制输出流存储了模型参数。

3.2 使用已导出的带有BERT模型的模型时发生值错误:ValueError: Tensor Tensor("dense_1/truediv:0", shape=(?, 100, 2015), dtype=float32) is not an element of this graph.

将导出到外部存储的模型重新载入后用于测试集标签预测时,还会发生如上的ValueError,这个问题的解决方法需要用到如下的结构👇

import tensorflow as tf

global graph
graph = tf.get_default_graph()

with graph.as_default():
	...

这里的关键在于with graph.as_default()的位置应该放在哪里,如过你把整个函数的内容都框在里面又会报错让你不要做这种愚蠢的事情(tensorflow真的会报这种错),笔者目前也没有太弄清楚这个位置应该怎么放,经验告诉我只要涉及测试集输入数据的地方,就需要放在这个scope里,比如:

with graph.as_default():	
			input1 = numpy.array(inputs[0])
			input2 = numpy.array(inputs[1])
			input3 = numpy.array(input_pos_onehot)
			print(input1.shape)											 # 
			print(input2.shape)											 # 
			print(input3.shape)											 #
			y_pred = model.predict([input1,input2,input3])
			print(y_pred.shape)
			pickle.dump(y_pred,open("y_pred.p","wb"))

这里原先的inputsinput_pos_onehot变量都是list类型的,如果在它们还是list时就放在这个scope里就会报错(比如在上面这段代码的前面是生成input1input2input3的流程,就别放在这个scope里了),但是写成这种形式就不会再报错了。笔者确实没有太弄清楚这个机制是什么样的,总之尽量少放点代码在里面为妙[汗]。

 

分享学习,共同进步!