简介:这份PTB(Penn Treebank Dataset)文本数据集面向自然语言处理初学者与深度学习研究者,可用于词嵌入、语言模型及序列到序列模型的训练与评估,帮助读者在句法分析与文本建模场景中完成从数据加载到困惑度评测的完整实践。压缩包共62个文件,约31.2MB,包含txt语料、sh训练与测试脚本、c/cpp源码、makefile构建文件及少量model、gz等资源,既有训练、验证和测试语料,也附带simple-examples示例与rnnlm相关实现,便于直接运行和二次修改。目前已有1840人学习下载。读者可据此搭建RNN、LSTM、GRU乃至Transformer的基础实验,理解分词预处理、词表索引映射、梯度裁剪与学习率调度等关键环节,并借助示例脚本快速复现训练流程、观察输出日志与模型效果,为后续NLP项目提供可参照的数据与代码基础。
1. PTB 文本数据集到底是什么:一份固定划分的英文句法语料
PTB(Penn Treebank Dataset)文本数据集在语言建模这个领域里几乎是默认的第一站:它把新闻语料切成句子,给每个词打上词性和句法结构标注,再整理成 train / valid / test 三个纯文本文件。很多人第一次跑序列模型,困惑度对比的基线就是它。它解决的问题很具体——你需要一份规模可控、划分固定、评测标准统一的英文语料,来判断一个模型到底有没有学到东西。做小规模语言模型实验、复现经典结果、或者带新人练一遍数据流水线,都很适合从它入手。麻烦也在这儿:它有不止一个流通版本,版本选错,困惑度能从 60 掉到 90,而你只会以为是模型的问题。
2. PTB 的三种流通版本:原始括号树、词级预处理版与字符级版
2.1 原始标注版的目录结构与新闻分节
拿到原始标注版,你看到的不是纯文本,而是一堆.mrg文件,每个文件里躺着若干棵括号树。一棵树的样子大致是:
( (S (NP-SBJ (DT The) (NN market)) (VP (VBD fell) (PP (IN on) (NP (NN news))))) ( . .) )括号里第一个位置是句法标签(S 是句子,NP 是名词短语,VP 是动词短语),第二个位置才是词。词性标签写在叶子节点上,用的是那套 36 个左右的标签集:NN / NNS / NNP / VB / VBD / JJ / RB / IN / DT / PRP / CD / MD 这些。树里还会出现两类容易忽略的东西:一类是空类别节点-NONE-,用来标注被删除或被移动的成分;另一类是功能标签,比如-SBJ(主语)、-TMP(时间状语)、-LOC(处所),它们挂在短语标签后面而不是独立成节点。
文件按分节组织,节号从 00 到 24。业界通用的划分是:02 到 21 节做训练,第 22 节做验证,第 23 节做测试,第 24 节留作额外测试集。这套划分是固定的,任何人都不要重划,否则你报出来的数字和别人比不了。
| 节号区间 | 用途 | 说明 |
|---|---|---|
| 00 到 01 | 常被排除 | 早期文件格式不统一 |
| 02 到 21 | 训练集 | 约 3.9 万句 |
| 22 | 验证集 | 约 1700 句 |
| 23 | 测试集 | 约 2400 句 |
| 24 | 额外测试 | 部分工作使用,报告时需注明 |
原始版的信息量最大,句法分析、成分句法、词性标注这些任务都得从它出发。但如果你只想跑语言模型,直接用它会很累:文件多、句子跨行、带标注,需要自己写解析。
2.2 词级预处理版:10000 词表和<unk>的来龙去脉
语言建模社区最常用的其实是预处理过的词级版本:三个纯文本文件,每行一句,词与词之间用空格分隔。这个版本做了一串“减法”:
第一,把低频词统一替换成<unk>。词表固定为 10000 个,按训练集词频从高到低取,超出的一律归入<unk>。第二,把数字替换成N,避免模型去记具体数值。第三,保留原有的大小写和标点。第四,也是最多人搞错的一点——这三个文件里没有<s>和</s>这两个句界符号,它们是在读取时按行补上去的。
这个版本的规模是公认的:训练集约 929,589 个 token,验证集约 73,760,测试集约 82,430,词表 10000(含<unk>、<s>、</s>三个特殊符号时是 10000 个位置)。验证集的 OOV 率大致落在 5% 到 6% 之间。
| 指标 | 训练 | 验证 | 测试 |
|---|---|---|---|
| token 数 | 929,589 | 73,760 | 82,430 |
| 句子数(行数) | 约 42,068 | 约 3,370 | 约 3,760 |
| OOV 率 | — | 约 5.5% | 约 5.4% |
词表这 10000 是个参数,不是必须遵循的标准。你完全可以自己从训练集重建词表,但只要你动过这个词表,报出来的困惑度就不能再和那些用 10000 词表的结果直接比。这件事听起来像常识,实际翻车的概率高得离谱,后面第 5 章会专门说。
2.3 字符级版与 BPC 指标
还有一条支线是字符级版本。它把整份语料拆成单个字符,通常保留 26 个字母大小写、标点、数字和空格,词与词之间的空格也当成一个字符。这样序列变长,但词表从 10000 掉到 50 左右,模型不用再处理 OOV。
字符级评测用的不是困惑度,而是 BPC(bits per character),意思是平均每预测一个字符需要多少比特。换算关系和词级困惑度对得上:
BPC = log2(PPL) / 平均每词字符数PTB 上平均每词大约 4.5 到 5 个字符(含空格和标点)。所以词级 PPL 60 大致对应 BPC 1.18 左右。这个式子不是装饰,它是你跨版本对齐结果的唯一桥梁——有人拿字符级 BPC 1.1 去和词级 PPL 80 比,得出“字符级更好”的结论,这是彻头彻尾的口径错误。
字符级的坑在于预处理差异更大:破折号怎么表示、引号是直引号还是弯引号、换行是否计入、连续空格是否压缩成单个,每个实现都不一样。用之前一定要把预处理脚本一起固定下来,只固定数据集本身没有意义。
3. 把 PTB 读进内存:括号树解析、词表构建与序列切分脚本
3.1 从.mrg括号树解析出纯词序列
原始版最有价值也最麻烦的部分就是解析。如果你的环境里能装 NLTK,最省事的路子是用它自带的括号树读取器:
from nltk.corpus import BracketParseCorpusReader from nltk.tree import Tree def prune_none(tree): """删掉 -NONE- 空类别子树,它们不对应真实词。""" for i in reversed(range(len(tree))): child = tree[i] if isinstance(child, Tree): if child.label().startswith("-NONE-"): tree.pop(i) # 整个子树剔除 else: prune_none(child) # 递归处理 return tree def read_sents(root, pattern): reader = BracketParseCorpusReader(root, pattern) sents = [] for tree in reader.parsed_sents(): prune_none(tree) sents.append(tree.leaves()) # 只保留叶子上的词 return sentsroot是数据目录,pattern是匹配分节文件的正则。parsed_sents()会返回Tree对象,leaves()拿到的是纯词序列。prune_none这一步不能省:空类别节点下挂着*T*-1、*这类占位符,不删掉它们就会被当成词进入词表,把词表撑大还污染统计。
如果环境里没法装依赖,可以用纯正则顶一下,代价是转义括号需要手动映射:
import re from pathlib import Path # 匹配最内层 (词性 词) 结构,词性用大写字母开头 TOKEN_RE = re.compile(r"\(([A-Z$][A-Za-z0-9$.\-]*)\s+([^()\s]+)\)") ESCAPE_MAP = {"-LRB-": "(", "-RRB-": ")", "-LSB-": "[", "-RSB-": "]", "-LCB-": "{", "-RCB-": "}"} def read_tokens_fast(path): text = " ".join(Path(path).read_text(errors="ignore").split()) toks = [] for pos, word in TOKEN_RE.findall(text): if pos.startswith("-NONE-"): continue toks.append(ESCAPE_MAP.get(word, word)) return toks正则版的速度快很多,但要注意它对嵌套结构的容错不如解析器,遇到格式异常的文件会静默漏词。我的习惯是:先用 NLTK 跑一遍统计句子数和 token 数,和公认的规模对上,再决定用哪个版本做正式流程。对不上就说明你的解析路径有问题,别急着往下走。
3.2 构建词表与<unk>替换的边界
词表必须只用训练集统计,这是红线。代码本身很短,关键在调用时传什么:
from collections import Counter PAD, UNK, BOS, EOS = "<pad>", "<unk>", "<s>", "</s>" SPECIALS = [PAD, UNK, BOS, EOS] def build_vocab(train_sents, max_size=10000): cnt = Counter() for sent in train_sents: # 只传训练集 cnt.update(sent) # 特殊符号先占位,剩下名额按词频补 words = [w for w, _ in cnt.most_common(max_size - len(SPECIALS))] vocab = SPECIALS + words return {w: i for i, w in enumerate(vocab)} def encode(sents, vocab): unk_id = vocab[UNK] ids = [] for sent in sents: ids.append(vocab[BOS]) ids.extend(vocab.get(w, unk_id) for w in sent) ids.append(vocab[EOS]) return idsmax_size要算上四个特殊符号,所以实际词数是 9996。这个细节直接决定你的词表大小是 10000 还是 10004,报结果时容易被追问。encode在每个句子前后插入<s>和</s>,因为预处理版文件里没有它们。
要不要把句末的</s>计入困惑度的 token 数?常见做法是计入。但如果你不计入,PPL 会往下掉几个点,看起来更好看,这属于自欺欺人。表里报数的时候,把口径写清楚。
3.3 按 BPTT 长度做序列切分与状态重置
语言模型的训练不是一句一句喂,而是把整个 token 流拼成一条长序列,再按 batch 切开。这一步的写法决定了你的模型能不能收敛:
import numpy as np def batchify(ids, batch_size): """把一维 token 流切成 (batch_size, 列数) 的矩阵。""" n = len(ids) // batch_size ids = ids[: n * batch_size] # 丢掉尾部除不尽的 return ids.reshape(batch_size, n) train_ids = np.array(encode(train_sents, vocab), dtype=np.int64) train_data = batchify(train_ids, batch_size=64)batch_size决定列数,列数决定一个 epoch 里能切出多少个 BPTT 片段。切完之后,每段取[i, i+bptt)做输入、[i+1, i+bptt+1)做标签。两个参数的影响很不一样:
batch_size越大,梯度噪声越小,但显存占用线性上涨。PTB 上 64 到 128 是常见区间,20 是早年小显存时代的配置。bptt决定反向传播跨多少步。35 是经典值,对应“截断 35 步”。调大到 70 通常能稍微降一点 PPL,但训练时间翻倍。
尾部被丢掉的那几十个 token 不用心疼,PTB 规模下影响在万分之几。真正要小心的是状态重置:如果你的实现是每段之间重置隐状态,那和跨段保留状态是两个不同的模型,PPL 差得出来。
4. 在 PTB 上配参数:词表规模、BPTT 长度与困惑度口径
4.1 词表大小与 OOV 率的取舍
10000 这个词表规模是历史惯性,不是最优解。词表调小,<unk>变多,模型学不到长尾词,但嵌入矩阵变小、训练更快;词表调大,OOV 变少,可稀有词出现次数太少,嵌入向量根本训不好。
| 词表规模 | 验证集 OOV | 典型 PPL 走势 | 适用场景 |
|---|---|---|---|
| 2000 | 12% 上下 | 明显偏高 | 只想快速跑通流程 |
| 5000 | 8% 上下 | 中等 | 小模型、教学演示 |
| 10000 | 5% 到 6% | 标准参照 | 与公开结果对比 |
| 20000 以上 | 3% 上下 | 略降但不可比 | 大模型自娱自乐 |
关键是最后一句:不同词表规模下的 PPL不可直接比较。OOV 少意味着模型要预测的词空间更大,熵天然更高,PPL 反而可能上升。所以要么严格用 10000,要么在报告里写清楚“自建词表 20000,不与公开结果对比”。
4.2 BPTT 长度、批大小与隐状态处理
常见的几组配置,我按使用频率排一下:
| 参数 | 保守配置 | 现代配置 | 说明 |
|---|---|---|---|
| batch_size | 20 | 64 到 128 | 越大越稳,越吃显存 |
| bptt | 35 | 35 到 70 | 35 是事实标准 |
| hidden_size | 650 | 1000 到 1500 | 再大容易过拟合 |
| dropout | 0.3 到 0.5 | 分层设置 | 输出层单独设 |
| 学习率 | 20(SGD) | 1e-3(Adam) | 两套体系别混用 |
隐状态的处理有两种流派。一种是每个 BPTT 段开始时重置为零,实现简单,但模型学不到跨段依赖;另一种是用detach()把上一段的隐状态截断梯度后传下去,既保留信息又不让反向传播无限延长。PTB 的句子平均长度二十几个词,BPTT 35 基本能覆盖一句,所以重置的影响没那么大,但在字符级上差异会很明显。
还有一个容易忽略的点:验证和测试阶段,batch 的列数往往除不尽 BPTT,最后一段会短一截。如果你用固定形状的 tensor 预分配,记得把多余位置 mask 掉,否则打分时会混进无效 token,PPL 会被稀释。
4.3 困惑度口径:per-token 还是 per-character
困惑度的定义是平均负对数似然的指数:
PPL = exp( 总 NLL / token 总数 )三个地方会改变结果。第一,</s>算不算 token。算,PPL 略低;不算,PPL 略高。第二,padding 有没有被 mask 掉。没 mask 的话,模型在 padding 上的损失被算进去,PPL 会离谱地低或者高。第三,用的是自然对数还是以 2 为底。用 log2 算出来的叫 bits per token,再取 2 的幂才是 PPL,两者别搞混。
import math import torch @torch.no_grad() def evaluate(model, data, bptt, criterion, device): model.eval() total_nll, total_tokens = 0.0, 0 hidden = model.init_hidden(data.size(0)) for i in range(0, data.size(1) - 1, bptt): x = data[:, i:i + bptt].to(device) y = data[:, i + 1:i + bptt + 1].to(device) hidden = model.repackage_hidden(hidden) # detach,截断梯度 logits, hidden = model(x, hidden) # 展平后按 token 求平均,而不是先对 batch 求平均 loss = criterion(logits.reshape(-1, logits.size(-1)), y.reshape(-1)) n = y.numel() total_nll += loss.item() * n total_tokens += n return math.exp(total_nll / total_tokens)这段代码里有两个细节值得盯。loss.item() * n是先还原成总 NLL 再累加,如果直接累加 batch 平均损失,最后除出来的是“平均的均值”,短句会被过度加权。repackage_hidden做的是detach,只截断梯度,不重置数值。
PPL 这个指标对预处理极端敏感,稍微改一下分词就可能差出十几个点。它的好处是计算便宜、可比性强,坏处是它衡量的是“平均每个词的惊讶程度”,和下游任务表现没有必然关系。真要判断模型好坏,除了 PPL,最好再看一眼生成样本。
5. PTB 使用中的避坑记录:五个让 PPL 对不上的地方
5.1 用全量语料建词表,验证集信息泄漏
现象:验证集 PPL 比预期低一截,测试集却掉不下去,两者差距异常大。
原因:图省事把 train / valid / test 拼起来统计词频,验证集和测试集里的词提前进了词表,模型等于提前看过答案。这种泄漏不会报错,只会让你的结果看起来“好得不真实”。
解决:词表统计函数只接收训练集,在函数签名上就限制住。写完之后打印一下验证集 OOV 率,落在 5% 到 6% 才算正常;如果接近 0,就是泄漏了。
5.2 原始版和预处理版混着用,数字没法比
现象:自己从.mrg解析出来的训练集有 100 多万 token,和公认的 92 万多对不上。
原因:原始版含标点、含空类别残留、保留了原始大小写和数字,预处理版做了低频词替换和数字归一化。两者不是同一份数据。
解决:先决定走哪条路。要和公开结果比,就用预处理版,或者严格复刻它的替换规则(词频阈值、N替换、大小写策略)。自己解析的版本单独命名,不要混着报。
5.3 句界符号的处理不一致
现象:同一个模型,两次跑出来的 PPL 差 3 到 5 个点。
原因:预处理版文件里没有<s>和</s>,有人按行补,有人不补,有人补了<s>但不计入损失。
解决:在数据加载层统一处理,写死“每行补<s>开头、</s>结尾,两者都计入 token 总数”。这个约定写进配置,别靠记忆。
5.4 测试集被反复用来调参
现象:测试集 PPL 特别好,换个随机种子就崩了。
原因:看到测试结果不理想就改超参,改完再测,来回几轮,测试集事实上变成了验证集。
解决:所有超参在验证集上定死,测试集只跑一次,跑完就存档。这个习惯比任何调参技巧都值钱,属于典型的“后悔药”型经验。
5.5 转义符号没还原,标点全变成词
现象:词表里出现-LRB-、-RRB-、-LSB-这种东西,还排在词频前列。
原因:括号树里左右圆括号、方括号是用转义标记表示的,解析时没映射回真实字符。
解决:在解析出口统一做一次映射表替换,把六个转义标记换成对应符号。做完之后检查词表前 50 个词,应该全是the、,、.、of这类,出现转义标记就说明漏了。
6. 用 n-gram 基线给 PTB 流水线做个体检
在把神经模型丢进去之前,我习惯先跑一个 n-gram 基线。这一步花不了多少时间,却能一次性验证数据加载、词表、句界、PPL 计算四条链路是不是都对。
用 kenlm 训练一个 5-gram,命令很短:
# 训练 5-gram,discount_fallback 用于小语料自动回退 lmplz -o 5 --discount_fallback < train.txt > ptb.arpa # 在测试集上打分,输出每句的对数概率和困惑度 query -v summary < test.txt > /dev/null 2> score.txt如果一切正常,5-gram 加 Kneser-Ney 平滑在 PTB 测试集上的 PPL 大致落在 130 到 150 之间。落不进这个区间,说明前面的环节有问题,别急着换模型。判断顺序和对应的排查方向我整理成了一张表:
| 检查项 | 期望值 | 不符时先看哪里 |
|---|---|---|
| 训练 token 数 | 929,589 | 分词、行切分、句界符号 |
| 词表规模 | 10000 | 特殊符号是否算进名额 |
| 验证集 OOV | 5% 到 6% | 词表是否误用了全量数据 |
| 5-gram PPL | 130 到 150 | 平滑配置、测试集是否带<unk> |
| 神经模型 PPL | 60 到 90 | BPTT、隐状态重置、PPL 口径 |
神经模型那一行给的是区间而不是定值,因为架构差异太大:两层 LSTM 加合适 dropout 通常能压到 80 以内,加了权重绑定和更细的正则能摸到 60 附近,再往下就是各种技巧的比拼了。关键是先确认自己站在正确的起跑线上。
还有一个我常做的动作:从测试集里抽 20 句,人工看一眼 token 序列。有没有连续的空格、有没有把整段拼成一行、<unk>的比例是不是集中在专有名词上。这些肉眼检查比任何指标都直白。
血泪经验是:PTB 的坑几乎全在数据层,模型层反而简单。我见过太多人换了两周模型结构,最后发现是词表建错了。所以每次开新项目,我都先把第 6 章这张表跑一遍,对上了再动网络结构。希望帮到你。
本文还有配套的精品资源,点击获取