Vinn's Studio

BERT

Word count: 2.5kReading time: 11 min
2021/05/25 Share

BERT 全称 Bidirectional Encoder Representations from Transformers,是一种较新的语言模型。自Google在 2018 年 10 月底公布 BERT 在 11 项 NLP 任务中的卓越表现后,BERT 引起了社区极大的关注与讨论,被认为是 NLP 领域的极大突破。

BERT 简介

BERT 是一种预训练语言表示的方法:在大量文本语料上预训练一个通用的”语言理解“模型(pre-training),然后用这个模型去执行各类下游子任务(fine-tuning)。它是第一个用在预训练 NLP 上的无监督、深度双向系统。

Google已开放源码,并提供了预训练模型的下载地址,这些模型已经在大规模数据集上进行了预训练。

git 地址: google-research/bert

BERT 原理

BERT 的具体原理不是本文的主要内容,以后有空再更新吧。

BERT 代码

BERT 本质上是一个两段式的 NLP 模型。第一阶段:Pre-training,利用无标记的语料训练一个语言模型;第二阶段:Fine-tuning,利用预训练好的语言模型,完成具体的NLP下游任务。

fine-tuning 代码

首先使用 git 克隆开源的 BERT 代码(fine-tune 代码):

1
git clone https://github.com/google-research/bert

pre-training 代码

BERT 还提供了下列预训练模型,需要提前下载预训练模型并用于之后的 fine-tune 过程:

其中,每个模型文件夹包含以下三种文件:

  • 配置文件(bert_config.json):用于指定模型的超参数
  • 词典文件(vocab.txt):用于 WordPiece 到 Word id 的映射
  • Tensorflow checkpoint(bert_model.ckpt):包含了预训练模型的权重(实际包含三个文件)

BERT 文本分类实例

接下来,以中文文本相似度任务为例,运行一次 BERT 的 fine-tuning 过程。为了完成文本分类这类任务,显然我们需要调整 run_classifier.py 中的代码。

自定义数据类

run_classifier.py 文件中包含一些名为 ****Processor 的类,它们都继承于同一个父类 DataProcessor(object),该父类提供了如下 4 个抽象方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class DataProcessor(object):
"""Base class for data converters for sequence classification data sets."""

def get_train_examples(self, data_dir):
"""Gets a collection of `InputExample`s for the train set."""
raise NotImplementedError()

def get_dev_examples(self, data_dir):
"""Gets a collection of `InputExample`s for the dev set."""
raise NotImplementedError()

def get_test_examples(self, data_dir):
"""Gets a collection of `InputExample`s for prediction."""
raise NotImplementedError()

def get_labels(self):
"""Gets the list of labels for this data set."""
raise NotImplementedError()

代码中继承该父类的这些 ****Processor(DataProcessor) 类,各自代表一个内置的示例数据集,且各自都实现了上述四个方法:前三个方法用于获取 训练、验证、测试 的输入数据,并将每条数据封装为 InputExample 类;第四个方法则用于获取 标签

InputExample 类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class InputExample(object):
"""A single training/test example for simple sequence classification."""

def __init__(self, guid, text_a, text_b=None, label=None):
"""Constructs a InputExample.

Args:
guid: Unique id for the example.
text_a: string. The untokenized text of the first sequence. For single
sequence tasks, only this sequence must be specified.
text_b: (Optional) string. The untokenized text of the second sequence.
Only must be specified for sequence pair tasks.
label: (Optional) string. The label of the example. This should be
specified for train and dev examples, but not for test examples.
"""
self.guid = guid
self.text_a = text_a
self.text_b = text_b
self.label = label

每个 InputExample 类就是一条输入的样本,其中:

  • 属性 guid 为该样本的唯一 ID;
  • 属性 text_a 为该样本的文本;
  • 属性 text_b 只在部分文本匹配/相似度分析等任务中会被用到,为该样本对中的另一条文本;
  • 属性 label 为该样本的标签;

如果我们需要处理自己的数据,就需要自定义新的数据类,并实现这四个方法,从而实现数据的获取过程。

