Skip to content

怎么让英文大语言模型支持中文?(二)—— 继续预训练篇

来源:AiGC面试宝典 作者:宁静致远 日期:2023年09月29日

一、为什么需要进行继续预训练?

在上一篇文章中,我们介绍了如何构建中文领域的 tokenization(分词)。接下来我们将介绍继续预训练(Continual Pre-training)

📝 通俗解释:如果说分词是给模型一本中文字典,那么继续预训练就是让模型学会使用这本新字典。想象一下,你给一个只会说英语的人一本中文字典,但他还需要学习才能真正用这个字典来理解中文。继续预训练就是这个"学习"的过程。

我们新增加了一些中文词汇到词表中,这些词汇是没有被训练过的,因此在进行指令微调之前,我们要先进行预训练。预训练的方式一般是语言模型训练(Language Model Training),简单来说,就是根据前文预测下一个字是什么。

为了方便起见,我们这里直接使用 IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese 模型,并且 tokenizer 也是自带的。

📝 通俗解释:继续预训练就像让模型"读"大量中文文章,学习中文的语法、词汇搭配和表达方式。这样后续进行指令微调时,模型才能更好地理解中文指令。


二、如何对继续预训练数据进行预处理?

同样的,我们使用的数据是《斗破苍穹》小说数据。首先看看数据处理方式:

  • 数据位于 data 目录下,分别为 corpus.txttest_corpus.txt
  • 每一行为一句或多句话

接下来看数据预处理的核心代码(test_dataset.py):

python
import os
import logging
import datasets
import transformers

from pprint import pprint
from itertools import chain
from datasets import load_dataset, concatenate_datasets
from transformers.testing_utils import CaptureLogger
from transformers import AutoTokenizer, LlamaTokenizer

tok_logger = transformers.utils.logging.get_logger("transformers.tokenization_utils_base")

logger = logging.getLogger(__name__)

lm_datasets = []
files = ["data/test_corpus.txt"]
data_cache_dir = "./cache_data"
preprocessing_num_workers = 1

# tokenizer = AutoTokenizer.from_pretrained("hfl/chinese-bert-wwm-ext")
tokenizer = LlamaTokenizer.from_pretrained("ziqingyang/chinese-llama-lora-7b")
tokenizer = AutoTokenizer.from_pretrained("IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese")

def print_dict(adict):
    for k, v in adict.items():
        print(k, v)

def tokenize_function(examples):
    with CaptureLogger(tok_logger) as cl:
        output = tokenizer(examples["text"])
        # CLM input could be much longer than block_size
        if "Token indices sequence length is longer than the" in cl.out:
            tok_logger.warning(
                "^^^^^^^^^^^^^^^^^^ Please ignore the warning above - this long input will be chunked into smaller bits"
                " before being passed to the model."
            )
    return output

block_size = 128

