中文GPT2预训练实战

GPT2是一个很好的长文本生成模型,但官方版本并没有开源中文预训练好的模型。因此,最近用开源的中文新闻,wiki,评论等从头训练了一个中文GPT2用于文本生成任务。

预训练使用的是HuggingFace的transformers库,这库是个好东西,把当前主流的transfomer-based模型都封装了一遍,使用起来方便很多。但由于不同模型的结构、参数等等细节不同,封装成统一的interface还是有难度,因此此库上也有一些折衷,也并不像想像中那么好使。就pretrain和fine-tune来说,都是训练一个language model,理论上调用它examples/run_language_modeling.py即可。不过真训练起来发现还是有些坑:

  • 中文Tokenizer用什么?
  • 大规模语料爆内存
  • fp16混合精度训练?

最后一个问题坑有点深,nvidia的apex库用起来并不是那么顺滑,尝试了多次并未成功。此外听朋友说即使fp16也仅能提升约20%的训练速度,看起来并不太promising。另外inference时也要使用apex,增加了额外依赖,麻烦。

这里就说说前两个问题。

中文Tokenizer

GPT2有对应的GPT2Tokenizer,用的是BPE分词,但是并没有中文分词器。理论上来说BPE是语言无关的,但对于中日韩文来说,还是字级别character-level的分词更合理些,直接用GPT2Tokenizer不太好。因此,要么自己train一个中文tokenizer,要么换tokenizer。

训练中文GPT2Tokenizer(失败)

根据语言自己训练一个tokenizer想法很直接,看了眼GPT2Tokenizer的源码,其实就是个BPETokenizer,于是直接用HuggingFace的tokenizer库就可以训练。这个库的底层是用Rust写的,可以最大程度地并行处理。

训练代码:

import os
from tokenizers import ByteLevelBPETokenizer
from transformers import GPT2Tokenizer
from transformers import CONFIG_MAPPING, AutoConfig, GPT2Config

model_type = 'gpt2'
model_path = './modeltest'
vocab_size = 50000

# training tokenizer
tokenizer = ByteLevelBPETokenizer()
tokenizer.train(files=['data/traint.txt'], min_frequency=5, vocab_size=vocab_size, special_tokens=["<|endoftext|>"])
tokenizer.save(model_path)

tokenizer = GPT2Tokenizer(os.path.join(model_path, 'vocab.json'), os.path.join(model_path, 'merges.txt'), errors='replace')
tokenizer.model_max_length = 1024
tokenizer.save_pretrained(model_path)

config = GPT2Config.from_pretrained(model_type)
config.bos_token_id = tokenizer.bos_token_id
config.eos_token_id = tokenizer.eos_token_id
config.vocab_size = vocab_size
config.save_pretrained(model_path)

tokenizer = GPT2Tokenizer.from_pretrained(model_path)
config = AutoConfig.from_pretrained(model_path)

text = '中国手机'
print (tokenizer.tokenize(text))
print (tokenizer.convert_tokens_to_ids(tokenizer.tokenize(text)))
注意ModelConfig和Tokenizer本身是耦合的,需要里面的bos_token_id, eos_token_id及vocab_size,因此需要加载默认的GPT2Config,改好这些配置后存储到自己的model文件夹中。

运行结果:

[00:00:00] Reading files                            █████████████████████████████████████████████████████████████████████████                 100
[00:00:00] Tokenize words                           █████████████████████████████████████████████████████████████████████████ 576      /      576
[00:00:00] Count pairs                              █████████████████████████████████████████████████████████████████████████ 576      /      576
[00:00:00] Compute merges                           █████████████████████████████████████████████████████████████████████████ 696      /      696

['ä¸Ń', 'åĽ½', 'æīĭ', 'æľ', 'º']
[435, 456, 484, 261, 119]

看起来不错?那就太naive了。数据量太小,换了个小数据集再测,发现tokenizer直接崩掉:

[00:00:00] Reading files                            █████████████████████████████████████████████████████████████████████████                 100
[00:00:00] Tokenize words                           █████████████████████████████████████████████████████████████████████████ 774      /      774
[00:00:00] Count pairs                              █████████████████████████████████████████████████████████████████████████ 774      /      774
thread 'thread '<unnamed>' panicked at 'called `Option::unwrap()` on a `None` value', /__w/tokenizers/tokenizers/tokenizers/src/models/bpe/trainer.rs:373:34
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
<unnamed>' panicked at 'called `Option::unwrap()` on a `None` value', /__w/tokenizers/tokenizers/tokenizers/src/models/bpe/trainer.rs:373:34
fatal runtime error: failed to initiate panic, error 5
Aborted (core dumped)

