简介:本源码包是一套面向中文自然语言处理研究与工程应用的GPT2-Chinese模型训练方案,整合了Sentencepiece与Bert Tokenizer两种主流分词编码方式,支持BPE与BERT词表两套预处理链路,适合构建中文预训练模型、文本生成系统及下游NLP任务。压缩包共42个文件,约13.8MB,其中9个Python脚本覆盖训练、评估、生成等核心流程,配合JSON配置、Shell脚本、词汇表与说明文档,可快速复现完整实验;8个PNG和5个JPG图像直观展示诗词、散文等生成效果。当前已有340人学习使用。借助该资源,读者能直接获得两种Tokenizer的完整接入代码、训练入口及模型配置,节省环境搭建与特征处理的时间;同时通过内置的词汇表与多种模型配置,可灵活调整模型规模并开展对照实验。适合算法工程师、科研人员和NLP学习者用于教学实践或二次开发。
1. 为什么 GPT2-Chinese 训练要先解决分词问题
手上有几部金庸小说、几百首律诗绝句、一批散文和斗破苍穹的文本,想微调一个能生成对应风格文本的中文 GPT-2,直接拿原生 GPT-2 的 BPE 词表去跑,出来的内容几乎全是[UNK]。中文不像英文天然有空格分词,单字拆太碎丢语义,整词拆又扛不住大规模未登录词。这类领域文本生成任务的核心矛盾就在分词:既要有像金庸、鹿鼎记这样的完整词元,又要能兜住韦小宝这类专有名词的形态变化。这个源码包给出了一条完整路径——用 Sentencepiece 从领域语料自建 BPE 词表,或沿用 Bert Tokenizer 的 WordPiece 词表,再桥接到 GPT-2 的 transformer 架构上。整个过程包含词表构建、训练、评估、生成四段代码,解决了中文建模里最容易被忽略的输入表征问题。
2. 词表构建:Sentencepiece 和 Bert Tokenizer 的选型分歧
2.1 为什么中文场景不推荐原版 GPT-2 BPE
原版 GPT-2 的encoder.json和vocab.bpe是在英文语料上统计出来的,词元粒度介于 subword 和词组之间。英文里ing、tion这类后缀出现频率高,BPE 合并后很稳定;但中文是连续字符流,单字组合接近上万,通用 BPE 合并出来的要么是残肢碎块,要么是低频的二字组合。实际跑一次生成,[UNK]比例能到 30% 以上,序列的语义密度被严重稀释。
因此项目里tokenizations/下面同时保留了bpe_tokenizer.py、tokenization_bert.py和tokenization_bert_word_level.py。三者的区别在于:
| 文件 | 分词粒度 | 词表来源 | 适用场景 |
|---|---|---|---|
| bpe_tokenizer.py | BPE 子词 | encoder.json + vocab.bpe | 英文或中英混合但词表足够大 |
| tokenization_bert.py | WordPiece 子词 | vocab.txt | 中文单字+少量词级,兼容 BERT 预训练权重 |
| tokenization_bert_word_level.py | 词级 WordPiece | 自建词表 | 领域文本,词表覆盖专有名词 |
2.2 make_vocab.py 的领域词表生成逻辑
源码里make_vocab.py配合make_vocab.sh完成词表构建。先看核心一行:
python make_vocab.py \ --input_file ./data/*.txt \ --vocab_size 30000 \ --model_type bpe \ --model_prefix vocab_all \ --character_coverage 0.9995这段命令调用 Sentencepiece 训练一个 BPE 模型,--input_file指向金庸小说、古诗、散文组成的语料目录;--vocab_size 30000决定词表容量,领域专有名词多时建议调到 32000 以上;--character_coverage 0.9995表示保留 99.95% 的字符覆盖,生僻字会被拆成更细粒度的子词而不是丢成[UNK]。--model_prefix vocab_all指定输出文件前缀,实际写出vocab_all.model和vocab_all.vocab。
如果你希望词表偏向古文风格,可以单独用古诗语料再训一份vocab_guwen.txt。项目里的vocab_guwen.txt、vocab_seg.txt就是这么来的。训练我之前踩过一个坑:语料里有大量全角空格和特殊符号,Sentencepiece 会把它们当成独立字符计入词表,白白浪费容量。后来在训练前先做了清洗:
import re def clean_corpus(text: str) -> str: text = re.sub(r"[\u3000\xa0]", " ", text) text = re.sub(r"[^\u4e00-\u9fa5,。!?、;:“”《》\w]", "", text) return text清洗后词表的有效 token 占比明显提高,生成时的乱码率也降下来。如果语料里中英混杂,不要用[^\u4e00-\u9fa5...]这种过滤,改成正则保留 ASCII 字母和数字。
2.3 Bert Tokenizer 桥接到 GPT-2 的兼容处理
使用 BERT 的 WordPiece 词表时,tokenization_bert.py对中文的处理是「单字切分 + 少量词级合并」。这意味着金庸可能被拆成金+庸,语义信息被切断。项目为此准备了tokenization_bert_word_level.py——它内部先使用thulac(词法分析工具)切词,再把切好的词送入 WordPiece 流程。
用一段代码说明两者的差异:
from tokenizations import tokenization_bert, tokenization_bert_word_level bert_tok = tokenization_bert.BertTokenizer(vocab_file="vocab.txt") word_tok = tokenization_bert_word_level.BertTokenizer(vocab_file="vocab_all.txt") text = "韦小宝在鹿鼎记里用匕首" print(bert_tok.tokenize(text)) # ['韦', '小', '宝', '在', '鹿', '鼎', '记', '里', '用', '匕', '首'] print(word_tok.tokenize(text)) # ['韦小宝', '在', '鹿鼎记', '里', '用', '匕首']word_tok保留了完整词边界,模型更容易学到词语级共现关系。诗歌、武侠这类需要韵律和固定搭配的语料,词级分词的效果明显好于单字切分。代价是词表变大,同样容量下覆盖的词语数量变少,所以词表大小必须和语料领域匹配。
3. 训练流程拆解:从 cache 到三套训练脚本
3.1 数据预处理的 cache 机制
项目里cache目录存放的是 tokenized 之后的二进制训练数据,格式是tokenized的 numpy 数组或 pickle。train.py首次运行会做这几件事:读取语料 → 用指定 tokenizer 转成 token id → 按n_ctx(模型最大序列长度)截断 → 写入 cache 文件。第二次运行直接加载 cache,不再重复分词。
这段逻辑简化后是这样:
# train.py 中数据加载部分 import os, pickle import numpy as np def load_data(cache_path, tokenizer, corpus_path, n_ctx=1024): if os.path.exists(cache_path): with open(cache_path, "rb") as f: return pickle.load(f) tokens = [] with open(corpus_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue tokens.extend(tokenizer.convert_tokens_to_ids(tokenizer.tokenize(line))) tokens.append(tokenizer.eos_token_id) # 每条样本结束加上 <eos> data = [] for i in range(0, len(tokens) - n_ctx, n_ctx): data.append(tokens[i: i + n_ctx]) with open(cache_path, "wb") as f: pickle.dump(data, f) return dataeos_token_id是 GPT-2 续写训练的关键。模型需要知道什么时候结束一段文本,否则会在句与句之间无缝续写,造成风格漂移。n_ctx是 GPT-2 的上下文窗口,项目默认 1024,显存有限时降到 512,但生成长文本时效果会衰减。
3.2 train_single.py 单机训练参数解释
train_single.py面向单 GPU 场景,核心参数包括:
| 参数 | 示例值 | 说明 |
|---|---|---|
| --model_config | config/model_config_small.json | 选择模型规模 |
| --tokenizer_path | tokenizations/vocab_all.txt | 词表路径 |
| --batch_size | 4 | 受限于显存,一般 2-8 |
| --lr | 1e-4 | 微调用小学习率 |
| --epochs | 10 | 领域数据量小可适当增加 |
| --warmup_steps | 2000 | 学习率预热步数 |
| --log_step | 100 | 打印 loss 间隔 |
| --raw_data_path | data/train.json | 原始语料路径 |
model_config_small.json对应的是 6 层 transformer、8 个 attention head、embedding 维度 512 的小模型。用model_config.json则是 12 层、768 维的标准 GPT-2 配置。领域数据量少于 50MB 时建议先用小模型跑通流程,再逐步加大,避免一上来就 OOM。
调用方式:
python train_single.py \ --model_config config/model_config_small.json \ --data_path cache/tokenized_data.pkl \ --tokenizer_path tokenizations/vocab_all.txt \ --batch_size 4 \ --lr 1e-4 \ --epochs 10 \ --warmup_steps 2000--data_path可以直接指向 cache 文件,跳过分词阶段。
3.3 eval.py 与训练监控
项目里eval.py是后验评估脚本,计算困惑度(perplexity)。困惑度越低,模型对语料的拟合越好。计算公式是exp(CE loss),代码实现:
import math import torch from torch.nn import CrossEntropyLoss def evaluate(model, eval_dataloader, device): model.eval() total_loss = 0.0 total_tokens = 0 loss_fct = CrossEntropyLoss(ignore_index=-100) with torch.no_grad(): for step, batch in enumerate(eval_dataloader): input_ids = batch["input_ids"].to(device) labels = batch["labels"].to(device) outputs = model(input_ids, labels=labels) # GPT2LMHeadModel 内置 loss loss = outputs.loss total_loss += loss.item() * input_ids.size(0) total_tokens += input_ids.size(0) avg_loss = total_loss / total_tokens ppl = math.exp(avg_loss) return pplignore_index=-100是标准做法,padding 部分不参与 loss 计算。这里注意,训练时如果对部分 token 做了 mask(比如只计算后半个句子的 loss),labels里对应位置的-100必须提前设好,否则 loss 会被 padding 稀释。
4. 采样生成与参数边界
4.1 generate.py 的最小可用配置
generate.py包含一个predict函数,接受前缀文本和生成参数。训练完成后的生成方式:
python generate.py \ --model_path model/final_model \ --tokenizer_path tokenizations/vocab_all.txt \ --prefix "风清扬负手立于华山之巅" \ --generate_num 200 \ --temperature 0.8 \ --topk 30 \ --topp 0.9 \ --repetition_penalty 1.2temperature控制概率分布的平滑度,值越低越保守,越高越发散;topk限制候选词数量,topp按累积概率截断;repetition_penalty抑制重复词,1.0 表示关闭,1.2 表示显著降低重复 n-gram 的概率。
实际生成时我习惯用这样的组合:正文叙述部分设temperature=0.7, topk=20;诗歌体裁设temperature=0.9, topk=10。后者需要更大随机性来组装不同韵脚,但候选太少会落入常见字的循环。
4.2 generate_texts.py 批量生成的陷阱
批量场景下,generate_texts.py连续生成多条文本。最常见的问题是循环中把历史生成也放进了上下文,导致 length 越滚越长,最后 out of memory。正确做法是每轮用固定长度seq_len的窗口切片,只保留最近 512 个 token。
import torch from transformers import GPT2LMHeadModel, GPT2Tokenizer tokenizer = GPT2Tokenizer.from_pretrained("tokenizations") model = GPT2LMHeadModel.from_pretrained("model/final_model") model.eval() def generate_text(model, tokenizer, prefix, max_len=200, topk=30, temperature=0.8): input_ids = tokenizer.encode(prefix, return_tensors="pt") with torch.no_grad(): for _ in range(max_len): outputs = model(input_ids) logits = outputs.logits[0, -1, :] / temperature probs = torch.nn.functional.softmax(logits, dim=-1) topk_probs, topk_indices = torch.topk(probs, topk) next_token = torch.multinomial(topk_probs, num_samples=1) next_id = topk_indices[next_token] input_ids = torch.cat([input_ids, next_id.unsqueeze(0)], dim=-1) if input_ids.shape[-1] > 1024: input_ids = input_ids[:, -512:] # 截断窗口 return tokenizer.decode(input_ids[0], skip_special_tokens=True)torch.multinomial按照归一化的概率采样,它不取最大概率,而是让概率低的词也有机会出现。这在前缀文本极短时很重要——比如只输入一个词,模型所有输出概率都接近均匀,topk 过滤后仍然有随机性。
5. 词表迁移与生成效果校验
训练完成后,我通常做两件事验证模型是否真正学到了领域风格。第一,拿几个完整句子测续写质量,检查句法是否通顺。第二,专门构造[UNK]环境下不存在的词,看模型是否崩坏。如果词表里没有凌波微步,生成的文本可能把每个字拆开乱拼,这时候需要回头补词表,而不是改模型结构。
5.1 用 Sentencepiece 增量扩展词表
假设初版词表没有某个武侠词汇,正确操作是增加语料后重新训练 Sentencepiece 模型,而不是手动往 vocab 里插入一行。原因是 WordPiece 的切分依赖预计算概率分布,手动插词会破坏其他词的分词路径。重新训练时,将新语料置于高权重位置:
import sentencepiece as spm spm.SentencePieceTrainer.train( input=["data/jinyong.txt", "data/poetry.txt", "data/new_terms.txt"], model_prefix="vocab_v2", vocab_size=32000, model_type="bpe", character_coverage=0.9995, user_defined_symbols=["凌波微步", "北冥神功", "降龙十八掌"], )user_defined_symbols强制这几个词作为整体 token,不参与 BPE 合并。训练完成后,把vocab_v2.model转成vocab_v2.txt供 GPT-2 使用。注意,改了词表意味着 embedding 层维度变化,必须从零开始预训练或做 embedding 映射,不能直接加载旧权重。
5.2 生成样本与预期对照
项目自带的poem_1.png、律诗绝句.png、金庸_鹿鼎記.jpg等图片,是 README 中模型生成结果的可视化。用生成样本做验收时,需要把生成内容按原文的文体格式对齐——律诗要求五言或七言、押韵位置一致;散文要求句子长度有起伏。如果模型输出的内容连基本的韵脚都对不上,问题往往不是模型容量,而是temperature设太高或者词表覆盖的韵部字不够全。
5.3 常见坑:与预训练权重的不兼容
从 Hugging Face 或原作者处下载的中文 GPT-2 权重,词表是固定的vocab.txt。直接用本项目源码里vocab_all.txt初始化会报 shape mismatch。解决方案是保留原始权重的 embedding,赋给新 tokenizer 里 id 相同的那部分。这部分逻辑在load_pretrained_weights里要做一次 token 对齐,代码模式如下:
pretrained_embed = old_model.transformer.wte.weight.data # [old_vocab, hidden] new_embed = new_model.transformer.wte.weight.data for old_id, token in enumerate(old_tokenizer.vocab): if token in new_tokenizer.vocab: new_id = new_tokenizer.vocab[token] new_embed[new_id] = pretrained_embed[old_id]只有词表完全一致,才能省掉这个映射过程。实际项目里,用了 BERT tokenizer 词表的场景可以直接复用预训练权重,而用了 Sentencepiece 自建词表的场景基本是从零训练,两者各有取舍。
本文还有配套的精品资源,点击获取