# 将所有文本进行拼接
def group_texts(examples):
    # Concatenate all texts.
    concatenated_examples = {k: list(chain(*examples[k])) for k in examples.keys()}
    total_length = len(concatenated_examples[list(examples.keys())[0]])
    # We drop the small remainder, we could add padding if the model supported it instead of this drop,
    # you can customize this part to your needs.
    if total_length >= block_size:
        total_length = (total_length // block_size) * block_size
    # Split by chunks of max_len.
    result = {
        k: [t[i : i + block_size] for i in range(0, total_length, block_size)]
        for k, t in concatenated_examples.items()
    }
    result["labels"] = result["input_ids"].copy()
    return result

for idx, file in enumerate(files):
    data_file = file
    filename = ''.join(file.split(".")[:-1])

    cache_path = os.path.join(data_cache_dir, filename)
    os.makedirs(cache_path, exist_ok=True)
    try:
        processed_dataset = datasets.load_from_disk(cache_path, keep_in_memory=False)
        print(f'training datasets-{filename} has been loaded from disk')
    except Exception:
        cache_dir = os.path.join(data_cache_dir, filename + "_text")
        os.makedirs(cache_dir, exist_ok=True)

        raw_dataset = load_dataset("text", data_files=data_file, cache_dir=cache_dir,
        keep_in_memory=False)
        print_dict(raw_dataset["train"][0])
        
        # 直接进行tokenize,需要注意的是只需要在句子开头加上bos_token
        tokenized_dataset = raw_dataset.map(
            tokenize_function,
            batched=True,
            num_proc=preprocessing_num_workers,
            remove_columns="text",
            load_from_cache_file=True,
            keep_in_memory=False,
            cache_file_names={k: os.path.join(cache_dir, f'tokenized.arrow') for k in raw_dataset},
            desc="Running tokenizer on dataset",
        )

        print_dict(tokenized_dataset["train"][0])

        grouped_datasets = tokenized_dataset.map(
            group_texts,
            batched=True,
            num_proc=preprocessing_num_workers,
            load_from_cache_file=True,
            keep_in_memory=False,
            cache_file_names={k: os.path.join(cache_dir, f'grouped.arrow') for k in
            tokenized_dataset},
            desc=f"Grouping texts in chunks of {block_size}",
        )
        processed_dataset = grouped_datasets

        print_dict(processed_dataset["train"][0])
        processed_dataset.save_to_disk(cache_path)

    if idx == 0:
        lm_datasets = processed_dataset['train']
    else:
        assert lm_datasets.features.type == processed_dataset["train"].features.type
        lm_datasets = concatenate_datasets([lm_datasets, processed_dataset["train"]])

lm_datasets = lm_datasets.train_test_split(test_size=0.1)

print_dict(lm_datasets["train"][0])

📝 通俗解释:这段代码做了两件事:

  1. 分词(tokenize):把中文文本切成一个个"词块"(token),变成数字 ID
  2. 分块(group):把很长的文本切成固定长度(128个词块)的小段,便于训练

数据预处理输出示例

text
text: 又一次上架了,这次比上次还激动,甚至激动到了上传了章节却不知道发出来的地步。

input_ids: [21134, 1348, 671, 3613, 677, 3373, 749, 8024, 6821, 3613, 3683, 677, 3613, 6820, 4080,
1220, 8024, 4493, 5635, 4080, 1220, 1168, 749, 677, 837, 749, 4995, 5688, 1316, 679, 4761, 6887,
1355, 1139, 3341, 4638, 1765, 3635, 511, 21133]

数据预处理流程总结

具体步骤如下:

  1. 分词处理:使用 tokenizer() 将文本转换为 ID 序列,可能在文本前后添加特殊标记(如 bos_token_ideos_token_id)。这里在 input_ids 前后添加了 2113421133 两个标记。

  2. 拼接分块:将所有文本的 input_idsattention_masktoken_type_ids 各自拼接起来,再设定一个最大长度 block_size,得到最终输入。

📝 通俗解释:就像把一本很长的书切成很多页,每页固定128个字。然后模型就一页一页地学习预测下一个字是什么。


三、如何构建模型?

test_model.py 中,我们可以先使用预训练模型看看效果:

python
from transformers import BertTokenizer, GPT2LMHeadModel, AutoModelForCausalLM

hf_model_path = 'IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese'
tokenizer = BertTokenizer.from_pretrained(hf_model_path)
# model = GPT2LMHeadModel.from_pretrained(hf_model_path)
model = AutoModelForCausalLM.from_pretrained(hf_model_path)

def generate_word_level(input_text, n_return=5, max_length=128, top_p=0.9):
    inputs = tokenizer(input_text, return_tensors='pt', add_special_tokens=False).to(model.device)
    gen = model.generate(
            inputs=inputs['input_ids'],
            max_length=max_length,
            do_sample=True,
            top_p=top_p,
            eos_token_id=21133,
            pad_token_id=0,
            num_return_sequences=n_return)

    sentences = tokenizer.batch_decode(gen)
    for idx, sentence in enumerate(sentences):
        print(f'sentence {idx}: {sentence}')
        print('*'*20)
    return gen

# 测试:生成"西湖的景色"的后续内容
outputs = generate_word_level('西湖的景色', n_return=5, max_length=128)
print(outputs)

预训练模型生成效果

sentence 0: 西湖的景色很美丽,古代有个名叫:西湖的湖南和江南的一段。湖面上有一座小小的湖泊,有一片湖泊和一座小岛,有一处小的小镇。在西湖里,每个人都是在湖边,你可以在小小湖里畅游。西湖上是古代建筑,但湖水不多。西湖上是一座水库,古代有个名叫:西湖的湖南和江南的一段。湖

sentence 1: 西湖的景色美不胜收。近日,位于湖北省湖北省石家庄市的石家庄旅游风景区被命名为"湖北省国家级森林公园"。园内有一座石屋,位于石屋与石屋的对面,总面积 3.2 平方公里...

sentence 2: 西湖的景色在古城、小镇和城郊中,有大片的湖泊,是古典中的佳肴,湖水清澈,湖中有一大块鱼,在湖水里散发着浓郁的清香...

📝 通俗解释:可以看到,原始模型虽然能生成中文,但内容有些混乱、不太通顺。这是因为模型主要在英文数据上训练,对中文的理解还不够好。继续预训练后会有明显改善。


四、继续预训练的关键配置

接下来使用该模型针对我们自己的数据进行继续预训练。需要注意以下几点:

1. 调整词表大小

如果我们自己定义了 tokenizer,需要将模型的嵌入层和 lm_head 层的词表数目进行重新设置:

python
model_vocab_size = model.get_output_embeddings().weight.size(0)
model.resize_token_embeddings(len(tokenizer))

📝 通俗解释:这就像给一本词典增加新词后,需要重新装订词典的封面,确保新词能被正确索引。

2. LoRA 参数设置

使用参数高效微调方法 LoRA 进行微调时,需要设置额外保存的参数:transformer.wtelm_head

3. 注意事项

  • 原始 chinese-llama-alpaca 使用 LoRA 保存参数有问题,需要进行修改
  • 使用 test_pretrained_model.py 时也要先对 vocab_size 进行重新设置

五、继续预训练命令

bash
torchrun --nnodes 1 --nproc_per_node 1 run_clm_pt_with_peft.py \
--deepspeed ds_zero2_no_offload.json \
--model_name_or_path IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese \
--tokenizer_name_or_path IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese \
--dataset_dir data \
--data_cache_dir temp_data_cache_dir \
--validation_split_percentage 0.001 \
--per_device_train_batch_size 32 \
--per_device_eval_batch_size 16 \
--do_train --seed $RANDOM \
--fp16 \
--max_steps 2500 \
--lr_scheduler_type cosine \
--learning_rate 2e-4 \
--warmup_ratio 0.05 \
--weight_decay 0.01 \
--logging_strategy steps \
--logging_steps 10 \
--save_strategy steps \
--save_total_limit 3 \
--save_steps 50 \
--gradient_accumulation_steps 1 \
--preprocessing_num_workers 8 \
--block_size 512 \
--output_dir output_dir \
--overwrite_output_dir \
--ddp_timeout 30000 \
--logging_first_step True \
--lora_rank 8 \
--lora_alpha 32 \
--trainable c_attn \
--modules_to_save transformer.wte,lm_head \
--lora_dropout 0.05 \
--torch_dtype float16 \
--gradient_checkpointing \
--ddp_find_unused_parameters False

📝 通俗解释:由于使用了 DeepSpeed 中的 ZeRO 技术,显存占用会更小,可以在显存有限的情况下训练更大的模型。


六、如何使用训练好的模型?

最后我们可以这样使用模型,在 test_pretrained_model.py 中:

python
import os
import torch
from transformers import BertTokenizer, GPT2LMHeadModel, AutoModelForCausalLM
from peft import PeftModel

hf_model_path = 'IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese'
tokenizer = BertTokenizer.from_pretrained(hf_model_path)
model = AutoModelForCausalLM.from_pretrained(hf_model_path)

# 调整词表大小
model_vocab_size = model.get_output_embeddings().weight.size(0)
model.resize_token_embeddings(len(tokenizer))

# 加载LoRA权重
model = PeftModel.from_pretrained(model, os.path.join("output_dir", "adapter_model"),
torch_dtype=torch.float32)
model.cuda()
model.eval()

def generate_word_level(input_text, n_return=5, max_length=128, top_p=0.9):
    inputs = tokenizer(input_text, return_tensors='pt', add_special_tokens=False).to(model.device)
    gen = model.generate(
                inputs=inputs['input_ids'],
                max_length=max_length,
                do_sample=True,
                top_p=top_p,
                eos_token_id=21133,
                pad_token_id=0,
                num_return_sequences=n_return)

    sentences = tokenizer.batch_decode(gen)
    for idx, sentence in enumerate(sentences):
        print(f'sentence {idx}: {sentence}')
        print('*'*20)
    return gen

# 测试斗破苍穹小说风格生成
outputs = generate_word_level('眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎', n_return=5, max_length=128)
print(outputs)

继续预训练后的生成效果

sentence 0: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎淡淡的道。<|endoftext|>

sentence 1: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎一怔。手掌猛然一僵。手指一扯。旋即在房门内停留。旋即一口鲜血喷涌而出。<|endoftext|>

sentence 2: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎顿时愣了愣。他这是何人?怎能知道这位灰袍老者出手啊?<|endoftext|>

sentence 3: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎心中有着什么感触?<|endoftext|>

sentence 4: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎微皱着眉头。转过身。轻声道:"柳翎。是你的人?"<|endoftext|>

对比:未经过继续预训练的模型结果

sentence 0: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎,男,1964 年生,河北齐齐哈尔市人。1979 年毕业于武汉工学院中文系...

sentence 1: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎的脸庞在不同时期会发出来,这样的眉目和眉目能够很容易的在一起...

sentence 2: 眼角斜瞥着柳翎那略微有些阴沉的脸庞。萧炎眼睛看向柳翎,眼眸里满是伤痕。"天边来客。"柳翎那无情的目光中透着几分冷漠的微笑...

📝 通俗解释:可以明显看到,经过继续预训练后,模型生成的内容更像是《斗破苍穹》的风格,语言流畅、情节合理。而未经预训练的模型生成的内容往往前言不搭后语,甚至会混入一些不相关的信息。这说明继续预训练确实让模型学会了中文小说的表达方式。


总结

本文介绍了如何对英文大语言模型进行继续预训练使其支持中文,主要包括:

  1. 数据预处理:将中文文本分词并切成固定长度的块
  2. 模型构建:加载预训练模型并调整词表大小
  3. 使用 LoRA 进行继续预训练:在中文小说数据上进行训练
  4. 模型使用:加载训练好的 LoRA 权重进行文本生成

通过继续预训练,模型能够更好地理解和生成中文内容,为后续的指令微调打下良好基础。

基于 MIT 许可发布