动手学深度学习——文本预处理代码
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.1 f.readlines()
lines = f.readlines()
表示把文本文件按行读出来。
最终得到的是一个列表,每个元素是一行字符串。
5.2 正则替换
re.sub('[^A-Za-z]+', ' ', line)
它的作用是:
把所有不是英文字母的字符,都替换成空格。
也就是说:
-
标点符号去掉
-
数字去掉
-
特殊符号去掉
-
多余非字母内容统一替换成空格
这样做的目的是让后面分词更干净。
5.3 strip()
.strip()
去掉首尾空格。
5.4 lower()
.lower()
把所有大写字母变成小写。
例如:
-
Time变成time -
Machine变成machine
这样可以避免:
-
Time -
time
被当成两个不同词。
6. 为什么要先清洗文本
因为原始文本里往往有很多不稳定因素,例如:
-
大小写不同
-
标点符号混杂
-
换行符
-
多余空格
-
特殊字符
如果不先清洗,后面构建词表时就会很混乱。
例如:
-
Hello -
hello -
hello, -
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.2 Counter(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 都会显得很乱。
更多推荐


所有评论(0)