Huggingface核心模块(一): Tokenizer

本文的内容和核心代码参考Huggingface Tokenizer

1. Tokenizer的基本方法

Tokenizer(标记器)是NLP中的一个核心模块,它将文本转换为token,token是NLP中的基本单位,可以是单词、子词、字符等。在模型的训练和预测过程中,模型只能处理数字,因此需要将文本转换为数字,这个过程就是tokenization

下面我们看这样一段文本

1
I like computer vision and mechanism learning.

模型并不能直接对上述文本进行处理,因为模型只能处理数字信息,相反的人只能看懂文本信息,对于数字化的二进制信息,人是无法读懂的。因此我们需要有一个能将文本转为数字的方法,这就是Tokenizer所做的事情了,在tokenizer的发展过程中,人们创造了许多分词的方法,下面我们一一介绍这些方法

1.1 基于词(Word-based)

基于词的方法很容易理解,就是将一段文本转换为单词,例如上述文本经过基于词的方法处理后,得到的结果如下

1
2
3
4
>>> text = "I like computer vision and transformers."
>>> tokenized_text = text.split()
>>> print(tokenized_text)
['I', 'like', 'computer', 'vision', 'and', 'transformers.']

上述结果是基于空格的分词,还可以根据标点符号进行分词,这样上述结果的transformers.就会被分为transformers.

除此之外还有一些单词的变体,例如happyhappily,他们有额外的标点符号规则,但是不论使用哪种tokenizer,最终我们都会得到一个词汇表,他记录着每一个词汇的id和词汇本身。每种语言都有非常多的词汇,例如英语中有超过50w个单词,如果每个单词都有一个id,那么词汇表的大小就会非常大,所以我们需要想办法对其进行压缩,下面基于字符的分词方法就解决了这个问题

1.2 基于字符(Character-based)

基于字符的方法将文本拆为字符,而不是单词,有下面两个好处

  • 词汇表要小得多
  • 词汇外(未知)标记(token)也要少得多,因为每个单词都是由字符组成的
1
2
>>> print(list(text))
['I', ' ', 'l', 'i', 'k', 'e', ' ', 'c', 'o', 'm', 'p', 'u', 't', 'e', 'r', ' ', 'v', 'i', 's', 'i', 'o', 'n', ' ', 'a', 'n', 'd', ' ', 't', 'r', 'a', 'n', 's', 'f', 'o', 'r', 'm', 'e', 'r', 's', '.']

