google开源的tensorflow版本的bert 源码见 https://github.com/google-research/bert。本文主要对该官方代码的一些关键部分进行解读。
首先我们来看数据预处理部分,分析原始数据集是如何转化成能够送入bert模型的特征的。
DataProcessor
DataProcessor
这个抽象基类定义了get_train_examples, get_dev_examples, get_test_examples, get_labels
这四个需要子类实现的方法,还定义了一个_read_tsv
函数来读取原始数据集tsv文件。
针对文本二分类任务,我们可以通过学习继承DataProcessor
类的子类ColaProcessor
的具体实现过程来了解数据处理的过程。我们可以发现子类ColaProcessor
处理原始数据的关键函数如下:
class ColaProcessor(DataProcessor):
"""Processor for the CoLA data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(
self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_labels(self):
"""See base class."""
return ["0", "1"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
# Only the test set has a header
if set_type == "test" and i == 0:
continue
guid = "%s-%s" % (set_type, i)
if set_type == "test":
text_a = tokenization.convert_to_unicode(line[1])
label = "0"
else:
text_a = tokenization.convert_to_unicode(line[3])
label = tokenization.convert_to_unicode(line[1])
examples.append(
InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
return examples
class InputExample(object):
"""A single training/test example for simple sequence classification."""
def __init__(self, guid, text_a, text_b=None, label=None):
self.guid = guid
self.text_a = text_a
self.text_b = text_b
self.label = label
该函数首先通过_read_tsv
读入原始数据集文件train.tsv
,然后调用_create_examples
将数据集中的每一行转换成一个InputExample
对象。
- 在函数
_create_examples
中,如果是训练集和验证集,那么line[1]
就是label,line[3]
就是文本内容,而对于测试集,line[1]
就是文本内容,没有label,因此全部设成0。这个具体可看CoLA数据集。注意这里将所有字符串用tokenization.convert_to_unicode
转成unicode字符串,是为了兼容python2和python3。 - 对象
InputExample
有四个属性,guid
仅仅是一个唯一的id标识,text_a
表示第一个句子,text_b
表示第二个句子(可选,针对句子对任务),label表示标签(可选,测试集没有)。
tokenizer
对InputExample里的句子字符串text_a
或text_b
进行分词操作的主要函数是FullTokenizer
。
tokenizer = tokenization.FullTokenizer(
vocab_file=FLAGS.vocab_file, do_lower_case=FLAGS.do_lower_case)
class FullTokenizer(object):
"""Runs end-to-end tokenziation."""
def __init__(self, vocab_file, do_lower_case=True):
self.vocab = load_vocab(vocab_file)
self.inv_vocab = {v: k for k, v in self.vocab.items()}
self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
self.wordpiece_tokenizer = WordpieceTokenizer(vocab=self.vocab)
def tokenize(self, text):
split_tokens = []
for token in self.basic_tokenizer.tokenize(text):
for sub_token in self.wordpiece_tokenizer.tokenize(token):
split_tokens.append(sub_token)
return split_tokens
def convert_tokens_to_ids(self, tokens):
return convert_by_vocab(self.vocab, tokens)
def convert_ids_to_tokens(self, ids):
return convert_by_vocab(self.inv_vocab, ids)
该函数通过load_vocab
加载词典,方便将后续分词得到的token映射到对应的id。通过调用BasicTokenizer
和WordpieceTokenizer
进行分词,前者根据标点符号、空格等进行普通的分词,后者则会对前者的结果进行更细粒度的分词。
- 注意
BasicTokenizer
会将中文切分成一个个的汉字,也就是在中文字符(字)前后加上空格,从而后续分词将每个中文字符当成一个词。 -
WordpieceTokenizer
基于传入的词典vocab
,对单词进行更细粒度的切分,比如"unaffable"被进一步切分为["un", "##aff", "##able"]。对于中文来说,WordpieceTokenizer什么也不干,因为前一步分词已经是基于字符的了。注意,对于在词典vocab
中找不到的单词,会设置为[UNK]
token。 - WordPiece是一种解决OOV问题的方法,具体可参考google/sentencepiece项目。
convert_single_example
接下来我们对Processor处理后得到的InputExample进行处理,得到能够送入网络的特征。
if FLAGS.do_train:
train_examples = processor.get_train_examples(FLAGS.data_dir)
num_train_steps = int(
len(train_examples) / FLAGS.train_batch_size * FLAGS.num_train_epochs)
num_warmup_steps = int(num_train_steps * FLAGS.warmup_proportion)
if FLAGS.do_train:
train_file = os.path.join(FLAGS.output_dir, "train.tf_record")
file_based_convert_examples_to_features(
train_examples, label_list, FLAGS.max_seq_length, tokenizer, train_file)
tf.logging.info("***** Running training *****")
tf.logging.info(" Num examples = %d", len(train_examples))
tf.logging.info(" Batch size = %d", FLAGS.train_batch_size)
tf.logging.info(" Num steps = %d", num_train_steps)
train_input_fn = file_based_input_fn_builder(
input_file=train_file,
seq_length=FLAGS.max_seq_length,
is_training=True,
drop_remainder=True)
estimator.train(input_fn=train_input_fn, max_steps=num_train_steps)
## 提取特征的函数
def file_based_convert_examples_to_features(
examples, label_list, max_seq_length, tokenizer, output_file):
"""Convert a set of `InputExample`s to a TFRecord file."""
writer = tf.python_io.TFRecordWriter(output_file)
for (ex_index, example) in enumerate(examples):
if ex_index % 10000 == 0:
tf.logging.info("Writing example %d of %d" % (ex_index, len(examples)))
feature = convert_single_example(ex_index, example, label_list,
max_seq_length, tokenizer)
def create_int_feature(values):
f = tf.train.Feature(int64_list=tf.train.Int64List(value=list(values)))
return f
features = collections.OrderedDict()
features["input_ids"] = create_int_feature(feature.input_ids)
features["input_mask"] = create_int_feature(feature.input_mask)
features["segment_ids"] = create_int_feature(feature.segment_ids)
features["label_ids"] = create_int_feature([feature.label_id])
features["is_real_example"] = create_int_feature(
[int(feature.is_real_example)])
tf_example = tf.train.Example(features=tf.train.Features(feature=features))
writer.write(tf_example.SerializeToString())
writer.close()
对于Processor处理后得到每个InputExample对象,file_based_convert_examples_to_features
函数会把这些对象转化成能够送入bert网络的特征,并将其保存到一个TFRecord文件中。可以发现,该过程提取特征的关键函数是convert_single_example
:
def convert_single_example(ex_index, example, label_list, max_seq_length,
tokenizer):
"""Converts a single `InputExample` into a single `InputFeatures`."""
## 将label映射为 id
label_map = {}
for (i, label) in enumerate(label_list):
label_map[label] = i
tokens_a = tokenizer.tokenize(example.text_a)
tokens_b = None
if example.text_b:
tokens_b = tokenizer.tokenize(example.text_b)
if tokens_b:
# Modifies `tokens_a` and `tokens_b` in place so that the total
# length is less than the specified length.
# Account for [CLS], [SEP], [SEP] with "- 3"
_truncate_seq_pair(tokens_a, tokens_b, max_seq_length - 3)
else:
# Account for [CLS] and [SEP] with "- 2"
if len(tokens_a) > max_seq_length - 2:
tokens_a = tokens_a[0:(max_seq_length - 2)]
# The convention in BERT is:
# (a) For sequence pairs:
# tokens: [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP]
# type_ids: 0 0 0 0 0 0 0 0 1 1 1 1 1 1
# (b) For single sequences:
# tokens: [CLS] the dog is hairy . [SEP]
# type_ids: 0 0 0 0 0 0 0
# For classification tasks, the first vector (corresponding to [CLS]) is
# used as the "sentence vector". Note that this only makes sense because
# the entire model is fine-tuned.
tokens = []
segment_ids = []
tokens.append("[CLS]")
segment_ids.append(0)
for token in tokens_a:
tokens.append(token)
segment_ids.append(0)
tokens.append("[SEP]")
segment_ids.append(0)
if tokens_b:
for token in tokens_b:
tokens.append(token)
segment_ids.append(1)
tokens.append("[SEP]")
segment_ids.append(1)
input_ids = tokenizer.convert_tokens_to_ids(tokens)
# The mask has 1 for real tokens and 0 for padding tokens. Only real
# tokens are attended to.
input_mask = [1] * len(input_ids)
# Zero-pad up to the sequence length.
while len(input_ids) < max_seq_length:
input_ids.append(0)
input_mask.append(0)
segment_ids.append(0)
assert len(input_ids) == max_seq_length
assert len(input_mask) == max_seq_length
assert len(segment_ids) == max_seq_length
label_id = label_map[example.label]
feature = InputFeatures(
input_ids=input_ids,
input_mask=input_mask,
segment_ids=segment_ids,
label_id=label_id,
is_real_example=True)
return feature
- 首先调用
tokenizer
函数对text_a
或text_b
进行分词,将句子转化为tokens,若分词后的句子(一个或两个)长度过长,则需要进行截断,保证在句子首尾加了[CLS]
和[SEP]
之后的总长度在max_seq_length
范围内。 -
segment_ids
也就是type_ids
用来区分单词来自第一条句子还是第二条句子,type=0
和type=1
对应的embedding会在模型pre-train阶段学得。尽管理论上这不是必要的,因为[SEP]
可以区分句子的边界,但是加上type
后模型会更容易知道这个词属于哪个序列。 -
convert_tokens_to_ids
利用词典vocab
,将句子分词后的token映射为id。 - 当句子长度小于
max_seq_length
时,会进行padding,补充到固定的max_seq_length
长度。input_mask=1
表示该token来自于句子,input_mask=0
表示该token是padding的。 - 最后将提取的
input_ids, input_mask, segment_ids
封装到InputFeatures
对象中。至此,送入网络前的数据处理过程完成。
下一篇会解读模型的网络结构,以及输入的ids到词向量的映射过程等。
参考:BERT代码阅读-李理的博客