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, 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)这段代码就是最基本的分词器。
9. 按单词切分是什么意思
如果:
token='word'那么:
line.split()会把一行按空格拆开。
例如:
"time traveller for so it will be convenient"会变成:
['time', 'traveller', 'for', 'so', 'it', 'will', 'be', 'convenient']也就是说,每个单词是一个 token。
这很符合日常直觉,也是最常见的文本表示方式之一。
10. 按字符切分是什么意思
如果:
token='char'那么:
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, tokens=None, min_freq=0, reserved_tokens=None): if tokens is None: tokens = [] if reserved_tokens is None: reserved_tokens = [] counter = count_corpus(tokens) self.token_freqs = sorted(counter.items(), key=lambda x: x[1], reverse=True) 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 转回 token
18. 为什么要有<unk>
<unk>表示:
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]这意味着:
单个 token
vocab['time']会返回它的编号。
一串 token
vocab[['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. 为什么文本预处理是后面语言模型的基础
因为不管后面是:
语言模型
RNN
LSTM
GRU
Transformer
它们都不能直接吃字符串。
它们真正看到的永远是:
token 序列
index 序列
embedding 向量
而这里的文本预处理,就是整个链条的起点。
没有这一节,后面的模型就根本没法开始。
24. 这节代码最容易混的点
这里顺手帮你总结几个特别容易混的点。
24.1 行、token、索引不是一回事
行:原始文本的一行
token:切分后的最小单位
索引:token 对应的数字编号
24.2 清洗文本和分词不是一回事
清洗:统一大小写、去掉符号
分词:把文本拆开
24.3 词表不是文本本身
词表只是“token 到编号”的映射表,
它不是训练数据本身。
24.4<unk>不是普通单词
它是一个占位符,用来处理词表外 token。
25. 本节总结
这一节我们学习了文本预处理代码,核心内容可以总结为以下几点。
25.1 文本预处理的目标
把原始文本转换成模型可处理的数字序列。
25.2 读取文本时通常要先清洗
包括:
去掉非字母字符
统一小写
去掉多余空格
25.3 文本可以按词或按字符切分
这两种 tokenization 方式各有特点。
25.4 词表负责 token 和索引之间的映射
它是后续语言模型训练的基础组件。
25.5<unk>用于处理未知词元
这是 NLP 中非常常见的机制。
26. 学习感悟
这一节看起来很基础,甚至不像“深度学习模型”,
但它其实特别重要。
因为你会慢慢发现:
很多 NLP 任务真正开始的地方,不是网络结构,而是“如何把语言变成机器可处理的形式”。
也就是说,文本预处理不是边角料,
而是语言建模这条链路上的第一块地基。
地基不清楚,后面的语言模型和 RNN 都会显得很乱。