这种方法虽然大大减少了词汇表的数量,但是由于现在是字符不是单词,直觉上讲,这种做法的意义不大,因为每个字符本身并没有意义,单词才具有意义,因此我们需要一种方法,既能减少词汇表的数量,又能保留单词的意义,就是第三种的基于子词(Subword-based的分词方法

1.3 基于子词(Subword-based)

基于子词的分词方法依赖这样一个原则:不应将常用词拆分为更小的子词,而应将稀有词分解为有意义的子词。

例如,“annoyingly”可能被认为是一个罕见的词,可以分解为“annoying”和“ly”。这两者都可能作为独立的子词出现的更频繁,同时“annoyingly”的含义由“annoying”和“ly”的复合含义保持。

这里我们使用transformers库中的AutoTokenizer初始化一个Bert的Tokenizer,并用它对上述文本进行分词

1
2
3
4
5
6
7
8
9
>>> text = "I like computer vision and transformers."
>>> from transformers import AutoTokenizer
>>> tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
Downloading (…)okenizer_config.json: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 29.0/29.0 [00:00<00:00, 24.8kB/s]
Downloading (…)lve/main/config.json: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 570/570 [00:00<00:00, 809kB/s]
Downloading (…)solve/main/vocab.txt: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 213k/213k [00:00<00:00, 382kB/s]
Downloading (…)/main/tokenizer.json: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 436k/436k [00:00<00:00, 1.30MB/s]
>>> tokenizer.tokenize(text)
['I', 'like', 'computer', 'vision', 'and', 'transform', '##ers', '.']

AutoTokenizer.from_pretrained("bert-base-cased")这一步会自动从Huggingface官网下载相应的bert分词模型,随后我们调用tokenizer的tokenize方法就能够对文本进行分词了,其中transformers被分为了transform##ers,即将一个不常用的词分为两个更有意义的常用词,这种分词方式可以减少词汇表的数量,同时保留了单词的含义

1.4 Huggingface中Tokenizer的使用

对于一段文本,我们的目的是对其进行编码,使得模型能够读懂它,这个过程主要有两步:标记(token)化,转换为输入id。这两步在Huggingface中只需要调用tokenizer即可解决,如下所示

1
2
3
>>> text = "I like computer vision and transformers."
>>> tokenizer(text)
{'input_ids': [101, 146, 1176, 2775, 4152, 1105, 11303, 1468, 119, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}

除此之外我们还可以通过几个tokenizer的方法来查看他的分词过程

1
2
3
4
5
6
7
8
9
>>> tokens = tokenizer.tokenize(text)
>>> print(tokens)
['I', 'like', 'computer', 'vision', 'and', 'transform', '##ers', '.']
>>> ids = tokenizer.convert_tokens_to_ids(tokens)
>>> print(ids)
[146, 1176, 2775, 4152, 1105, 11303, 1468, 119]
>>> decoded_string = tokenizer.decode(ids)
>>> print(decoded_string)
I like computer vision and transformers.

这里我们调用了三个tokenizer的方法,分别是tokenizeconvert_tokens_to_idsdecode,其中tokenize方法将文本分词,convert_tokens_to_ids方法将分词后的文本转换为id,decode方法将id转换为文本。

注意这里直接调用tokenizer和调用其方法进行分词结果略有不同,直接调用的前后加入了两个id:101和102。这是给文本前后加入启示和终止符,这样我们就知道句子什么时候开始什么时候结束了,后面会详细介绍这些方法

2. Tokenizer与Model

如上所述,Tokenizer将文本处理数字形式,转化为模型可以处理的形式,下面我们看一下Huggingface中的Tokenizer和Model是如何配合使用的

2.1 将文本输入到模型中

下面我们使用一个分类模型来演示如何将文本输入到模型中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>>> from transformers import AutoTokenizer, AutoModelForSequenceClassification
>>> checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
>>> tokenizer = AutoTokenizer.from_pretrained(checkpoint)
Downloading (…)okenizer_config.json: 100%|████████████████████████████████████████████████████████████████████████████████████| 48.0/48.0 [00:00<00:00, 105kB/s]
Downloading (…)lve/main/config.json: 100%|█████████████████████████████████████████████████████████████████████████████████████| 629/629 [00:00<00:00, 1.46MB/s]
Downloading (…)solve/main/vocab.txt: 100%|████████████████████████████████████████████████████████████████████████████████████| 232k/232k [00:00<00:00, 546kB/s]
>>> model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
Downloading model.safetensors: 100%|█████████████████████████████████████████████████████████████████████████████████████████| 268M/268M [00:10<00:00, 24.5MB/s]
>>> sequence = "I've been waiting for a HuggingFace course my whole life."
>>> tokens = tokenizer.tokenize(sequence)
>>> ids = tokenizer.convert_tokens_to_ids(tokens)
>>> input_ids = torch.tensor([ids])
>>> model(input_ids)
>>> SequenceClassifierOutput(loss=None, logits=tensor([[-2.7276, 2.8789]], grad_fn=<AddmmBackward0>), hidden_states=None, attentions=None)


'''
tokens
['i', "'", 've', 'been', 'waiting', 'for', 'a', 'hugging', '##face', 'course', 'my', 'whole', 'life', '.']
ids
[1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012]
'''

tokenizer在将文本转换为id之后我们需要确保输入到模型中的input_ids二维的,因为模型通常需要处理多个句子,所以这里我们将单个句子拓展为2维后进行了输入

2.2 多序列处理

在示例中我们能够对单个序列进行处理了,但是当处理多序列时会遇到以下问题

  • 序列长度不一致,如何对齐?
  • 序列过长如何截断?

假设我们有两个句子,他们的id如下

1
2
3
>>> sequence1_ids = [[200, 200, 200]]
>>> sequence2_ids = [[200, 200]]
>>> batched_ids = [[200, 200, 200], [200, 200]]

他们合起来的输入到网络中为batched_ids,但是这无法输入到网络中,因为这个矩阵同一维度的大小是不一样的,所需要对齐操作,如下

1
2
3
4
5
6
7
8
9
10
>>> sequence1_ids = [[200, 200, 200]]
>>> sequence2_ids = [[200, 200]]
>>> batched_ids = [[200, 200, 200], [200, 200, tokenizer.pad_token_id]]
>>> model(torch.tensor(sequence1_ids)).logits
tensor([[ 1.5694, -1.3895]], grad_fn=<AddmmBackward0>)
>>> model(torch.tensor(sequence2_ids)).logits
tensor([[ 0.5803, -0.4125]], grad_fn=<AddmmBackward0>)
>>> model(torch.tensor(batched_ids)).logits
tensor([[ 1.5694, -1.3895],
[ 1.3374, -1.2163]], grad_fn=<AddmmBackward0>)

注意这里sequence2batched_ids的第二行结果应该是一样的,但是为什么得到的值不同呢?因为Transformer的注意力机制会考虑当前序列的所有token,因此会考虑pad_token_id。为了在填充与不填充句子时获得相同的结果,我们需要告诉Transformer哪一部分被padding了,这通过attention_mask参数来实现,如下

1
2
3
4
5
>>> batched_ids = [[200, 200, 200], [200, 200, tokenizer.pad_token_id]]
>>> attention_mask = [[1, 1, 1], [1, 1, 0]]
>>> model(torch.tensor(batched_ids), attention_mask=torch.tensor(attention_mask)).logits
tensor([[ 1.5694, -1.3895],
[ 0.5803, -0.4125]], grad_fn=<AddmmBackward0>)

这里的结果就和上述单序列输入的结果相同了

2.3 Tokenizer的参数使用

上面我们介绍了多序列的处理方法,幸运的是,在Huggingface中并不需要我们手动去padding,我们只需要直接调用tokenizer分词器即可,下面介绍在tokenizer中的常用方法

1. 直接调用tokenizer分词器

Huggingface中实例化的tokenizer本身就是可调用对象,可以直接对文本进行分词

1
2
3
4
5
6
7
8
>>> sequence = "I've been waiting for a HuggingFace course my whole life."
>>> tokenizer(sequence)
{'input_ids': [101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
>>> sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]
>>> tokenizer(sequences)
{'input_ids': [[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102], [101, 2061, 2031, 1045, 999, 102]],
'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]]}

2. padding参数

上面例子tokenizer并没有进行填充等操作,下面我们加入padding参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]
# 不加任何参数
>>> tokenizer(sequences)
{'input_ids': [[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102], [101, 2061, 2031, 1045, 999, 102]],
'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]]}
# 按照序列中最长的序列进行填充,将所有短序列填充到最长序列的长度
# "longest"也可以用True代替
>>> tokenizer(sequences, padding="longest")
{'input_ids': [[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102], [101, 2061, 2031, 1045, 999, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]}
# 按照能够容纳的最长序列进行填充,这里太长了展示不开
>>> tokenizer(sequences, padding="max_length")
{'input_ids': [[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102, 0, ..., 0], [101, 2061, 2031, 1045, 999, 102, 0, ..., 0]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, ..., 0], [1, 1, 1, 1, 1, 1, 0, ..., 0]]}
# 指定最长长度
>>> tokenizer(sequences, padding="max_length", max_length=8)
{'input_ids': [[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102], [101, 2061, 2031, 1045, 999, 102, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0]]}

3. truncation参数

truncation参数表示当序列长度超过max_length时,是否进行截断,Huggingface中默认是False,即不进行截断

1
>>> tokenizer(sequences, padding=True)

4. return_tensors参数

return_tensors参数表示返回的数据类型,pt返回PyTorch张量,tf返回TensorFlow张量,np返回NumPy数组

1
2
3
4
5
6
# Returns PyTorch tensors
>>> model_inputs = tokenizer(sequences, padding=True, return_tensors="pt")
# Returns TensorFlow tensors
>>> model_inputs = tokenizer(sequences, padding=True, return_tensors="tf")
# Returns NumPy arrays
>>> model_inputs = tokenizer(sequences, padding=True, return_tensors="np")