假设我们的输入数据已经划分为 train.csvdev.csvtest.csv三个文件,且每个文件的格式如下:

text label
1 今天天气真不错 感叹
2 中国是一个国家 陈述
3 你也想起舞吗? 疑问

参考代码内置的那些示例数据集的写法,为我们的输入数据实现一个名为 TextClassifierProcessor 的类:(以训练集为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 文本分类数据集
class TextClassifierProcessor(DataProcessor):
"""Processor for the text classifier data set."""

def get_train_examples(self, data_dir):
"""See base class."""
file_path = os.path.join(data_dir, 'train.csv') # 训练集数据储存在 data_dir 下的 train.csv 文件中
examples = []
with open(file_path, encoding='utf-8') as f:
reader = f.readlines()
for (i, line) in enumerate(reader):
guid = "train-%d" % (i)
split_line = line.strip().split(",") # .csv 文件数据集的 文本 和 标签 是以逗号隔开的,因此先将每行数据以逗号隔开,则 split_line[0] 为样本的序号,可以不使用
# 跳过第一行数据(因为是标题);如果分隔出的 split_line 的长度不够,说明因为某些原因数据没有识别完全,因此也要跳过
if i == 0 or len(split_line) < 3:
continue
text_a = tokenization.convert_to_unicode(split_line[1]) # split_line[1] 为 文本 赋值给 text_a
text_b = None # text_b 只有在成对文本相关任务时才用到
label = str(split_line[2]) # split_line[2] 为 标签 赋值给 label
examples.append(
InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples

def get_labels(self):
"""See base class."""
return ['陈述', '疑问', '设问', '反问', '感叹']

对于测试集和验证集的处理方式与相同。

为了方便,可以构建一个字典类型的变量,存放数字类别和文本标签中间的对应关系。当然也可以直接使用文本标签,想用哪种用哪种。

调整 main 函数

定义完 TextClassifierProcessor 类之后,还需要将其加入到 main 函数中的 processors 变量。

找到 main() 函数,增加我们新定义的数据类(给他取一个 task_mask 名为 classifier),如下所示:

1
2
3
4
5
6
7
8
9
10
def main(_):
tf.logging.set_verbosity(tf.logging.INFO)

processors = {
"cola": ColaProcessor,
"mnli": MnliProcessor,
"mrpc": MrpcProcessor,
"xnli": XnliProcessor,
"classifier":TextClassifierProcessor # 增加此行
}

修改输出

run_classifier.py 文件中,预测部分的会输出两个文件,分别是 predict.tf_recordtest_results.tsv。其中 test_results.tsv 中存放的是每个测试数据得到的属于所有类别的概率值,维度为 [n*num_labels]

但这个结果并不能直接反应得到的预测结果,因此修改处理代码,直接获取得到的预测类别。

原始的输出代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
if FLAGS.do_predict:
predict_examples = processor.get_test_examples(FLAGS.data_dir)
num_actual_predict_examples = len(predict_examples)
if FLAGS.use_tpu:
# TPU requires a fixed batch size for all batches, therefore the number
# of examples must be a multiple of the batch size, or else examples
# will get dropped. So we pad with fake examples which are ignored
# later on.
while len(predict_examples) % FLAGS.predict_batch_size != 0:
predict_examples.append(PaddingInputExample())

predict_file = os.path.join(FLAGS.output_dir, "predict.tf_record")
file_based_convert_examples_to_features(predict_examples, label_list,
FLAGS.max_seq_length, tokenizer,
predict_file)

tf.logging.info("***** Running prediction*****")
tf.logging.info(" Num examples = %d (%d actual, %d padding)",
len(predict_examples), num_actual_predict_examples,
len(predict_examples) - num_actual_predict_examples)
tf.logging.info(" Batch size = %d", FLAGS.predict_batch_size)

predict_drop_remainder = True if FLAGS.use_tpu else False
predict_input_fn = file_based_input_fn_builder(
input_file=predict_file,
seq_length=FLAGS.max_seq_length,
is_training=False,
drop_remainder=predict_drop_remainder)

result = estimator.predict(input_fn=predict_input_fn)

output_predict_file = os.path.join(FLAGS.output_dir, "test_results.tsv")
# with tf.gfile.GFile(output_predict_file, "w") as writer:
# num_written_lines = 0
# tf.logging.info("***** Predict results *****")
# for (i, prediction) in enumerate(result):
# probabilities = prediction["probabilities"]
# if i >= num_actual_predict_examples:
# break
# output_line = "\t".join(
# str(class_probability)
# for class_probability in probabilities) + "\n"
# writer.write(output_line)
# num_written_lines += 1
# assert num_written_lines == num_actual_predict_examples

将上述代码中最后注释掉的那部分修改为如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
real_label = [] # 测试数据的真实标签
for i in range(len(predict_examples)):
real_label.append(predict_examples[i].label)

result_predict_file = os.path.join(
FLAGS.output_dir, "test_labels_out.txt")

right = 0 # 用于记录预测正确的个数
f_res = open(result_predict_file, 'w') #将结果保存到此文件中
with tf.gfile.GFile(output_predict_file, "w") as writer:
num_written_lines = 0
tf.logging.info("***** Predict results *****")
for (i, prediction) in enumerate(result):
probabilities = prediction["probabilities"] #预测结果
if i >= num_actual_predict_examples:
break
output_line = "\t".join(
str(class_probability)
for class_probability in probabilities) + "\n"
# 获取概率值最大的类别的下标Index
index = np.argmax(probabilities, axis = 0)
# 将真实标签和预测标签及对应的概率值写入到结果文件中
res_line = 'real: %s, \tpred:%s, \tscore = %.2f\n' \
%(real_label[i], label_list[index], probabilities[index])
f_res.write(res_line)
writer.write(output_line)
num_written_lines += 1

if real_label[i] == label_list[index]:
right += 1
print('precision = %.2f' %(right / len(real_label)))
assert num_written_lines == num_actual_predict_examples

该代码额外将模型预测得到的类别输出到 test_labels_out.txt 文件中。

训练过程

准备好数据集,修改完数据类后,接下来就是如何 fine-tuning 模型。 查看 run_classifier.py 文件的入口部分,包含了 fine-tuning 模型所需的必要参数,如下:

1
2
3
4
5
6
7
if __name__ == "__main__":
flags.mark_flag_as_required("data_dir")
flags.mark_flag_as_required("task_name")
flags.mark_flag_as_required("vocab_file")
flags.mark_flag_as_required("bert_config_file")
flags.mark_flag_as_required("output_dir")
tf.app.run()

必要参数的解释如下:

  • data_dir :数据存放路径
  • task_mask :数据集的任务名称,对于该文本分类任务,则为 classifier
  • vocab_file :字典文件的地址
  • bert_config_file :配置文件
  • output_dir :模型输出地址

由于需要设置的参数较多,因此我们将其统一放置到 shell 脚本中,在run_classifier.py 的相同路径下创建名为 fine-tuning_classifier.sh 的脚本,如下所示:

1
#!/usr/bin/env bashexport BERT_BASE_DIR=../chinese_L-12_H-768_A-12 #全局变量 下载的预训练bert地址export MY_DATASET=../data #全局变量 数据集所在地址python run_classifier.py --task_name=classifier  --do_train=true --do_eval=true --do_predict=true --data_dir=$MY_DATASET --vocab_file=$BERT_BASE_DIR/vocab.txt --bert_config_file=$BERT_BASE_DIR/bert_config.json --init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt --max_seq_length=32  --train_batch_size=64 --learning_rate=5e-5 --num_train_epochs=10.0 --output_dir=../fine_tuning_out/text_classifier_64_epoch10_5e5

在命令行使用如下命令执行该脚本:

1
sh ./fine-tuning_classifier.sh

output_dir 中就能得到 fine-tune 训练好的模型,以及在测试集上的预测结果等。

CATALOG
  1. BERT 简介
  2. BERT 原理
  3. BERT 代码
    1. fine-tuning 代码
    2. pre-training 代码
  4. BERT 文本分类实例
    1. 自定义数据类
    2. 调整 main 函数
    3. 修改输出
    4. 训练过程