动手学深度学习——文本预处理代码
1. 前言上一篇我们已经进入了序列模型部分。我们知道文本本质上也是一种序列例如一句话我正在学习深度学习对于人来说文字天然可以理解。但对于神经网络来说文字本身只是字符串模型并不能直接处理。所以在真正训练语言模型、RNN 之前必须先解决一个基础问题如何把原始文本处理成模型可以读懂的数字形式这就是本节“文本预处理代码”的核心任务。这一节主要要解决如何读取原始文本如何把文本拆成词元token如何建立词表vocabulary如何把词元转换成数字索引这几步看起来基础但它们是后面语言模型和 RNN 的真正起点。2. 文本预处理到底在做什么文本预处理可以概括成一句话把原始自然语言文本变成可供模型训练的离散符号序列和数字序列。原始文本例如Time traveller for so it will be convenient to speak of him模型不能直接拿这串字符训练因为神经网络需要的是数值输入。所以必须一步步转换第一步读取文本从文件中读出原始句子。第二步清洗文本统一大小写、去掉多余符号等。第三步分词把句子拆成更小单位例如单词或字符。第四步建立词表为每个词元分配唯一编号。第五步文本数字化把词元序列转换成索引序列。这样文本才真正变成模型能吃进去的数据。3. 李沐这里为什么常用《时间机器》《动手学深度学习》在文本预处理部分一个经典示例就是H. G. Wells 的小说《The Time Machine》原因很简单3.1 文本公开、经典它是公开文本适合教学使用。3.2 长度适中不至于太小也不会大到不方便讲解。3.3 英文词汇结构清晰适合展示分词、词表构建等基础操作。所以这一节你会经常看到“time machine”文本作为例子。4. 读取原始文本第一步通常是把文本文件读进来。常见代码大致如下import re def read_time_machine(): with open(timemachine.txt, r) as f: lines f.readlines() return [re.sub([^A-Za-z], , line).strip().lower() for line in lines]这段代码很经典里面做了几件关键事情。5. 这段代码怎么理解我们逐行看。5.1f.readlines()lines f.readlines()表示把文本文件按行读出来。最终得到的是一个列表每个元素是一行字符串。5.2 正则替换re.sub([^A-Za-z], , line)它的作用是把所有不是英文字母的字符都替换成空格。也就是说标点符号去掉数字去掉特殊符号去掉多余非字母内容统一替换成空格这样做的目的是让后面分词更干净。5.3strip().strip()去掉首尾空格。5.4lower().lower()把所有大写字母变成小写。例如Time变成timeMachine变成machine这样可以避免Timetime被当成两个不同词。6. 为什么要先清洗文本因为原始文本里往往有很多不稳定因素例如大小写不同标点符号混杂换行符多余空格特殊字符如果不先清洗后面构建词表时就会很混乱。例如Hellohellohello,hello!本来其实应该看作同一个词但不清洗的话模型会把它们当作不同 token。所以清洗文本是必要的第一步。7. 什么是词元token在文本处理中一个很核心的概念就是词元token词元可以简单理解为文本中被拆分出来的最小处理单位这个单位可以是单词字符子词在李沐这一节最开始通常先讲最简单的两种按单词切分按字符切分8. 分词函数怎么写常见写法如下def tokenize(lines, tokenword): if token word: return [line.split() for line in lines] elif token char: return [list(line) for line in lines] else: print(错误未知词元类型 token)这段代码就是最基本的分词器。9. 按单词切分是什么意思如果tokenword那么line.split()会把一行按空格拆开。例如time traveller for so it will be convenient会变成[time, traveller, for, so, it, will, be, convenient]也就是说每个单词是一个 token。这很符合日常直觉也是最常见的文本表示方式之一。10. 按字符切分是什么意思如果tokenchar那么list(line)会把整行拆成一个个字符。例如time变成[t, i, m, e]这种方式粒度更细。字符级建模的好处是词表更小不会遇到“未知单词”那么严重的问题但缺点是序列更长语义表达更分散。11. 为什么既可以按词切也可以按字符切因为不同任务、不同模型的需求不一样。按词切分优点更符合语言语义单位序列更短表达更自然缺点词表可能非常大容易有未登录词问题按字符切分优点词表很小几乎没有 OOV未登录词问题缺点序列会更长模型更难捕捉高层语义所以两种方式各有取舍。李沐这里先都展示一下让你建立基本认识。12. 什么是词表Vocabulary当文本被切成 token 后还不能直接送入模型。因为模型还是不能读字符串。所以必须再做一步给每个 token 分配一个唯一的整数编号这个“token 到编号”的映射表就叫词表Vocabulary例如{time: 0, traveller: 1, for: 2, so: 3, ...}以后只要看到time就用编号0表示。这样文本就变成数字序列了。13. 为什么要先统计词频在建立词表之前通常会先统计所有 token 的出现次数。因为词频信息很重要13.1 可以按频率排序高频词通常更值得优先保留。13.2 可以过滤低频词有些出现极少的词可能噪声较大可以统一处理成unk。13.3 便于分析文本分布例如哪些词最常见是否存在长尾现象。所以词频统计是词表构建的前置步骤。14. 统计词频的代码常见写法如下from collections import Counter def count_corpus(tokens): if len(tokens) 0 or isinstance(tokens[0], list): tokens [token for line in tokens for token in line] return Counter(tokens)这段代码也很经典。15. 这段代码怎么理解15.1 为什么要展平如果输入是[[time, traveller], [for, so]]这其实是“按行切好的 token 列表”。但Counter需要的是一个平铺的一维列表所以先用列表推导式把它展开成[time, traveller, for, so]15.2Counter(tokens)Counter(tokens)会统计每个 token 出现的次数。例如Counter({the: 2261, i: 1267, and: 1245, ...})这就得到了词频表。16. 词表类怎么写李沐这里通常会封装一个Vocab类大致形式如下class Vocab: def __init__(self, tokensNone, min_freq0, reserved_tokensNone): if tokens is None: tokens [] if reserved_tokens is None: reserved_tokens [] counter count_corpus(tokens) self.token_freqs sorted(counter.items(), keylambda x: x[1], reverseTrue) self.idx_to_token [unk] reserved_tokens self.token_to_idx {token: idx for idx, token in enumerate(self.idx_to_token)} for token, freq in self.token_freqs: if freq min_freq: break if token not in self.token_to_idx: self.idx_to_token.append(token) self.token_to_idx[token] len(self.idx_to_token) - 1这是文本预处理部分最核心的一段代码之一。17.Vocab类的核心思想是什么它本质上在做两件事第一件建立 token 到 index 的映射self.token_to_idx例如{unk: 0, the: 1, i: 2, and: 3, ...}第二件建立 index 到 token 的映射self.idx_to_token例如[unk, the, i, and, ...]这两个方向都要有。为什么因为训练时常常要把 token 转成 index生成文本或解释结果时又常常要把 index 转回 token18. 为什么要有unkunk表示unknown token未知词元它通常对应索引0。这样做是因为测试时可能会遇到词表中没见过的词不能让程序直接报错所以统一把未知词映射到unk例如训练集中没出现过lycoris那测试时看到它就可以映射成unk。这是 NLP 中非常常见的做法。19.min_freq有什么作用min_freq表示最小出现频率阈值。如果一个 token 出现次数太低例如只出现 1 次有时我们会选择不把它放进词表。这样做的好处是减小词表规模减少稀有词带来的噪声提高模型训练稳定性所以min_freq是控制词表大小的重要参数。20. 如何把 token 转成索引通常Vocab类里还会定义def __getitem__(self, tokens): if not isinstance(tokens, (list, tuple)): return self.token_to_idx.get(tokens, self.unk) return [self.__getitem__(token) for token in tokens]这意味着单个 tokenvocab[time]会返回它的编号。一串 tokenvocab[[time, traveller]]会返回一个编号列表。这就完成了“文本数字化”。21. 如何把索引转回 token同理也常会定义def to_tokens(self, indices): if not isinstance(indices, (list, tuple)): return self.idx_to_token[indices] return [self.idx_to_token[index] for index in indices]这样就能把模型输出的数字再还原成可读文本。例如vocab.to_tokens([1, 2, 3])可能返回[the, i, and]这一步在后面语言模型生成文本时很重要。22. 一个完整的小流程把这节内容串起来大概就是lines read_time_machine() tokens tokenize(lines, word) vocab Vocab(tokens)这里lines是清洗后的文本行tokens是分词结果vocab是建立好的词表然后你就可以看tokens[0] vocab[tokens[0]]第一句看原始 token第二句看它们对应的数字索引。这就把文本从“字符串世界”带到了“数字世界”。23. 为什么文本预处理是后面语言模型的基础因为不管后面是语言模型RNNLSTMGRUTransformer它们都不能直接吃字符串。它们真正看到的永远是token 序列index 序列embedding 向量而这里的文本预处理就是整个链条的起点。没有这一节后面的模型就根本没法开始。24. 这节代码最容易混的点这里顺手帮你总结几个特别容易混的点。24.1 行、token、索引不是一回事行原始文本的一行token切分后的最小单位索引token 对应的数字编号24.2 清洗文本和分词不是一回事清洗统一大小写、去掉符号分词把文本拆开24.3 词表不是文本本身词表只是“token 到编号”的映射表它不是训练数据本身。24.4unk不是普通单词它是一个占位符用来处理词表外 token。25. 本节总结这一节我们学习了文本预处理代码核心内容可以总结为以下几点。25.1 文本预处理的目标把原始文本转换成模型可处理的数字序列。25.2 读取文本时通常要先清洗包括去掉非字母字符统一小写去掉多余空格25.3 文本可以按词或按字符切分这两种 tokenization 方式各有特点。25.4 词表负责 token 和索引之间的映射它是后续语言模型训练的基础组件。25.5unk用于处理未知词元这是 NLP 中非常常见的机制。26. 学习感悟这一节看起来很基础甚至不像“深度学习模型”但它其实特别重要。因为你会慢慢发现很多 NLP 任务真正开始的地方不是网络结构而是“如何把语言变成机器可处理的形式”。也就是说文本预处理不是边角料而是语言建模这条链路上的第一块地基。地基不清楚后面的语言模型和 RNN 都会显得很乱。