ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零训练中文GPT2:Sentencepiece与Bert Tokenizer分词器选型实战

2026/10/4 23:00:55 拓冰建站 浏览量
从零训练中文GPT2:Sentencepiece与Bert Tokenizer分词器选型实战 简介该资源面向中文自然语言处理方向的研究人员与开发者提供一套基于Sentencepiece和Bert Tokenizer的GPT2-Chinese模型训练源码帮助快速搭建并训练中文语言模型适合具备一定深度学习基础、希望深入理解中文分词与模型训练流程的读者。压缩包共42个文件、约13.8MB以9个Python脚本为核心覆盖训练、单次训练、文本生成与模型评估等环节另含PNG与JPG图表、txt词表与说明、json配置、sh脚本及license等分别承担可视化、语料词表、参数配置与命令行简化等用途。目录中同时提供BPE与Bert分词相关实现及多份词表文件便于对比不同分词方案对训练效果的影响。目前已有341人学习下载。读者可据此掌握从数据预处理、词表构建到模型训练与文本生成的完整链路并可按需修改脚本、扩展配置用于古诗文、散文等中文语料的生成实验。1. 从零训练一个中文 GPT2为什么分词器选型比模型结构更先决定成败很多人第一次跑 GPT2-Chinese 训练卡住的地方不是显存不够也不是梯度爆炸而是分词器。你拿一份中文语料用默认的 GPT2 tokenizer 直接编码会发现一个常用汉字被切成两三个 byte 级 token序列长度直接膨胀两三倍训练成本翻倍不说模型还学不到词级别的语义单元。这个标题讲的就是这件事用 Sentencepiece 和 Bert Tokenizer 两套分词方案配合 GPT2-Chinese 的模型结构从源码层面把中文预训练跑通。它解决的是中文语料在 BPE 体系下 token 效率低、生僻字切碎、词边界丢失的问题。适合手里有几十到几百 MB 中文文本、想自己从零训一个中文小模型或者想搞懂 GPT2-Chinese 源码里 tokenizer 和 model 怎么对接的工程师。下面按「分词器怎么选 → 数据怎么走 → 模型怎么搭 → 训练怎么调 → 坑在哪」的顺序拆开讲。2. Sentencepiece 与 Bert Tokenizer 的选型对比中文场景下谁更省 token2.1 两套分词器的本质差异Sentencepiece 是语言无关的子词切分工具它把输入当成原始字符流不依赖预分词规则直接跑 BPE 或 unigram 算法。对中文来说这意味着它可以把「机器学习」切成「机器」「学习」两个子词也可以切成单字完全由语料统计决定。Bert Tokenizer 走的是 WordPiece 路线但它对中文的处理是先按字切分再对字做子词合并本质上中文部分退化成字级 tokenizer。两者在中文上的核心区别是Sentencepiece 能学到跨字的词级单元Bert Tokenizer 的中文词表基本就是常用字表加少量词。从 token 效率看同样一句「自然语言处理技术在中文模型训练中的应用」Sentencepiece 在 32k 词表下大约产出 18 到 22 个 tokenBert Tokenizer 中文版大约产出 24 到 28 个 token。差距来自 Sentencepiece 能把高频双字词、三字词合并成一个 token而 Bert Tokenizer 的中文词表里多字词占比很低。训练 GPT2 这种自回归模型时token 数直接决定序列长度和注意力计算量所以这个差距会被放大。2.2 用 Sentencepiece 训练中文词表的最小命令先装依赖再准备一份纯文本语料每行一句或一段UTF-8 编码。pip install sentencepieceimport sentencepiece as spm spm.SentencePieceTrainer.train( inputcorpus.txt, # 纯文本语料一行一条 model_prefixspm_zh_32k, # 输出模型前缀 vocab_size32000, # 词表大小中文建议 32k 起步 model_typebpe, # 中文用 bpe 或 unigram 都行 character_coverage0.9995, # 覆盖 99.95% 的字符生僻字留一点 num_threads16, # 按 CPU 核数调 max_sentence_length8192, # 单句最大长度防止长文本截断 pad_id0, unk_id1, bos_id2, eos_id3 # 特殊 token 编号 )这段代码的逻辑是把语料喂给 Sentencepiece 训练器让它统计字符和子词频率迭代合并出 32k 个 token。character_coverage设 0.9995 是为了让极少数生僻字落到 unk避免词表被长尾字符撑大。model_type选 bpe 是因为 GPT2 原生就是 BPE 体系对接起来最顺。训练完会得到spm_zh_32k.model和spm_zh_32k.vocab两个文件前者用于编码解码后者可以转成 GPT2 需要的 vocab.json 和 merges.txt。2.3 把 Sentencepiece 模型转成 GPT2 可用的格式GPT2-Chinese 源码里 tokenizer 通常读的是vocab.json和merges.txt所以需要转换。import json vocab {} merges [] with open(spm_zh_32k.vocab, r, encodingutf-8) as f: for line in f: piece, _ line.strip().split(\t) vocab[piece] len(vocab) # 特殊 token 手动补进去 for tok in [|endoftext|, pad, unk, s, /s]: if tok not in vocab: vocab[tok] len(vocab) with open(vocab.json, w, encodingutf-8) as f: json.dump(vocab, f, ensure_asciiFalse) # merges.txt 从 spm 的 vocab 里提取合并规则这里简化处理 with open(merges.txt, w, encodingutf-8) as f: f.write(#version: 0.2\n) for piece in vocab: if len(piece) 1 and not piece.startswith(): f.write(piece[0] piece[1:] \n)转换的关键是 vocab.json 的 key 必须是 token 字符串value 是整数 id且 id 要和模型 embedding 层对齐。merges.txt 的格式是每行两个子词用空格分隔表示合并顺序。实际项目中Sentencepiece 的 BPE 合并规则和 GPT2 原生 BPE 不完全一致所以更稳妥的做法是直接用 SentencepieceProcessor 在数据加载时编码不走 vocab.json 这条路。但如果你要复用 GPT2-Chinese 的源码结构就得做这层转换转换后务必用几条测试句子验证 encode/decode 是否闭环。2.4 Bert Tokenizer 在中文 GPT2 里的适用边界Bert Tokenizer 不是不能用它的优势是现成、稳定、和 HuggingFace 生态无缝对接。如果你只是想快速验证模型结构不想花时间训词表直接拿bert-base-chinese的 tokenizer 也能跑。但要注意两点一是它的中文词表只有 21128 个 token其中多字词很少序列会偏长二是它的特殊 token 是[CLS]、[SEP]、[MASK]和 GPT2 的|endoftext|体系不兼容需要在数据预处理时做映射。我一般会在小规模实验阶段用 Bert Tokenizer 快速跑通确认模型能收敛后再换成自己训的 Sentencepiece 词表做正式训练。3. 数据管道搭建从原始中文文本到 GPT2 训练样本3.1 语料清洗的四个必做步骤原始中文文本通常带 HTML 标签、多余空白、乱码字符和重复段落。不清洗直接训模型会学到一堆噪声。常见做法是第一步去 HTML 和 URL。用正则把[^]和https?://\S替换成空字符串。第二步统一空白。把连续空格、制表符、换行合并成单个空格或保留段落换行。第三步过滤乱码。统计字符的 Unicode 范围把非中文、非英文、非常用标点的字符比例超过阈值的行丢掉。第四步去重。用 SimHash 或简单的 MD5 对段落做去重重复段落会让模型过拟合。import re import hashlib def clean_line(line): line re.sub(r[^], , line) line re.sub(rhttps?://\S, , line) line re.sub(r\s, , line).strip() return line def is_valid(line, min_len10, max_len512): if len(line) min_len or len(line) max_len: return False chinese len(re.findall(r[\u4e00-\u9fff], line)) if chinese / max(len(line), 1) 0.3: return False return True seen set() with open(corpus_clean.txt, w, encodingutf-8) as fout: for line in open(corpus_raw.txt, encodingutf-8): line clean_line(line) if not is_valid(line): continue h hashlib.md5(line.encode()).hexdigest() if h in seen: continue seen.add(h) fout.write(line \n)这段代码做了三件事清洗、过滤、去重。min_len和max_len控制单条样本长度太短的没信息量太长的训练时截断浪费。中文占比阈值 0.3 是为了滤掉中英混杂但英文为主的段落。去重用 MD5 是最简单的方式对大规模语料可以换成 SimHash 做近似去重。3.2 用 Sentencepiece 编码并生成训练样本GPT2 是自回归模型训练样本就是 token id 序列标签是序列左移一位。核心逻辑是把长文本拼成固定长度的块。import sentencepiece as spm import numpy as np sp spm.SentencePieceProcessor(model_filespm_zh_32k.model) block_size 512 # 训练序列长度 all_ids [] for line in open(corpus_clean.txt, encodingutf-8): ids sp.encode(line, out_typeint) all_ids.extend(ids) all_ids.append(sp.eos_id()) # 每条之间加 eos 分隔 all_ids np.array(all_ids, dtypenp.uint16) n_blocks len(all_ids) // (block_size 1) with open(train_ids.bin, wb) as f: f.write(all_ids[:n_blocks * (block_size 1)].tobytes())这里block_size设 512意味着每个训练样本是 512 个 token预测下一个 token。eos_id加在每条文本末尾让模型学会句子边界。用uint16存储是因为 32k 词表用 16 位整数足够比 int64 省一半内存。生成后的 bin 文件在训练时用np.memmap读取避免一次性加载到内存。3.3 数据加载器的实现与参数说明import numpy as np import torch from torch.utils.data import Dataset class GPT2Dataset(Dataset): def __init__(self, bin_path, block_size): self.data np.memmap(bin_path, dtypenp.uint16, moder) self.block_size block_size def __len__(self): return len(self.data) // (self.block_size 1) def __getitem__(self, idx): start idx * (self.block_size 1) chunk self.data[start:start self.block_size 1].astype(np.int64) x torch.from_numpy(chunk[:-1]) y torch.from_numpy(chunk[1:]) return x, ynp.memmap让磁盘文件像内存数组一样访问适合大数据集。__getitem__里 x 是前 block_size 个 tokeny 是后 block_size 个 token实现自回归的 shift。注意astype(np.int64)是因为 PyTorch 的 embedding 层要求 int64 索引uint16 直接转会有类型问题。DataLoader 的num_workers设 4 到 8pin_memoryTrue加速 GPU 传输。4. GPT2-Chinese 模型结构搭建从配置到前向传播4.1 模型配置参数怎么定GPT2-Chinese 的模型结构基本沿用 GPT2 的 decoder-only Transformer关键参数有n_layer层数、n_head注意力头数、n_embd隐藏维度、vocab_size词表大小、block_size最大序列长度。中文小模型常见配置是 n_layer12、n_head12、n_embd768参数量约 110M和 GPT2-base 对齐。如果显存有限可以降到 n_layer6、n_head8、n_embd512参数量约 40M单卡 8G 显存能跑。vocab_size 必须和分词器词表严格一致否则 embedding 层索引越界。block_size 要大于等于训练时的序列长度一般设 1024 留余量。dropout 在预训练阶段设 0.1微调时降到 0.05 或 0。4.2 核心模块的代码实现import torch import torch.nn as nn import math class CausalSelfAttention(nn.Module): def __init__(self, n_embd, n_head, block_size, dropout0.1): super().__init__() assert n_embd % n_head 0 self.n_head n_head self.n_embd n_embd self.c_attn nn.Linear(n_embd, 3 * n_embd) self.c_proj nn.Linear(n_embd, n_embd) self.attn_dropout nn.Dropout(dropout) self.resid_dropout nn.Dropout(dropout) self.register_buffer( bias, torch.tril(torch.ones(block_size, block_size)).view(1, 1, block_size, block_size) ) def forward(self, x): B, T, C x.size() qkv self.c_attn(x) q, k, v qkv.split(self.n_embd, dim2) q q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) k k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) v v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) att (q k.transpose(-2, -1)) / math.sqrt(k.size(-1)) att att.masked_fill(self.bias[:, :, :T, :T] 0, float(-inf)) att torch.softmax(att, dim-1) att self.attn_dropout(att) y att v y y.transpose(1, 2).contiguous().view(B, T, C) return self.resid_dropout(self.c_proj(y))这段是因果自注意力的核心。c_attn把输入映射成 q、k、v 三份bias是下三角矩阵保证每个位置只能看到自己和之前的 token。masked_fill把上三角位置设成负无穷softmax 后权重为 0。math.sqrt(k.size(-1))是缩放因子防止点积过大导致梯度消失。多头注意力的实现是把隐藏维度拆成 n_head 份每份独立算注意力再拼回来。4.3 把 Transformer Block 堆起来class Block(nn.Module): def __init__(self, n_embd, n_head, block_size, dropout0.1): super().__init__() self.ln1 nn.LayerNorm(n_embd) self.attn CausalSelfAttention(n_embd, n_head, block_size, dropout) self.ln2 nn.LayerNorm(n_embd) self.mlp nn.Sequential( nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd), nn.Dropout(dropout) ) def forward(self, x): x x self.attn(self.ln1(x)) x x self.mlp(self.ln2(x)) return x class GPT2Chinese(nn.Module): def __init__(self, vocab_size, n_layer12, n_head12, n_embd768, block_size1024, dropout0.1): super().__init__() self.block_size block_size self.tok_emb nn.Embedding(vocab_size, n_embd) self.pos_emb nn.Embedding(block_size, n_embd) self.drop nn.Dropout(dropout) self.blocks nn.Sequential(*[ Block(n_embd, n_head, block_size, dropout) for _ in range(n_layer) ]) self.ln_f nn.LayerNorm(n_embd) self.head nn.Linear(n_embd, vocab_size, biasFalse) self.apply(self._init_weights) def _init_weights(self, module): if isinstance(module, nn.Linear): module.weight.data.normal_(mean0.0, std0.02) if module.bias is not None: module.bias.data.zero_() elif isinstance(module, nn.Embedding): module.weight.data.normal_(mean0.0, std0.02) def forward(self, idx, targetsNone): B, T idx.size() pos torch.arange(0, T, dtypetorch.long, deviceidx.device) x self.drop(self.tok_emb(idx) self.pos_emb(pos)) x self.blocks(x) x self.ln_f(x) logits self.head(x) loss None if targets is not None: loss nn.functional.cross_entropy( logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index-1 ) return logits, lossBlock 里用的是 Pre-LN 结构即 LayerNorm 在注意力和 MLP 之前这种结构训练更稳定不容易梯度爆炸。残差连接x x ...保证梯度能直接回传。_init_weights用正态分布初始化std0.02 是 GPT2 原论文的设置。forward 里 token embedding 和 position embedding 相加过 dropout 后进 Transformer 堆最后 LayerNorm 加线性头输出 logits。loss 用交叉熵ignore_index-1留给 padding 位置。5. 训练循环与调参学习率、批次、梯度累积怎么配合5.1 优化器和学习率调度GPT2 训练用 AdamWweight_decay 设 0.01beta10.9beta20.95。学习率用 warmup 加 cosine 衰减峰值学习率一般设 1e-4 到 3e-4warmup 步数占总步数的 1% 到 5%。from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR def get_lr_scheduler(optimizer, warmup_steps, max_steps, max_lr, min_lr): def lr_lambda(step): if step warmup_steps: return step / max(1, warmup_steps) progress (step - warmup_steps) / max(1, max_steps - warmup_steps) return max(min_lr / max_lr, 0.5 * (1 math.cos(math.pi * progress))) return LambdaLR(optimizer, lr_lambda) optimizer AdamW(model.parameters(), lr3e-4, weight_decay0.01, betas(0.9, 0.95)) scheduler get_lr_scheduler(optimizer, warmup_steps2000, max_steps100000, max_lr3e-4, min_lr3e-5)warmup 的作用是让模型在初期不要被大梯度带偏cosine 衰减让后期学习率平滑下降帮助收敛到更平坦的极小值。min_lr设峰值学习率的十分之一避免后期完全不学。5.2 梯度累积与混合精度单卡显存不够时用梯度累积模拟大 batch。比如目标 batch size 是 64单卡只能放 8就累积 8 步再更新一次。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() accum_steps 8 optimizer.zero_grad() for step, (x, y) in enumerate(dataloader): x, y x.cuda(), y.cuda() with autocast(): logits, loss model(x, y) loss loss / accum_steps scaler.scale(loss).backward() if (step 1) % accum_steps 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad()autocast用 float16 做前向减少显存占用和加速计算。GradScaler处理 float16 的梯度下溢问题。clip_grad_norm_把梯度范数限制在 1.0防止梯度爆炸。注意 loss 要除以 accum_steps保证累积后的梯度等价于大 batch 的梯度。5.3 训练过程中的监控指标必须盯的指标有三个训练 loss、验证 loss、梯度范数。训练 loss 正常应该在前几百步快速下降然后缓慢下降。验证 loss 如果开始上升说明过拟合要加 dropout 或减模型规模。梯度范数如果经常超过 1.0说明学习率太大或数据有问题。if step % 100 0: print(fstep {step}, loss {loss.item() * accum_steps:.4f}, flr {scheduler.get_last_lr()[0]:.6f}, fgrad_norm {torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0):.4f})每 100 步打印一次loss 要乘回 accum_steps 才是真实值。grad_norm 在 clip 之前算如果持续大于 1.0 就要警惕。6. 避坑与排查中文 GPT2 训练里最容易翻车的五个地方6.1 词表大小和 embedding 层不匹配现象训练启动时报IndexError: index out of range in self。原因分词器词表是 32000但模型配置里 vocab_size 写成了 30000或者转换 vocab.json 时漏了特殊 token。解决训练前先跑一遍assert tokenizer.vocab_size model.tok_emb.num_embeddings不相等就改配置。转换 vocab.json 时把|endoftext|、pad、unk都补进去id 从 0 开始连续分配。6.2 序列长度超过 block_size 导致位置编码越界现象报IndexError: index out of range在 pos_emb 那一行。原因数据块长度设了 1024但模型 block_size 设了 512位置编码表只有 512 行。解决数据管道的 block_size 必须小于等于模型的 block_size。如果要用长序列把模型的 block_size 调大同时位置编码表也要相应扩大。注意 GPT2 的位置编码是绝对位置编码扩大后需要重新训练或做插值。6.3 学习率太大导致 loss 震荡不收敛现象loss 在前几百步上下跳动不下降或者突然变成 nan。原因峰值学习率设了 1e-3 甚至更高对 Transformer 来说太大。解决中文 GPT2 从零训练峰值学习率建议 1e-4 到 3e-4warmup 至少 1000 步。如果已经出现 nan检查数据里有没有空样本或全 padding 样本这些会让 loss 变成 nan。6.4 数据里混入大量重复文本导致过拟合现象训练 loss 降到很低但验证 loss 很高生成结果和训练集某几段一模一样。原因语料去重没做好同一段文本重复出现几百次。解决用 SimHash 做近似去重汉明距离小于 3 的视为重复。另外检查数据里有没有模板化文本比如爬虫抓到的导航栏、页脚这些要单独过滤。6.5 混合精度训练时 loss 出现 inf现象用 autocast 后 loss 偶尔变成 infGradScaler 跳过更新。原因float16 的动态范围有限某些层的激活值溢出。解决在autocast上下文里对 softmax 和 layer_norm 强制用 float32。PyTorch 的 autocast 会自动处理大部分情况但自定义的 attention 计算里q k.transpose可能溢出可以手动.float()再算。另外把 GradScaler 的init_scale从默认的 65536 降到 32768减少溢出概率。7. 进阶技巧用验证集困惑度和生成样例判断模型是否真的学到了中文训练 loss 低不代表模型学到了中文语法。我一般会在训练中途跑两个验证一是计算验证集的困惑度二是用固定 prompt 生成样例看语义连贯性。困惑度用math.exp(valid_loss)算中文小模型在 100M 参数量下困惑度降到 30 以下算及格20 以下算不错。生成样例用 top-k 采样k50temperature0.8prompt 选「今天天气」这种常见开头看模型能不能续出通顺句子。torch.no_grad() def generate(model, tokenizer, prompt, max_new_tokens50, top_k50, temperature0.8): model.eval() ids tokenizer.encode(prompt, out_typeint) ids torch.tensor(ids, dtypetorch.long).unsqueeze(0).cuda() for _ in range(max_new_tokens): logits, _ model(ids[:, -model.block_size:]) logits logits[:, -1, :] / temperature top_k_logits, top_k_indices torch.topk(logits, top_k) probs torch.softmax(top_k_logits, dim-1) next_id top_k_indices.gather(-1, torch.multinomial(probs, 1)) ids torch.cat([ids, next_id], dim1) return tokenizer.decode(ids[0].tolist())这段生成代码里top_k限制候选 token 数量避免采样到低概率的乱码。temperature控制随机性0.8 比 1.0 更保守。model.block_size截断保证不越界。生成结果如果出现大量重复字或语法混乱说明模型还没学好要么继续训要么检查数据质量。还有一个我踩过的坑验证集和训练集必须来自同一份清洗后的语料但要做时间或来源上的切分。如果验证集里混了训练集见过的段落困惑度会虚低误导判断。我一般会留出 5% 的语料做验证且确保这部分在训练时完全没被用过。另外中文 GPT2 对 prompt 的格式敏感训练时如果每条文本末尾加了 eos生成时也要在 prompt 末尾加 eos否则模型不知道句子该在哪结束。这些细节看起来小但直接决定你训出来的模型能不能用。希望帮到你。本文还有配套的精品资源点击获取