- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
文本是序列数据中最常见、也最具代表性的形式,一篇文章既可以看作一串单词序列,也可以看作一串字符序列。本章节以《动手学深度学习》中 H. G. Wells 的经典小说The Time Machine为语料,系统讲解文本预处理的标准四步流水线:加载文本、词元化(tokenization)、构建词表(vocabulary)、把文本转换为数字索引序列。读完本文,你将完整掌握d2l工具库中read_time_machine、tokenize、Vocab、count_corpus与load_corpus_time_machine五个核心 API 的用法与底层实现,并能为后续的语言模型训练(如 语言模型与数据集 一节中的 RNN 训练)准备好可直接喂给模型的数据。
为什么文本需要预处理:四个标准步骤
在进入具体代码之前,先建立整体认知。模型只能接收数值输入,而原始文本是字符串,二者之间存在天然的鸿沟。因此,无论语料规模多大,文本预处理通常都遵循以下四个步骤:
- 加载文本:把原始文本作为字符串读入内存;
- 词元化:把字符串拆分为词元(token),词元是文本的基本单位,可以是单词,也可以是字符;
- 构建词表:建立一个词表(vocabulary),把拆分出的字符串词元映射到从 0 开始的数字索引;
- 转换为索引序列:把整篇文本转换为数字索引序列,方便模型直接操作。
这四个步骤环环相扣,构成了后续所有自然语言处理实验的数据基础。原文以The Time Machine为例——这是一个只有 3 万多个单词的小型语料库,用来演示完整的预处理流程刚刚好;而现实中的文档集合动辄包含数十亿个单词,更需要这套流水线来保证数据可被高效、一致地处理。
读取数据集:read_time_machine与数据下载机制
数据从哪里来:DATA_HUB注册表
在动手前,需要先理解数据是如何获取的。d2l工具库维护了一个全局数据注册表DATA_HUB,每个数据集以名称 -> (URL, SHA-1 哈希)的形式登记。时光机器数据集的定义如下(以 d2l/torch.py 为例,四个后端实现完全一致):
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt', '090b5e7e70c295757f55df93cb0a180b9691891a')其中d2l.DATA_URL指向数据集存储地址(见 d2l/torch.py),第二个参数是该文件的 SHA-1 校验值。d2l.download函数(d2l/torch.py)实现了完整的下载与缓存逻辑:
- 从
DATA_HUB中取出 URL 与 SHA-1 哈希; - 在本地
../data目录(cache_dir)下创建文件; - 若本地文件已存在,则逐块计算其 SHA-1 值并与注册表比对,校验一致则直接命中缓存、不重复下载;
- 校验失败或文件不存在时才发起下载。
这套"注册表 + 哈希校验"的机制保证了:数据下载一次后即可复用,且能防止文件损坏带来的静默错误。
读取与清洗:read_time_machine
read_time_machine函数(d2l/torch.py)负责把下载好的文本文件读入内存,并做初步清洗:
def read_time_machine(): """将时间机器数据集加载到文本行的列表中""" with open(d2l.download('time_machine'), 'r') as f: lines = f.readlines() return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]这个函数做了三件事:
- 按行读取:
f.readlines()把全文拆成文本行列表,每一行是一个字符串; - 正则清洗:
re.sub('[^A-Za-z]+', ' ', line)把除英文字母(大小写)之外的所有字符(数字、标点、空白等)统一替换为空格,从而忽略标点符号; - 统一小写:
.strip().lower()去掉行首行尾空白并把字母转为小写,从而忽略字母大小写。
读取后即可验证效果,原文档中lines[0]与lines[10]的输出展示了清洗后的干净文本行。re模块是 Python 标准库的正则表达式工具,[^A-Za-z]+是"匹配任意非英文字母的连续字符"的字符类取反写法——这是本函数清洗逻辑的核心。
词元化:tokenize把文本拆成基本单位
词元化是文本预处理中承上启下的一步。tokenize函数(d2l/torch.py)接收文本行列表,把每个文本序列拆分成词元列表,最终返回"词元列表的列表":
def tokenize(lines, token='word'): """将文本行拆分为单词或字符词元""" if token == 'word': return [line.split() for line in lines] elif token == 'char': return [list(line) for line in lines] else: print('错误:未知词元类型:' + token)函数通过token参数支持两种粒度的词元化:
| 参数值 | 拆分方式 | 说明 |
|---|---|---|
'word'(默认) | line.split() | 按空白把每行拆成单词列表,词元是单词 |
'char' | list(line) | 把每行拆成单个字符列表,词元是字符 |
'word'模式下,由于read_time_machine已把标点替换为空格,split()可以干净地切分出纯单词序列;'char'模式则直接利用 Python 字符串的可迭代性逐字符切分。若传入未知的词元类型,函数会打印错误提示——从源码实现看,该分支没有显式return,会返回None,因此调用时应始终使用受支持的两种取值。
原文档在词元化后打印了前 11 行文本的单词词元结果,可以直观看到清洗后的文本被切分成了整齐的单词序列。
构建词表:Vocab与count_corpus
词表的作用
词元本质是字符串,而模型需要数字输入,因此必须建立字符串词元到数字索引的映射。Vocab类(d2l/torch.py)就是这样一个双向字典:既支持"词元 → 索引"(token_to_idx),也支持"索引 → 词元"(idx_to_token)。
词表的构建遵循两条重要约定:
- 按频率分配索引:对语料中所有唯一词元做频率统计,词频越高的词元索引越小;
- 稀有词元移除:很少出现的词元通常被删除以降低复杂度;语料中不存在或已被删除的词元,统一映射到索引为 0 的特殊未知词元
<unk>。
此外,Vocab还支持通过reserved_tokens参数预留一组特殊词元,例如:
<pad>:填充词元,用于对齐变长序列;<bos>:序列开始词元(beginning of sequence);<eos>:序列结束词元(end of sequence)。
构造器与频率排序
Vocab.__init__的完整签名是Vocab(tokens=None, min_freq=0, reserved_tokens=None),三个参数的含义:
| 参数 | 默认值 | 作用 |
|---|---|---|
tokens | None | 语料词元列表,用于统计频率;可为 1D 列表或 2D 列表(词元列表的列表) |
min_freq | 0 | 词频下限,低于该值的词元将被排除在词表之外 |
reserved_tokens | None | 需要预留的特殊词元列表(如<pad>、<bos>、<eos>),其索引排在<unk>之后 |
构造逻辑(对照 d2l/torch.py)可以拆解为:
- 调用
count_corpus(tokens)统计词频,得到collections.Counter; - 按词频降序排序(
key=lambda x: x[1], reverse=True),词频相同再按词元本身排序,保证确定性; - 初始化
idx_to_token = ['<unk>'] + reserved_tokens,即<unk>的索引固定为 0,预留词元紧随其后; - 依频率从高到低遍历,把词频不低于
min_freq且未重复的词元追加进词表,同时反向填充token_to_idx映射。
这里有一个值得注意的实现细节:仓库四个后端版本的Vocab在遍历频率表时都采用了"遇到词频低于min_freq的词元立即break"的策略(见 d2l/torch.py)。由于频率表已按降序排列,一旦遇到不达标的词元,其后的词频必然更低,无需继续扫描——这是一个细微但有效的剪枝优化。
双向映射与查询接口
Vocab提供了四个核心接口:
__len__:返回词表大小,即len(self.idx_to_token);__getitem__:词元 → 索引。传入单个词元时返回其索引,若词元不在词表中则返回self.unk(即 0);传入列表或元组时递归地对每个词元映射,返回索引列表。这正是vocab[tokens[i]]能把整行词元批量转成索引的底层原理;to_tokens(indices):索引 → 词元,是__getitem__的逆操作,同样支持单个索引与索引列表两种输入;unk属性:在 torch 等后端的实现中以@property形式固定返回 0(见 d2l/torch.py),保证未知词元索引恒为 0。
原文档用时光机器语料构建词表后,打印了token_to_idx的前 10 项,展示出<unk>与最高频词元(如the、i、and等)的索引分配情况;随后演示了vocab[tokens[i]]把第 0 行与第 10 行的单词词元批量转换为索引序列。
频率统计:count_corpus
count_corpus(d2l/torch.py)是词表构建的统计基础,它要处理两种输入形态:
def count_corpus(tokens): """统计词元的频率""" # 这里的tokens是1D列表或2D列表 if len(tokens) == 0 or isinstance(tokens[0], list): # 将词元列表展平成一个列表 tokens = [token for line in tokens for token in line] return collections.Counter(tokens)- 若输入是"词元列表的列表"(2D,即每行一个词元列表),先用列表推导式展平成一维词元列表;
- 若输入是一维词元列表,则直接使用;
- 最后交给
collections.Counter统计每个词元的出现次数。
这个设计让Vocab既能直接接收tokenize产出的 2D 结果,也能接收预先展平的一维列表,增强了复用性。
整合所有功能:load_corpus_time_machine
前三个步骤的函数已经齐备,最后用load_corpus_time_machine(d2l/torch.py)把它们打包成一条开箱即用的流水线:
def load_corpus_time_machine(max_tokens=-1): """返回时光机器数据集的词元索引列表和词表""" lines = read_time_machine() tokens = tokenize(lines, 'char') vocab = Vocab(tokens) # 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落, # 所以将所有文本行展平到一个列表中 corpus = [vocab[token] for line in tokens for token in line] if max_tokens > 0: corpus = corpus[:max_tokens] return corpus, vocab该函数返回两个对象:
corpus:词元索引列表,即整篇文本转换成的数字序列;vocab:时光机器语料库对应的词表对象。
与原文档中基于单词的演示相比,这里做了两处关键改动:
- 改用字符级词元化:
tokenize(lines, 'char')。原文明确说明,这是为了简化后续章节中的训练——字符级词表规模远小于单词级词表,训练成本更低(这也是 RNN 从零开始 等后续章节采用字符级建模的原因之一); - 返回展平的单列表:时光机器数据集中每个文本行不一定是完整的句子或段落(可能是断行、单词甚至空行),因此用列表推导式
[vocab[token] for line in tokens for token in line]把所有行的索引展平成一个连续的索引序列,而不是"词元列表的列表"。这样既保留了行间的上下文连续性,也便于后续按固定步长切分子序列。
max_tokens参数提供序列长度上限:默认-1表示不截断;传入正整数时只保留前max_tokens个索引,用于控制训练数据规模。调用load_corpus_time_machine()后,原文档验证了len(corpus)(整个语料的索引总数)与len(vocab)(词表大小,字符级词表通常只有几十个字符索引加<unk>)。
在仓库中的落地:四后端统一实现与下游消费
同一套 API,四个深度学习框架后端
值得注意的是,这套文本预处理工具并非只存在于某个单框架版本中。在仓库的 d2l 包中,read_time_machine、tokenize、Vocab、count_corpus、load_corpus_time_machine五个 API 被同时实现于四个后端:
- d2l/mxnet.py(对应
from d2l import mxnet as d2l) - d2l/torch.py(对应
from d2l import torch as d2l) - d2l/tensorflow.py(对应
from d2l import tensorflow as d2l) - d2l/paddle.py(对应
from d2l import paddle as d2l)
从源码结构看,四个后端的预处理逻辑(正则清洗、词元化分支、频率排序与break剪枝、展平处理)保持一致,仅顶层import collections/import re与数据下载的d2l.DATA_HUB注册方式相同。这意味着:文本预处理环节与具体深度学习框架解耦,无论读者选用 PyTorch、MXNet、TensorFlow 还是 PaddlePaddle,都能以完全相同的调用方式获得相同的结果,差异只体现在后续模型构建部分。这一设计正是"能运行、可讨论"的 d2l-zh 项目风格的体现。
下游如何消费:max_tokens=10000的默认截断
这套预处理流水线的价值在下一个章节得到直接验证。语言模型与数据集 一节中,SeqDataLoader在初始化时直接调用d2l.load_corpus_time_machine(max_tokens)获取索引序列与词表(见 language-models-and-dataset.md),随后基于corpus按随机抽样或顺序分区生成小批量子序列用于 RNN 语言模型训练;而公开的load_data_time_machine(batch_size, num_steps, use_random_iter=False, max_tokens=10000)接口(language-models-and-dataset.md)把max_tokens的默认值设为10000——即默认只用时光机器语料的前 1 万个字符索引来训练语言模型,在保证示例可复现的同时显著缩短训练时间。这正体现了本文四个预处理步骤的最终目的:把原始文本转换成模型可以直接切分、批量读取的数字序列。
小结
- 文本是序列数据最常见的形式之一,预处理的目标是把字符串形态的文本转换为数值形态、便于模型操作的索引序列;
- 标准预处理流水线包含四步:以字符串形式加载文本 → 拆分为词元(单词或字符)→ 构建词表将词元映射为从 0 开始的数字索引 → 将文本转换为词元索引序列;
- 词表构建以词频为核心:
<unk>固定占索引 0,稀有词元通过min_freq剔除,<pad>、<bos>、<eos>等特殊词元可经reserved_tokens预留; d2l工具库在 MXNet、PyTorch、TensorFlow、PaddlePaddle 四个后端中提供了完全一致的预处理 API,可直接被后续语言模型章节复用。
延伸思考(原文档练习)
- 词元化是关键的预处理步骤,且因语言而异:英文可基于空格与标点切分,而中文没有天然的词边界。可以尝试调研基于子词(如 BPE、WordPiece)、基于形态学规则、以及基于统计的分词(如 jieba 等中文分词工具背后的原理)等不同思路,理解它们各自适用的语言与场景。
- 用
tokenize(lines)(单词级)构建词表并改变Vocab的min_freq参数:增大min_freq会剔除更多低频词,词表大小len(vocab)将随之减小,被剔除的词元在查询时会全部落入<unk>(索引 0)。不妨分别取min_freq=0、1、2、5观察词表规模的变化曲线,体会"频率阈值"这一超参数对词表规模与未知词占比的权衡。
- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
相关推荐
动手学深度学习(d2l-zh)实战:softmax回归从零开始实现
动手学深度学习(d2l zh)实战:softmax回归从零开始实现 本文基于《动手学深度学习》中文版开源仓库(d2l zh)的 softmax 回归从零实现章节
人工智能深度学习机器学习教程动手学深度学习(d2l-zh):多层感知机从零开始实现——从参数初始化到完整训练实战
动手学深度学习(d2l zh):多层感知机从零开始实现——从参数初始化到完整训练实战 导读 本文以《动手学深度学习》(d2l zh)仓库中的 mlp scrat
人工智能深度学习机器学习教程动手学深度学习(d2l-zh)转置卷积完全指南:原理、矩阵本质与上采样实战
动手学深度学习(d2l zh)转置卷积完全指南:原理、矩阵本质与上采样实战 转置卷积(transposed convolution)是深度学习框架中最常被误解的
人工智能深度学习机器学习教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考