引言

本次实现的是CS336 assignment1中的tokenizer部分,本次实验中使用了BPE算法实现了tokenizer的训练,同时通过了测试脚本

具体实现

具体实现过程中用了比较死板的方法实现相关的算法,没有用双指针或者哈希查找等算法优化查找过程。所以时间复杂度上为$O(VN)$,其中V为词表大小,而N为预分词的切片数,测试对比了主流的tokenizer,比如gpt2使用的tiktoken,encode和训练速度明显慢了一个数量级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import os
import regex
from typing import Iterable, Iterator
from collections import Counter
from collections import deque
import pickle

class tokenizer:
def __init__(self,vocab,merges,special_tokens=None):
self.merges=merges
if special_tokens:
vocab_reverse={byte_token:token_id for token_id,byte_token in vocab.items()}
for special_token in set(special_tokens):
byte_special_token= special_token.encode('utf-8')
if byte_special_token not in vocab_reverse:
vocab[len(vocab)]=byte_special_token
self.vocab=vocab
self.vocab_reverse={byte_token:token_id for token_id,byte_token in self.vocab.items()}
self.pair2id={pair:i for i,pair in enumerate(merges)}
self.special_tokens=special_tokens


def encode(self,text:str)->list[int]:
if self.special_tokens:
sorted_special_tokens=sorted(self.special_tokens,key=len,reverse=True)
sp_tokens_deal_pattern='('+'|'.join(map(regex.escape,sorted_special_tokens))+')'
document=regex.split(sp_tokens_deal_pattern,text)
else:
document=[text]

PAT=r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
pre_tokenize_pattern=regex.compile(PAT)

ids=[]
for doc in document:

# 如果doc为空,直接跳过
if not doc:
continue

# 如果doc本身就是一个词,直接映射
byte_doc=doc.encode('utf-8')
if self.special_tokens and doc in self.special_tokens and byte_doc in self.vocab_reverse:
ids.append(self.vocab_reverse[byte_doc])
continue

# 如果doc包含多个词
for match in pre_tokenize_pattern.finditer(doc):
substr=match.group().encode('utf-8')
byte_substr=[bytes([i]) for i in substr]
while(len(byte_substr)>=2):
new_byte_substr=[]
pairs=[(byte_substr[i],byte_substr[i+1]) for i in range(len(byte_substr)-1)]
vaild_pairs=[pair for pair in pairs if pair in self.pair2id]
if not vaild_pairs:
break
first_merge_pair=min(vaild_pairs,key=lambda p:self.pair2id[p])
index=0
while(index<len(byte_substr)):
if index<len(byte_substr)-1 and byte_substr[index]==first_merge_pair[0] and byte_substr[index+1]==first_merge_pair[1]:
new_byte_substr.append(first_merge_pair[0]+first_merge_pair[1])
index+=2

else:
new_byte_substr.append(byte_substr[index])
index+=1
byte_substr=new_byte_substr

ids.extend(self.vocab_reverse[byte] for byte in byte_substr)

return ids




def decode(self,ids)->str:
return b"".join(self.vocab[token_id] for token_id in ids).decode('utf-8',errors='replace')

def encode_iterable(self,iterator:Iterator[str])->Iterator[int]:
for line in iterator:
for token_id in self.encode(line):
yield token_id



@classmethod
def merge_substr(cls,str_tuple,max_pair,new_token_id)->tuple[int,...]:
i=0
new_str_tuple=[]
while i<len(str_tuple):
if i<len(str_tuple)-1 and str_tuple[i]==max_pair[0] and str_tuple[i+1]==max_pair[1]:
new_str_tuple.append(new_token_id)
i+=2

else:
new_str_tuple.append(str_tuple[i])
i+=1

return tuple(new_str_tuple)


@classmethod
def train(cls,
input_path: str | os.PathLike,
vocab_size: int,
special_tokens: list[str],
**kwargs):
with open(input_path,'rb') as f:
text=f.read().decode('utf-8')
if special_tokens:
sorted_special_tokens=sorted(special_tokens,key=len,reverse=True)
sp_tokens_deal_pattern='|'.join(map(regex.escape,sorted_special_tokens))
document=regex.split(sp_tokens_deal_pattern,text)
else:
document=[text]

PAT=r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
pre_tokenize_pattern=regex.compile(PAT)

substr_counter:dict[tuple[int,...],int]=Counter()
for doc in document:
if not doc:
continue
for match in pre_tokenize_pattern.finditer(doc):
substr=match.group()
if substr:
str_tuple=tuple(substr.encode('utf-8'))
substr_counter[str_tuple]+=1

vocab={token:bytes([token]) for token in range(256)}
merges=[]

if special_tokens:
for special_token in special_tokens:
vocab[len(vocab)]=special_token.encode('utf-8')

merge_iters=vocab_size-len(vocab)

# 合成循环
for _ in range(merge_iters):
pair_dict={}
# 对于每个切分后的子串
for substr,count in substr_counter.items():
# 对于每对字符
for i in range(len(substr)-1):
pair=(substr[i],substr[i+1])
pair_dict[pair]=pair_dict.get(pair,0)+count
# 如果没有合成字典,则已经没有可以合成的词了,break
if not pair_dict:
break

max_pair=max(pair_dict,key=lambda p:(pair_dict[p],vocab[p[0]],vocab[p[1]]))
new_token_id=len(vocab)
new_substr_counter={}

for substr,count in substr_counter.items():
if max_pair[0] not in substr or max_pair[1] not in substr:
new_substr_counter[substr]=new_substr_counter.get(substr,0)+count
continue

new_substr=tokenizer.merge_substr(substr,max_pair,new_token_id)
new_substr_counter[new_substr]=new_substr_counter.get(new_substr,0)+count


vocab[new_token_id]=vocab[max_pair[0]]+vocab[max_pair[1]]
substr_counter=new_substr_counter
merges.append((vocab[max_pair[0]],vocab[max_pair[1]]))

return vocab,merges

@classmethod
def from_files(cls, vocab_filepath, merges_filepath, special_tokens=None):
with open(vocab_filepath,'rb') as f:
vocab=pickle.load(f)
with open(merges_filepath,'rb') as f:
merges=pickle.load(f)
return cls(vocab,merges,special_tokens)

这里先暂时不讲算法细节,因为后面我还会去优化这个算法,这里先讲一些我实现时遇到的一些坑:

首先就是resource模块加载失败的问题,评测代码(如 test_tokenizer.py)通常借用 Linux 专有的 resource 模块(如 resource.getrusage)来测量内存峰值。但在 Windows 上 Python 标准库并没有这个模块。写跨平台测试或适配层时,必须做环境隔离或异常降级(try: import resource except ImportError: resource = None),避免非核心监控逻辑阻塞核心业务测试。

还有就是编码问题,笔者的电脑默认编码因为一些特殊原因不是utf-8,所以读取gpt2_merges.txt 或特殊 Unicode 字符时,抛出了 `UnicodeDecodeError: ‘gbk’/‘cp936’ codec can’t decode…,这里建议读取文件时显示使用utf-8的方式读取,避免因为编码原因报错

未完待续

后面会优化encode和训练算法。。。