如何让英文大语言模型支持中文:继续预训练
前言
在上一篇文章中,我们介绍了如何构建中文领域的 Tokenization。本文将介绍如何进行继续预训练(Continual Pre-training)。
我们在词表中新增加了一些中文词汇,这些词汇在原始模型中未得到充分训练。因此,在进行指令微调之前,我们需要进行预训练。预训练的基本原理是根据上一个字预测下一个字是什么。为了简化流程,本文直接使用 IDEA-CCNL/Wenzhong2.0-GPT2-110M-BertTokenizer-chinese 模型,并沿用其自带的 tokenizer。
相关代码已上传至 GitHub:https://github.com/taishan1994/chinese_llm_pretrained
数据处理
我们使用的数据依然是《斗破苍穹》小说数据。数据位于 data 目录下,包含 corpus.txt 和 test_corpus.txt,每一行包含一句或多句话。
数据预处理逻辑
在 test_dataset.py 脚本中,主要完成了以下工作:
- 加载 Tokenizer:使用
LlamaTokenizer 或 BertTokenizer 加载预训练模型的词表。
- 分词处理:对文本进行 tokenize,注意不同模型的 tokenizer 可能会在文本前后添加特殊标记(如
bos_token_id 和 eos_token_id)。
- 文本拼接与切分:将所有文本的
input_ids 拼接起来,设定最大长度 block_size,将长文本切分为固定长度的块。
- 标签设置:将
input_ids 复制为 labels,用于计算损失函数。
以下是核心代码实现:
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("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"])
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):
concatenated_examples = {k: list(chain(*examples[k])) for k in examples.keys()}
total_length = len(concatenated_examples[list(examples.keys())[0]])
if total_length >= block_size:
total_length = (total_length // block_size) * block_size
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])
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])
处理结果示例
经过处理后,数据集包含 input_ids、attention_mask、token_type_ids 和 labels 字段。例如:
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]
...
可以看到,输入序列前后添加了特殊标记(如 21134 和 21133),且所有文本已被切分为长度为 128 的块。
构建模型
在 test_model.py 中,我们可以初步加载预训练模型测试效果。
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 = 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)
注意:由于我们修改了词表(增加了中文词汇),如果使用的是自定义 tokenizer,需要重新设置模型的嵌入层和 lm_head 层的词表数目:
model_vocab_size = model.get_output_embeddings().weight.size(0)
model.resize_token_embeddings(len(tokenizer))
继续预训练配置
针对自己的数据进行继续预训练时,建议使用参数高效微调方法(如 LoRA)以节省显存。需要注意以下几点:
- 词表对齐:确保模型权重与 tokenizer 词表大小一致。
- LoRA 参数保存:需要额外保存
transformer.wte 和 lm_head 参数。可以通过 find_lora_names.py 获取具体名称。
- 权重管理:避免重复保存 LoRA 权重,只保留一份即可。
- 推理前准备:使用
test_pretrained_model.py 时,同样需要先对 vocab_size 进行重新设置。
训练指令
以下命令使用了 DeepSpeed ZeRo 优化以减少显存占用,并结合 PEFT 进行 LoRA 微调:
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
关键参数说明:
--lora_rank 8:LoRA 秩设置为 8,平衡性能与参数量。
--fp16 / --torch_dtype float16:使用半精度训练以节省显存。
--gradient_checkpointing:启用梯度检查点,进一步降低显存峰值。
--modules_to_save:指定需要保存完整权重的模块,包括词嵌入和语言模型头。
使用模型
训练完成后,在 test_pretrained_model.py 中加载模型进行测试。
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))
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)
效果对比
经过继续预训练的模型输出:
生成的文本更符合小说语境,能够延续'萧炎'这一角色的对话风格。
未经过继续预训练的模型输出:
模型倾向于生成百科式描述或重复无意义的句子,无法理解特定小说情节。
通过对比可以看出,模型确实得到了有效的训练,能够更好地适应中文小说语料。
总结
本文详细介绍了如何让英文大语言模型支持中文的继续预训练流程。主要包括以下步骤:
- 构建中文词表:扩展原有词表以适应中文词汇。
- 数据处理:清洗语料并进行 Tokenization 与分块。
- 模型适配:调整模型 Embedding 层以匹配新词表。
- 继续预训练:利用 LoRA 技术结合 DeepSpeed 进行高效训练。
- 效果验证:对比训练前后的生成效果。
完成继续预训练后,模型具备了基础的中文语言能力。接下来,可以进一步进行指令微调(Instruction Tuning),使模型更好地遵循用户指令。后续文章将详细介绍指令微调的具体实施方法。