ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

中文GPT-2训练从分词到生成:基于Sentencepiece的领域词表构建实践

中文GPT-2训练从分词到生成:基于Sentencepiece的领域词表构建实践 简介本源码包是一套面向中文自然语言处理研究与工程应用的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.pyBPE 子词encoder.json vocab.bpe英文或中英混合但词表足够大tokenization_bert.pyWordPiece 子词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_filevocab.txt) word_tok tokenization_bert_word_level.BertTokenizer(vocab_filevocab_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_ctx1024): if os.path.exists(cache_path): with open(cache_path, rb) as f: return pickle.load(f) tokens [] with open(corpus_path, r, encodingutf-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_configconfig/model_config_small.json选择模型规模--tokenizer_pathtokenizations/vocab_all.txt词表路径--batch_size4受限于显存一般 2-8--lr1e-4微调用小学习率--epochs10领域数据量小可适当增加--warmup_steps2000学习率预热步数--log_step100打印 loss 间隔--raw_data_pathdata/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, labelslabels) # 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比如只计算后半个句子的 losslabels里对应位置的-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 的概率。实际生成时我习惯用这样的组合正文叙述部分设temperature0.7, topk20诗歌体裁设temperature0.9, topk10。后者需要更大随机性来组装不同韵脚但候选太少会落入常见字的循环。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_len200, topk30, temperature0.8): input_ids tokenizer.encode(prefix, return_tensorspt) 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_samples1) 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_tokensTrue)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_prefixvocab_v2, vocab_size32000, model_typebpe, character_coverage0.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 自建词表的场景基本是从零训练两者各有取舍。本文还有配套的精品资源点击获取
返回列表