BERT代码解读(1)-输入

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_atext_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。通过调用BasicTokenizerWordpieceTokenizer进行分词,前者根据标点符号、空格等进行普通的分词,后者则会对前者的结果进行更细粒度的分词。

  • 注意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_atext_b进行分词,将句子转化为tokens,若分词后的句子(一个或两个)长度过长,则需要进行截断,保证在句子首尾加了[CLS][SEP]之后的总长度在max_seq_length范围内。
  • segment_ids也就是type_ids用来区分单词来自第一条句子还是第二条句子,type=0type=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代码阅读-李理的博客

你可能感兴趣的:(BERT代码解读(1)-输入)