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 都会显得很乱。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