Debug了好长时间,发现原来是因为数据集中有一个~字符……这只是一个测试,当然可以通过处理数据集的方式来解决问题,但鬼知道还有哪些特殊字符会引起tokenizer core dump。而且因为底层是Rust,这玩意儿也不懂,无法通过改源码的方式Fix……

如果看下生成的merges.txt和vocab.json,会发现里面都是bytes,并非可见字符,也是BPE分词应有的效果,但考虑到在中文上的适用性,还是算了。

换BertTokenizer

看到大部分中文预训练模型都是直接用的Google开源的BertTokenizer,我们也直接用吧:

from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained("bert-base-chinese")
tokenizer.save_pretrained(model_path)

但用的时候要注意设置bos_token_id, eos_token_id及vocab_size到GPT2Config中:

config = CONFIG_MAPPING['gpt2']()
config.bos_token_id = tokenizer.cls_token_id
config.eos_token_id = tokenizer.sep_token_id
config.vocab_size = tokenizer.vocab_size

BertTokenizerFast

用大规模语料训练时发现,预处理数据太慢了,htop一看只用了一个CPU核,不慌,BertTokenizerFast可以上场了,它们都是基于tokenizer库直接开发的,可以用上所有的CPU核,分词速度与核数按比例提升:

from transformers import BertTokenizerFast

tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese")

大规模语料爆内存

训练语料大概10几G,在creating feature时就崩了且没有任何有用的提示:

transformers.data.datasets.language_modeling -   Creating features from dataset file at /data/transformers/data
./pretrain.sh: line 13:  2692 Killed                  CUDA_VISIBLE_DEVICES=0 python run_pretrain.py
--output_dir output --model_type gpt2 --model_name_or_path $MODEL_PATH --do_train --train_data_file
$TRAIN_FILE --per_gpu_train_batch_size 16 --per_gpu_eval_batch_size 4 --seed 1024

htop观察了一下,是内存爆了out of memory,注意是内存不是显存,如果是显存问题可以通过减少batch_size,但内存……

流式Dataset

原始数据用的是LineByLineTextDataset,看了一下 LineByLineTextDataset实现

class LineByLineTextDataset(Dataset):
    """
    This will be superseded by a framework-agnostic approach
    soon.
    """

    def __init__(self, tokenizer: PreTrainedTokenizer, file_path: str, block_size: int, local_rank=-1):
        assert os.path.isfile(file_path)
        # Here, we do not cache the features, operating under the assumption
        # that we will soon use fast multithreaded tokenizers from the
        # `tokenizers` repo everywhere =)
        logger.info("Creating features from dataset file at %s", file_path)

        with open(file_path, encoding="utf-8") as f:
            lines = [line for line in f.read().splitlines() if (len(line) > 0 and not line.isspace())]

        batch_encoding = tokenizer.batch_encode_plus(lines, add_special_tokens=True, max_length=block_size)
        self.examples = batch_encoding["input_ids"]

    def __len__(self):
        return len(self.examples)

    def __getitem__(self, i) -> torch.Tensor:
        return torch.tensor(self.examples[i], dtype=torch.long)

注意到它是把所有数据都load到内存中,然后存储batch_encoding["input_ids"],再看下batch_encoding的代码,里面好多额外的数据结构,内存撑不住了。最好改成流式读取和处理的方式,下面实现了一个StreamingLineByLineTextDataset,简单起见,只在class中存原始数据,而在__getitem__方法中将其转换成tensor:

import torch
from torch.utils.data.dataset import Dataset

class StreamingLineByLineTextDataset(Dataset):
    def __init__(self, tokenizer: PreTrainedTokenizer, file_path: str, block_size: int, local_rank=-1):
        assert os.path.isfile(file_path)

        self.tokenizer = tokenizer
        self.block_size = block_size

        logger.info("Creating features from dataset file at %s", file_path)

        with open(file_path, encoding="utf-8") as f:
            self.lines = [line for line in f.read().splitlines() if (len(line) > 0 and not line.isspace())]

        logger.info("Loaded lines: %s", len(self.lines))

    def __len__(self):
        return len(self.lines)

    def __getitem__(self, i) -> torch.Tensor:
        batch_encoding = self.tokenizer.batch_encode_plus([self.lines[i]], add_special_tokens = True, max_length = self.block_size)
        input_ids = batch_encoding["input_ids"]
        return torch.tensor(input_ids[0], dtype=torch.long)

小结

以上简单记录了下训练中文GPT2模型中遇到的一些坑和解决方案,期待训练出来的模型效果。