ARTICLE DETAIL

资讯详情

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

从零训练GPT2-Chinese:Sentencepiece与Bert Tokenizer分词器选型及词表对齐实战

从零训练GPT2-Chinese:Sentencepiece与Bert Tokenizer分词器选型及词表对齐实战 简介该资源面向中文自然语言处理研究者与开发者提供一套基于Sentencepiece和Bert Tokenizer的GPT2-Chinese模型训练源码帮助快速搭建并训练中文语言模型适合具备一定深度学习基础、希望复现或二次开发中文GPT2的读者。压缩包共42个文件、约13.8MB以9个Python脚本为核心覆盖训练、单次训练、文本生成与模型评估等环节另含8个PNG与5个JPG图像、7个文本文件、5个JSON配置、3个Shell脚本以及分词相关的seg、bpe词表与许可证文件目录结构清晰便于按模块查阅。目前已有341人学习下载。读者可据此掌握从语料分词、词表构建到模型训练与文本生成的完整流程理解BPE子词切分与BERT分词器在中文场景下的差异并借助现成脚本与配置快速开展实验、排错与扩展降低中文模型训练门槛。1. 从零训练 GPT2-Chinese为什么分词器选型决定了模型天花板很多团队第一次做中文 GPT 训练卡住的地方不是显存不够也不是训练脚本跑不起来而是分词这一步就埋了雷。用默认的 GPT2 英文 BPE 直接喂中文语料你会发现一个汉字被切成两三个 byte 级 token序列长度直接翻倍训练成本飙升生成出来的句子还经常出现半个字的乱码。GPT2-Chinese 这类中文模型训练项目核心要解决的就是「中文怎么切、词表怎么建、tokenizer 怎么和模型结构对齐」这三件事。标题里提到的 Sentencepiece 和 Bert Tokenizer正是两条主流路线前者是 Google 的 subword 工具支持 unigram 和 BPE天然适合从原始中文语料直接训练词表后者是 HuggingFace 生态里最成熟的中文分词器基于字级别加 WordPiece开箱即用。这套源码方案的价值在于它把分词器训练、词表对齐、模型配置、训练循环串成了一条可复现的链路适合想自己从零训一个中文 GPT 的工程师也适合需要定制领域词表的团队。下面我按实际落地的顺序把选型、训练、对齐、踩坑一条条拆开讲。2. Sentencepiece 与 Bert Tokenizer 的选型对比与词表训练实操2.1 两条分词路线的本质差异Sentencepiece 的设计哲学是「把句子当成一串 Unicode 字符流」它不依赖预分词pre-tokenization直接从原始文本学习 subword 单元。这对中文特别友好因为中文没有天然空格分隔Sentencepiece 可以自己决定一个词是「人工智能」整体还是拆成「人工」「智能」。它支持两种算法unigram 语言模型和 BPE。unigram 更适合中文因为它基于概率选择最优切分词表利用率高。Bert Tokenizer 走的是另一条路先做基本字符切分再用 WordPiece 做子词合并。中文场景下Bert Tokenizer 通常配置成字级别character-level也就是每个汉字一个 token词表大小控制在 21128 左右。这种方案的好处是词表小、覆盖全、不会出现 OOV缺点是序列长一个 512 字的句子就是 512 个 token训练时注意力计算量大。选型的关键判断点如果你的语料是通用中文追求训练稳定和生态兼容Bert Tokenizer 字级别方案更省心如果你有大量领域术语医疗、法律、金融希望模型学到「心肌梗死」作为一个整体 tokenSentencepiece unigram 更合适。GPT2-Chinese 源码里两条路线都有实现实际项目里我一般先用 Bert Tokenizer 跑通 baseline再换 Sentencepiece 做领域优化。2.2 用 Sentencepiece 训练中文词表的完整命令先准备一份纯文本语料每行一个句子编码统一 UTF-8。语料量建议至少 100MB 以上否则词表学不充分。下面是训练 unigram 词表的最小命令# 训练 Sentencepiece unigram 模型 # input: 纯文本语料每行一句 # model_prefix: 输出模型前缀会生成 .model 和 .vocab 两个文件 # vocab_size: 词表大小中文 GPT 常用 30000-50000 # character_coverage: 字符覆盖率中文建议 0.9995 以上 # model_type: unigram 或 bpe中文推荐 unigram spm_train \ --inputcorpus.txt \ --model_prefixspm_zh_32k \ --vocab_size32000 \ --character_coverage0.9995 \ --model_typeunigram \ --num_threads16 \ --train_extremely_large_corpustrue逻辑说明spm_train会先统计所有字符频率然后迭代优化 unigram 语言模型最终输出词表。character_coverage0.9995意味着覆盖 99.95% 的字符剩下 0.05% 的罕见字会被映射到 UNK。中文里生僻字不少这个值设太低会导致大量 UNK设太高会让词表被罕见字占据。train_extremely_large_corpustrue在语料超过 10GB 时开启避免内存溢出。参数调整建议vocab_size不是越大越好。32000 对通用中文够用50000 适合领域术语多的场景。超过 64000 后词表尾部会出现大量低频碎片反而降低训练效率。训练完后用spm_encode验证一下切分效果# 验证切分效果 echo 人工智能正在改变世界 | spm_encode --modelspm_zh_32k.model --output_formatpiece # 输出示例: ▁人工 智能 正在 改变 世界如果发现「人工智能」被拆成「人工」「智能」说明语料里这个词出现频率不够或者 vocab_size 太小。可以增加语料或调大词表。2.3 Bert Tokenizer 字级别词表的构建与对齐Bert Tokenizer 的方案更直接用tokenizers库训练一个 WordPiece 模型或者直接复用 bert-base-chinese 的词表。复用词表的好处是省事坏处是词表里包含大量英文和符号中文 token 只占一部分。自己训练的话流程如下from tokenizers import Tokenizer, models, trainers, pre_tokenizers # 初始化 WordPiece 模型中文场景下 unk_token 必须设置 tokenizer Tokenizer(models.WordPiece(unk_token[UNK])) # 预分词器中文按字切分英文按空格和标点 tokenizer.pre_tokenizer pre_tokenizers.BertPreTokenizer() # 训练器配置 trainer trainers.WordPieceTrainer( vocab_size21128, # 与 bert-base-chinese 对齐 special_tokens[[PAD], [UNK], [CLS], [SEP], [MASK]], min_frequency2, # 最低词频低于此值的子词被丢弃 continuing_subword_prefix## # WordPiece 的子词前缀 ) # 从语料文件训练 tokenizer.train(files[corpus.txt], trainertrainer) tokenizer.save(bert_zh_tokenizer.json)逻辑说明BertPreTokenizer会先按空格和标点切分中文部分按字切分然后 WordPiece 在字级别上做子词合并。min_frequency2控制词表纯度设太低会引入大量只出现一次的组合。continuing_subword_prefix##是 WordPiece 的标准约定表示这个 token 是某个词的后半部分。关键对齐点训练完 tokenizer 后必须确认vocab_size和模型 embedding 层大小一致。GPT2-Chinese 源码里模型配置的vocab_size必须等于 tokenizer 的实际词表大小否则加载时会报 size mismatch。我一般会在训练脚本里加一行断言assert tokenizer.get_vocab_size() model.config.vocab_size, \ f词表不匹配: tokenizer{tokenizer.get_vocab_size()}, model{model.config.vocab_size}这个断言能省掉后面几个小时的调试时间。3. GPT2-Chinese 模型结构配置与训练脚本落地3.1 模型配置文件的每个参数怎么定GPT2-Chinese 的模型结构基本沿用 GPT2 的 decoder-only Transformer但中文场景下几个参数需要调整。下面是一份我常用的配置# model_config.py config { vocab_size: 32000, # 必须与 tokenizer 词表大小一致 n_positions: 1024, # 最大序列长度中文建议 512-1024 n_embd: 768, # 隐藏层维度base 版用 768 n_layer: 12, # Transformer 层数base 版 12 层 n_head: 12, # 注意力头数必须能整除 n_embd n_inner: 3072, # FFN 中间层维度通常是 n_embd 的 4 倍 activation_function: gelu, resid_pdrop: 0.1, # 残差 dropout embd_pdrop: 0.1, # embedding dropout attn_pdrop: 0.1, # 注意力 dropout layer_norm_epsilon: 1e-5, initializer_range: 0.02, bos_token_id: 0, # 起始 token id eos_token_id: 1, # 结束 token id }参数说明n_positions决定模型能处理的最大长度。中文 token 密度高512 长度大概对应 400-500 个汉字1024 对应 800-1000 字。如果显存有限优先降n_positions而不是n_embd因为后者对模型能力影响更大。n_inner设成n_embd的 4 倍是 GPT2 的默认做法中文任务里可以降到 3 倍省显存但不要低于 2 倍。resid_pdrop等 dropout 参数在数据量小于 1GB 时建议调到 0.2防止过拟合。3.2 训练循环里的梯度累积与学习率调度GPT2-Chinese 训练脚本的核心是梯度累积 学习率 warmup。中文语料通常 batch size 上不去梯度累积是必备技巧# train.py 核心训练循环 from transformers import AdamW, get_linear_schedule_with_warmup # 优化器weight_decay 只作用于非 bias 和非 LayerNorm 参数 no_decay [bias, LayerNorm.weight] optimizer_grouped_parameters [ { params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], weight_decay: 0.01, }, { params: [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], weight_decay: 0.0, }, ] optimizer AdamW(optimizer_grouped_parameters, lr5e-5, betas(0.9, 0.95)) # 学习率调度warmup 占总步数的 5% total_steps len(dataloader) * num_epochs // gradient_accumulation_steps scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.05 * total_steps), num_training_stepstotal_steps ) # 梯度累积训练 model.train() for epoch in range(num_epochs): for step, batch in enumerate(dataloader): outputs model(**batch) loss outputs.loss / gradient_accumulation_steps loss.backward() if (step 1) % gradient_accumulation_steps 0: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad()逻辑说明weight_decay不作用于 bias 和 LayerNorm 是 BERT 以来的标准做法能稳定训练。betas(0.9, 0.95)是 GPT 系列的推荐值比默认的 (0.9, 0.999) 更适合语言模型。clip_grad_norm_设 1.0 防止梯度爆炸中文长序列训练时尤其重要。梯度累积步数根据显存定单卡 24GB 跑 768 维度模型batch size 大概能到 8累积 4 步等效 batch size 32。学习率 5e-5 是 base 版模型的起点。如果 loss 在前 1000 步就震荡降到 3e-5如果 loss 下降太慢升到 1e-4 但不要超过。warmup 比例 5% 是经验值小数据集可以调到 10%。3.3 数据预处理管道与动态 padding中文训练数据预处理的关键是动态 padding按 batch 内最长序列补齐而不是全局最长from torch.utils.data import Dataset import torch class ChineseTextDataset(Dataset): def __init__(self, file_path, tokenizer, max_length512): self.tokenizer tokenizer self.max_length max_length self.samples [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if len(line) 10: # 过滤过短句子 continue self.samples.append(line) def __len__(self): return len(self.samples) def __getitem__(self, idx): text self.samples[idx] # 编码时不加特殊 tokenGPT 是自回归模型 encoding self.tokenizer( text, truncationTrue, max_lengthself.max_length, paddingFalse, # 动态 padding 在 collate_fn 里做 return_tensorsNone ) input_ids encoding[input_ids] # 标签就是输入本身shift 在模型内部做 return { input_ids: input_ids, labels: input_ids.copy() } def collate_fn(batch): # 动态 padding 到 batch 内最大长度 max_len max(len(item[input_ids]) for item in batch) input_ids [] labels [] attention_mask [] for item in batch: pad_len max_len - len(item[input_ids]) input_ids.append(item[input_ids] [0] * pad_len) # 0 是 pad token labels.append(item[labels] [-100] * pad_len) # -100 在 loss 中忽略 attention_mask.append([1] * len(item[input_ids]) [0] * pad_len) return { input_ids: torch.tensor(input_ids, dtypetorch.long), labels: torch.tensor(labels, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long) }逻辑说明GPT 是自回归模型labels就是input_ids模型内部会自动做 shift预测下一个 token。padding 部分用-100标记PyTorch 的 CrossEntropyLoss 默认忽略这个值。attention_mask让模型不关注 padding 位置。动态 padding 能把训练速度提升 20%-30%因为避免了大量无效计算。4. 分词器与模型对齐中的避坑与排查4.1 词表大小不匹配导致加载失败现象加载模型时抛出RuntimeError: Error(s) in loading state_dict for GPT2LMHeadModel: size mismatch for transformer.wte.weight。原因tokenizer 的词表大小和模型配置的vocab_size不一致。常见于先训练了 tokenizer后来改了vocab_size但忘了同步更新模型配置。解决在训练脚本开头加断言或者用model.resize_token_embeddings(len(tokenizer))动态调整 embedding 层。注意 resize 后新加入的 token embedding 是随机初始化的需要额外训练步数收敛。4.2 Sentencepiece 的 UNK 泛滥现象训练 loss 正常下降但生成时频繁出现unk或者句子中间突然断掉。原因character_coverage设得太低或者语料里包含大量罕见字符如 emoji、生僻字、特殊符号。这些字符被映射到 UNK模型学不到它们的表示。解决把character_coverage调到 0.99995或者在训练前清洗语料过滤掉非中文、非英文、非常用标点的字符。如果领域内确实有特殊符号把它们加入user_defined_symbols参数。4.3 学习率过大导致 loss 震荡不收敛现象训练前几百步 loss 从 10 降到 5然后突然跳到 8之后一直在 6-9 之间震荡。原因学习率太大或者 warmup 步数不够。中文 GPT 从零训练时embedding 层梯度很大没有 warmup 直接上 5e-5 容易炸。解决把 warmup 比例从 5% 提到 10%或者把峰值学习率降到 3e-5。同时检查clip_grad_norm_是否生效max_norm 设 1.0 不要设太大。4.4 动态 padding 与位置编码的冲突现象模型在短句上表现正常长句生成质量急剧下降或者生成到一半开始重复。原因动态 padding 时不同 batch 的最大长度不同但位置编码是固定的。如果模型在训练时见过 512 长度推理时输入 1024位置编码超出范围。解决确保n_positions大于等于推理时的最大长度。如果显存不够用滑动窗口或截断。另外检查attention_mask是否正确传递padding 位置必须 mask 掉。4.5 梯度累积与 BatchNorm 的配合问题现象用了梯度累积后训练 loss 比不用时高收敛更慢。原因GPT 用的是 LayerNorm 不是 BatchNorm梯度累积本身没问题。但如果 dropout 在累积期间没有正确缩放或者优化器 step 的时机不对会导致等效 batch size 计算错误。解决确认loss outputs.loss / gradient_accumulation_steps这行没漏。另外optimizer.zero_grad()必须在optimizer.step()之后调用顺序不能反。如果用了混合精度训练scaler.step(optimizer)和scaler.update()也要放在正确位置。5. 从训练到生成验证分词器与模型配合的进阶技巧训练完模型后验证分词器和模型是否真正对齐比看 loss 曲线更有说服力。我一般用三个测试第一用训练语料里的句子做自回归生成看能否复现原句第二用领域外句子测试泛化第三检查特殊 token 的行为。先看一个生成验证脚本from transformers import GPT2LMHeadModel, GPT2Tokenizer import torch # 加载训练好的模型和分词器 model GPT2LMHeadModel.from_pretrained(./output/checkpoint-10000) tokenizer GPT2Tokenizer.from_pretrained(./spm_zh_32k.model) # 或 bert tokenizer model.eval() device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) def generate(prompt, max_length100, temperature0.8, top_k50, top_p0.9): input_ids tokenizer.encode(prompt, return_tensorspt).to(device) with torch.no_grad(): output model.generate( input_ids, max_lengthmax_length, temperaturetemperature, # 温度越高越随机越低越确定 top_ktop_k, # 只从概率最高的 k 个 token 采样 top_ptop_p, # 核采样累积概率达到 p 的 token 集合 repetition_penalty1.2, # 重复惩罚中文生成必备 do_sampleTrue, pad_token_idtokenizer.pad_token_id, eos_token_idtokenizer.eos_token_id ) return tokenizer.decode(output[0], skip_special_tokensTrue) # 测试 print(generate(人工智能的未来)) print(generate(今天天气))参数说明temperature0.8是中文生成的甜点值低于 0.5 会太死板高于 1.2 会胡言乱语。top_k50和top_p0.9通常二选一我一般同时开让采样更稳定。repetition_penalty1.2对中文特别重要因为中文 token 重复率高不惩罚的话模型容易陷入「的的的的」循环。验证分词器对齐的另一个技巧是检查 tokenizer 的decode(encode(text))是否等于原文本。对于 Sentencepiece注意▁符号的处理# 检查分词器往返一致性 test_sentences [ 人工智能正在改变世界, 深度学习模型需要大量数据, 今天天气真好 ] for sent in test_sentences: encoded tokenizer.encode(sent) decoded tokenizer.decode(encoded, skip_special_tokensTrue) # Sentencepiece 的 ▁ 会被 decode 成空格需要处理 decoded_clean decoded.replace( , ) assert decoded_clean sent, f往返失败: {sent} - {decoded_clean} print(f通过: {sent} - {len(encoded)} tokens)如果往返失败说明分词器有信息损失通常是 UNK 或者特殊字符处理问题。这时候要回头检查character_coverage和语料清洗。最后一个进阶技巧是词表裁剪。训练完 Sentencepiece 后词表尾部往往有大量低频 token它们对生成贡献很小但占用 embedding 参数。可以把频率低于阈值的 token 合并到 UNK然后 resize 模型 embedding# 词表裁剪保留频率最高的 N 个 token def prune_vocab(spm_model_path, output_path, keep_size30000): import sentencepiece as spm sp spm.SentencePieceProcessor() sp.load(spm_model_path) # 获取所有 token 和频率 vocab [(sp.id_to_piece(i), sp.get_score(i)) for i in range(sp.get_piece_size())] # 按 score 排序保留前 keep_size 个 vocab_sorted sorted(vocab, keylambda x: x[1], reverseTrue)[:keep_size] # 重新映射 id # 这里需要重写模型 embedding实际项目中要同步调整模型配置 print(f裁剪后词表大小: {len(vocab_sorted)})这个操作有风险裁剪后必须重新训练或微调模型否则 embedding 和 tokenizer 不对齐。我一般只在推理部署时做训练阶段不动词表。说到底GPT2-Chinese 训练里最容易被低估的就是分词器。我自己的习惯是每换一次语料先跑一遍分词器训练和往返测试确认没有 UNK 泛滥和序列长度异常再启动模型训练。这个前置检查花 10 分钟能省掉后面几小时的 loss 排查。希望帮到你。本文还有配套的精品资源点击获取
返回列表